diff --git a/.core_files.yaml b/.core_files.yaml index 27daff11f357b6..1a220eef7a242f 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -6,7 +6,7 @@ core: &core - homeassistant/helpers/* - homeassistant/package_constraints.txt - homeassistant/util/* - - pyproject.yaml + - pyproject.toml - requirements.txt - setup.cfg @@ -21,6 +21,7 @@ base_platforms: &base_platforms - homeassistant/components/climate/* - homeassistant/components/cover/* - homeassistant/components/device_tracker/* + - homeassistant/components/diagnostics/* - homeassistant/components/fan/* - homeassistant/components/geo_location/* - homeassistant/components/humidifier/* @@ -65,6 +66,7 @@ components: &components - homeassistant/components/homeassistant/** - homeassistant/components/image/* - homeassistant/components/input_boolean/* + - homeassistant/components/input_button/* - homeassistant/components/input_datetime/* - homeassistant/components/input_number/* - homeassistant/components/input_select/* @@ -101,17 +103,29 @@ tests: &tests - codecov.yaml - requirements_test_pre_commit.txt - requirements_test.txt + - tests/auth/** + - tests/backports/* - tests/common.py - tests/conftest.py + - tests/hassfest/* + - tests/helpers/* - tests/ignore_uncaught_exceptions.py - tests/mock/* + - tests/scripts/* - tests/test_util/* - tests/testing_config/** + - tests/util/** other: &other - .github/workflows/* - homeassistant/scripts/** +requirements: + - .github/workflows/* + - homeassistant/package_constraints.txt + - requirements*.txt + - setup.py + any: - *base_platforms - *components diff --git a/.coveragerc b/.coveragerc index 05bb880c2f2628..6f410b62cf1006 100644 --- a/.coveragerc +++ b/.coveragerc @@ -27,6 +27,7 @@ omit = homeassistant/components/adguard/sensor.py homeassistant/components/adguard/switch.py homeassistant/components/ads/* + homeassistant/components/advantage_air/diagnostics.py homeassistant/components/aemet/weather_update_coordinator.py homeassistant/components/aftership/* homeassistant/components/agent_dvr/alarm_control_panel.py @@ -55,6 +56,7 @@ omit = homeassistant/components/amcrest/* homeassistant/components/ampio/* homeassistant/components/android_ip_webcam/* + homeassistant/components/androidtv/__init__.py homeassistant/components/anel_pwrctrl/switch.py homeassistant/components/anthemav/media_player.py homeassistant/components/apcupsd/* @@ -65,7 +67,6 @@ omit = homeassistant/components/aquostv/media_player.py homeassistant/components/arcam_fmj/media_player.py homeassistant/components/arcam_fmj/__init__.py - homeassistant/components/arduino/* homeassistant/components/arest/binary_sensor.py homeassistant/components/arest/sensor.py homeassistant/components/arest/switch.py @@ -73,6 +74,9 @@ omit = homeassistant/components/arris_tg2492lg/* homeassistant/components/aruba/device_tracker.py homeassistant/components/arwn/sensor.py + homeassistant/components/aseko_pool_live/__init__.py + homeassistant/components/aseko_pool_live/entity.py + homeassistant/components/aseko_pool_live/sensor.py homeassistant/components/asterisk_cdr/mailbox.py homeassistant/components/asterisk_mbox/* homeassistant/components/asuswrt/__init__.py @@ -91,7 +95,6 @@ omit = homeassistant/components/azure_service_bus/* homeassistant/components/baidu/tts.py homeassistant/components/balboa/__init__.py - homeassistant/components/balboa/entity.py homeassistant/components/beewi_smartclim/sensor.py homeassistant/components/bbb_gpio/* homeassistant/components/bbox/device_tracker.py @@ -118,6 +121,7 @@ omit = homeassistant/components/bmp280/sensor.py homeassistant/components/bmw_connected_drive/__init__.py homeassistant/components/bmw_connected_drive/binary_sensor.py + homeassistant/components/bmw_connected_drive/button.py homeassistant/components/bmw_connected_drive/device_tracker.py homeassistant/components/bmw_connected_drive/lock.py homeassistant/components/bmw_connected_drive/notify.py @@ -176,7 +180,6 @@ omit = homeassistant/components/coolmaster/climate.py homeassistant/components/coolmaster/const.py homeassistant/components/cppm_tracker/device_tracker.py - homeassistant/components/cpuspeed/sensor.py homeassistant/components/crownstone/__init__.py homeassistant/components/crownstone/const.py homeassistant/components/crownstone/listeners.py @@ -203,7 +206,6 @@ omit = homeassistant/components/devolo_home_control/climate.py homeassistant/components/devolo_home_control/const.py homeassistant/components/devolo_home_control/cover.py - homeassistant/components/devolo_home_control/devolo_multi_level_switch.py homeassistant/components/devolo_home_control/light.py homeassistant/components/devolo_home_control/sensor.py homeassistant/components/devolo_home_control/subscriber.py @@ -216,6 +218,7 @@ omit = homeassistant/components/dlib_face_detect/image_processing.py homeassistant/components/dlib_face_identify/image_processing.py homeassistant/components/dlink/switch.py + homeassistant/components/dnsip/__init__.py homeassistant/components/dnsip/sensor.py homeassistant/components/dominos/* homeassistant/components/doods/* @@ -254,6 +257,10 @@ omit = homeassistant/components/eight_sleep/* homeassistant/components/eliqonline/sensor.py homeassistant/components/elkm1/* + homeassistant/components/elmax/__init__.py + homeassistant/components/elmax/common.py + homeassistant/components/elmax/const.py + homeassistant/components/elmax/switch.py homeassistant/components/elv/* homeassistant/components/emby/media_player.py homeassistant/components/emoncms/sensor.py @@ -365,9 +372,11 @@ omit = homeassistant/components/freebox/switch.py homeassistant/components/fritz/__init__.py homeassistant/components/fritz/binary_sensor.py + homeassistant/components/fritz/button.py homeassistant/components/fritz/common.py homeassistant/components/fritz/const.py homeassistant/components/fritz/device_tracker.py + homeassistant/components/fritz/diagnostics.py homeassistant/components/fritz/sensor.py homeassistant/components/fritz/services.py homeassistant/components/fritz/switch.py @@ -383,6 +392,8 @@ omit = homeassistant/components/garages_amsterdam/sensor.py homeassistant/components/gc100/* homeassistant/components/geniushub/* + homeassistant/components/github/__init__.py + homeassistant/components/github/coordinator.py homeassistant/components/github/sensor.py homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitter/sensor.py @@ -391,7 +402,12 @@ omit = homeassistant/components/glances/sensor.py homeassistant/components/gntp/notify.py homeassistant/components/goalfeed/* - homeassistant/components/google/* + homeassistant/components/goodwe/__init__.py + homeassistant/components/goodwe/const.py + homeassistant/components/goodwe/number.py + homeassistant/components/goodwe/select.py + homeassistant/components/goodwe/sensor.py + homeassistant/components/google/__init__.py homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py homeassistant/components/google_pubsub/__init__.py @@ -448,6 +464,7 @@ omit = homeassistant/components/homematic/* homeassistant/components/home_plus_control/api.py homeassistant/components/home_plus_control/switch.py + homeassistant/components/homewizard/diagnostics.py homeassistant/components/homeworks/* homeassistant/components/honeywell/__init__.py homeassistant/components/honeywell/climate.py @@ -496,6 +513,10 @@ omit = homeassistant/components/insteon/schemas.py homeassistant/components/insteon/switch.py homeassistant/components/insteon/utils.py + homeassistant/components/intellifire/__init__.py + homeassistant/components/intellifire/coordinator.py + homeassistant/components/intellifire/binary_sensor.py + homeassistant/components/intellifire/sensor.py homeassistant/components/incomfort/* homeassistant/components/intesishome/* homeassistant/components/ios/* @@ -542,11 +563,7 @@ omit = homeassistant/components/knx/__init__.py homeassistant/components/knx/climate.py homeassistant/components/knx/cover.py - homeassistant/components/knx/expose.py - homeassistant/components/knx/knx_entity.py - homeassistant/components/knx/light.py homeassistant/components/knx/notify.py - homeassistant/components/knx/schema.py homeassistant/components/kodi/__init__.py homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/const.py @@ -564,17 +581,17 @@ omit = homeassistant/components/lametric/* homeassistant/components/lannouncer/notify.py homeassistant/components/lastfm/sensor.py + homeassistant/components/launch_library/__init__.py homeassistant/components/launch_library/const.py + homeassistant/components/launch_library/diagnostics.py homeassistant/components/launch_library/sensor.py homeassistant/components/lcn/binary_sensor.py homeassistant/components/lcn/climate.py homeassistant/components/lcn/cover.py homeassistant/components/lcn/helpers.py - homeassistant/components/lcn/light.py homeassistant/components/lcn/scene.py homeassistant/components/lcn/sensor.py homeassistant/components/lcn/services.py - homeassistant/components/lcn/switch.py homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/media_player.py homeassistant/components/life360/* @@ -593,12 +610,14 @@ omit = homeassistant/components/logi_circle/sensor.py homeassistant/components/london_underground/sensor.py homeassistant/components/lookin/__init__.py + homeassistant/components/lookin/coordinator.py homeassistant/components/lookin/entity.py homeassistant/components/lookin/models.py homeassistant/components/lookin/sensor.py homeassistant/components/lookin/climate.py + homeassistant/components/lookin/media_player.py + homeassistant/components/lookin/light.py homeassistant/components/luci/device_tracker.py - homeassistant/components/luftdaten/__init__.py homeassistant/components/luftdaten/sensor.py homeassistant/components/lupusec/* homeassistant/components/lutron/* @@ -697,6 +716,7 @@ omit = homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/__init__.py homeassistant/components/nanoleaf/button.py + homeassistant/components/nanoleaf/diagnostics.py homeassistant/components/nanoleaf/entity.py homeassistant/components/nanoleaf/light.py homeassistant/components/neato/__init__.py @@ -716,7 +736,9 @@ omit = homeassistant/components/netgear_lte/* homeassistant/components/netio/switch.py homeassistant/components/neurio_energy/sensor.py + homeassistant/components/nexia/entity.py homeassistant/components/nexia/climate.py + homeassistant/components/nexia/switch.py homeassistant/components/nextcloud/* homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py @@ -763,6 +785,8 @@ omit = homeassistant/components/onvif/event.py homeassistant/components/onvif/parsers.py homeassistant/components/onvif/sensor.py + homeassistant/components/open_meteo/diagnostics.py + homeassistant/components/open_meteo/weather.py homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py homeassistant/components/openexchangerates/sensor.py @@ -793,6 +817,22 @@ omit = homeassistant/components/orvibo/switch.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py + homeassistant/components/overkiz/__init__.py + homeassistant/components/overkiz/binary_sensor.py + homeassistant/components/overkiz/button.py + homeassistant/components/overkiz/cover.py + homeassistant/components/overkiz/cover_entities/* + homeassistant/components/overkiz/coordinator.py + homeassistant/components/overkiz/diagnostics.py + homeassistant/components/overkiz/entity.py + homeassistant/components/overkiz/executor.py + homeassistant/components/overkiz/light.py + homeassistant/components/overkiz/lock.py + homeassistant/components/overkiz/number.py + homeassistant/components/overkiz/scene.py + homeassistant/components/overkiz/select.py + homeassistant/components/overkiz/sensor.py + homeassistant/components/overkiz/switch.py homeassistant/components/ovo_energy/__init__.py homeassistant/components/ovo_energy/const.py homeassistant/components/ovo_energy/sensor.py @@ -808,6 +848,7 @@ omit = homeassistant/components/philips_js/light.py homeassistant/components/philips_js/media_player.py homeassistant/components/philips_js/remote.py + homeassistant/components/philips_js/switch.py homeassistant/components/pi_hole/sensor.py homeassistant/components/pi4ioe5v9xxxx/binary_sensor.py homeassistant/components/pi4ioe5v9xxxx/switch.py @@ -837,7 +878,6 @@ omit = homeassistant/components/progettihwsw/__init__.py homeassistant/components/progettihwsw/binary_sensor.py homeassistant/components/progettihwsw/switch.py - homeassistant/components/prometheus/* homeassistant/components/prowl/notify.py homeassistant/components/proxmoxve/* homeassistant/components/proxy/camera.py @@ -846,7 +886,6 @@ omit = homeassistant/components/pushbullet/sensor.py homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py - homeassistant/components/pvoutput/sensor.py homeassistant/components/pyload/sensor.py homeassistant/components/qbittorrent/sensor.py homeassistant/components/qnap/sensor.py @@ -879,6 +918,7 @@ omit = homeassistant/components/rest/switch.py homeassistant/components/ridwell/__init__.py homeassistant/components/ridwell/sensor.py + homeassistant/components/ridwell/switch.py homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py homeassistant/components/rocketchat/notify.py @@ -907,6 +947,7 @@ omit = homeassistant/components/sabnzbd/* homeassistant/components/saj/sensor.py homeassistant/components/samsungtv/bridge.py + homeassistant/components/samsungtv/diagnostics.py homeassistant/components/satel_integra/* homeassistant/components/schluter/* homeassistant/components/scrape/sensor.py @@ -925,6 +966,14 @@ omit = homeassistant/components/sense/sensor.py homeassistant/components/sensehat/light.py homeassistant/components/sensehat/sensor.py + homeassistant/components/senseme/__init__.py + homeassistant/components/senseme/binary_sensor.py + homeassistant/components/senseme/discovery.py + homeassistant/components/senseme/entity.py + homeassistant/components/senseme/fan.py + homeassistant/components/senseme/light.py + homeassistant/components/senseme/switch.py + homeassistant/components/sensibo/__init__.py homeassistant/components/sensibo/climate.py homeassistant/components/serial/sensor.py homeassistant/components/serial_pm/sensor.py @@ -984,10 +1033,12 @@ omit = homeassistant/components/solaredge/sensor.py homeassistant/components/solaredge_local/sensor.py homeassistant/components/solarlog/* + homeassistant/components/solax/__init__.py homeassistant/components/solax/sensor.py homeassistant/components/soma/__init__.py homeassistant/components/soma/cover.py homeassistant/components/soma/sensor.py + homeassistant/components/soma/utils.py homeassistant/components/somfy/__init__.py homeassistant/components/somfy/api.py homeassistant/components/somfy/climate.py @@ -996,7 +1047,17 @@ omit = homeassistant/components/somfy/switch.py homeassistant/components/somfy_mylink/__init__.py homeassistant/components/somfy_mylink/cover.py - homeassistant/components/sonos/* + homeassistant/components/sonos/__init__.py + homeassistant/components/sonos/alarms.py + homeassistant/components/sonos/diagnostics.py + homeassistant/components/sonos/entity.py + homeassistant/components/sonos/favorites.py + homeassistant/components/sonos/helpers.py + homeassistant/components/sonos/household_coordinator.py + homeassistant/components/sonos/media_browser.py + homeassistant/components/sonos/media_player.py + homeassistant/components/sonos/speaker.py + homeassistant/components/sonos/switch.py homeassistant/components/sony_projector/switch.py homeassistant/components/spc/* homeassistant/components/spider/* @@ -1013,6 +1074,7 @@ omit = homeassistant/components/stiebel_eltron/* homeassistant/components/stookalert/__init__.py homeassistant/components/stookalert/binary_sensor.py + homeassistant/components/stookalert/diagnostics.py homeassistant/components/stream/* homeassistant/components/streamlabswater/* homeassistant/components/suez_water/* @@ -1041,8 +1103,12 @@ omit = homeassistant/components/synology_chat/notify.py homeassistant/components/synology_dsm/__init__.py homeassistant/components/synology_dsm/binary_sensor.py + homeassistant/components/synology_dsm/button.py homeassistant/components/synology_dsm/camera.py + homeassistant/components/synology_dsm/diagnostics.py + homeassistant/components/synology_dsm/common.py homeassistant/components/synology_dsm/sensor.py + homeassistant/components/synology_dsm/service.py homeassistant/components/synology_dsm/switch.py homeassistant/components/synology_srm/device_tracker.py homeassistant/components/syslog/notify.py @@ -1053,7 +1119,6 @@ omit = homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/* - homeassistant/components/tahoma/* homeassistant/components/tank_utility/sensor.py homeassistant/components/tankerkoenig/* homeassistant/components/tapsaff/binary_sensor.py @@ -1141,6 +1206,7 @@ omit = homeassistant/components/transmission/errors.py homeassistant/components/travisci/sensor.py homeassistant/components/tuya/__init__.py + homeassistant/components/tuya/alarm_control_panel.py homeassistant/components/tuya/base.py homeassistant/components/tuya/binary_sensor.py homeassistant/components/tuya/button.py @@ -1148,6 +1214,7 @@ omit = homeassistant/components/tuya/climate.py homeassistant/components/tuya/const.py homeassistant/components/tuya/cover.py + homeassistant/components/tuya/diagnostics.py homeassistant/components/tuya/fan.py homeassistant/components/tuya/humidifier.py homeassistant/components/tuya/light.py @@ -1175,7 +1242,11 @@ omit = homeassistant/components/upnp/* homeassistant/components/upc_connect/* homeassistant/components/uscis/sensor.py - homeassistant/components/vallox/* + homeassistant/components/vallox/__init__.py + homeassistant/components/vallox/const.py + homeassistant/components/vallox/fan.py + homeassistant/components/vallox/sensor.py + homeassistant/components/vallox/binary_sensor.py homeassistant/components/vasttrafik/sensor.py homeassistant/components/velbus/__init__.py homeassistant/components/velbus/binary_sensor.py @@ -1195,6 +1266,7 @@ omit = homeassistant/components/verisure/binary_sensor.py homeassistant/components/verisure/camera.py homeassistant/components/verisure/coordinator.py + homeassistant/components/verisure/diagnostics.py homeassistant/components/verisure/lock.py homeassistant/components/verisure/sensor.py homeassistant/components/verisure/switch.py @@ -1204,9 +1276,11 @@ omit = homeassistant/components/vesync/const.py homeassistant/components/vesync/fan.py homeassistant/components/vesync/light.py + homeassistant/components/vesync/sensor.py homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py homeassistant/components/vicare/binary_sensor.py + homeassistant/components/vicare/button.py homeassistant/components/vicare/climate.py homeassistant/components/vicare/const.py homeassistant/components/vicare/__init__.py @@ -1234,8 +1308,6 @@ omit = homeassistant/components/waze_travel_time/__init__.py homeassistant/components/waze_travel_time/helpers.py homeassistant/components/waze_travel_time/sensor.py - homeassistant/components/webostv/* - homeassistant/components/whois/sensor.py homeassistant/components/wiffi/* homeassistant/components/wirelesstag/* homeassistant/components/wolflink/__init__.py @@ -1284,11 +1356,15 @@ omit = homeassistant/components/xs1/* homeassistant/components/yale_smart_alarm/__init__.py homeassistant/components/yale_smart_alarm/alarm_control_panel.py + homeassistant/components/yale_smart_alarm/binary_sensor.py homeassistant/components/yale_smart_alarm/const.py homeassistant/components/yale_smart_alarm/coordinator.py + homeassistant/components/yale_smart_alarm/entity.py + homeassistant/components/yale_smart_alarm/lock.py homeassistant/components/yamaha_musiccast/__init__.py homeassistant/components/yamaha_musiccast/media_player.py homeassistant/components/yamaha_musiccast/number.py + homeassistant/components/yamaha_musiccast/select.py homeassistant/components/yandex_transport/* homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 974022834fbf2a..92de30ffe5a14c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -107,7 +107,7 @@ To help with the load of incoming pull requests: - [ ] I have reviewed two other [open pull requests][prs] in this repository. -[prs]: https://github.com/home-assistant/core/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+-label%3Awaiting-for-upstream+sort%3Acreated-desc+review%3Anone +[prs]: https://github.com/home-assistant/core/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+-label%3Awaiting-for-upstream+sort%3Acreated-desc+review%3Anone+-status%3Afailure "c1 or a3" X10 device. entity (type dictionary) diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index dba25b44d75054..aae640381a2d3a 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -6,7 +6,9 @@ import wakeonlan from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -23,10 +25,10 @@ ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the wake on LAN component.""" - async def send_magic_packet(call): + async def send_magic_packet(call: ServiceCall) -> None: """Send magic packet to wake up a device.""" mac_address = call.data.get(CONF_MAC) broadcast_address = call.data.get(CONF_BROADCAST_ADDRESS) diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index f7e5426c73ffb3..2f019ca6158afe 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -1,6 +1,7 @@ """Support for wake on lan.""" +from __future__ import annotations + import logging -import platform import subprocess as sp import voluptuous as vol @@ -14,9 +15,12 @@ CONF_MAC, CONF_NAME, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -39,7 +43,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up a wake on lan switch.""" broadcast_address = config.get(CONF_BROADCAST_ADDRESS) broadcast_port = config.get(CONF_BROADCAST_PORT) @@ -148,24 +157,14 @@ def turn_off(self, **kwargs): def update(self): """Check if device is on and update the state. Only called if assumed state is false.""" - if platform.system().lower() == "windows": - ping_cmd = [ - "ping", - "-n", - "1", - "-w", - str(DEFAULT_PING_TIMEOUT * 1000), - str(self._host), - ] - else: - ping_cmd = [ - "ping", - "-c", - "1", - "-W", - str(DEFAULT_PING_TIMEOUT), - str(self._host), - ] + ping_cmd = [ + "ping", + "-c", + "1", + "-W", + str(DEFAULT_PING_TIMEOUT), + str(self._host), + ] status = sp.call(ping_cmd, stdout=sp.DEVNULL, stderr=sp.DEVNULL) self._state = not bool(status) diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index c40d3fb37a8403..f1ce91a5bdf6db 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -4,26 +4,40 @@ from datetime import timedelta from http import HTTPStatus import logging -from typing import Any, Dict +from typing import Any import requests from wallbox import Wallbox from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import CONF_DATA_KEY, CONF_MAX_CHARGING_CURRENT_KEY, CONF_STATION, DOMAIN +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from ...helpers.entity import DeviceInfo +from .const import ( + CONF_CURRENT_VERSION_KEY, + CONF_DATA_KEY, + CONF_MAX_CHARGING_CURRENT_KEY, + CONF_NAME_KEY, + CONF_PART_NUMBER_KEY, + CONF_SERIAL_NUMBER_KEY, + CONF_SOFTWARE_KEY, + CONF_STATION, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor", "number"] +PLATFORMS = [Platform.SENSOR, Platform.NUMBER] UPDATE_INTERVAL = 30 -class WallboxCoordinator(DataUpdateCoordinator[Dict[str, Any]]): +class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Wallbox Coordinator class.""" def __init__(self, station: str, wallbox: Wallbox, hass: HomeAssistant) -> None: @@ -132,3 +146,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class WallboxEntity(CoordinatorEntity): + """Defines a base Wallbox entity.""" + + coordinator: WallboxCoordinator + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Wallbox device.""" + return DeviceInfo( + identifiers={ + (DOMAIN, self.coordinator.data[CONF_DATA_KEY][CONF_SERIAL_NUMBER_KEY]) + }, + name=f"Wallbox - {self.coordinator.data[CONF_NAME_KEY]}", + manufacturer="Wallbox", + model=self.coordinator.data[CONF_DATA_KEY][CONF_PART_NUMBER_KEY], + sw_version=self.coordinator.data[CONF_DATA_KEY][CONF_SOFTWARE_KEY][ + CONF_CURRENT_VERSION_KEY + ], + ) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index e753d548987b04..263df7b4924792 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -10,10 +10,15 @@ CONF_CHARGING_TIME_KEY = "charging_time" CONF_COST_KEY = "cost" CONF_CURRENT_MODE_KEY = "current_mode" +CONF_CURRENT_VERSION_KEY = "currentVersion" CONF_DATA_KEY = "config_data" CONF_DEPOT_PRICE_KEY = "depot_price" +CONF_SERIAL_NUMBER_KEY = "serial_number" +CONF_PART_NUMBER_KEY = "part_number" +CONF_SOFTWARE_KEY = "software" CONF_MAX_AVAILABLE_POWER_KEY = "max_available_power" CONF_MAX_CHARGING_CURRENT_KEY = "max_charging_current" +CONF_NAME_KEY = "name" CONF_STATE_OF_CHARGE_KEY = "state_of_charge" CONF_STATUS_DESCRIPTION_KEY = "status_description" CONF_CONNECTIONS = "connections" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 64c1f1e1abb491..2bea3b1ef70a0d 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -6,27 +6,28 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_CLASS_CURRENT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import InvalidAuth, WallboxCoordinator -from .const import CONF_MAX_AVAILABLE_POWER_KEY, CONF_MAX_CHARGING_CURRENT_KEY, DOMAIN +from . import InvalidAuth, WallboxCoordinator, WallboxEntity +from .const import ( + CONF_DATA_KEY, + CONF_MAX_AVAILABLE_POWER_KEY, + CONF_MAX_CHARGING_CURRENT_KEY, + CONF_SERIAL_NUMBER_KEY, + DOMAIN, +) @dataclass class WallboxNumberEntityDescription(NumberEntityDescription): """Describes Wallbox sensor entity.""" - min_value: float = 0 - NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CONF_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( key=CONF_MAX_CHARGING_CURRENT_KEY, name="Max. Charging Current", - device_class=DEVICE_CLASS_CURRENT, min_value=6, ), } @@ -54,7 +55,7 @@ async def async_setup_entry( ) -class WallboxNumber(CoordinatorEntity, NumberEntity): +class WallboxNumber(WallboxEntity, NumberEntity): """Representation of the Wallbox portal.""" entity_description: WallboxNumberEntityDescription @@ -71,7 +72,7 @@ def __init__( self.entity_description = description self._coordinator = coordinator self._attr_name = f"{entry.title} {description.name}" - self._attr_min_value = description.min_value + self._attr_unique_id = f"{description.key}-{coordinator.data[CONF_DATA_KEY][CONF_SERIAL_NUMBER_KEY]}" @property def max_value(self) -> float: diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 835a8c405dae42..d19ea7347ca548 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -6,18 +6,13 @@ from typing import cast from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) -from homeassistant.components.wallbox import WallboxCoordinator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, @@ -27,8 +22,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import WallboxCoordinator, WallboxEntity from .const import ( CONF_ADDED_ENERGY_KEY, CONF_ADDED_RANGE_KEY, @@ -36,9 +31,11 @@ CONF_CHARGING_SPEED_KEY, CONF_COST_KEY, CONF_CURRENT_MODE_KEY, + CONF_DATA_KEY, CONF_DEPOT_PRICE_KEY, CONF_MAX_AVAILABLE_POWER_KEY, CONF_MAX_CHARGING_CURRENT_KEY, + CONF_SERIAL_NUMBER_KEY, CONF_STATE_OF_CHARGE_KEY, CONF_STATUS_DESCRIPTION_KEY, DOMAIN, @@ -63,23 +60,23 @@ class WallboxSensorEntityDescription(SensorEntityDescription): name="Charging Power", precision=2, native_unit_of_measurement=POWER_KILO_WATT, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), CONF_MAX_AVAILABLE_POWER_KEY: WallboxSensorEntityDescription( key=CONF_MAX_AVAILABLE_POWER_KEY, name="Max Available Power", precision=0, native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, ), CONF_CHARGING_SPEED_KEY: WallboxSensorEntityDescription( key=CONF_CHARGING_SPEED_KEY, icon="mdi:speedometer", name="Charging Speed", precision=0, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), CONF_ADDED_RANGE_KEY: WallboxSensorEntityDescription( key=CONF_ADDED_RANGE_KEY, @@ -87,28 +84,28 @@ class WallboxSensorEntityDescription(SensorEntityDescription): name="Added Range", precision=0, native_unit_of_measurement=LENGTH_KILOMETERS, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, ), CONF_ADDED_ENERGY_KEY: WallboxSensorEntityDescription( key=CONF_ADDED_ENERGY_KEY, name="Added Energy", precision=2, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), CONF_COST_KEY: WallboxSensorEntityDescription( key=CONF_COST_KEY, icon="mdi:ev-station", name="Cost", - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, ), CONF_STATE_OF_CHARGE_KEY: WallboxSensorEntityDescription( key=CONF_STATE_OF_CHARGE_KEY, name="State of Charge", native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_BATTERY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, ), CONF_CURRENT_MODE_KEY: WallboxSensorEntityDescription( key=CONF_CURRENT_MODE_KEY, @@ -130,8 +127,8 @@ class WallboxSensorEntityDescription(SensorEntityDescription): key=CONF_MAX_CHARGING_CURRENT_KEY, name="Max. Charging Current", native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, ), } @@ -151,7 +148,7 @@ async def async_setup_entry( ) -class WallboxSensor(CoordinatorEntity, SensorEntity): +class WallboxSensor(WallboxEntity, SensorEntity): """Representation of the Wallbox portal.""" entity_description: WallboxSensorEntityDescription @@ -167,6 +164,7 @@ def __init__( super().__init__(coordinator) self.entity_description = description self._attr_name = f"{entry.title} {description.name}" + self._attr_unique_id = f"{description.key}-{coordinator.data[CONF_DATA_KEY][CONF_SERIAL_NUMBER_KEY]}" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/wallbox/translations/el.json b/homeassistant/components/wallbox/translations/el.json new file mode 100644 index 00000000000000..da02cbb297fb40 --- /dev/null +++ b/homeassistant/components/wallbox/translations/el.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "reauth_invalid": "\u039f \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03b1\u03c0\u03ad\u03c4\u03c5\u03c7\u03b5. \u039f \u03c3\u03b5\u03b9\u03c1\u03b9\u03b1\u03ba\u03cc\u03c2 \u03b1\u03c1\u03b9\u03b8\u03bc\u03cc\u03c2 \u03b4\u03b5\u03bd \u03c4\u03b1\u03b9\u03c1\u03b9\u03ac\u03b6\u03b5\u03b9 \u03bc\u03b5 \u03c4\u03bf\u03bd \u03b1\u03c1\u03c7\u03b9\u03ba\u03cc" + }, + "step": { + "user": { + "data": { + "station": "\u03a3\u03b5\u03b9\u03c1\u03b9\u03b1\u03ba\u03cc\u03c2 \u03b1\u03c1\u03b9\u03b8\u03bc\u03cc\u03c2 \u03c3\u03c4\u03b1\u03b8\u03bc\u03bf\u03cd" + } + } + } + }, + "title": "Wallbox" +} \ No newline at end of file diff --git a/homeassistant/components/wallbox/translations/es.json b/homeassistant/components/wallbox/translations/es.json index 3adfd671804c67..72c7b0587d6ca1 100644 --- a/homeassistant/components/wallbox/translations/es.json +++ b/homeassistant/components/wallbox/translations/es.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "No se pudo conectar", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "reauth_invalid": "Fallo en la reautenticaci\u00f3n; el n\u00famero de serie no coincide con el original", "unknown": "Error inesperado" }, "step": { diff --git a/homeassistant/components/wallbox/translations/fr.json b/homeassistant/components/wallbox/translations/fr.json index 4b2eddb6ef9a51..dc7972fd01d0ad 100644 --- a/homeassistant/components/wallbox/translations/fr.json +++ b/homeassistant/components/wallbox/translations/fr.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide", + "reauth_invalid": "\u00c9chec de la r\u00e9authentification\u00a0; Le num\u00e9ro de s\u00e9rie ne correspond pas \u00e0 l'original", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/wallbox/translations/it.json b/homeassistant/components/wallbox/translations/it.json index 112a0c139702c3..ce4bdef9d95690 100644 --- a/homeassistant/components/wallbox/translations/it.json +++ b/homeassistant/components/wallbox/translations/it.json @@ -7,7 +7,7 @@ "error": { "cannot_connect": "Impossibile connettersi", "invalid_auth": "Autenticazione non valida", - "reauth_invalid": "Riautenticazione non riuscita; Il numero di serie non corrisponde all'originale", + "reauth_invalid": "Nuova autenticazione non riuscita; Il numero di serie non corrisponde all'originale", "unknown": "Errore imprevisto" }, "step": { diff --git a/homeassistant/components/wallbox/translations/lt.json b/homeassistant/components/wallbox/translations/lt.json new file mode 100644 index 00000000000000..1cf4189b1ba368 --- /dev/null +++ b/homeassistant/components/wallbox/translations/lt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "Slapta\u017eodis", + "username": "Prisijungimo vardas" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 98ee8db4e89aca..8a3b9f046ce303 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -1,4 +1,6 @@ """Support for the World Air Quality Index service.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -7,18 +9,24 @@ import voluptuous as vol from waqiasync import WaqiClient -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_TEMPERATURE, ATTR_TIME, CONF_TOKEN, - DEVICE_CLASS_AQI, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -63,12 +71,17 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the requested World Air Quality Index locations.""" - token = config.get(CONF_TOKEN) + token = config[CONF_TOKEN] station_filter = config.get(CONF_STATIONS) - locations = config.get(CONF_LOCATIONS) + locations = config[CONF_LOCATIONS] client = WaqiClient(token, async_get_clientsession(hass), timeout=TIMEOUT) dev = [] @@ -102,8 +115,8 @@ class WaqiSensor(SensorEntity): _attr_icon = ATTR_ICON _attr_native_unit_of_measurement = ATTR_UNIT - _attr_device_class = DEVICE_CLASS_AQI - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_device_class = SensorDeviceClass.AQI + _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, client, station): """Initialize the sensor.""" diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 85d6a791d7ffab..57358f3d601545 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -31,6 +31,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp +from homeassistant.helpers.typing import ConfigType from homeassistant.util.temperature import convert as convert_temperature # mypy: allow-untyped-defs, no-check-untyped-defs @@ -96,7 +97,7 @@ ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up water_heater devices.""" component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL @@ -350,15 +351,3 @@ async def async_service_temperature_set(entity, service): kwargs[value] = temp await entity.async_set_temperature(**kwargs) - - -class WaterHeaterDevice(WaterHeaterEntity): - """Representation of a water heater (for backwards compatibility).""" - - def __init_subclass__(cls, **kwargs): - """Print deprecation warning.""" - super().__init_subclass__(**kwargs) - _LOGGER.warning( - "WaterHeaterDevice is deprecated, modify %s to extend WaterHeaterEntity", - cls.__name__, - ) diff --git a/homeassistant/components/water_heater/reproduce_state.py b/homeassistant/components/water_heater/reproduce_state.py index 5fbe3f935f8d42..b3be0f62273106 100644 --- a/homeassistant/components/water_heater/reproduce_state.py +++ b/homeassistant/components/water_heater/reproduce_state.py @@ -8,6 +8,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -18,7 +19,6 @@ from . import ( ATTR_AWAY_MODE, ATTR_OPERATION_MODE, - ATTR_TEMPERATURE, DOMAIN, SERVICE_SET_AWAY_MODE, SERVICE_SET_OPERATION_MODE, diff --git a/homeassistant/components/water_heater/translations/el.json b/homeassistant/components/water_heater/translations/el.json index 827a171a7acf2f..69f757b346f4ee 100644 --- a/homeassistant/components/water_heater/translations/el.json +++ b/homeassistant/components/water_heater/translations/el.json @@ -1,4 +1,10 @@ { + "device_automation": { + "action_type": { + "turn_off": "\u0391\u03c0\u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 {entity_name}", + "turn_on": "\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 {entity_name}" + } + }, "state": { "_": { "eco": "\u039f\u03b9\u03ba\u03bf\u03bb\u03bf\u03b3\u03b9\u03ba\u03cc", diff --git a/homeassistant/components/waterfurnace/__init__.py b/homeassistant/components/waterfurnace/__init__.py index 9ae5836f69dd29..1da170f2b75a15 100644 --- a/homeassistant/components/waterfurnace/__init__.py +++ b/homeassistant/components/waterfurnace/__init__.py @@ -7,9 +7,16 @@ import voluptuous as vol from waterfurnace.waterfurnace import WaterFurnace, WFCredentialError, WFException -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback +from homeassistant.components import persistent_notification +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -35,13 +42,13 @@ ) -def setup(hass, base_config): +def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: """Set up waterfurnace platform.""" - config = base_config.get(DOMAIN) + config = base_config[DOMAIN] - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] wfconn = WaterFurnace(username, password) # NOTE(sdague): login will throw an exception if this doesn't @@ -55,7 +62,7 @@ def setup(hass, base_config): hass.data[DOMAIN] = WaterFurnaceData(hass, wfconn) hass.data[DOMAIN].start() - discovery.load_platform(hass, "sensor", DOMAIN, {}, config) + discovery.load_platform(hass, Platform.SENSOR, DOMAIN, {}, config) return True @@ -86,7 +93,8 @@ def _reconnect(self): self._fails += 1 if self._fails > MAX_FAILS: _LOGGER.error("Failed to refresh login credentials. Thread stopped") - self.hass.components.persistent_notification.create( + persistent_notification.create( + self.hass, "Error:
Connection to waterfurnace website failed " "the maximum number of times. Thread has stopped", title=NOTIFICATION_TITLE, diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 5d7832ca58d746..15f3f64c9ba9bd 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -1,13 +1,15 @@ """Support for Waterfurnace.""" +from __future__ import annotations -from homeassistant.components.sensor import ENTITY_ID_FORMAT, SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, - POWER_WATT, - TEMP_FAHRENHEIT, +from homeassistant.components.sensor import ( + ENTITY_ID_FORMAT, + SensorDeviceClass, + SensorEntity, ) -from homeassistant.core import callback +from homeassistant.const import PERCENTAGE, POWER_WATT, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify from . import DOMAIN as WF_DOMAIN, UPDATE_TOPIC @@ -40,13 +42,21 @@ def __init__( "tstatactivesetpoint", None, TEMP_FAHRENHEIT, - DEVICE_CLASS_TEMPERATURE, + SensorDeviceClass.TEMPERATURE, ), WFSensorConfig( - "Leaving Air", "leavingairtemp", None, TEMP_FAHRENHEIT, DEVICE_CLASS_TEMPERATURE + "Leaving Air", + "leavingairtemp", + None, + TEMP_FAHRENHEIT, + SensorDeviceClass.TEMPERATURE, ), WFSensorConfig( - "Room Temp", "tstatroomtemp", None, TEMP_FAHRENHEIT, DEVICE_CLASS_TEMPERATURE + "Room Temp", + "tstatroomtemp", + None, + TEMP_FAHRENHEIT, + SensorDeviceClass.TEMPERATURE, ), WFSensorConfig("Loop Temp", "enteringwatertemp", None, TEMP_FAHRENHEIT), WFSensorConfig( @@ -64,7 +74,12 @@ def __init__( ] -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Waterfurnace sensor.""" if discovery_info is None: return diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py index cd3599683d0ee6..6271e3b9b82cd1 100644 --- a/homeassistant/components/watson_iot/__init__.py +++ b/homeassistant/components/watson_iot/__init__.py @@ -21,9 +21,10 @@ STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import state as state_helper import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -68,7 +69,7 @@ ) -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Watson IoT Platform component.""" conf = config[DOMAIN] diff --git a/homeassistant/components/watttime/__init__.py b/homeassistant/components/watttime/__init__.py index d33e6fceb2e01d..2f07658b923f58 100644 --- a/homeassistant/components/watttime/__init__.py +++ b/homeassistant/components/watttime/__init__.py @@ -13,6 +13,7 @@ CONF_LONGITUDE, CONF_PASSWORD, CONF_USERNAME, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -23,7 +24,7 @@ DEFAULT_UPDATE_INTERVAL = timedelta(minutes=5) -PLATFORMS: list[str] = ["sensor"] +PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/watttime/diagnostics.py b/homeassistant/components/watttime/diagnostics.py new file mode 100644 index 00000000000000..080c7c37b07bb1 --- /dev/null +++ b/homeassistant/components/watttime/diagnostics.py @@ -0,0 +1,42 @@ +"""Diagnostics support for WattTime.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +TO_REDACT = { + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + return async_redact_data( + { + "entry": { + "data": dict(entry.data), + "options": dict(entry.options), + }, + "data": coordinator.data, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index 0b1ae54b5d150d..41842c6ed709a6 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -5,9 +5,9 @@ from typing import Any, cast from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, MASS_POUNDS, PERCENTAGE @@ -38,14 +38,14 @@ name="Marginal Operating Emissions Rate", icon="mdi:blur", native_unit_of_measurement=f"{MASS_POUNDS} CO2/MWh", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT, name="Relative Marginal Emissions Intensity", icon="mdi:blur", native_unit_of_measurement=PERCENTAGE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/watttime/translations/es.json b/homeassistant/components/watttime/translations/es.json index 922aed60d97c5c..189ea8b70cbed7 100644 --- a/homeassistant/components/watttime/translations/es.json +++ b/homeassistant/components/watttime/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositivo ya se ha configurado" + "already_configured": "El dispositivo ya se ha configurado", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", @@ -22,6 +23,13 @@ }, "description": "Escoja una ubicaci\u00f3n para monitorizar:" }, + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a" + }, + "description": "Vuelva a ingresar la contrase\u00f1a de {username} :", + "title": "Volver a autenticar la integraci\u00f3n" + }, "user": { "data": { "password": "Contrase\u00f1a", @@ -30,5 +38,15 @@ "description": "Introduzca su nombre de usuario y contrase\u00f1a:" } } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Mostrar la ubicaci\u00f3n en el mapa" + }, + "title": "Configurar WattTime" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/fr.json b/homeassistant/components/watttime/translations/fr.json index fb916a3f333b7c..91a6c1268172b4 100644 --- a/homeassistant/components/watttime/translations/fr.json +++ b/homeassistant/components/watttime/translations/fr.json @@ -6,24 +6,46 @@ }, "error": { "invalid_auth": "Authentification invalide", - "unknown": "Erreur inattendue" + "unknown": "Erreur inattendue", + "unknown_coordinates": "Aucune donn\u00e9e pour la latitude/longitude" }, "step": { "coordinates": { "data": { + "latitude": "Latitude", "longitude": "Longitude" - } + }, + "description": "Saisissez la latitude et la longitude \u00e0 surveiller\u00a0:" }, "location": { "data": { "location_type": "Emplacement" - } + }, + "description": "Choisissez un emplacement \u00e0 surveiller\u00a0:" }, "reauth_confirm": { "data": { "password": "Mot de passe" }, + "description": "Veuillez saisir \u00e0 nouveau le mot de passe pour {username}\u00a0:", "title": "R\u00e9-authentifier l'int\u00e9gration" + }, + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Saisissez votre nom d'utilisateur et votre mot de passe\u00a0:" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Afficher l'emplacement surveill\u00e9 sur la carte" + }, + "title": "Configurer WattTime" } } } diff --git a/homeassistant/components/watttime/translations/it.json b/homeassistant/components/watttime/translations/it.json index ecca75e5b5d6ed..c5142df73b86ab 100644 --- a/homeassistant/components/watttime/translations/it.json +++ b/homeassistant/components/watttime/translations/it.json @@ -15,7 +15,7 @@ "latitude": "Latitudine", "longitude": "Logitudine" }, - "description": "Immettere la latitudine e la longitudine da monitorare:" + "description": "Immetti la latitudine e la longitudine da monitorare:" }, "location": { "data": { @@ -27,8 +27,8 @@ "data": { "password": "Password" }, - "description": "Si prega di reinserire la password per {username}:", - "title": "Autenticare nuovamente l'integrazione" + "description": "Digita nuovamente la password per {username}:", + "title": "Autentica nuovamente l'integrazione" }, "user": { "data": { diff --git a/homeassistant/components/watttime/translations/tr.json b/homeassistant/components/watttime/translations/tr.json index a9531c6deaff7e..c0b5b1dfdb916c 100644 --- a/homeassistant/components/watttime/translations/tr.json +++ b/homeassistant/components/watttime/translations/tr.json @@ -32,8 +32,8 @@ }, "user": { "data": { - "password": "\u015eifre", - "username": "Kullan\u0131c\u0131 ad\u0131" + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" }, "description": "Kullan\u0131c\u0131 ad\u0131n\u0131z\u0131 ve \u015fifrenizi girin:" } diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index fa605d19c49246..3b193d6c06bb00 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -1,12 +1,13 @@ """The waze_travel_time component.""" from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import ( async_entries_for_config_entry, async_get, ) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index 54097ad37bd9a9..45aeada2a7a5da 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -1,14 +1,11 @@ """Config flow for Waze Travel Time integration.""" from __future__ import annotations -import logging -from typing import Any - import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_NAME, CONF_REGION -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from .const import ( @@ -22,12 +19,7 @@ CONF_REALTIME, CONF_UNITS, CONF_VEHICLE_TYPE, - DEFAULT_AVOID_FERRIES, - DEFAULT_AVOID_SUBSCRIPTION_ROADS, - DEFAULT_AVOID_TOLL_ROADS, DEFAULT_NAME, - DEFAULT_REALTIME, - DEFAULT_VEHICLE_TYPE, DOMAIN, REGIONS, UNITS, @@ -35,52 +27,6 @@ ) from .helpers import is_valid_config_entry -_LOGGER = logging.getLogger(__name__) - - -def is_dupe_import( - hass: HomeAssistant, entry: config_entries.ConfigEntry, user_input: dict[str, Any] -) -> bool: - """Return whether imported config already exists.""" - entry_data = {**entry.data, **entry.options} - defaults = { - CONF_REALTIME: DEFAULT_REALTIME, - CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, - CONF_UNITS: hass.config.units.name, - CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, - CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, - CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, - } - - for key in ( - CONF_ORIGIN, - CONF_DESTINATION, - CONF_REGION, - CONF_INCL_FILTER, - CONF_EXCL_FILTER, - CONF_REALTIME, - CONF_VEHICLE_TYPE, - CONF_UNITS, - CONF_AVOID_FERRIES, - CONF_AVOID_SUBSCRIPTION_ROADS, - CONF_AVOID_TOLL_ROADS, - ): - # If the key is present the check is simple - if key in user_input and user_input[key] != entry_data[key]: - return False - - # If the key is not present, then we have to check if the key has a default and - # if the default is in the options. If it doesn't have a default, we have to check - # if the key is in the options - if key not in user_input: - if key in defaults and defaults[key] != entry_data[key]: - return False - - if key not in defaults and key in entry_data: - return False - - return True - class WazeOptionsFlow(config_entries.OptionsFlow): """Handle an options flow for Waze Travel Time.""" @@ -159,24 +105,12 @@ async def async_step_user(self, user_input=None): user_input = user_input or {} if user_input: - # We need to prevent duplicate imports - if self.source == config_entries.SOURCE_IMPORT and any( - is_dupe_import(self.hass, entry, user_input) - for entry in self.hass.config_entries.async_entries(DOMAIN) - if entry.source == config_entries.SOURCE_IMPORT - ): - return self.async_abort(reason="already_configured") - - if ( - self.source == config_entries.SOURCE_IMPORT - or await self.hass.async_add_executor_job( - is_valid_config_entry, - self.hass, - _LOGGER, - user_input[CONF_ORIGIN], - user_input[CONF_DESTINATION], - user_input[CONF_REGION], - ) + if await self.hass.async_add_executor_job( + is_valid_config_entry, + self.hass, + user_input[CONF_ORIGIN], + user_input[CONF_DESTINATION], + user_input[CONF_REGION], ): return self.async_create_entry( title=user_input.get(CONF_NAME, DEFAULT_NAME), diff --git a/homeassistant/components/waze_travel_time/helpers.py b/homeassistant/components/waze_travel_time/helpers.py index 26e529b8e9378d..67d8b5674b282f 100644 --- a/homeassistant/components/waze_travel_time/helpers.py +++ b/homeassistant/components/waze_travel_time/helpers.py @@ -1,70 +1,15 @@ """Helpers for Waze Travel Time integration.""" -import re - from WazeRouteCalculator import WazeRouteCalculator, WRCError -from homeassistant.components.waze_travel_time.const import ENTITY_ID_PATTERN -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE -from homeassistant.helpers import location +from homeassistant.helpers.location import find_coordinates -def is_valid_config_entry(hass, logger, origin, destination, region): +def is_valid_config_entry(hass, origin, destination, region): """Return whether the config entry data is valid.""" - origin = resolve_location(hass, logger, origin) - destination = resolve_location(hass, logger, destination) + origin = find_coordinates(hass, origin) + destination = find_coordinates(hass, destination) try: WazeRouteCalculator(origin, destination, region).calc_all_routes_info() except WRCError: return False return True - - -def resolve_location(hass, logger, loc): - """Resolve a location.""" - if re.fullmatch(ENTITY_ID_PATTERN, loc): - return get_location_from_entity(hass, logger, loc) - - return resolve_zone(hass, loc) - - -def get_location_from_entity(hass, logger, entity_id): - """Get the location from the entity_id.""" - if (state := hass.states.get(entity_id)) is None: - logger.error("Unable to find entity %s", entity_id) - return None - - # Check if the entity has location attributes. - if location.has_location(state): - logger.debug("Getting %s location", entity_id) - return _get_location_from_attributes(state) - - # Check if device is inside a zone. - zone_state = hass.states.get(f"zone.{state.state}") - if location.has_location(zone_state): - logger.debug( - "%s is in %s, getting zone location", entity_id, zone_state.entity_id - ) - return _get_location_from_attributes(zone_state) - - # If zone was not found in state then use the state as the location. - if entity_id.startswith("sensor."): - return state.state - - # When everything fails just return nothing. - return None - - -def resolve_zone(hass, friendly_name): - """Get a lat/long from a zones friendly_name.""" - states = hass.states.all() - for state in states: - if state.domain == "zone" and state.name == friendly_name: - return _get_location_from_attributes(state) - - return friendly_name - - -def _get_location_from_attributes(state): - """Get the lat/long string from an states attributes.""" - attr = state.attributes - return f"{attr.get(ATTR_LATITUDE)},{attr.get(ATTR_LONGITUDE)}" diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 9df95daf9ee4d1..c983be49765bd7 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -6,26 +6,22 @@ import re from WazeRouteCalculator import WazeRouteCalculator, WRCError -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, - CONF_ENTITY_NAMESPACE, CONF_NAME, CONF_REGION, - CONF_SCAN_INTERVAL, CONF_UNIT_SYSTEM_IMPERIAL, EVENT_HOMEASSISTANT_STARTED, TIME_MINUTES, ) -from homeassistant.core import Config, CoreState, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.location import find_coordinates from .const import ( CONF_AVOID_FERRIES, @@ -46,66 +42,12 @@ DEFAULT_VEHICLE_TYPE, DOMAIN, ENTITY_ID_PATTERN, - REGIONS, - UNITS, - VEHICLE_TYPES, ) -from .helpers import get_location_from_entity, resolve_zone _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=5) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_ORIGIN): cv.string, - vol.Required(CONF_DESTINATION): cv.string, - vol.Required(CONF_REGION): vol.In(REGIONS), - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_INCL_FILTER): cv.string, - vol.Optional(CONF_EXCL_FILTER): cv.string, - vol.Optional(CONF_REALTIME, default=DEFAULT_REALTIME): cv.boolean, - vol.Optional(CONF_VEHICLE_TYPE, default=DEFAULT_VEHICLE_TYPE): vol.In( - VEHICLE_TYPES - ), - vol.Optional(CONF_UNITS): vol.In(UNITS), - vol.Optional( - CONF_AVOID_TOLL_ROADS, default=DEFAULT_AVOID_TOLL_ROADS - ): cv.boolean, - vol.Optional( - CONF_AVOID_SUBSCRIPTION_ROADS, default=DEFAULT_AVOID_SUBSCRIPTION_ROADS - ): cv.boolean, - vol.Optional(CONF_AVOID_FERRIES, default=DEFAULT_AVOID_FERRIES): cv.boolean, - # Remove options to exclude from import - vol.Remove(CONF_ENTITY_NAMESPACE): cv.string, - vol.Remove(CONF_SCAN_INTERVAL): cv.time_period, - }, - extra=vol.REMOVE_EXTRA, -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: Config, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Waze travel time sensor platform.""" - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - _LOGGER.warning( - "Your Waze configuration has been imported into the UI; " - "please remove it from configuration.yaml as support for it " - "will be removed in a future release" - ) - async def async_setup_entry( hass: HomeAssistant, @@ -237,24 +179,14 @@ def update(self): _LOGGER.debug("Fetching Route for %s", self._attr_name) # Get origin latitude and longitude from entity_id. if self._origin_entity_id is not None: - self._waze_data.origin = get_location_from_entity( - self.hass, _LOGGER, self._origin_entity_id - ) + self._waze_data.origin = find_coordinates(self.hass, self._origin_entity_id) # Get destination latitude and longitude from entity_id. if self._destination_entity_id is not None: - self._waze_data.destination = get_location_from_entity( - self.hass, _LOGGER, self._destination_entity_id + self._waze_data.destination = find_coordinates( + self.hass, self._destination_entity_id ) - # Get origin from zone name. - self._waze_data.origin = resolve_zone(self.hass, self._waze_data.origin) - - # Get destination from zone name. - self._waze_data.destination = resolve_zone( - self.hass, self._waze_data.destination - ) - self._waze_data.update() diff --git a/homeassistant/components/waze_travel_time/translations/el.json b/homeassistant/components/waze_travel_time/translations/el.json new file mode 100644 index 00000000000000..2dbb86c6dd4c6b --- /dev/null +++ b/homeassistant/components/waze_travel_time/translations/el.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "destination": "\u03a0\u03c1\u03bf\u03bf\u03c1\u03b9\u03c3\u03bc\u03cc\u03c2" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "units": "\u039c\u03bf\u03bd\u03ac\u03b4\u03b5\u03c2", + "vehicle_type": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03bf\u03c7\u03ae\u03bc\u03b1\u03c4\u03bf\u03c2" + }, + "description": "\u039f\u03b9 \u03b5\u03af\u03c3\u03bf\u03b4\u03bf\u03b9 `substring` \u03b8\u03b1 \u03c3\u03b1\u03c2 \u03b5\u03c0\u03b9\u03c4\u03c1\u03ad\u03c8\u03bf\u03c5\u03bd \u03bd\u03b1 \u03b1\u03bd\u03b1\u03b3\u03ba\u03ac\u03c3\u03b5\u03c4\u03b5 \u03c4\u03b7\u03bd \u03bf\u03bb\u03bf\u03ba\u03bb\u03ae\u03c1\u03c9\u03c3\u03b7 \u03bd\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03b5\u03b9 \u03bc\u03b9\u03b1 \u03c3\u03c5\u03b3\u03ba\u03b5\u03ba\u03c1\u03b9\u03bc\u03ad\u03bd\u03b7 \u03b4\u03b9\u03b1\u03b4\u03c1\u03bf\u03bc\u03ae \u03ae \u03bd\u03b1 \u03b1\u03c0\u03bf\u03c6\u03cd\u03b3\u03b5\u03b9 \u03bc\u03b9\u03b1 \u03c3\u03c5\u03b3\u03ba\u03b5\u03ba\u03c1\u03b9\u03bc\u03ad\u03bd\u03b7 \u03b4\u03b9\u03b1\u03b4\u03c1\u03bf\u03bc\u03ae \u03c3\u03c4\u03bf\u03bd \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03bc\u03cc \u03c4\u03bf\u03c5 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03c4\u03b1\u03be\u03b9\u03b4\u03b9\u03bf\u03cd." + } + } + }, + "title": "\u03a7\u03c1\u03cc\u03bd\u03bf\u03c2 \u03c4\u03b1\u03be\u03b9\u03b4\u03b9\u03bf\u03cd Waze" +} \ No newline at end of file diff --git a/homeassistant/components/waze_travel_time/translations/it.json b/homeassistant/components/waze_travel_time/translations/it.json index bfbe94c2a237a2..95ff6a1d30b8eb 100644 --- a/homeassistant/components/waze_travel_time/translations/it.json +++ b/homeassistant/components/waze_travel_time/translations/it.json @@ -22,8 +22,8 @@ "step": { "init": { "data": { - "avoid_ferries": "Evitare i traghetti?", - "avoid_subscription_roads": "Evitare le strade che richiedono una vignetta/abbonamento?", + "avoid_ferries": "Vuoi evitare i traghetti?", + "avoid_subscription_roads": "Vuoi evitare le strade che richiedono una vignetta/abbonamento?", "avoid_toll_roads": "Evitare le strade a pedaggio?", "excl_filter": "Sottostringa NON nella descrizione del percorso selezionato", "incl_filter": "Sottostringa nella descrizione del percorso selezionato", diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index b9fa7e2ae39141..15250059fecc33 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp +from homeassistant.helpers.typing import ConfigType # mypy: allow-untyped-defs, no-check-untyped-defs @@ -78,7 +79,7 @@ class Forecast(TypedDict, total=False): wind_speed: float | None -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the weather component.""" component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL @@ -262,29 +263,31 @@ def state_attributes(self): self.temperature_unit, self.precision, ) - if ATTR_FORECAST_PRESSURE in forecast_entry: + if ( + native_pressure := forecast_entry.get(ATTR_FORECAST_PRESSURE) + ) is not None: if (unit := self.pressure_unit) is not None: pressure = round( - self.hass.config.units.pressure( - forecast_entry[ATTR_FORECAST_PRESSURE], unit - ), + self.hass.config.units.pressure(native_pressure, unit), ROUNDING_PRECISION, ) forecast_entry[ATTR_FORECAST_PRESSURE] = pressure - if ATTR_FORECAST_WIND_SPEED in forecast_entry: + if ( + native_wind_speed := forecast_entry.get(ATTR_FORECAST_WIND_SPEED) + ) is not None: if (unit := self.wind_speed_unit) is not None: wind_speed = round( - self.hass.config.units.wind_speed( - forecast_entry[ATTR_FORECAST_WIND_SPEED], unit - ), + self.hass.config.units.wind_speed(native_wind_speed, unit), ROUNDING_PRECISION, ) forecast_entry[ATTR_FORECAST_WIND_SPEED] = wind_speed - if ATTR_FORECAST_PRECIPITATION in forecast_entry: + if ( + native_precip := forecast_entry.get(ATTR_FORECAST_PRECIPITATION) + ) is not None: if (unit := self.precipitation_unit) is not None: precipitation = round( self.hass.config.units.accumulated_precipitation( - forecast_entry[ATTR_FORECAST_PRECIPITATION], unit + native_precip, unit ), ROUNDING_PRECISION, ) diff --git a/homeassistant/components/weather/translations/pt.json b/homeassistant/components/weather/translations/pt.json index b0cf7848faa0d7..5875b8a7192255 100644 --- a/homeassistant/components/weather/translations/pt.json +++ b/homeassistant/components/weather/translations/pt.json @@ -3,7 +3,7 @@ "_": { "clear-night": "Limpo, Noite", "cloudy": "Nublado", - "exceptional": "Excepcional", + "exceptional": "Excecional", "fog": "Nevoeiro", "hail": "Granizo", "lightning": "Rel\u00e2mpago", diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 983ead616f2539..46fdc89871f26d 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus +from ipaddress import ip_address import logging import secrets @@ -13,8 +14,10 @@ from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.network import get_url +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util.aiohttp import MockRequest +from homeassistant.util import network +from homeassistant.util.aiohttp import MockRequest, serialize_response _LOGGER = logging.getLogger(__name__) @@ -22,12 +25,6 @@ URL_WEBHOOK_PATH = "/api/webhook/{webhook_id}" -WS_TYPE_LIST = "webhook/list" - -SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_LIST} -) - @callback @bind_hass @@ -37,6 +34,8 @@ def async_register( name: str, webhook_id: str, handler: Callable[[HomeAssistant, str, Request], Awaitable[Response | None]], + *, + local_only=False, ) -> None: """Register a webhook.""" handlers = hass.data.setdefault(DOMAIN, {}) @@ -44,7 +43,12 @@ def async_register( if webhook_id in handlers: raise ValueError("Handler is already defined!") - handlers[webhook_id] = {"domain": domain, "name": name, "handler": handler} + handlers[webhook_id] = { + "domain": domain, + "name": name, + "handler": handler, + "local_only": local_only, + } @callback @@ -78,7 +82,9 @@ def async_generate_path(webhook_id: str) -> str: @bind_hass -async def async_handle_webhook(hass, webhook_id, request): +async def async_handle_webhook( + hass: HomeAssistant, webhook_id: str, request: Request +) -> Response: """Handle a webhook.""" handlers = hass.data.setdefault(DOMAIN, {}) @@ -100,6 +106,17 @@ async def async_handle_webhook(hass, webhook_id, request): _LOGGER.debug("%s", content) return Response(status=HTTPStatus.OK) + if webhook["local_only"]: + try: + remote = ip_address(request.remote) + except ValueError: + _LOGGER.debug("Unable to parse remote ip %s", request.remote) + return Response(status=HTTPStatus.OK) + + if not network.is_local(remote): + _LOGGER.warning("Received remote request for local webhook %s", webhook_id) + return Response(status=HTTPStatus.OK) + try: response = await webhook["handler"](hass, webhook_id, request) if response is None: @@ -110,12 +127,11 @@ async def async_handle_webhook(hass, webhook_id, request): return Response(status=HTTPStatus.OK) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the webhook component.""" hass.http.register_view(WebhookView) - hass.components.websocket_api.async_register_command( - WS_TYPE_LIST, websocket_list, SCHEMA_WS_LIST - ) + websocket_api.async_register_command(hass, websocket_list) + websocket_api.async_register_command(hass, websocket_handle) return True @@ -127,7 +143,7 @@ class WebhookView(HomeAssistantView): requires_auth = False cors_allowed = True - async def _handle(self, request: Request, webhook_id): + async def _handle(self, request: Request, webhook_id: str) -> Response: """Handle webhook call.""" # pylint: disable=no-self-use _LOGGER.debug("Handling webhook %s payload for %s", request.method, webhook_id) @@ -139,13 +155,59 @@ async def _handle(self, request: Request, webhook_id): put = _handle +@websocket_api.websocket_command( + { + "type": "webhook/list", + } +) @callback def websocket_list(hass, connection, msg): """Return a list of webhooks.""" handlers = hass.data.setdefault(DOMAIN, {}) result = [ - {"webhook_id": webhook_id, "domain": info["domain"], "name": info["name"]} + { + "webhook_id": webhook_id, + "domain": info["domain"], + "name": info["name"], + "local_only": info["local_only"], + } for webhook_id, info in handlers.items() ] connection.send_message(websocket_api.result_message(msg["id"], result)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "webhook/handle", + vol.Required("webhook_id"): str, + vol.Required("method"): vol.In(["GET", "POST", "PUT"]), + vol.Optional("body", default=""): str, + vol.Optional("headers", default={}): {str: str}, + vol.Optional("query", default=""): str, + } +) +@websocket_api.async_response +async def websocket_handle(hass, connection, msg): + """Handle an incoming webhook via the WS API.""" + request = MockRequest( + content=msg["body"].encode("utf-8"), + headers=msg["headers"], + method=msg["method"], + query_string=msg["query"], + mock_source=f"{DOMAIN}/ws", + ) + + response = await async_handle_webhook(hass, msg["webhook_id"], request) + + response_dict = serialize_response(response) + body = response_dict.get("body") + + connection.send_result( + msg["id"], + { + "body": body, + "status": response_dict["status"], + "headers": {"Content-Type": response.content_type}, + }, + ) diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index 4e17c5e9e3452d..4eaf60595a5900 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -8,6 +8,8 @@ from homeassistant.core import HassJob, callback import homeassistant.helpers.config_validation as cv +from . import async_register, async_unregister + # mypy: allow-untyped-defs DEPENDENCIES = ("webhook",) @@ -40,7 +42,8 @@ async def async_attach_trigger(hass, config, action, automation_info): trigger_data = automation_info["trigger_data"] webhook_id = config.get(CONF_WEBHOOK_ID) job = HassJob(action) - hass.components.webhook.async_register( + async_register( + hass, automation_info["domain"], automation_info["name"], webhook_id, @@ -50,6 +53,6 @@ async def async_attach_trigger(hass, config, action, automation_info): @callback def unregister(): """Unregister webhook.""" - hass.components.webhook.async_unregister(webhook_id) + async_unregister(hass, webhook_id) return unregister diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index db5a618ff5ca6d..9f78dcd09642b7 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -1,39 +1,63 @@ """Support for LG webOS Smart TV.""" +from __future__ import annotations + import asyncio +from collections.abc import Callable from contextlib import suppress import json import logging import os +from pickle import loads +from typing import Any -from aiopylgtv import PyLGTVCmdException, PyLGTVPairException, WebOsClient -from sqlitedict import SqliteDict +from aiowebostv import WebOsClient, WebOsTvPairError +import sqlalchemy as db import voluptuous as vol -from websockets.exceptions import ConnectionClosed +from homeassistant.components import notify as hass_notify +from homeassistant.components.automation import AutomationActionType +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_COMMAND, ATTR_ENTITY_ID, + CONF_CLIENT_SECRET, CONF_CUSTOMIZE, CONF_HOST, CONF_ICON, CONF_NAME, + CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import ( + Context, + Event, + HassJob, + HomeAssistant, + ServiceCall, + callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery, entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_BUTTON, + ATTR_CONFIG_ENTRY_ID, ATTR_PAYLOAD, ATTR_SOUND_OUTPUT, CONF_ON_ACTION, CONF_SOURCES, + DATA_CONFIG_ENTRY, + DATA_HASS_CONFIG, DEFAULT_NAME, DOMAIN, + PLATFORMS, SERVICE_BUTTON, SERVICE_COMMAND, SERVICE_SELECT_SOUND_OUTPUT, WEBOSTV_CONFIG_FILE, + WEBOSTV_EXCEPTIONS, ) CUSTOMIZE_SCHEMA = vol.Schema( @@ -41,22 +65,25 @@ ) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Optional(CONF_CUSTOMIZE, default={}): CUSTOMIZE_SCHEMA, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ICON): cv.string, - } - ) - ], - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Optional(CONF_CUSTOMIZE, default={}): CUSTOMIZE_SCHEMA, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ICON): cv.string, + } + ) + ], + ) + }, + ), extra=vol.ALLOW_EXTRA, ) @@ -82,12 +109,126 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup(hass, config): +def read_client_keys(config_file: str) -> dict[str, str]: + """Read legacy client keys from file.""" + if not os.path.isfile(config_file): + return {} + + # Try to parse the file as being JSON + with open(config_file, encoding="utf8") as json_file: + try: + client_keys = json.load(json_file) + if isinstance(client_keys, dict): + return client_keys + return {} + except (json.JSONDecodeError, UnicodeDecodeError): + pass + + # If the file is not JSON, read it as Sqlite DB + engine = db.create_engine(f"sqlite:///{config_file}") + table = db.Table("unnamed", db.MetaData(), autoload=True, autoload_with=engine) + results = engine.connect().execute(db.select([table])).fetchall() + db_client_keys = {k: loads(v) for k, v in results} + return db_client_keys + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LG WebOS TV platform.""" - hass.data[DOMAIN] = {} + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(DATA_CONFIG_ENTRY, {}) + hass.data[DOMAIN][DATA_HASS_CONFIG] = config - async def async_service_handler(service): - method = SERVICE_TO_METHOD.get(service.service) + if DOMAIN not in config: + return True + + config_file = hass.config.path(WEBOSTV_CONFIG_FILE) + if not ( + client_keys := await hass.async_add_executor_job(read_client_keys, config_file) + ): + _LOGGER.debug("No pairing keys, Not importing webOS Smart TV YAML config") + return True + + async def async_migrate_task( + entity_id: str, conf: dict[str, str], key: str + ) -> None: + _LOGGER.debug("Migrating webOS Smart TV entity %s unique_id", entity_id) + client = WebOsClient(conf[CONF_HOST], key) + tries = 0 + while not client.is_connected(): + try: + await client.connect() + except WEBOSTV_EXCEPTIONS: + if tries == 0: + _LOGGER.warning( + "Please make sure webOS TV %s is turned on to complete " + "the migration of configuration.yaml to the UI", + entity_id, + ) + wait_time = 2 ** min(tries, 4) * 5 + tries += 1 + await asyncio.sleep(wait_time) + except WebOsTvPairError: + return + + ent_reg = entity_registry.async_get(hass) + if not ( + new_entity_id := ent_reg.async_get_entity_id( + Platform.MEDIA_PLAYER, DOMAIN, key + ) + ): + _LOGGER.debug( + "Not updating webOSTV Smart TV entity %s unique_id, entity missing", + entity_id, + ) + return + + uuid = client.hello_info["deviceUUID"] + ent_reg.async_update_entity(new_entity_id, new_unique_id=uuid) + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + **conf, + CONF_CLIENT_SECRET: key, + CONF_UNIQUE_ID: uuid, + }, + ) + + ent_reg = entity_registry.async_get(hass) + + tasks = [] + for conf in config[DOMAIN]: + host = conf[CONF_HOST] + if (key := client_keys.get(host)) is None: + _LOGGER.debug( + "Not importing webOS Smart TV host %s without pairing key", host + ) + continue + + if entity_id := ent_reg.async_get_entity_id(Platform.MEDIA_PLAYER, DOMAIN, key): + tasks.append(asyncio.create_task(async_migrate_task(entity_id, conf, key))) + + async def async_tasks_cancel(_event: Event) -> None: + """Cancel config flow import tasks.""" + for task in tasks: + if not task.done(): + task.cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_tasks_cancel) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set the config entry up.""" + host = entry.data[CONF_HOST] + key = entry.data[CONF_CLIENT_SECRET] + + wrapper = WebOsClientWrapper(host, client_key=key) + await wrapper.connect() + + async def async_service_handler(service: ServiceCall) -> None: + method = SERVICE_TO_METHOD[service.service] data = service.data.copy() data["method"] = method["method"] async_dispatcher_send(hass, DOMAIN, data) @@ -98,120 +239,123 @@ async def async_service_handler(service): DOMAIN, service, async_service_handler, schema=schema ) - tasks = [async_setup_tv(hass, config, conf) for conf in config[DOMAIN]] - if tasks: - await asyncio.gather(*tasks) + hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id] = wrapper + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + # set up notify platform, no entry support for notify component yet, + # have to use discovery to load platform. + hass.async_create_task( + discovery.async_load_platform( + hass, + "notify", + DOMAIN, + { + CONF_NAME: entry.title, + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + }, + hass.data[DOMAIN][DATA_HASS_CONFIG], + ) + ) + + if not entry.update_listeners: + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + async def async_on_stop(_event: Event) -> None: + """Unregister callbacks and disconnect.""" + await wrapper.shutdown() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_on_stop) + ) return True -def convert_client_keys(config_file): - """In case the config file contains JSON, convert it to a Sqlite config file.""" - # Return early if config file is non-existing - if not os.path.isfile(config_file): - return +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) - # Try to parse the file as being JSON - with open(config_file, encoding="utf8") as json_file: - try: - json_conf = json.load(json_file) - except (json.JSONDecodeError, UnicodeDecodeError): - json_conf = None - # If the file contains JSON, convert it to an Sqlite DB - if json_conf: - _LOGGER.warning("LG webOS TV client-key file is being migrated to Sqlite!") +async def async_control_connect(host: str, key: str | None) -> WebOsClient: + """LG Connection.""" + client = WebOsClient(host, key) + try: + await client.connect() + except WebOsTvPairError: + _LOGGER.warning("Connected to LG webOS TV %s but not paired", host) + raise - # Clean the JSON file - os.remove(config_file) + return client - # Write the data to the Sqlite DB - with SqliteDict(config_file) as conf: - for host, key in json_conf.items(): - conf[host] = key - conf.commit() +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_setup_tv(hass, config, conf): - """Set up a LG WebOS TV based on host parameter.""" + if unload_ok: + client = hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) + await hass_notify.async_reload(hass, DOMAIN) + await client.shutdown() - host = conf[CONF_HOST] - config_file = hass.config.path(WEBOSTV_CONFIG_FILE) - await hass.async_add_executor_job(convert_client_keys, config_file) - - client = await WebOsClient.create(host, config_file) - hass.data[DOMAIN][host] = {"client": client} - - if client.is_registered(): - await async_setup_tv_finalize(hass, config, conf, client) - else: - _LOGGER.warning("LG webOS TV %s needs to be paired", host) - await async_request_configuration(hass, config, conf, client) - - -async def async_connect(client): - """Attempt a connection, but fail gracefully if tv is off for example.""" - with suppress( - OSError, - ConnectionClosed, - ConnectionRefusedError, - asyncio.TimeoutError, - asyncio.CancelledError, - PyLGTVPairException, - PyLGTVCmdException, - ): - await client.connect() + # unregister service calls, check if this is the last entry to unload + if unload_ok and not hass.data[DOMAIN][DATA_CONFIG_ENTRY]: + for service in SERVICE_TO_METHOD: + hass.services.async_remove(DOMAIN, service) + return unload_ok -async def async_setup_tv_finalize(hass, config, conf, client): - """Make initial connection attempt and call platform setup.""" - async def async_on_stop(event): - """Unregister callbacks and disconnect.""" - client.clear_state_update_callbacks() - await client.disconnect() +class PluggableAction: + """A pluggable action handler.""" - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_on_stop) + def __init__(self) -> None: + """Initialize.""" + self._actions: dict[Callable[[], None], tuple[HassJob, dict[str, Any]]] = {} - await async_connect(client) - hass.async_create_task( - hass.helpers.discovery.async_load_platform("media_player", DOMAIN, conf, config) - ) - hass.async_create_task( - hass.helpers.discovery.async_load_platform("notify", DOMAIN, conf, config) - ) + def __bool__(self) -> bool: + """Return if we have something attached.""" + return bool(self._actions) + @callback + def async_attach( + self, action: AutomationActionType, variables: dict[str, Any] + ) -> Callable[[], None]: + """Attach a device trigger for turn on.""" -async def async_request_configuration(hass, config, conf, client): - """Request configuration steps from the user.""" - host = conf.get(CONF_HOST) - name = conf.get(CONF_NAME) - configurator = hass.components.configurator + @callback + def _remove() -> None: + del self._actions[_remove] - async def lgtv_configuration_callback(data): - """Handle actions when configuration callback is called.""" - try: - await client.connect() - except PyLGTVPairException: - _LOGGER.warning("Connected to LG webOS TV %s but not paired", host) - return - except ( - OSError, - ConnectionClosed, - asyncio.TimeoutError, - asyncio.CancelledError, - PyLGTVCmdException, - ): - _LOGGER.error("Unable to connect to host %s", host) - return + job = HassJob(action) - await async_setup_tv_finalize(hass, config, conf, client) - configurator.async_request_done(request_id) + self._actions[_remove] = (job, variables) - request_id = configurator.async_request_config( - name, - lgtv_configuration_callback, - description="Click start and accept the pairing request on your TV.", - description_image="/static/images/config_webos.png", - submit_caption="Start pairing request", - ) + return _remove + + @callback + def async_run(self, hass: HomeAssistant, context: Context | None = None) -> None: + """Run all turn on triggers.""" + for job, variables in self._actions.values(): + hass.async_run_hass_job(job, variables, context) + + +class WebOsClientWrapper: + """Wrapper for a WebOS TV client with Home Assistant specific functions.""" + + def __init__(self, host: str, client_key: str) -> None: + """Set up the client.""" + self.host = host + self.client_key = client_key + self.turn_on = PluggableAction() + self.client: WebOsClient | None = None + + async def connect(self) -> None: + """Attempt a connection, but fail gracefully if tv is off for example.""" + self.client = WebOsClient(self.host, self.client_key) + with suppress(*WEBOSTV_EXCEPTIONS, WebOsTvPairError): + await self.client.connect() + + async def shutdown(self) -> None: + """Unregister callbacks and disconnect.""" + assert self.client + self.client.clear_state_update_callbacks() + await self.client.disconnect() diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py new file mode 100644 index 00000000000000..4c8ff6e5fd3742 --- /dev/null +++ b/homeassistant/components/webostv/config_flow.py @@ -0,0 +1,201 @@ +"""Config flow to configure webostv component.""" +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urlparse + +from aiowebostv import WebOsTvPairError +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import ssdp +from homeassistant.const import ( + CONF_CLIENT_SECRET, + CONF_CUSTOMIZE, + CONF_HOST, + CONF_NAME, + CONF_UNIQUE_ID, +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from . import async_control_connect +from .const import CONF_SOURCES, DEFAULT_NAME, DOMAIN, WEBOSTV_EXCEPTIONS +from .helpers import async_get_sources + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + }, + extra=vol.ALLOW_EXTRA, +) + +_LOGGER = logging.getLogger(__name__) + + +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """WebosTV configuration flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize workflow.""" + self._host: str = "" + self._name: str = "" + self._uuid: str | None = None + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: + """Set the config entry up from yaml.""" + self._host = import_info[CONF_HOST] + self._name = import_info.get(CONF_NAME) or import_info[CONF_HOST] + await self.async_set_unique_id( + import_info[CONF_UNIQUE_ID], raise_on_progress=False + ) + data = { + CONF_HOST: self._host, + CONF_CLIENT_SECRET: import_info[CONF_CLIENT_SECRET], + } + self._abort_if_unique_id_configured() + + options: dict[str, list[str]] | None = None + if sources := import_info.get(CONF_CUSTOMIZE, {}).get(CONF_SOURCES): + if not isinstance(sources, list): + sources = [s.strip() for s in sources.split(",")] + options = {CONF_SOURCES: sources} + + _LOGGER.debug("WebOS Smart TV host %s imported from YAML config", self._host) + return self.async_create_entry(title=self._name, data=data, options=options) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + self._host = user_input[CONF_HOST] + self._name = user_input[CONF_NAME] + return await self.async_step_pairing() + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + @callback + def _async_check_configured_entry(self) -> None: + """Check if entry is configured, update unique_id if needed.""" + for entry in self._async_current_entries(include_ignore=False): + if entry.data[CONF_HOST] != self._host: + continue + + if self._uuid and not entry.unique_id: + _LOGGER.debug( + "Updating unique_id for host %s, unique_id: %s", + self._host, + self._uuid, + ) + self.hass.config_entries.async_update_entry(entry, unique_id=self._uuid) + + raise data_entry_flow.AbortFlow("already_configured") + + async def async_step_pairing( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Display pairing form.""" + self._async_check_configured_entry() + + self.context[CONF_HOST] = self._host + self.context["title_placeholders"] = {"name": self._name} + errors = {} + + if ( + self.context["source"] == config_entries.SOURCE_IMPORT + or user_input is not None + ): + try: + client = await async_control_connect(self._host, None) + except WebOsTvPairError: + return self.async_abort(reason="error_pairing") + except WEBOSTV_EXCEPTIONS: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id( + client.hello_info["deviceUUID"], raise_on_progress=False + ) + self._abort_if_unique_id_configured({CONF_HOST: self._host}) + data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} + return self.async_create_entry(title=self._name, data=data) + + return self.async_show_form(step_id="pairing", errors=errors) + + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: + """Handle a flow initialized by discovery.""" + assert discovery_info.ssdp_location + host = urlparse(discovery_info.ssdp_location).hostname + assert host + self._host = host + self._name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + + uuid = discovery_info.upnp[ssdp.ATTR_UPNP_UDN] + assert uuid + if uuid.startswith("uuid:"): + uuid = uuid[5:] + await self.async_set_unique_id(uuid) + self._abort_if_unique_id_configured({CONF_HOST: self._host}) + + for progress in self._async_in_progress(): + if progress.get("context", {}).get(CONF_HOST) == self._host: + return self.async_abort(reason="already_in_progress") + + self._uuid = uuid + return await self.async_step_pairing() + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + self.options = config_entry.options + self.host = config_entry.data[CONF_HOST] + self.key = config_entry.data[CONF_CLIENT_SECRET] + + async def async_step_init(self, user_input: ConfigType | None = None) -> FlowResult: + """Manage the options.""" + errors = {} + if user_input is not None: + options_input = {CONF_SOURCES: user_input[CONF_SOURCES]} + return self.async_create_entry(title="", data=options_input) + # Get sources + sources_list = await async_get_sources(self.host, self.key) + if not sources_list: + errors["base"] = "cannot_retrieve" + + sources = [s for s in self.options.get(CONF_SOURCES, []) if s in sources_list] + if not sources: + sources = sources_list + + options_schema = vol.Schema( + { + vol.Optional( + CONF_SOURCES, + description={"suggested_value": sources}, + ): cv.multi_select({source: source for source in sources_list}), + } + ) + + return self.async_show_form( + step_id="init", data_schema=options_schema, errors=errors + ) diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 9091491a29da98..9be44d86469433 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -1,9 +1,17 @@ """Constants used for LG webOS Smart TV.""" -DOMAIN = "webostv" +import asyncio + +from aiowebostv import WebOsTvCommandError +from websockets.exceptions import ConnectionClosed, ConnectionClosedOK +DOMAIN = "webostv" +PLATFORMS = ["media_player"] +DATA_CONFIG_ENTRY = "config_entry" +DATA_HASS_CONFIG = "hass_config" DEFAULT_NAME = "LG webOS Smart TV" ATTR_BUTTON = "button" +ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_PAYLOAD = "payload" ATTR_SOUND_OUTPUT = "sound_output" @@ -16,4 +24,14 @@ LIVE_TV_APP_ID = "com.webos.app.livetv" +WEBOSTV_EXCEPTIONS = ( + OSError, + ConnectionClosed, + ConnectionClosedOK, + ConnectionRefusedError, + WebOsTvCommandError, + asyncio.TimeoutError, + asyncio.CancelledError, +) + WEBOSTV_CONFIG_FILE = "webostv.conf" diff --git a/homeassistant/components/webostv/device_trigger.py b/homeassistant/components/webostv/device_trigger.py new file mode 100644 index 00000000000000..47cdf974cc76e6 --- /dev/null +++ b/homeassistant/components/webostv/device_trigger.py @@ -0,0 +1,96 @@ +"""Provides device automations for control of LG webOS Smart TV.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import ConfigType + +from . import trigger +from .const import DOMAIN +from .helpers import ( + async_get_client_wrapper_by_device_entry, + async_get_device_entry_by_device_id, + async_is_device_config_entry_not_loaded, +) +from .triggers.turn_on import PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE + +TRIGGER_TYPES = {TURN_ON_PLATFORM_TYPE} +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + try: + if async_is_device_config_entry_not_loaded(hass, config[CONF_DEVICE_ID]): + return config + except ValueError as err: + raise InvalidDeviceAutomationConfig(err) from err + + if config[CONF_TYPE] == TURN_ON_PLATFORM_TYPE: + device_id = config[CONF_DEVICE_ID] + try: + device = async_get_device_entry_by_device_id(hass, device_id) + async_get_client_wrapper_by_device_entry(hass, device) + except ValueError as err: + raise InvalidDeviceAutomationConfig(err) from err + + return config + + +async def async_get_triggers( + _hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device triggers for device.""" + triggers = [] + base_trigger = { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + } + + triggers.append({**base_trigger, CONF_TYPE: TURN_ON_PLATFORM_TYPE}) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: AutomationTriggerInfo, +) -> CALLBACK_TYPE | None: + """Attach a trigger.""" + trigger_type = config[CONF_TYPE] + + if trigger_type == TURN_ON_PLATFORM_TYPE: + trigger_config = { + CONF_PLATFORM: trigger_type, + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + } + trigger_config = await trigger.async_validate_trigger_config( + hass, trigger_config + ) + return await trigger.async_attach_trigger( + hass, trigger_config, action, automation_info + ) + + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py new file mode 100644 index 00000000000000..70a253d5cebd84 --- /dev/null +++ b/homeassistant/components/webostv/helpers.py @@ -0,0 +1,109 @@ +"""Helper functions for webOS Smart TV.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from . import WebOsClientWrapper, async_control_connect +from .const import DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, WEBOSTV_EXCEPTIONS + + +@callback +def async_get_device_entry_by_device_id( + hass: HomeAssistant, device_id: str +) -> DeviceEntry: + """ + Get Device Entry from Device Registry by device ID. + + Raises ValueError if device ID is invalid. + """ + device_reg = dr.async_get(hass) + device = device_reg.async_get(device_id) + + if device is None: + raise ValueError(f"Device {device_id} is not a valid {DOMAIN} device.") + + return device + + +@callback +def async_is_device_config_entry_not_loaded( + hass: HomeAssistant, device_id: str +) -> bool: + """Return whether device's config entries are not loaded.""" + device = async_get_device_entry_by_device_id(hass, device_id) + return any( + (entry := hass.config_entries.async_get_entry(entry_id)) + and entry.state != ConfigEntryState.LOADED + for entry_id in device.config_entries + ) + + +@callback +def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> str: + """ + Get device ID from an entity ID. + + Raises ValueError if entity or device ID is invalid. + """ + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + if ( + entity_entry is None + or entity_entry.device_id is None + or entity_entry.platform != DOMAIN + ): + raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") + + return entity_entry.device_id + + +@callback +def async_get_client_wrapper_by_device_entry( + hass: HomeAssistant, device: DeviceEntry +) -> WebOsClientWrapper: + """ + Get WebOsClientWrapper from Device Registry by device entry. + + Raises ValueError if client wrapper is not found. + """ + for config_entry_id in device.config_entries: + wrapper: WebOsClientWrapper | None + if wrapper := hass.data[DOMAIN][DATA_CONFIG_ENTRY].get(config_entry_id): + break + + if not wrapper: + raise ValueError( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) + + return wrapper + + +async def async_get_sources(host: str, key: str) -> list[str]: + """Construct sources list.""" + try: + client = await async_control_connect(host, key) + except WEBOSTV_EXCEPTIONS: + return [] + + sources = [] + found_live_tv = False + for app in client.apps.values(): + sources.append(app["title"]) + if app["id"] == LIVE_TV_APP_ID: + found_live_tv = True + + for source in client.inputs.values(): + sources.append(source["label"]) + if source["appId"] == LIVE_TV_APP_ID: + found_live_tv = True + + if not found_live_tv: + sources.append("Live TV") + + # Preserve order when filtering duplicates + return list(dict.fromkeys(sources)) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 9697f903926b44..1494180dd0576f 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -1,9 +1,11 @@ { "domain": "webostv", "name": "LG webOS Smart TV", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/webostv", - "requirements": ["aiopylgtv==0.4.0"], - "dependencies": ["configurator"], + "requirements": ["aiowebostv==0.1.2", "sqlalchemy==1.4.27"], "codeowners": ["@bendavid", "@thecode"], - "iot_class": "local_polling" -} + "ssdp": [{"st": "urn:lge-com:service:webos-second-screen:1"}], + "quality_scale": "platinum", + "iot_class": "local_push" +} \ No newline at end of file diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 36480e90f122fc..5aac52f6f7bd8f 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -1,15 +1,21 @@ """Support for interface with an LG webOS Smart TV.""" -import asyncio +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Coroutine from contextlib import suppress from datetime import timedelta from functools import wraps import logging +from typing import Any, TypeVar, cast -from aiopylgtv import PyLGTVCmdException, PyLGTVPairException, WebOsClient -from websockets.exceptions import ConnectionClosed +from aiowebostv import WebOsClient, WebOsTvPairError +from typing_extensions import Concatenate, ParamSpec from homeassistant import util -from homeassistant.components.media_player import DEVICE_CLASS_TV, MediaPlayerEntity +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, +) from homeassistant.components.media_player.const import ( MEDIA_TYPE_CHANNEL, SUPPORT_NEXT_TRACK, @@ -18,32 +24,37 @@ SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) -from homeassistant.components.webostv.const import ( - ATTR_PAYLOAD, - ATTR_SOUND_OUTPUT, - CONF_ON_ACTION, - CONF_SOURCES, - DOMAIN, - LIVE_TV_APP_ID, -) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, - CONF_CUSTOMIZE, - CONF_HOST, - CONF_NAME, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, STATE_OFF, STATE_ON, ) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.script import Script +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import WebOsClientWrapper +from .const import ( + ATTR_PAYLOAD, + ATTR_SOUND_OUTPUT, + CONF_SOURCES, + DATA_CONFIG_ENTRY, + DOMAIN, + LIVE_TV_APP_ID, + WEBOSTV_EXCEPTIONS, +) _LOGGER = logging.getLogger(__name__) @@ -55,6 +66,7 @@ | SUPPORT_SELECT_SOURCE | SUPPORT_PLAY_MEDIA | SUPPORT_PLAY + | SUPPORT_STOP ) SUPPORT_WEBOSTV_VOLUME = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP @@ -64,64 +76,67 @@ SCAN_INTERVAL = timedelta(seconds=10) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the LG webOS Smart TV platform.""" + unique_id = config_entry.unique_id + assert unique_id + name = config_entry.title + sources = config_entry.options.get(CONF_SOURCES) + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] - if discovery_info is None: - return - - host = discovery_info[CONF_HOST] - name = discovery_info[CONF_NAME] - customize = discovery_info[CONF_CUSTOMIZE] - turn_on_action = discovery_info.get(CONF_ON_ACTION) - - client = hass.data[DOMAIN][host]["client"] - on_script = Script(hass, turn_on_action, name, DOMAIN) if turn_on_action else None + async_add_entities([LgWebOSMediaPlayerEntity(wrapper, name, sources, unique_id)]) - entity = LgWebOSMediaPlayerEntity(client, name, customize, on_script) - async_add_entities([entity], update_before_add=False) +_T = TypeVar("_T", bound="LgWebOSMediaPlayerEntity") +_P = ParamSpec("_P") -def cmd(func): +def cmd( + func: Callable[Concatenate[_T, _P], Awaitable[None]] # type: ignore[misc] +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc] """Catch command exceptions.""" @wraps(func) - async def wrapper(obj, *args, **kwargs): + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap all command methods.""" try: - await func(obj, *args, **kwargs) - except ( - asyncio.TimeoutError, - asyncio.CancelledError, - PyLGTVCmdException, - ) as exc: - # If TV is off, we expect calls to fail. - if obj.state == STATE_OFF: - level = logging.INFO - else: - level = logging.ERROR - _LOGGER.log( - level, - "Error calling %s on entity %s: %r", + await func(self, *args, **kwargs) + except WEBOSTV_EXCEPTIONS as exc: + if self.state != STATE_OFF: + raise HomeAssistantError( + f"Error calling {func.__name__} on entity {self.entity_id}, state:{self.state}" + ) from exc + _LOGGER.warning( + "Error calling %s on entity %s, state:%s, error: %r", func.__name__, - obj.entity_id, + self.entity_id, + self.state, exc, ) - return wrapper + return cmd_wrapper class LgWebOSMediaPlayerEntity(MediaPlayerEntity): """Representation of a LG webOS Smart TV.""" - def __init__(self, client: WebOsClient, name: str, customize, on_script=None): + def __init__( + self, + wrapper: WebOsClientWrapper, + name: str, + sources: list[str] | None, + unique_id: str, + ) -> None: """Initialize the webos device.""" - self._client = client + self._wrapper = wrapper + self._client: WebOsClient = wrapper.client self._name = name - self._unique_id = client.client_key - self._customize = customize - self._on_script = on_script + self._unique_id = unique_id + self._sources = sources # Assume that the TV is not paused self._paused = False @@ -129,19 +144,21 @@ def __init__(self, client: WebOsClient, name: str, customize, on_script=None): self._current_source = None self._source_list: dict = {} - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Connect and subscribe to dispatcher signals and state updates.""" - async_dispatcher_connect(self.hass, DOMAIN, self.async_signal_handler) + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.async_signal_handler) + ) await self._client.register_state_update_callback( self.async_handle_state_update ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Call disconnect on removal.""" self._client.unregister_state_update_callback(self.async_handle_state_update) - async def async_signal_handler(self, data): + async def async_signal_handler(self, data: dict[str, Any]) -> None: """Handle domain-specific signal by calling appropriate method.""" if (entity_ids := data[ATTR_ENTITY_ID]) == ENTITY_MATCH_NONE: return @@ -154,23 +171,22 @@ async def async_signal_handler(self, data): } await getattr(self, data["method"])(**params) - async def async_handle_state_update(self): + async def async_handle_state_update(self, _client: WebOsClient) -> None: """Update state from WebOsClient.""" self.update_sources() - self.async_write_ha_state() - def update_sources(self): + def update_sources(self) -> None: """Update list of sources from current source, apps, inputs and configured list.""" source_list = self._source_list self._source_list = {} - conf_sources = self._customize[CONF_SOURCES] + conf_sources = self._sources found_live_tv = False for app in self._client.apps.values(): if app["id"] == LIVE_TV_APP_ID: found_live_tv = True - if app["id"] == self._client.current_appId: + if app["id"] == self._client.current_app_id: self._current_source = app["title"] self._source_list[app["title"]] = app elif ( @@ -184,7 +200,7 @@ def update_sources(self): for source in self._client.inputs.values(): if source["appId"] == LIVE_TV_APP_ID: found_live_tv = True - if source["appId"] == self._client.current_appId: + if source["appId"] == self._client.current_app_id: self._current_source = source["label"] self._source_list[source["label"]] = source elif ( @@ -194,10 +210,14 @@ def update_sources(self): ): self._source_list[source["label"]] = source - # special handling of live tv since this might not appear in the app or input lists in some cases - if not found_live_tv: + # empty list, TV may be off, keep previous list + if not self._source_list and source_list: + self._source_list = source_list + # special handling of live tv since this might + # not appear in the app or input lists in some cases + elif not found_live_tv: app = {"id": LIVE_TV_APP_ID, "title": "Live TV"} - if LIVE_TV_APP_ID == self._client.current_appId: + if LIVE_TV_APP_ID == self._client.current_app_id: self._current_source = app["title"] self._source_list["Live TV"] = app elif ( @@ -207,41 +227,33 @@ def update_sources(self): or any(word in app["id"] for word in conf_sources) ): self._source_list["Live TV"] = app - if not self._source_list and source_list: - self._source_list = source_list @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) - async def async_update(self): + async def async_update(self) -> None: """Connect.""" - if not self._client.is_connected(): - with suppress( - OSError, - ConnectionClosed, - ConnectionRefusedError, - asyncio.TimeoutError, - asyncio.CancelledError, - PyLGTVPairException, - PyLGTVCmdException, - ): - await self._client.connect() + if self._client.is_connected(): + return + + with suppress(*WEBOSTV_EXCEPTIONS, WebOsTvPairError): + await self._client.connect() @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique id of the device.""" return self._unique_id @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def device_class(self): + def device_class(self) -> MediaPlayerDeviceClass: """Return the device class of the device.""" - return DEVICE_CLASS_TV + return MediaPlayerDeviceClass.TV @property - def state(self): + def state(self) -> str: """Return the state of the device.""" if self._client.is_on: return STATE_ON @@ -249,57 +261,57 @@ def state(self): return STATE_OFF @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool: """Boolean if volume is currently muted.""" - return self._client.muted + return cast(bool, self._client.muted) @property - def volume_level(self): + def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" if self._client.volume is not None: - return self._client.volume / 100.0 + return cast(float, self._client.volume / 100.0) return None @property - def source(self): + def source(self) -> str | None: """Return the current input source.""" return self._current_source @property - def source_list(self): + def source_list(self) -> list[str]: """List of available input sources.""" return sorted(self._source_list) @property - def media_content_type(self): + def media_content_type(self) -> str | None: """Content type of current playing media.""" - if self._client.current_appId == LIVE_TV_APP_ID: + if self._client.current_app_id == LIVE_TV_APP_ID: return MEDIA_TYPE_CHANNEL return None @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" - if (self._client.current_appId == LIVE_TV_APP_ID) and ( + if (self._client.current_app_id == LIVE_TV_APP_ID) and ( self._client.current_channel is not None ): - return self._client.current_channel.get("channelName") + return cast(str, self._client.current_channel.get("channelName")) return None @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" - if self._client.current_appId in self._client.apps: - icon = self._client.apps[self._client.current_appId]["largeIcon"] + if self._client.current_app_id in self._client.apps: + icon: str = self._client.apps[self._client.current_app_id]["largeIcon"] if not icon.startswith("http"): - icon = self._client.apps[self._client.current_appId]["icon"] + icon = self._client.apps[self._client.current_app_id]["icon"] return icon return None @property - def supported_features(self): + def supported_features(self) -> int: """Flag media player features that are supported.""" supported = SUPPORT_WEBOSTV @@ -308,56 +320,78 @@ def supported_features(self): elif self._client.sound_output != "lineout": supported = supported | SUPPORT_WEBOSTV_VOLUME | SUPPORT_VOLUME_SET - if self._on_script: - supported = supported | SUPPORT_TURN_ON + if self._wrapper.turn_on: + supported |= SUPPORT_TURN_ON return supported @property - def extra_state_attributes(self): + def device_info(self) -> DeviceInfo: + """Return device information.""" + device_info = DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + manufacturer="LG", + name=self._name, + ) + + if self._client.system_info is None and self.state == STATE_OFF: + return device_info + + maj_v = self._client.software_info.get("major_ver") + min_v = self._client.software_info.get("minor_ver") + if maj_v and min_v: + device_info["sw_version"] = f"{maj_v}.{min_v}" + + model = self._client.system_info.get("modelName") + if model: + device_info["model"] = model + + return device_info + + @property + def extra_state_attributes(self) -> dict[str, str] | None: """Return device specific state attributes.""" if self._client.sound_output is None and self.state == STATE_OFF: - return {} + return None return {ATTR_SOUND_OUTPUT: self._client.sound_output} @cmd - async def async_turn_off(self): + async def async_turn_off(self) -> None: """Turn off media player.""" await self._client.power_off() - async def async_turn_on(self): - """Turn on the media player.""" - if self._on_script: - await self._on_script.async_run(context=self._context) + async def async_turn_on(self) -> None: + """Turn on media player.""" + self._wrapper.turn_on.async_run(self.hass, self._context) @cmd - async def async_volume_up(self): + async def async_volume_up(self) -> None: """Volume up the media player.""" await self._client.volume_up() @cmd - async def async_volume_down(self): + async def async_volume_down(self) -> None: """Volume down media player.""" await self._client.volume_down() @cmd - async def async_set_volume_level(self, volume): + async def async_set_volume_level(self, volume: int) -> None: """Set volume level, range 0..1.""" tv_volume = int(round(volume * 100)) await self._client.set_volume(tv_volume) @cmd - async def async_mute_volume(self, mute): + async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" await self._client.set_mute(mute) @cmd - async def async_select_sound_output(self, sound_output): + async def async_select_sound_output(self, sound_output: str) -> None: """Select the sound output.""" await self._client.change_sound_output(sound_output) @cmd - async def async_media_play_pause(self): + async def async_media_play_pause(self) -> None: """Simulate play pause media player.""" if self._paused: await self.async_media_play() @@ -365,7 +399,7 @@ async def async_media_play_pause(self): await self.async_media_pause() @cmd - async def async_select_source(self, source): + async def async_select_source(self, source: str) -> None: """Select input source.""" if (source_dict := self._source_list.get(source)) is None: _LOGGER.warning("Source %s not found for %s", source, self.name) @@ -376,7 +410,9 @@ async def async_select_source(self, source): await self._client.set_input(source_dict["id"]) @cmd - async def async_play_media(self, media_type, media_id, **kwargs): + async def async_play_media( + self, media_type: str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" _LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id) @@ -411,46 +447,44 @@ async def async_play_media(self, media_type, media_id, **kwargs): await self._client.set_channel(partial_match_channel_id) @cmd - async def async_media_play(self): + async def async_media_play(self) -> None: """Send play command.""" self._paused = False await self._client.play() @cmd - async def async_media_pause(self): + async def async_media_pause(self) -> None: """Send media pause command to media player.""" self._paused = True await self._client.pause() @cmd - async def async_media_stop(self): + async def async_media_stop(self) -> None: """Send stop command to media player.""" await self._client.stop() @cmd - async def async_media_next_track(self): + async def async_media_next_track(self) -> None: """Send next track command.""" - current_input = self._client.get_input() - if current_input == LIVE_TV_APP_ID: + if self._client.current_app_id == LIVE_TV_APP_ID: await self._client.channel_up() else: await self._client.fast_forward() @cmd - async def async_media_previous_track(self): + async def async_media_previous_track(self) -> None: """Send the previous track command.""" - current_input = self._client.get_input() - if current_input == LIVE_TV_APP_ID: + if self._client.current_app_id == LIVE_TV_APP_ID: await self._client.channel_down() else: await self._client.rewind() @cmd - async def async_button(self, button): + async def async_button(self, button: str) -> None: """Send a button press.""" await self._client.button(button) @cmd - async def async_command(self, command, **kwargs): + async def async_command(self, command: str, **kwargs: Any) -> None: """Send a command.""" await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 34277eb3c09131..df2ed7e50634c1 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -1,62 +1,57 @@ """Support for LG WebOS TV notification service.""" -import asyncio +from __future__ import annotations + import logging +from typing import Any -from aiopylgtv import PyLGTVCmdException, PyLGTVPairException -from websockets.exceptions import ConnectionClosed +from aiowebostv import WebOsClient, WebOsTvPairError from homeassistant.components.notify import ATTR_DATA, BaseNotificationService -from homeassistant.const import CONF_HOST, CONF_ICON +from homeassistant.const import CONF_ICON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN +from .const import ATTR_CONFIG_ENTRY_ID, DATA_CONFIG_ENTRY, DOMAIN, WEBOSTV_EXCEPTIONS _LOGGER = logging.getLogger(__name__) -async def async_get_service(hass, config, discovery_info=None): +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> BaseNotificationService | None: """Return the notify service.""" if discovery_info is None: return None - host = discovery_info.get(CONF_HOST) - icon_path = discovery_info.get(CONF_ICON) - - client = hass.data[DOMAIN][host]["client"] - - svc = LgWebOSNotificationService(client, icon_path) + client = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + discovery_info[ATTR_CONFIG_ENTRY_ID] + ].client - return svc + return LgWebOSNotificationService(client) class LgWebOSNotificationService(BaseNotificationService): """Implement the notification service for LG WebOS TV.""" - def __init__(self, client, icon_path): + def __init__(self, client: WebOsClient) -> None: """Initialize the service.""" self._client = client - self._icon_path = icon_path - async def async_send_message(self, message="", **kwargs): + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to the tv.""" try: if not self._client.is_connected(): await self._client.connect() data = kwargs.get(ATTR_DATA) - icon_path = ( - data.get(CONF_ICON, self._icon_path) if data else self._icon_path - ) + icon_path = data.get(CONF_ICON, "") if data else None await self._client.send_message(message, icon_path=icon_path) - except PyLGTVPairException: + except WebOsTvPairError: _LOGGER.error("Pairing with TV failed") except FileNotFoundError: _LOGGER.error("Icon %s not found", icon_path) - except ( - OSError, - ConnectionClosed, - asyncio.TimeoutError, - asyncio.CancelledError, - PyLGTVCmdException, - ): + except WEBOSTV_EXCEPTIONS: _LOGGER.error("TV unreachable") diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json new file mode 100644 index 00000000000000..41755e94f0142b --- /dev/null +++ b/homeassistant/components/webostv/strings.json @@ -0,0 +1,47 @@ +{ + "config": { + "flow_title": "LG webOS Smart TV", + "step": { + "user": { + "title": "Connect to webOS TV", + "description": "Turn on TV, fill the following fields click submit", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "name": "[%key:common::config_flow::data::name%]" + } + }, + "pairing": { + "title": "webOS TV Pairing", + "description": "Click submit and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" + } + }, + "error": { + "cannot_connect": "Failed to connect, please turn on your TV or check ip address" + }, + "abort": { + "error_pairing": "Connected to LG webOS TV but not paired", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "title": "Options for webOS Smart TV", + "description": "Select enabled sources", + "data": { + "sources": "Sources list" + } + } + }, + "error": { + "script_not_found": "Script not found", + "cannot_retrieve": "Unable to retrieve the list of sources. Make sure device is switched on" + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "Device is requested to turn on" + } + } +} diff --git a/homeassistant/components/webostv/translations/bg.json b/homeassistant/components/webostv/translations/bg.json new file mode 100644 index 00000000000000..cb9aea4f85dbe7 --- /dev/null +++ b/homeassistant/components/webostv/translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/ca.json b/homeassistant/components/webostv/translations/ca.json new file mode 100644 index 00000000000000..512165b6ba7955 --- /dev/null +++ b/homeassistant/components/webostv/translations/ca.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "error_pairing": "Connectat per\u00f2 no vinculat a TV LG webOS" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, engega el televisor i comprova l'adre\u00e7a IP" + }, + "flow_title": "Smart TV LG webOS", + "step": { + "pairing": { + "description": "Fes clic a envia i accepta la sol\u00b7licitud de vinculaci\u00f3 del televisor.\n\n![Image](/static/images/config_webos.png)", + "title": "Vinculaci\u00f3 de TV webOS" + }, + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom" + }, + "description": "Enc\u00e9n el televisor, omple els camps i fes clic a envia", + "title": "Connexi\u00f3 a TV webOS" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "Es demani que el dispositiu s'engegui" + } + }, + "options": { + "error": { + "cannot_retrieve": "No es pot obtenir la llista de fonts. Assegura't que el dispositiu est\u00e0 enc\u00e8s", + "script_not_found": "No s'ha trobat l'script" + }, + "step": { + "init": { + "data": { + "sources": "Llista de fonts" + }, + "description": "Selecci\u00f3 de fonts activades", + "title": "Opcions de Smart TV webOS" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/cs.json b/homeassistant/components/webostv/translations/cs.json new file mode 100644 index 00000000000000..ef9650f28ae993 --- /dev/null +++ b/homeassistant/components/webostv/translations/cs.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/de.json b/homeassistant/components/webostv/translations/de.json new file mode 100644 index 00000000000000..6586ed63900487 --- /dev/null +++ b/homeassistant/components/webostv/translations/de.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "error_pairing": "Verbunden mit LG webOS TV, aber nicht gekoppelt" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, bitte schalte deinen Fernseher ein oder \u00fcberpr\u00fcfe die IP-Adresse" + }, + "flow_title": "LG webOS Smart TV", + "step": { + "pairing": { + "description": "Klicke auf Senden und akzeptiere die Kopplungsanfrage auf deinem Fernsehger\u00e4t.\n\n![Bild](/static/images/config_webos.png)", + "title": "webOS TV-Kopplung" + }, + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "Schalte den TV ein, f\u00fclle die folgenden Felder aus und klicke auf Senden", + "title": "Mit webOS TV verbinden" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "Ger\u00e4t wird zum Einschalten aufgefordert" + } + }, + "options": { + "error": { + "cannot_retrieve": "Die Liste der Quellen kann nicht abgerufen werden. Stelle sicher, dass das Ger\u00e4t eingeschaltet ist.", + "script_not_found": "Skript nicht gefunden" + }, + "step": { + "init": { + "data": { + "sources": "Quellenliste" + }, + "description": "Aktivierte Quellen ausw\u00e4hlen", + "title": "Optionen f\u00fcr webOS Smart TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/el.json b/homeassistant/components/webostv/translations/el.json new file mode 100644 index 00000000000000..115f2d4cdf83a1 --- /dev/null +++ b/homeassistant/components/webostv/translations/el.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "error_pairing": "\u03a3\u03c5\u03bd\u03b4\u03b5\u03b4\u03b5\u03bc\u03ad\u03bd\u03bf \u03bc\u03b5 LG webOS TV \u03b1\u03bb\u03bb\u03ac \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03c3\u03c5\u03b6\u03b5\u03c5\u03c7\u03b8\u03b5\u03af" + }, + "error": { + "cannot_connect": "\u0397 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7 \u03b1\u03c0\u03ad\u03c4\u03c5\u03c7\u03b5, \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03ae \u03c3\u03b1\u03c2 \u03ae \u03b5\u03bb\u03ad\u03b3\u03be\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IP" + }, + "flow_title": "LG webOS Smart TV", + "step": { + "pairing": { + "description": "\u039a\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03b7\u03bd \u03c5\u03c0\u03bf\u03b2\u03bf\u03bb\u03ae \u03ba\u03b1\u03b9 \u03b1\u03c0\u03bf\u03b4\u03b5\u03c7\u03c4\u03b5\u03af\u03c4\u03b5 \u03c4\u03bf \u03b1\u03af\u03c4\u03b7\u03bc\u03b1 \u03c3\u03cd\u03b6\u03b5\u03c5\u03be\u03b7\u03c2 \u03c3\u03c4\u03b7\u03bd \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03ae \u03c3\u03b1\u03c2. \n\n ![Image](/static/images/config_webos.png)", + "title": "\u03a3\u03cd\u03b6\u03b5\u03c5\u03be\u03b7 \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03b7\u03c2 webOS" + }, + "user": { + "data": { + "name": "\u038c\u03bd\u03bf\u03bc\u03b1" + }, + "description": "\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03b7, \u03c3\u03c5\u03bc\u03c0\u03bb\u03b7\u03c1\u03ce\u03c3\u03c4\u03b5 \u03c4\u03b1 \u03c0\u03b1\u03c1\u03b1\u03ba\u03ac\u03c4\u03c9 \u03c0\u03b5\u03b4\u03af\u03b1 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03b7\u03bd \u03c5\u03c0\u03bf\u03b2\u03bf\u03bb\u03ae", + "title": "\u03a3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7 \u03c3\u03c4\u03bf webOS TV" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "\u0396\u03b7\u03c4\u03b5\u03af\u03c4\u03b1\u03b9 \u03b7 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + } + }, + "options": { + "error": { + "cannot_retrieve": "\u0394\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03b1\u03bd\u03ac\u03ba\u03c4\u03b7\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03bb\u03af\u03c3\u03c4\u03b1\u03c2 \u03c0\u03b7\u03b3\u03ce\u03bd. \u0392\u03b5\u03b2\u03b1\u03b9\u03c9\u03b8\u03b5\u03af\u03c4\u03b5 \u03cc\u03c4\u03b9 \u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b5\u03af\u03bd\u03b1\u03b9 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03b7", + "script_not_found": "\u03a4\u03bf \u03c3\u03b5\u03bd\u03ac\u03c1\u03b9\u03bf \u03b4\u03b5\u03bd \u03b2\u03c1\u03ad\u03b8\u03b7\u03ba\u03b5" + }, + "step": { + "init": { + "data": { + "sources": "\u039b\u03af\u03c3\u03c4\u03b1 \u03c0\u03b7\u03b3\u03ce\u03bd" + }, + "description": "\u0395\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03b5\u03c2 \u03c0\u03b7\u03b3\u03ad\u03c2", + "title": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03c2 \u03b3\u03b9\u03b1 webOS Smart TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/en.json b/homeassistant/components/webostv/translations/en.json new file mode 100644 index 00000000000000..bd39d6899eefea --- /dev/null +++ b/homeassistant/components/webostv/translations/en.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "error_pairing": "Connected to LG webOS TV but not paired" + }, + "error": { + "cannot_connect": "Failed to connect, please turn on your TV or check ip address" + }, + "flow_title": "LG webOS Smart TV", + "step": { + "pairing": { + "description": "Click submit and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)", + "title": "webOS TV Pairing" + }, + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "Turn on TV, fill the following fields click submit", + "title": "Connect to webOS TV" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "Device is requested to turn on" + } + }, + "options": { + "error": { + "cannot_retrieve": "Unable to retrieve the list of sources. Make sure device is switched on", + "script_not_found": "Script not found" + }, + "step": { + "init": { + "data": { + "sources": "Sources list" + }, + "description": "Select enabled sources", + "title": "Options for webOS Smart TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/et.json b/homeassistant/components/webostv/translations/et.json new file mode 100644 index 00000000000000..1cadc13a06ba8c --- /dev/null +++ b/homeassistant/components/webostv/translations/et.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "already_in_progress": "Sidumine on juba k\u00e4imas", + "error_pairing": "\u00dchendatud LG webOS teleriga kuid pole seotud" + }, + "error": { + "cannot_connect": "\u00dchenduse loomine nurjus, l\u00fclita teler sisse v\u00f5i kontrolli IP-aadressi" + }, + "flow_title": "LG webOS Smart TV", + "step": { + "pairing": { + "description": "Kl\u00f5psa nuppu submit ja n\u00f5ustu oma teleri paaritamisn\u00f5udega.\n\n![Image](/static/images/config_webos.png)", + "title": "webOS TV sidumine" + }, + "user": { + "data": { + "host": "Host", + "name": "Nimi" + }, + "description": "L\u00fclita teler sisse, t\u00e4ida j\u00e4rgmised v\u00e4ljadja kl\u00f5psa nuppu Esita", + "title": "\u00dchendu webOS TV-ga" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "Seadmel palutakse sisse l\u00fclituda" + } + }, + "options": { + "error": { + "cannot_retrieve": "Allikate loendit ei saa tuua. Veendu, et seade on sisse l\u00fclitatud", + "script_not_found": "Skripti ei leitud" + }, + "step": { + "init": { + "data": { + "sources": "Allikate loend" + }, + "description": "Vali lubatud allikad", + "title": "WebOS Smart TV valikud" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/fr.json b/homeassistant/components/webostv/translations/fr.json new file mode 100644 index 00000000000000..82b4196eb751cf --- /dev/null +++ b/homeassistant/components/webostv/translations/fr.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "error_pairing": "Connect\u00e9 au t\u00e9l\u00e9viseur LG webOS mais non jumel\u00e9" + }, + "error": { + "cannot_connect": "Impossible de vous connecter, veuillez allumer votre t\u00e9l\u00e9viseur ou v\u00e9rifier l\u2019adresse IP" + }, + "flow_title": "LG webOS Smart TV", + "step": { + "pairing": { + "description": "Cliquez sur soumettre et acceptez la demande de jumelage sur votre t\u00e9l\u00e9viseur.\n\n![Image](/static/images/config_webos.png)", + "title": "Appairage webOS TV" + }, + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom" + }, + "description": "Allumez la t\u00e9l\u00e9vision, remplissez les champs suivants, cliquez sur Envoyer", + "title": "Se connecter \u00e0 webOS TV" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "L'appareil est invit\u00e9 \u00e0 s'allumer" + } + }, + "options": { + "error": { + "cannot_retrieve": "Impossible de r\u00e9cup\u00e9rer la liste des sources. Assurez-vous que l'appareil est allum\u00e9", + "script_not_found": "Script introuvable" + }, + "step": { + "init": { + "data": { + "sources": "Liste des sources" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/he.json b/homeassistant/components/webostv/translations/he.json new file mode 100644 index 00000000000000..c9b7e4fb2efbb7 --- /dev/null +++ b/homeassistant/components/webostv/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "name": "\u05e9\u05dd" + } + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "\u05d4\u05d4\u05ea\u05e7\u05df \u05d4\u05ea\u05d1\u05e7\u05e9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/id.json b/homeassistant/components/webostv/translations/id.json new file mode 100644 index 00000000000000..44ee9452fef154 --- /dev/null +++ b/homeassistant/components/webostv/translations/id.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nama" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/it.json b/homeassistant/components/webostv/translations/it.json new file mode 100644 index 00000000000000..c5653248030ebd --- /dev/null +++ b/homeassistant/components/webostv/translations/it.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "error_pairing": "Collegato a TV webOS LG, ma non accoppiato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, accendi la TV o controlla l'indirizzo IP" + }, + "flow_title": "Smart TV webOS LG", + "step": { + "pairing": { + "description": "Fai clic su Invia e accetta la richiesta di associazione sulla TV. \n\n ![Immagine](/static/images/config_webos.png)", + "title": "Accoppiamento webOS TV" + }, + "user": { + "data": { + "host": "Host", + "name": "Nome" + }, + "description": "Accendi la TV, compila i seguenti campi e fai clic su Invia", + "title": "Collegati a TV webOS" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "Richiesta l'accensione del dispositivo" + } + }, + "options": { + "error": { + "cannot_retrieve": "Impossibile recuperare l'elenco delle sorgenti. Assicurati che il dispositivo sia acceso", + "script_not_found": "Script non trovato" + }, + "step": { + "init": { + "data": { + "sources": "Elenco delle sorgenti" + }, + "description": "Seleziona le sorgenti abilitate", + "title": "Opzioni per Smart TV webOS" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/ja.json b/homeassistant/components/webostv/translations/ja.json new file mode 100644 index 00000000000000..75d085fc98d081 --- /dev/null +++ b/homeassistant/components/webostv/translations/ja.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "error_pairing": "LG webOS TV\u306b\u63a5\u7d9a\u3055\u308c\u3066\u3044\u307e\u3059\u304c\u3001\u30da\u30a2\u30ea\u30f3\u30b0\u3055\u308c\u3066\u3044\u307e\u305b\u3093" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002TV\u306e\u96fb\u6e90\u3092\u5165\u308c\u308b\u304b\u3001IP\u30a2\u30c9\u30ec\u30b9\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044" + }, + "flow_title": "WebOS\u30b9\u30de\u30fc\u30c8TV", + "step": { + "pairing": { + "description": "\u9001\u4fe1(submit)\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u3001TV\u3067\u30da\u30a2\u30ea\u30f3\u30b0\u306e\u30ea\u30af\u30a8\u30b9\u30c8\u3092\u53d7\u3051\u5165\u308c\u307e\u3059\u3002 \n\n![Image](/static/images/config_webos.png)", + "title": "webOS TV\u30da\u30a2\u30ea\u30f3\u30b0" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "name": "\u540d\u524d" + }, + "description": "\u30c6\u30ec\u30d3\u306e\u96fb\u6e90\u3092\u5165\u308c\u3001\u4ee5\u4e0b\u306e\u9805\u76ee\u3092\u5165\u529b\u3057\u3066\u9001\u4fe1\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "title": "WebOS TV\u306b\u63a5\u7d9a\u3059\u308b" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "\u30c7\u30d0\u30a4\u30b9\u3092\u30aa\u30f3\u306b\u3059\u308b\u3088\u3046\u306b\u8981\u6c42\u3055\u308c\u307e\u3057\u305f" + } + }, + "options": { + "error": { + "cannot_retrieve": "\u30bd\u30fc\u30b9\u306e\u30ea\u30b9\u30c8\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3002\u30c7\u30d0\u30a4\u30b9\u306e\u96fb\u6e90\u304c\u5165\u3063\u3066\u3044\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", + "script_not_found": "\u30b9\u30af\u30ea\u30d7\u30c8\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093" + }, + "step": { + "init": { + "data": { + "sources": "\u30bd\u30fc\u30b9\u30ea\u30b9\u30c8" + }, + "description": "\u6709\u52b9\u306a\u30bd\u30fc\u30b9\u306e\u9078\u629e", + "title": "WebOS\u30b9\u30de\u30fc\u30c8TV\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/nl.json b/homeassistant/components/webostv/translations/nl.json new file mode 100644 index 00000000000000..f7325c02cff85c --- /dev/null +++ b/homeassistant/components/webostv/translations/nl.json @@ -0,0 +1,46 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom is al aan de gang", + "error_pairing": "Verbonden met LG webOS TV maar niet gekoppeld" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken, zet uw TV aan of controleer het ip adres" + }, + "flow_title": "LG webOS Smart TV", + "step": { + "pairing": { + "description": "Klik op verzenden en accepteer het koppelingsverzoek op uw tv.\n\n![Image](/static/images/config_webos.png)", + "title": "webOS TV-koppeling" + }, + "user": { + "data": { + "host": "Host", + "name": "Naam" + }, + "description": "Zet de TV aan, vul de volgende velden in en klik op verzenden", + "title": "Verbinding maken met webOS TV" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "Apparaat is gevraagd om in te schakelen" + } + }, + "options": { + "error": { + "cannot_retrieve": "Kan de lijst met bronnen niet ophalen. Zorg ervoor dat het apparaat is ingeschakeld", + "script_not_found": "Script niet gevonden" + }, + "step": { + "init": { + "data": { + "sources": "Bronnenlijst" + }, + "description": "Selecteer ingeschakelde bronnen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/no.json b/homeassistant/components/webostv/translations/no.json new file mode 100644 index 00000000000000..3cb8a11154c6d1 --- /dev/null +++ b/homeassistant/components/webostv/translations/no.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "error_pairing": "Koblet til LG webOS TV, men ikke sammenkoblet" + }, + "error": { + "cannot_connect": "Kunne ikke koble til. Sl\u00e5 p\u00e5 TV-en eller sjekk IP-adressen" + }, + "flow_title": "LG webOS Smart TV", + "step": { + "pairing": { + "description": "Klikk p\u00e5 send og godta sammenkoblingsforesp\u00f8rselen p\u00e5 TV-en. \n\n ![Image](/static/images/config_webos.png)", + "title": "webOS TV-sammenkobling" + }, + "user": { + "data": { + "host": "Vert", + "name": "Navn" + }, + "description": "Sl\u00e5 p\u00e5 TV, fyll ut f\u00f8lgende felt, klikk p\u00e5 send", + "title": "Koble til webOS TV" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "Enheten blir bedt om \u00e5 sl\u00e5 p\u00e5" + } + }, + "options": { + "error": { + "cannot_retrieve": "Kan ikke hente listen over kilder. S\u00f8rg for at enheten er sl\u00e5tt p\u00e5", + "script_not_found": "Skriptet ikke funnet" + }, + "step": { + "init": { + "data": { + "sources": "Kildeliste" + }, + "description": "Velg aktiverte kilder", + "title": "Alternativer for webOS Smart TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/pl.json b/homeassistant/components/webostv/translations/pl.json new file mode 100644 index 00000000000000..6f974191dc744e --- /dev/null +++ b/homeassistant/components/webostv/translations/pl.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "error_pairing": "Po\u0142\u0105czono z telewizorem LG webOS, ale nie sparowano" + }, + "error": { + "cannot_connect": "Nie uda\u0142o si\u0119 po\u0142\u0105czy\u0107, w\u0142\u0105cz telewizor lub sprawd\u017a adres IP" + }, + "flow_title": "Smart telewizor LG webOS", + "step": { + "pairing": { + "title": "Parowanie webOS TV" + }, + "user": { + "description": "W\u0142\u0105cz telewizor, wype\u0142nij wymgane pola i kliknij prze\u015blij", + "title": "Po\u0142\u0105cz z webOS TV" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "Urz\u0105dzenie jest poproszone o w\u0142\u0105czenie si\u0119" + } + }, + "options": { + "error": { + "cannot_retrieve": "Nie mo\u017cna pobra\u0107 listy \u017ar\u00f3de\u0142. Upewnij si\u0119, \u017ce urz\u0105dzenie jest w\u0142\u0105czone", + "script_not_found": "Skrypt nie zosta\u0142 znaleziony" + }, + "step": { + "init": { + "data": { + "sources": "Lista \u017ar\u00f3de\u0142" + }, + "description": "Wybierz dost\u0119pne \u017ar\u00f3d\u0142a", + "title": "Opcje webOS Smart TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/ru.json b/homeassistant/components/webostv/translations/ru.json new file mode 100644 index 00000000000000..a8c2a5dadfdfdc --- /dev/null +++ b/homeassistant/components/webostv/translations/ru.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "already_in_progress": "\u041f\u0440\u043e\u0446\u0435\u0441\u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "error_pairing": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a LG webOS TV, \u043d\u043e \u043d\u0435 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u043e." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435, \u0432\u043a\u043b\u044e\u0447\u0435\u043d \u043b\u0438 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0438 \u0432\u0435\u0440\u043d\u043e \u043b\u0438 \u0443\u043a\u0430\u0437\u0430\u043d IP-\u0430\u0434\u0440\u0435\u0441." + }, + "flow_title": "LG webOS Smart TV", + "step": { + "pairing": { + "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c' \u0438 \u043f\u0440\u0438\u043c\u0438\u0442\u0435 \u0437\u0430\u043f\u0440\u043e\u0441 \u043d\u0430 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u043d\u0430 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0435. \n\n![Image](/static/images/config_webos.png)", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u043e\u043c" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440, \u0437\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0435 \u043f\u043e\u043b\u044f \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c'.", + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a webOS TV" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "\u0417\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + } + }, + "options": { + "error": { + "cannot_retrieve": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0441\u043f\u0438\u0441\u043e\u043a \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u0432. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043e.", + "script_not_found": "\u0421\u043a\u0440\u0438\u043f\u0442 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d." + }, + "step": { + "init": { + "data": { + "sources": "\u0421\u043f\u0438\u0441\u043e\u043a \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u0432" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 webOS Smart TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/tr.json b/homeassistant/components/webostv/translations/tr.json new file mode 100644 index 00000000000000..94e5d3abef3b89 --- /dev/null +++ b/homeassistant/components/webostv/translations/tr.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "error_pairing": "LG webOS TV'ye ba\u011fl\u0131 ancak e\u015flenmemi\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flant\u0131 kurulamad\u0131, l\u00fctfen TV'nizi a\u00e7\u0131n veya ip adresini kontrol edin" + }, + "flow_title": "LG webOS Ak\u0131ll\u0131 TV", + "step": { + "pairing": { + "description": "G\u00f6nder'e t\u0131klay\u0131n ve TV'nizdeki e\u015fle\u015ftirme iste\u011fini kabul edin. \n\n ![Resim](/static/images/config_webos.png)", + "title": "webOS TV E\u015fle\u015ftirme" + }, + "user": { + "data": { + "host": "Sunucu", + "name": "Ad" + }, + "description": "TV'yi a\u00e7\u0131n, a\u015fa\u011f\u0131daki alanlar\u0131 doldurun, g\u00f6nder'e t\u0131klay\u0131n", + "title": "webOS TV'ye ba\u011flan\u0131n" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "Cihaz\u0131n a\u00e7\u0131lmas\u0131 isteniyor" + } + }, + "options": { + "error": { + "cannot_retrieve": "Kaynak listesi al\u0131namad\u0131. Cihaz\u0131n a\u00e7\u0131k oldu\u011fundan emin olun", + "script_not_found": "Komut dosyas\u0131 bulunamad\u0131" + }, + "step": { + "init": { + "data": { + "sources": "Kaynak listesi" + }, + "description": "Etkin kaynaklar\u0131 se\u00e7in", + "title": "WebOS Smart TV se\u00e7enekleri" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/translations/zh-Hant.json b/homeassistant/components/webostv/translations/zh-Hant.json new file mode 100644 index 00000000000000..2908d0a85ce224 --- /dev/null +++ b/homeassistant/components/webostv/translations/zh-Hant.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "error_pairing": "\u5df2\u9023\u7dda\u81f3 LG webOS TV \u4f46\u672a\u914d\u5c0d" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u78ba\u8a8d\u96fb\u8996\u5df2\u958b\u555f\u6216\u6aa2\u67e5 IP \u4f4d\u5740" + }, + "flow_title": "LG webOS Smart TV", + "step": { + "pairing": { + "description": "\u9ede\u9078\u50b3\u9001\u4e26\u65bc\u96fb\u8996\u4e0a\u63a5\u53d7\u914d\u5c0d\u3002\n\n![Image](/static/images/config_webos.png)", + "title": "webOS TV \u914d\u5c0d\u4e2d" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31" + }, + "description": "\u6253\u958b\u96fb\u8996\u4e26\u586b\u5beb\u4e0b\u5217\u6b04\u4f4d\u5f8c\u50b3\u9001", + "title": "\u9023\u7dda\u81f3 webOS TV" + } + } + }, + "device_automation": { + "trigger_type": { + "webostv.turn_on": "\u88dd\u7f6e\u5fc5\u9808\u70ba\u958b\u555f\u72c0\u614b" + } + }, + "options": { + "error": { + "cannot_retrieve": "\u7121\u6cd5\u63a5\u6536\u4f86\u6e90\u5217\u8868\uff0c\u8acb\u78ba\u5b9a\u88dd\u7f6e\u70ba\u958b\u555f\u72c0\u614b", + "script_not_found": "\u627e\u4e0d\u5230\u8173\u672c" + }, + "step": { + "init": { + "data": { + "sources": "\u4f86\u6e90\u5217\u8868" + }, + "description": "\u9078\u64c7\u5df2\u555f\u7528\u4f86\u6e90", + "title": "webOS Smart TV \u8a2d\u5b9a\u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/webostv/trigger.py b/homeassistant/components/webostv/trigger.py new file mode 100644 index 00000000000000..1ad7058e1de9e4 --- /dev/null +++ b/homeassistant/components/webostv/trigger.py @@ -0,0 +1,53 @@ +"""webOS Smart TV trigger dispatcher.""" +from __future__ import annotations + +from typing import cast + +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .triggers import TriggersPlatformModule, turn_on + +TRIGGERS = { + "turn_on": turn_on, +} + + +def _get_trigger_platform(config: ConfigType) -> TriggersPlatformModule: + """Return trigger platform.""" + platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) + if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: + raise ValueError( + f"Unknown webOS Smart TV trigger platform {config[CONF_PLATFORM]}" + ) + return cast(TriggersPlatformModule, TRIGGERS[platform_split[1]]) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + platform = _get_trigger_platform(config) + return cast(ConfigType, platform.TRIGGER_SCHEMA(config)) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: AutomationTriggerInfo, +) -> CALLBACK_TYPE: + """Attach trigger of specified platform.""" + platform = _get_trigger_platform(config) + assert hasattr(platform, "async_attach_trigger") + return cast( + CALLBACK_TYPE, + await getattr(platform, "async_attach_trigger")( + hass, config, action, automation_info + ), + ) diff --git a/homeassistant/components/webostv/triggers/__init__.py b/homeassistant/components/webostv/triggers/__init__.py new file mode 100644 index 00000000000000..710caffef7a8d0 --- /dev/null +++ b/homeassistant/components/webostv/triggers/__init__.py @@ -0,0 +1,12 @@ +"""webOS Smart TV triggers.""" +from __future__ import annotations + +from typing import Protocol + +import voluptuous as vol + + +class TriggersPlatformModule(Protocol): + """Protocol type for the triggers platform.""" + + TRIGGER_SCHEMA: vol.Schema diff --git a/homeassistant/components/webostv/triggers/turn_on.py b/homeassistant/components/webostv/triggers/turn_on.py new file mode 100644 index 00000000000000..71949ce58cefb0 --- /dev/null +++ b/homeassistant/components/webostv/triggers/turn_on.py @@ -0,0 +1,88 @@ +"""webOS Smart TV device turn on trigger.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from ..const import DOMAIN +from ..helpers import ( + async_get_client_wrapper_by_device_entry, + async_get_device_entry_by_device_id, + async_get_device_id_from_entity_id, +) + +# Platform type should be . +PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" + +TRIGGER_TYPE_TURN_ON = "turn_on" + +TRIGGER_SCHEMA = vol.All( + cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): PLATFORM_TYPE, + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + }, + ), + cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), +) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: AutomationTriggerInfo, + *, + platform_type: str = PLATFORM_TYPE, +) -> CALLBACK_TYPE | None: + """Attach a trigger.""" + device_ids = set() + if ATTR_DEVICE_ID in config: + device_ids.update(config.get(ATTR_DEVICE_ID, [])) + + if ATTR_ENTITY_ID in config: + device_ids.update( + { + async_get_device_id_from_entity_id(hass, entity_id) + for entity_id in config.get(ATTR_ENTITY_ID, []) + } + ) + + trigger_data = automation_info["trigger_data"] + + unsubs = [] + + for device_id in device_ids: + device = async_get_device_entry_by_device_id(hass, device_id) + device_name = device.name_by_user or device.name + + variables = { + **trigger_data, + CONF_PLATFORM: platform_type, + ATTR_DEVICE_ID: device_id, + "description": f"webostv turn on trigger for {device_name}", + } + + client_wrapper = async_get_client_wrapper_by_device_entry(hass, device) + + unsubs.append( + client_wrapper.turn_on.async_attach(action, {"trigger": variables}) + ) + + @callback + def async_remove() -> None: + """Remove state listeners async.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + return async_remove diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index 13939338c3e57d..c98ca54d25a57a 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -10,7 +10,7 @@ from homeassistant.loader import bind_hass from . import commands, connection, const, decorators, http, messages # noqa: F401 -from .connection import ActiveConnection # noqa: F401 +from .connection import ActiveConnection, current_connection # noqa: F401 from .const import ( # noqa: F401 ERR_HOME_ASSISTANT_ERROR, ERR_INVALID_FORMAT, diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a4abbd30dfff1f..4020601dc3f0f2 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -9,9 +9,12 @@ import voluptuous as vol from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_READ -from homeassistant.bootstrap import SIGNAL_BOOTSTRAP_INTEGRATONS -from homeassistant.components.websocket_api.const import ERR_NOT_FOUND -from homeassistant.const import EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL +from homeassistant.const import ( + EVENT_STATE_CHANGED, + EVENT_TIME_CHANGED, + MATCH_ALL, + SIGNAL_BOOTSTRAP_INTEGRATONS, +) from homeassistant.core import Context, Event, HomeAssistant, callback from homeassistant.exceptions import ( HomeAssistantError, @@ -33,6 +36,7 @@ from . import const, decorators, messages from .connection import ActiveConnection +from .const import ERR_NOT_FOUND @callback @@ -44,6 +48,7 @@ def async_register_commands( async_reg(hass, handle_call_service) async_reg(hass, handle_entity_source) async_reg(hass, handle_execute_script) + async_reg(hass, handle_fire_event) async_reg(hass, handle_get_config) async_reg(hass, handle_get_services) async_reg(hass, handle_get_states) @@ -353,7 +358,9 @@ async def handle_render_template( return @callback - def _template_listener(event: Event, updates: list[TrackTemplateResult]) -> None: + def _template_listener( + event: Event | None, updates: list[TrackTemplateResult] + ) -> None: nonlocal info track_template_result = updates.pop() result = track_template_result.result @@ -526,3 +533,22 @@ async def handle_execute_script( script_obj = Script(hass, msg["sequence"], f"{const.DOMAIN} script", const.DOMAIN) await script_obj.async_run(msg.get("variables"), context=context) connection.send_message(messages.result_message(msg["id"], {"context": context})) + + +@decorators.websocket_command( + { + vol.Required("type"): "fire_event", + vol.Required("event_type"): str, + vol.Optional("event_data"): dict, + } +) +@decorators.require_admin +@decorators.async_response +async def handle_fire_event( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle fire event command.""" + context = connection.context(msg) + + hass.bus.async_fire(msg["event_type"], msg.get("event_data"), context=context) + connection.send_result(msg["id"], {"context": context}) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index aec56fdfbf24f9..075aed86453414 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Callable, Hashable +from contextvars import ContextVar from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -17,6 +18,11 @@ from .http import WebSocketAdapter +current_connection = ContextVar["ActiveConnection | None"]( + "current_connection", default=None +) + + class ActiveConnection: """Handle an active websocket client connection.""" @@ -36,6 +42,7 @@ def __init__( self.refresh_token_id = refresh_token.id self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 + current_connection.set(self) def context(self, msg: dict[str, Any]) -> Context: """Return a context.""" diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 69716b97076b00..9428d6fd87d3e7 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -2,23 +2,24 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable from concurrent import futures from functools import partial import json -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Final +from typing import TYPE_CHECKING, Any, Final from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder if TYPE_CHECKING: - from .connection import ActiveConnection + from .connection import ActiveConnection # noqa: F401 WebSocketCommandHandler = Callable[ - [HomeAssistant, "ActiveConnection", Dict[str, Any]], None + [HomeAssistant, "ActiveConnection", dict[str, Any]], None ] AsyncWebSocketCommandHandler = Callable[ - [HomeAssistant, "ActiveConnection", Dict[str, Any]], Awaitable[None] + [HomeAssistant, "ActiveConnection", dict[str, Any]], Awaitable[None] ] DOMAIN: Final = "websocket_api" diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py index d6f27aff6aec09..29cc2f4a44de1e 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -1,12 +1,10 @@ """Entity to track connections to websocket API.""" from __future__ import annotations -from typing import Any - from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( DATA_CONNECTIONS, @@ -19,7 +17,7 @@ async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, - discovery_info: dict[str, Any] | None = None, + discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the API streams platform.""" entity = APICount() diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 27d3a0cbf2587c..3b08a39f2ce881 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -1,23 +1,22 @@ """Support for WeMo device discovery.""" from __future__ import annotations +from collections.abc import Sequence +from datetime import datetime import logging +from typing import Optional import pywemo import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.fan import DOMAIN as FAN_DOMAIN -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import gather_with_concurrency from .const import DOMAIN @@ -29,36 +28,35 @@ # Mapping from Wemo model_name to domain. WEMO_MODEL_DISPATCH = { - "Bridge": [LIGHT_DOMAIN], - "CoffeeMaker": [SWITCH_DOMAIN], - "Dimmer": [LIGHT_DOMAIN], - "Humidifier": [FAN_DOMAIN], - "Insight": [BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN], - "LightSwitch": [SWITCH_DOMAIN], - "Maker": [BINARY_SENSOR_DOMAIN, SWITCH_DOMAIN], - "Motion": [BINARY_SENSOR_DOMAIN], - "OutdoorPlug": [SWITCH_DOMAIN], - "Sensor": [BINARY_SENSOR_DOMAIN], - "Socket": [SWITCH_DOMAIN], + "Bridge": [Platform.LIGHT], + "CoffeeMaker": [Platform.SWITCH], + "Dimmer": [Platform.LIGHT], + "Humidifier": [Platform.FAN], + "Insight": [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH], + "LightSwitch": [Platform.SWITCH], + "Maker": [Platform.BINARY_SENSOR, Platform.SWITCH], + "Motion": [Platform.BINARY_SENSOR], + "OutdoorPlug": [Platform.SWITCH], + "Sensor": [Platform.BINARY_SENSOR], + "Socket": [Platform.SWITCH], } _LOGGER = logging.getLogger(__name__) +HostPortTuple = tuple[str, Optional[int]] -def coerce_host_port(value): + +def coerce_host_port(value: str) -> HostPortTuple: """Validate that provided value is either just host or host:port. Returns (host, None) or (host, port) respectively. """ - host, _, port = value.partition(":") + host, _, port_str = value.partition(":") if not host: raise vol.Invalid("host cannot be empty") - if port: - port = cv.port(port) - else: - port = None + port = cv.port(port_str) if port_str else None return host, port @@ -82,7 +80,7 @@ def coerce_host_port(value): ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up for WeMo devices.""" hass.data[DOMAIN] = { "config": config.get(DOMAIN, {}), @@ -112,11 +110,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: discovery_responder = pywemo.ssdp.DiscoveryResponder(registry.port) await hass.async_add_executor_job(discovery_responder.start) - static_conf = config.get(CONF_STATIC, []) + static_conf: Sequence[HostPortTuple] = config.get(CONF_STATIC, []) wemo_dispatcher = WemoDispatcher(entry) wemo_discovery = WemoDiscovery(hass, wemo_dispatcher, static_conf) - async def async_stop_wemo(event): + async def async_stop_wemo(event: Event) -> None: """Shutdown Wemo subscriptions and subscription thread on exit.""" _LOGGER.debug("Shutting down WeMo event subscriptions") await hass.async_add_executor_job(registry.stop) @@ -127,7 +125,7 @@ async def async_stop_wemo(event): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_wemo) ) - # Need to do this at least once in case statics are defined and discovery is disabled + # Need to do this at least once in case statistics are defined and discovery is disabled await wemo_discovery.discover_statics() if config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY): @@ -142,8 +140,8 @@ class WemoDispatcher: def __init__(self, config_entry: ConfigEntry) -> None: """Initialize the WemoDispatcher.""" self._config_entry = config_entry - self._added_serial_numbers = set() - self._loaded_components = set() + self._added_serial_numbers: set[str] = set() + self._loaded_components: set[str] = set() async def async_add_unique_device( self, hass: HomeAssistant, wemo: pywemo.WeMoDevice @@ -153,7 +151,7 @@ async def async_add_unique_device( return coordinator = await async_register_device(hass, self._config_entry, wemo) - for component in WEMO_MODEL_DISPATCH.get(wemo.model_name, [SWITCH_DOMAIN]): + for component in WEMO_MODEL_DISPATCH.get(wemo.model_name, [Platform.SWITCH]): # Three cases: # - First time we see component, we need to load it and initialize the backlog # - Component is being loaded, add to backlog @@ -191,16 +189,18 @@ def __init__( self, hass: HomeAssistant, wemo_dispatcher: WemoDispatcher, - static_config: list[tuple[[str, str | None]]], + static_config: Sequence[HostPortTuple], ) -> None: """Initialize the WemoDiscovery.""" self._hass = hass self._wemo_dispatcher = wemo_dispatcher - self._stop = None + self._stop: CALLBACK_TYPE | None = None self._scan_delay = 0 self._static_config = static_config - async def async_discover_and_schedule(self, *_) -> None: + async def async_discover_and_schedule( + self, event_time: datetime | None = None + ) -> None: """Periodically scan the network looking for WeMo devices.""" _LOGGER.debug("Scanning network for WeMo devices") try: @@ -229,26 +229,23 @@ def async_stop_discovery(self) -> None: self._stop() self._stop = None - async def discover_statics(self): + async def discover_statics(self) -> None: """Initialize or Re-Initialize connections to statically configured devices.""" - if self._static_config: - _LOGGER.debug("Adding statically configured WeMo devices") - for device in await gather_with_concurrency( - MAX_CONCURRENCY, - *( - self._hass.async_add_executor_job( - validate_static_config, host, port - ) - for host, port in self._static_config - ), - ): - if device: - await self._wemo_dispatcher.async_add_unique_device( - self._hass, device - ) + if not self._static_config: + return + _LOGGER.debug("Adding statically configured WeMo devices") + for device in await gather_with_concurrency( + MAX_CONCURRENCY, + *( + self._hass.async_add_executor_job(validate_static_config, host, port) + for host, port in self._static_config + ), + ): + if device: + await self._wemo_dispatcher.async_add_unique_device(self._hass, device) -def validate_static_config(host, port): +def validate_static_config(host: str, port: int | None) -> pywemo.WeMoDevice | None: """Handle a static config.""" url = pywemo.setup_url_for_address(host, port) diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index 1341d5526a3d2f..cde13d632fe93f 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -1,19 +1,28 @@ """Support for WeMo binary sensors.""" import asyncio +from typing import cast from pywemo import Insight, Maker from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoEntity +from .entity import WemoBinaryStateEntity, WemoEntity +from .wemo_device import DeviceCoordinator -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up WeMo binary sensors.""" - async def _discovered_wemo(coordinator): + async def _discovered_wemo(coordinator: DeviceCoordinator) -> None: """Handle a discovered Wemo device.""" if isinstance(coordinator.wemo, Insight): async_add_entities([InsightBinarySensor(coordinator)]) @@ -32,14 +41,9 @@ async def _discovered_wemo(coordinator): ) -class WemoBinarySensor(WemoEntity, BinarySensorEntity): +class WemoBinarySensor(WemoBinaryStateEntity, BinarySensorEntity): """Representation a WeMo binary sensor.""" - @property - def is_on(self) -> bool: - """Return true if the state is on. Standby is on.""" - return self.wemo.get_state() - class MakerBinarySensor(WemoEntity, BinarySensorEntity): """Maker device's sensor port.""" @@ -49,7 +53,7 @@ class MakerBinarySensor(WemoEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the Maker's sensor is pulled low.""" - return self.wemo.has_sensor and self.wemo.sensor_state == 0 + return cast(int, self.wemo.has_sensor) != 0 and self.wemo.sensor_state == 0 class InsightBinarySensor(WemoBinarySensor): diff --git a/homeassistant/components/wemo/config_flow.py b/homeassistant/components/wemo/config_flow.py index b778779ea3caa5..73f4303cfd6751 100644 --- a/homeassistant/components/wemo/config_flow.py +++ b/homeassistant/components/wemo/config_flow.py @@ -2,12 +2,13 @@ import pywemo +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_flow -from . import DOMAIN +from .const import DOMAIN -async def _async_has_devices(hass): +async def _async_has_devices(hass: HomeAssistant) -> bool: """Return if there are devices that can be discovered.""" return bool(await hass.async_add_executor_job(pywemo.discover_devices)) diff --git a/homeassistant/components/wemo/device_trigger.py b/homeassistant/components/wemo/device_trigger.py index da9a157e1a4f58..1cdc29fa995ca3 100644 --- a/homeassistant/components/wemo/device_trigger.py +++ b/homeassistant/components/wemo/device_trigger.py @@ -1,10 +1,20 @@ """Triggers for WeMo devices.""" +from __future__ import annotations + +from typing import Any + from pywemo.subscribe import EVENT_TYPE_LONG_PRESS import voluptuous as vol +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN as WEMO_DOMAIN, WEMO_SUBSCRIPTION_EVENT from .wemo_device import async_get_coordinator @@ -18,7 +28,9 @@ ) -async def async_get_triggers(hass, device_id): +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: """Return a list of triggers.""" wemo_trigger = { @@ -44,7 +56,12 @@ async def async_get_triggers(hass, device_id): return triggers -async def async_attach_trigger(hass, config, action, automation_info): +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: AutomationTriggerInfo, +) -> CALLBACK_TYPE: """Attach a trigger.""" event_config = event_trigger.TRIGGER_SCHEMA( { diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index 2811d371f6b930..9884b5b340c3bb 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -4,10 +4,10 @@ from collections.abc import Generator import contextlib import logging +from typing import cast from pywemo.exceptions import ActionException -from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -19,6 +19,8 @@ class WemoEntity(CoordinatorEntity): """Common methods for Wemo entities.""" + coordinator: DeviceCoordinator # Override CoordinatorEntity.coordinator type. + # Most pyWeMo devices are associated with a single Home Assistant entity. When # that is not the case, name_suffix & unique_id_suffix can be used to provide # names and unique ids for additional Home Assistant entities. @@ -30,55 +32,63 @@ def __init__(self, coordinator: DeviceCoordinator) -> None: super().__init__(coordinator) self.wemo = coordinator.wemo self._device_info = coordinator.device_info - self._available = True @property - def name_suffix(self): + def name_suffix(self) -> str | None: """Suffix to append to the WeMo device name.""" return self._name_suffix @property def name(self) -> str: """Return the name of the device if any.""" + wemo_name: str = self.wemo.name if suffix := self.name_suffix: - return f"{self.wemo.name} {suffix}" - return self.wemo.name - - @property - def available(self) -> bool: - """Return true if the device is available.""" - return super().available and self._available + return f"{wemo_name} {suffix}" + return wemo_name @property - def unique_id_suffix(self): + def unique_id_suffix(self) -> str | None: """Suffix to append to the WeMo device's unique ID.""" if self._unique_id_suffix is None and self.name_suffix is not None: - return self._name_suffix.lower() + return self.name_suffix.lower() return self._unique_id_suffix @property def unique_id(self) -> str: """Return the id of this WeMo device.""" + serial_number: str = self.wemo.serialnumber if suffix := self.unique_id_suffix: - return f"{self.wemo.serialnumber}_{suffix}" - return self.wemo.serialnumber + return f"{serial_number}_{suffix}" + return serial_number @property def device_info(self) -> DeviceInfo: """Return the device info.""" return self._device_info - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._available = True - super()._handle_coordinator_update() - @contextlib.contextmanager - def _wemo_exception_handler(self, message: str) -> Generator[None, None, None]: - """Wrap device calls to set `_available` when wemo exceptions happen.""" + def _wemo_call_wrapper(self, message: str) -> Generator[None, None, None]: + """Wrap calls to the device that change its state. + + 1. Takes care of making available=False when communications with the + device fails. + 2. Ensures all entities sharing the same coordinator are aware of + updates to the device state. + """ try: yield except ActionException as err: _LOGGER.warning("Could not %s for %s (%s)", message, self.name, err) - self._available = False + self.coordinator.last_exception = err + self.coordinator.last_update_success = False # Used for self.available. + finally: + self.hass.add_job(self.coordinator.async_update_listeners) + + +class WemoBinaryStateEntity(WemoEntity): + """Base for devices that return on/off state via device.get_state().""" + + @property + def is_on(self) -> bool: + """Return true if the state is on.""" + return cast(int, self.wemo.get_state()) != 0 diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 00f9b77aa6193b..be78dd1752dbbd 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -1,14 +1,20 @@ """Support for WeMo humidifier.""" +from __future__ import annotations + import asyncio from datetime import timedelta import math +from typing import Any +from pywemo.ouimeaux_device.humidifier import DesiredHumidity, FanMode, Humidifier import voluptuous as vol from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( int_states_in_range, percentage_to_ranged_value, @@ -20,7 +26,8 @@ SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY, ) -from .entity import WemoEntity +from .entity import WemoBinaryStateEntity +from .wemo_device import DeviceCoordinator SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 @@ -32,26 +39,7 @@ ATTR_FILTER_EXPIRED = "filter_expired" ATTR_WATER_LEVEL = "water_level" -# The WEMO_ constants below come from pywemo itself -WEMO_ON = 1 -WEMO_OFF = 0 - -WEMO_HUMIDITY_45 = 0 -WEMO_HUMIDITY_50 = 1 -WEMO_HUMIDITY_55 = 2 -WEMO_HUMIDITY_60 = 3 -WEMO_HUMIDITY_100 = 4 - -WEMO_FAN_OFF = 0 -WEMO_FAN_MINIMUM = 1 -WEMO_FAN_MEDIUM = 4 -WEMO_FAN_MAXIMUM = 5 - -SPEED_RANGE = (WEMO_FAN_MINIMUM, WEMO_FAN_MAXIMUM) # off is not included - -WEMO_WATER_EMPTY = 0 -WEMO_WATER_LOW = 1 -WEMO_WATER_GOOD = 2 +SPEED_RANGE = (FanMode.Minimum, FanMode.Maximum) # off is not included SUPPORTED_FEATURES = SUPPORT_SET_SPEED @@ -63,10 +51,14 @@ } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up WeMo binary sensors.""" - async def _discovered_wemo(coordinator): + async def _discovered_wemo(coordinator: DeviceCoordinator) -> None: """Handle a discovered Wemo device.""" async_add_entities([WemoHumidifier(coordinator)]) @@ -92,24 +84,26 @@ async def _discovered_wemo(coordinator): ) -class WemoHumidifier(WemoEntity, FanEntity): +class WemoHumidifier(WemoBinaryStateEntity, FanEntity): """Representation of a WeMo humidifier.""" - def __init__(self, coordinator): + wemo: Humidifier + + def __init__(self, coordinator: DeviceCoordinator) -> None: """Initialize the WeMo switch.""" super().__init__(coordinator) - if self.wemo.fan_mode != WEMO_FAN_OFF: + if self.wemo.fan_mode != FanMode.Off: self._last_fan_on_mode = self.wemo.fan_mode else: - self._last_fan_on_mode = WEMO_FAN_MEDIUM + self._last_fan_on_mode = FanMode.High @property - def icon(self): + def icon(self) -> str: """Return the icon of device based on its type.""" return "mdi:water-percent" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" return { ATTR_CURRENT_HUMIDITY: self.wemo.current_humidity_percent, @@ -138,67 +132,56 @@ def supported_features(self) -> int: @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - if self.wemo.fan_mode != WEMO_FAN_OFF: + if self.wemo.fan_mode != FanMode.Off: self._last_fan_on_mode = self.wemo.fan_mode super()._handle_coordinator_update() - @property - def is_on(self) -> bool: - """Return true if the state is on.""" - return self.wemo.get_state() - def turn_on( self, - speed: str = None, - percentage: int = None, - preset_mode: str = None, - **kwargs, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn the fan on.""" self.set_percentage(percentage) - def turn_off(self, **kwargs) -> None: + def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - with self._wemo_exception_handler("turn off"): - self.wemo.set_state(WEMO_FAN_OFF) - - self.schedule_update_ha_state() + with self._wemo_call_wrapper("turn off"): + self.wemo.set_state(FanMode.Off) - def set_percentage(self, percentage: int) -> None: + def set_percentage(self, percentage: int | None) -> None: """Set the fan_mode of the Humidifier.""" if percentage is None: named_speed = self._last_fan_on_mode elif percentage == 0: - named_speed = WEMO_FAN_OFF + named_speed = FanMode.Off else: - named_speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + named_speed = FanMode( + math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + ) - with self._wemo_exception_handler("set speed"): + with self._wemo_call_wrapper("set speed"): self.wemo.set_state(named_speed) - self.schedule_update_ha_state() - def set_humidity(self, target_humidity: float) -> None: """Set the target humidity level for the Humidifier.""" if target_humidity < 50: - pywemo_humidity = WEMO_HUMIDITY_45 + pywemo_humidity = DesiredHumidity.FortyFivePercent elif 50 <= target_humidity < 55: - pywemo_humidity = WEMO_HUMIDITY_50 + pywemo_humidity = DesiredHumidity.FiftyPercent elif 55 <= target_humidity < 60: - pywemo_humidity = WEMO_HUMIDITY_55 + pywemo_humidity = DesiredHumidity.FiftyFivePercent elif 60 <= target_humidity < 100: - pywemo_humidity = WEMO_HUMIDITY_60 + pywemo_humidity = DesiredHumidity.SixtyPercent elif target_humidity >= 100: - pywemo_humidity = WEMO_HUMIDITY_100 + pywemo_humidity = DesiredHumidity.OneHundredPercent - with self._wemo_exception_handler("set humidity"): + with self._wemo_call_wrapper("set humidity"): self.wemo.set_humidity(pywemo_humidity) - self.schedule_update_ha_state() - def reset_filter_life(self) -> None: """Reset the filter life to 100%.""" - with self._wemo_exception_handler("reset filter life"): + with self._wemo_call_wrapper("reset filter life"): self.wemo.reset_filter_life() - - self.schedule_update_ha_state() diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index c46a4e78440ffe..b7c6aefa86803d 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -1,5 +1,8 @@ """Support for Belkin WeMo lights.""" +from __future__ import annotations + import asyncio +from typing import Any, Optional, cast from pywemo.ouimeaux_device import bridge @@ -14,14 +17,16 @@ SUPPORT_TRANSITION, LightEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoEntity +from .entity import WemoBinaryStateEntity, WemoEntity from .wemo_device import DeviceCoordinator SUPPORT_WEMO = ( @@ -32,10 +37,14 @@ WEMO_OFF = 0 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up WeMo lights.""" - async def _discovered_wemo(coordinator: DeviceCoordinator): + async def _discovered_wemo(coordinator: DeviceCoordinator) -> None: """Handle a discovered Wemo device.""" if isinstance(coordinator.wemo, bridge.Bridge): async_setup_bridge(hass, config_entry, async_add_entities, coordinator) @@ -53,12 +62,17 @@ async def _discovered_wemo(coordinator: DeviceCoordinator): @callback -def async_setup_bridge(hass, config_entry, async_add_entities, coordinator): +def async_setup_bridge( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + coordinator: DeviceCoordinator, +) -> None: """Set up a WeMo link.""" known_light_ids = set() @callback - def async_update_lights(): + def async_update_lights() -> None: """Check to see if the bridge has any new lights.""" new_lights = [] @@ -87,7 +101,7 @@ def __init__(self, coordinator: DeviceCoordinator, light: bridge.Light) -> None: @property def name(self) -> str: """Return the name of the device if any.""" - return self.light.name + return cast(str, self.light.name) @property def available(self) -> bool: @@ -95,9 +109,9 @@ def available(self) -> bool: return super().available and self.light.state.get("available") @property - def unique_id(self): + def unique_id(self) -> str: """Return the ID of this light.""" - return self.light.uniqueID + return cast(str, self.light.uniqueID) @property def device_info(self) -> DeviceInfo: @@ -111,33 +125,33 @@ def device_info(self) -> DeviceInfo: ) @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" - return self.light.state.get("level", 255) + return cast(int, self.light.state.get("level", 255)) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float] | None: """Return the hs color values of this light.""" if xy_color := self.light.state.get("color_xy"): return color_util.color_xy_to_hs(*xy_color) return None @property - def color_temp(self): + def color_temp(self) -> int | None: """Return the color temperature of this light in mireds.""" - return self.light.state.get("temperature_mireds") + return cast(Optional[int], self.light.state.get("temperature_mireds")) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" - return self.light.state.get("onoff") != WEMO_OFF + return cast(int, self.light.state.get("onoff")) != WEMO_OFF @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_WEMO - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" xy_color = None @@ -155,7 +169,7 @@ def turn_on(self, **kwargs): "force_update": False, } - with self._wemo_exception_handler("turn on"): + with self._wemo_call_wrapper("turn on"): if xy_color is not None: self.light.set_color(xy_color, transition=transition_time) @@ -166,55 +180,42 @@ def turn_on(self, **kwargs): self.light.turn_on(**turn_on_kwargs) - self.schedule_update_ha_state() - - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" transition_time = int(kwargs.get(ATTR_TRANSITION, 0)) - with self._wemo_exception_handler("turn off"): + with self._wemo_call_wrapper("turn off"): self.light.turn_off(transition=transition_time) - self.schedule_update_ha_state() - -class WemoDimmer(WemoEntity, LightEntity): +class WemoDimmer(WemoBinaryStateEntity, LightEntity): """Representation of a WeMo dimmer.""" @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_BRIGHTNESS @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 1 and 100.""" - wemo_brightness = int(self.wemo.get_brightness()) + wemo_brightness: int = self.wemo.get_brightness() return int((wemo_brightness * 255) / 100) - @property - def is_on(self) -> bool: - """Return true if the state is on.""" - return self.wemo.get_state() - - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the dimmer on.""" # Wemo dimmer switches use a range of [0, 100] to control # brightness. Level 255 might mean to set it to previous value if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] brightness = int((brightness / 255) * 100) - with self._wemo_exception_handler("set brightness"): + with self._wemo_call_wrapper("set brightness"): self.wemo.set_brightness(brightness) else: - with self._wemo_exception_handler("turn on"): + with self._wemo_call_wrapper("turn on"): self.wemo.on() - self.schedule_update_ha_state() - - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the dimmer off.""" - with self._wemo_exception_handler("turn off"): + with self._wemo_call_wrapper("turn off"): self.wemo.off() - - self.schedule_update_ha_state() diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 654ac92df56624..eed5c510936409 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -2,18 +2,16 @@ import asyncio from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) -from homeassistant.const import ( - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import convert @@ -22,10 +20,14 @@ from .wemo_device import DeviceCoordinator -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up WeMo sensors.""" - async def _discovered_wemo(coordinator: DeviceCoordinator): + async def _discovered_wemo(coordinator: DeviceCoordinator) -> None: """Handle a discovered Wemo device.""" async_add_entities( [InsightCurrentPower(coordinator), InsightTodayEnergy(coordinator)] @@ -47,6 +49,7 @@ class InsightSensor(WemoEntity, SensorEntity): @property def name_suffix(self) -> str: """Return the name of the entity if any.""" + assert self.entity_description.name return self.entity_description.name @property @@ -55,7 +58,7 @@ def unique_id_suffix(self) -> str: return self.entity_description.key @property - def available(self) -> str: + def available(self) -> bool: """Return true if sensor is available.""" return ( self.entity_description.key in self.wemo.insight_params @@ -69,20 +72,19 @@ class InsightCurrentPower(InsightSensor): entity_description = SensorEntityDescription( key="currentpower", name="Current Power", - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=POWER_WATT, ) @property def native_value(self) -> StateType: """Return the current power consumption.""" - return ( - convert( - self.wemo.insight_params.get(self.entity_description.key), float, 0.0 - ) - / 1000.0 + milliwatts = convert( + self.wemo.insight_params.get(self.entity_description.key), float, 0.0 ) + assert isinstance(milliwatts, float) + return milliwatts / 1000.0 class InsightTodayEnergy(InsightSensor): @@ -91,15 +93,16 @@ class InsightTodayEnergy(InsightSensor): entity_description = SensorEntityDescription( key="todaymw", name="Today Energy", - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ) @property def native_value(self) -> StateType: """Return the current energy use today.""" - miliwatts = convert( + milliwatt_seconds = convert( self.wemo.insight_params.get(self.entity_description.key), float, 0.0 ) - return round(miliwatts / (1000.0 * 1000.0 * 60), 2) + assert isinstance(milliwatt_seconds, float) + return round(milliwatt_seconds / (1000.0 * 1000.0 * 60), 2) diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index d1240d034b5d67..8f8e5dcb5e3ff6 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -1,16 +1,23 @@ """Support for WeMo switches.""" +from __future__ import annotations + import asyncio from datetime import datetime, timedelta +from typing import Any, cast from pywemo import CoffeeMaker, Insight, Maker from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import convert from .const import DOMAIN as WEMO_DOMAIN -from .entity import WemoEntity +from .entity import WemoBinaryStateEntity +from .wemo_device import DeviceCoordinator SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 @@ -29,10 +36,14 @@ WEMO_STANDBY = 8 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up WeMo switches.""" - async def _discovered_wemo(coordinator): + async def _discovered_wemo(coordinator: DeviceCoordinator) -> None: """Handle a discovered Wemo device.""" async_add_entities([WemoSwitch(coordinator)]) @@ -46,13 +57,13 @@ async def _discovered_wemo(coordinator): ) -class WemoSwitch(WemoEntity, SwitchEntity): +class WemoSwitch(WemoBinaryStateEntity, SwitchEntity): """Representation of a WeMo switch.""" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the device.""" - attr = {} + attr: dict[str, Any] = {} if isinstance(self.wemo, Maker): # Is the maker sensor on or off. if self.wemo.maker_params["hassensor"]: @@ -81,10 +92,11 @@ def extra_state_attributes(self): attr["on_total_time"] = WemoSwitch.as_uptime( self.wemo.insight_params.get("ontotal", 0) ) - attr["power_threshold_w"] = ( - convert(self.wemo.insight_params.get("powerthreshold"), float, 0.0) - / 1000.0 + threshold = convert( + self.wemo.insight_params.get("powerthreshold"), float, 0.0 ) + assert isinstance(threshold, float) + attr["power_threshold_w"] = threshold / 1000.0 if isinstance(self.wemo, CoffeeMaker): attr[ATTR_COFFEMAKER_MODE] = self.wemo.mode @@ -92,7 +104,7 @@ def extra_state_attributes(self): return attr @staticmethod - def as_uptime(_seconds): + def as_uptime(_seconds: int) -> str: """Format seconds into uptime string in the format: 00d 00h 00m 00s.""" uptime = datetime(1, 1, 1) + timedelta(seconds=_seconds) return "{:0>2d}d {:0>2d}h {:0>2d}m {:0>2d}s".format( @@ -100,26 +112,28 @@ def as_uptime(_seconds): ) @property - def current_power_w(self): + def current_power_w(self) -> float | None: """Return the current power usage in W.""" - if isinstance(self.wemo, Insight): - return ( - convert(self.wemo.insight_params.get("currentpower"), float, 0.0) - / 1000.0 - ) + if not isinstance(self.wemo, Insight): + return None + milliwatts = convert(self.wemo.insight_params.get("currentpower"), float, 0.0) + assert isinstance(milliwatts, float) + return milliwatts / 1000.0 @property - def today_energy_kwh(self): + def today_energy_kwh(self) -> float | None: """Return the today total energy usage in kWh.""" - if isinstance(self.wemo, Insight): - miliwatts = convert(self.wemo.insight_params.get("todaymw"), float, 0.0) - return round(miliwatts / (1000.0 * 1000.0 * 60), 2) + if not isinstance(self.wemo, Insight): + return None + milliwatt_seconds = convert(self.wemo.insight_params.get("todaymw"), float, 0.0) + assert isinstance(milliwatt_seconds, float) + return round(milliwatt_seconds / (1000.0 * 1000.0 * 60), 2) @property - def detail_state(self): + def detail_state(self) -> str: """Return the state of the device.""" if isinstance(self.wemo, CoffeeMaker): - return self.wemo.mode_string + return cast(str, self.wemo.mode_string) if isinstance(self.wemo, Insight): standby_state = int(self.wemo.insight_params.get("state", 0)) if standby_state == WEMO_ON: @@ -129,29 +143,21 @@ def detail_state(self): if standby_state == WEMO_STANDBY: return STATE_STANDBY return STATE_UNKNOWN + assert False # Unreachable code statement. @property - def icon(self): + def icon(self) -> str | None: """Return the icon of device based on its type.""" if isinstance(self.wemo, CoffeeMaker): return "mdi:coffee" return None - @property - def is_on(self) -> bool: - """Return true if the state is on. Standby is on.""" - return self.wemo.get_state() - - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - with self._wemo_exception_handler("turn on"): + with self._wemo_call_wrapper("turn on"): self.wemo.on() - self.schedule_update_ha_state() - - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - with self._wemo_exception_handler("turn off"): + with self._wemo_call_wrapper("turn off"): self.wemo.off() - - self.schedule_update_ha_state() diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index a4f20eb55f5996..3ca47544fd74f0 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -16,7 +16,10 @@ CONF_UNIQUE_ID, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import async_get as async_get_device_registry +from homeassistant.helpers.device_registry import ( + CONNECTION_UPNP, + async_get as async_get_device_registry, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -61,7 +64,7 @@ def subscription_callback( ) else: updated = self.wemo.subscription_update(event_type, params) - self.hass.add_job(self._async_subscription_callback(updated)) + self.hass.create_task(self._async_subscription_callback(updated)) async def _async_subscription_callback(self, updated: bool) -> None: """Update the state by the Wemo device.""" @@ -120,13 +123,21 @@ async def _async_locked_update(self, force_update: bool) -> None: except ActionException as err: raise UpdateFailed("WeMo update failed") from err + @callback + def async_update_listeners(self) -> None: + """Update all listeners.""" + for update_callback in self._listeners: + update_callback() + def _device_info(wemo: WeMoDevice) -> DeviceInfo: return DeviceInfo( + connections={(CONNECTION_UPNP, wemo.udn)}, identifiers={(DOMAIN, wemo.serialnumber)}, manufacturer="Belkin", model=wemo.model_name, name=wemo.name, + sw_version=wemo.firmware_version, ) @@ -165,4 +176,5 @@ async def async_register_device( @callback def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordinator: """Return DeviceCoordinator for device_id.""" - return hass.data[DOMAIN]["devices"][device_id] + coordinator: DeviceCoordinator = hass.data[DOMAIN]["devices"][device_id] + return coordinator diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index c53b9a59ef4e63..659b4602f0d868 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -5,6 +5,7 @@ from whirlpool.auth import Auth from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -12,7 +13,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["climate"] +PLATFORMS = [Platform.CLIMATE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index ceb68ec29eb6cb..40d3e80d3531e5 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -23,7 +23,10 @@ SWING_HORIZONTAL, SWING_OFF, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import AUTH_INSTANCE_KEY, DOMAIN @@ -61,7 +64,11 @@ SUPPORTED_TARGET_TEMPERATURE_STEP = 1 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up entry.""" auth: Auth = hass.data[DOMAIN][config_entry.entry_id][AUTH_INSTANCE_KEY] if not (said_list := auth.get_said_list()): diff --git a/homeassistant/components/whirlpool/translations/fr.json b/homeassistant/components/whirlpool/translations/fr.json index 0cfccfa88ad80f..63e63fd1953927 100644 --- a/homeassistant/components/whirlpool/translations/fr.json +++ b/homeassistant/components/whirlpool/translations/fr.json @@ -1,8 +1,14 @@ { "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, "step": { "user": { "data": { + "password": "Mot de passe", "username": "Nom d'utilisateur" } } diff --git a/homeassistant/components/whois/__init__.py b/homeassistant/components/whois/__init__.py index 3f3ffefde4877d..aa64ebecf83132 100644 --- a/homeassistant/components/whois/__init__.py +++ b/homeassistant/components/whois/__init__.py @@ -1 +1,54 @@ -"""The whois component.""" +"""The Whois integration.""" +from __future__ import annotations + +from whois import Domain, query as whois_query +from whois.exceptions import ( + FailedParsingWhoisOutput, + UnknownDateFormat, + UnknownTld, + WhoisCommandFailed, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, PLATFORMS, SCAN_INTERVAL + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + + async def _async_query_domain() -> Domain | None: + """Query WHOIS for domain information.""" + try: + return await hass.async_add_executor_job( + whois_query, entry.data[CONF_DOMAIN] + ) + except UnknownTld as ex: + raise UpdateFailed("Could not set up whois, TLD is unknown") from ex + except (FailedParsingWhoisOutput, WhoisCommandFailed, UnknownDateFormat) as ex: + raise UpdateFailed("An error occurred during WHOIS lookup") from ex + + coordinator: DataUpdateCoordinator[Domain | None] = DataUpdateCoordinator( + hass, + LOGGER, + name=f"{DOMAIN}_APK", + update_interval=SCAN_INTERVAL, + update_method=_async_query_domain, + ) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/whois/config_flow.py b/homeassistant/components/whois/config_flow.py new file mode 100644 index 00000000000000..93a15f7fa39fcc --- /dev/null +++ b/homeassistant/components/whois/config_flow.py @@ -0,0 +1,80 @@ +"""Config flow to configure the Whois integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +import whois +from whois.exceptions import ( + FailedParsingWhoisOutput, + UnknownDateFormat, + UnknownTld, + WhoisCommandFailed, +) + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_DOMAIN, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class WhoisFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Whois.""" + + VERSION = 1 + + imported_name: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + domain = user_input[CONF_DOMAIN].lower() + + await self.async_set_unique_id(domain) + self._abort_if_unique_id_configured() + + try: + await self.hass.async_add_executor_job(whois.query, domain) + except UnknownTld: + errors["base"] = "unknown_tld" + except WhoisCommandFailed: + errors["base"] = "whois_command_failed" + except FailedParsingWhoisOutput: + errors["base"] = "unexpected_response" + except UnknownDateFormat: + errors["base"] = "unknown_date_format" + else: + return self.async_create_entry( + title=self.imported_name or user_input[CONF_DOMAIN], + data={ + CONF_DOMAIN: domain, + }, + ) + else: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_DOMAIN, default=user_input.get(CONF_DOMAIN, "") + ): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle a flow initialized by importing a config.""" + self.imported_name = config[CONF_NAME] + return await self.async_step_user( + user_input={ + CONF_DOMAIN: config[CONF_DOMAIN], + } + ) diff --git a/homeassistant/components/whois/const.py b/homeassistant/components/whois/const.py new file mode 100644 index 00000000000000..8530d2e558fe11 --- /dev/null +++ b/homeassistant/components/whois/const.py @@ -0,0 +1,22 @@ +"""Constants for the Whois integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "whois" +PLATFORMS = [Platform.SENSOR] + +LOGGER = logging.getLogger(__package__) + +SCAN_INTERVAL = timedelta(hours=24) + +DEFAULT_NAME = "Whois" + +ATTR_EXPIRES = "expires" +ATTR_NAME_SERVERS = "name_servers" +ATTR_REGISTRAR = "registrar" +ATTR_UPDATED = "updated" diff --git a/homeassistant/components/whois/diagnostics.py b/homeassistant/components/whois/diagnostics.py new file mode 100644 index 00000000000000..19b3822e55c6f5 --- /dev/null +++ b/homeassistant/components/whois/diagnostics.py @@ -0,0 +1,27 @@ +"""Diagnostics support for Whois.""" +from __future__ import annotations + +from typing import Any + +from whois import Domain + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: DataUpdateCoordinator[Domain] = hass.data[DOMAIN][entry.entry_id] + return { + "creation_date": coordinator.data.creation_date, + "expiration_date": coordinator.data.expiration_date, + "last_updated": coordinator.data.last_updated, + "status": coordinator.data.status, + "statuses": coordinator.data.statuses, + "dnssec": coordinator.data.dnssec, + } diff --git a/homeassistant/components/whois/manifest.json b/homeassistant/components/whois/manifest.json index f591d7bb478242..acfb9e2178a77e 100644 --- a/homeassistant/components/whois/manifest.json +++ b/homeassistant/components/whois/manifest.json @@ -2,7 +2,8 @@ "domain": "whois", "name": "Whois", "documentation": "https://www.home-assistant.io/integrations/whois", - "requirements": ["python-whois==0.7.3"], - "codeowners": [], + "requirements": ["whois==0.9.13"], + "config_flow": true, + "codeowners": ["@frenck"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 5d5e595fa50950..3d0b25640b3a58 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -1,24 +1,42 @@ """Get WHOIS information for a given host.""" -from datetime import timedelta -import logging +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import cast import voluptuous as vol -import whois +from whois import Domain -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_NAME, TIME_DAYS +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Whois" - -ATTR_EXPIRES = "expires" -ATTR_NAME_SERVERS = "name_servers" -ATTR_REGISTRAR = "registrar" -ATTR_UPDATED = "updated" - -SCAN_INTERVAL = timedelta(hours=24) +from .const import ( + ATTR_EXPIRES, + ATTR_NAME_SERVERS, + ATTR_REGISTRAR, + ATTR_UPDATED, + DEFAULT_NAME, + DOMAIN, + LOGGER, +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -28,114 +46,207 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +@dataclass +class WhoisSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Domain], datetime | int | str | None] + + +@dataclass +class WhoisSensorEntityDescription( + SensorEntityDescription, WhoisSensorEntityDescriptionMixin +): + """Describes a Whois sensor entity.""" + + +def _days_until_expiration(domain: Domain) -> int | None: + """Calculate days left until domain expires.""" + if domain.expiration_date is None: + return None + # We need to cast here, as (unlike Pyright) mypy isn't able to determine the type. + return cast(int, (domain.expiration_date - domain.expiration_date.utcnow()).days) + + +def _ensure_timezone(timestamp: datetime | None) -> datetime | None: + """Calculate days left until domain expires.""" + if timestamp is None: + return None + + # If timezone info isn't provided by the Whois, assume UTC. + if timestamp.tzinfo is None: + return timestamp.replace(tzinfo=timezone.utc) + + return timestamp + + +SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( + WhoisSensorEntityDescription( + key="admin", + name="Admin", + icon="mdi:account-star", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda domain: getattr(domain, "admin", None), + ), + WhoisSensorEntityDescription( + key="creation_date", + name="Created", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda domain: _ensure_timezone(domain.creation_date), + ), + WhoisSensorEntityDescription( + key="days_until_expiration", + name="Days Until Expiration", + icon="mdi:calendar-clock", + native_unit_of_measurement=TIME_DAYS, + value_fn=_days_until_expiration, + ), + WhoisSensorEntityDescription( + key="expiration_date", + name="Expires", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda domain: _ensure_timezone(domain.expiration_date), + ), + WhoisSensorEntityDescription( + key="last_updated", + name="Last Updated", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda domain: _ensure_timezone(domain.last_updated), + ), + WhoisSensorEntityDescription( + key="owner", + name="Owner", + icon="mdi:account", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda domain: getattr(domain, "owner", None), + ), + WhoisSensorEntityDescription( + key="registrant", + name="Registrant", + icon="mdi:account-edit", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda domain: getattr(domain, "registrant", None), + ), + WhoisSensorEntityDescription( + key="registrar", + name="Registrar", + icon="mdi:store", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda domain: domain.registrar if domain.registrar else None, + ), + WhoisSensorEntityDescription( + key="reseller", + name="Reseller", + icon="mdi:store", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda domain: getattr(domain, "reseller", None), + ), +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the WHOIS sensor.""" - domain = config.get(CONF_DOMAIN) - name = config.get(CONF_NAME) - - try: - if "expiration_date" in whois.whois(domain): - add_entities([WhoisSensor(name, domain)], True) - else: - _LOGGER.error( - "WHOIS lookup for %s didn't contain an expiration date", domain + LOGGER.warning( + "Configuration of the Whois platform in YAML is deprecated and will be " + "removed in Home Assistant 2022.4; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_DOMAIN: config[CONF_DOMAIN], CONF_NAME: config[CONF_NAME]}, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the platform from config_entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + WhoisSensorEntity( + coordinator=coordinator, + description=description, + domain=entry.data[CONF_DOMAIN], ) - return - except whois.BaseException as ex: # pylint: disable=broad-except - _LOGGER.error("Exception %s occurred during WHOIS lookup for %s", ex, domain) - return + for description in SENSORS + ], + ) -class WhoisSensor(SensorEntity): +class WhoisSensorEntity(CoordinatorEntity, SensorEntity): """Implementation of a WHOIS sensor.""" - def __init__(self, name, domain): - """Initialize the sensor.""" - self.whois = whois.whois + entity_description: WhoisSensorEntityDescription - self._name = name + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: WhoisSensorEntityDescription, + domain: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self._attr_name = f"{domain} {description.name}" + self._attr_unique_id = f"{domain}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, domain)}, + entry_type=DeviceEntryType.SERVICE, + ) self._domain = domain - self._state = None - self._attributes = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name + def native_value(self) -> datetime | int | str | None: + """Return the state of the sensor.""" + if self.coordinator.data is None: + return None + return self.entity_description.value_fn(self.coordinator.data) @property - def icon(self): - """Return the icon to represent this sensor.""" - return "mdi:calendar-clock" + def extra_state_attributes(self) -> dict[str, int | float | None] | None: + """Return the state attributes of the monitored installation.""" - @property - def native_unit_of_measurement(self): - """Return the unit of measurement to present the value in.""" - return TIME_DAYS + # Only add attributes to the original sensor + if self.entity_description.key != "days_until_expiration": + return None - @property - def native_value(self): - """Return the expiration days for hostname.""" - return self._state + if self.coordinator.data is None: + return None - @property - def extra_state_attributes(self): - """Get the more info attributes.""" - return self._attributes - - def _empty_state_and_attributes(self): - """Empty the state and attributes on an error.""" - self._state = None - self._attributes = None - - def update(self): - """Get the current WHOIS data for the domain.""" - try: - response = self.whois(self._domain) - except whois.BaseException as ex: # pylint: disable=broad-except - _LOGGER.error("Exception %s occurred during WHOIS lookup", ex) - self._empty_state_and_attributes() - return - - if response: - if "expiration_date" not in response: - _LOGGER.error( - "Failed to find expiration_date in whois lookup response. " - "Did find: %s", - ", ".join(response.keys()), - ) - self._empty_state_and_attributes() - return - - if not response["expiration_date"]: - _LOGGER.error("Whois response contains empty expiration_date") - self._empty_state_and_attributes() - return - - attrs = {} - - expiration_date = response["expiration_date"] - if isinstance(expiration_date, list): - attrs[ATTR_EXPIRES] = expiration_date[0].isoformat() - expiration_date = expiration_date[0] - else: - attrs[ATTR_EXPIRES] = expiration_date.isoformat() - - if "nameservers" in response: - attrs[ATTR_NAME_SERVERS] = " ".join(response["nameservers"]) - - if "updated_date" in response: - update_date = response["updated_date"] - if isinstance(update_date, list): - attrs[ATTR_UPDATED] = update_date[0].isoformat() - else: - attrs[ATTR_UPDATED] = update_date.isoformat() - - if "registrar" in response: - attrs[ATTR_REGISTRAR] = response["registrar"] - - time_delta = expiration_date - expiration_date.now() - - self._attributes = attrs - self._state = time_delta.days + attrs = {} + if expiration_date := self.coordinator.data.expiration_date: + attrs[ATTR_EXPIRES] = expiration_date.isoformat() + + if name_servers := self.coordinator.data.name_servers: + attrs[ATTR_NAME_SERVERS] = " ".join(name_servers) + + if last_updated := self.coordinator.data.last_updated: + attrs[ATTR_UPDATED] = last_updated.isoformat() + + if registrar := self.coordinator.data.registrar: + attrs[ATTR_REGISTRAR] = registrar + + if not attrs: + return None + + return attrs diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json new file mode 100644 index 00000000000000..553293962cde49 --- /dev/null +++ b/homeassistant/components/whois/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "domain": "Domain name" + } + } + }, + "error": { + "unexpected_response": "Unexpected response from whois server", + "unknown_date_format": "Unknown date format in whois server response", + "unknown_tld": "The given TLD is unknown or not available to this integration", + "whois_command_failed": "Whois command failed: could not retrieve whois information" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/whois/translations/bg.json b/homeassistant/components/whois/translations/bg.json new file mode 100644 index 00000000000000..058ed137c3b85f --- /dev/null +++ b/homeassistant/components/whois/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, + "step": { + "user": { + "data": { + "domain": "\u0418\u043c\u0435 \u043d\u0430 \u0434\u043e\u043c\u0435\u0439\u043d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/ca.json b/homeassistant/components/whois/translations/ca.json new file mode 100644 index 00000000000000..d5f35fd9f088f3 --- /dev/null +++ b/homeassistant/components/whois/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El servei ja est\u00e0 configurat" + }, + "error": { + "unexpected_response": "Resposta inesperada del servidor whois", + "unknown_date_format": "Format de data desconegut a la resposta del servidor whois", + "unknown_tld": "El TLD \u00e9s desconegut o no est\u00e0 disponible per a aquesta integraci\u00f3", + "whois_command_failed": "La comanda whois ha fallat: no s'ha pogut obtenir la informaci\u00f3 whois" + }, + "step": { + "user": { + "data": { + "domain": "Nom del domini" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/cs.json b/homeassistant/components/whois/translations/cs.json new file mode 100644 index 00000000000000..8440070c91a4ee --- /dev/null +++ b/homeassistant/components/whois/translations/cs.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Slu\u017eba je ji\u017e nastavena" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/de.json b/homeassistant/components/whois/translations/de.json new file mode 100644 index 00000000000000..3288dca40eb2c0 --- /dev/null +++ b/homeassistant/components/whois/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Der Dienst ist bereits konfiguriert" + }, + "error": { + "unexpected_response": "Unerwartete Antwort vom Whois-Server", + "unknown_date_format": "Unbekanntes Datumsformat in Antwort des Whois-Servers", + "unknown_tld": "Die angegebene TLD ist unbekannt oder f\u00fcr diese Integration nicht verf\u00fcgbar", + "whois_command_failed": "Whois-Befehl fehlgeschlagen: Whois-Informationen konnten nicht abgerufen werden" + }, + "step": { + "user": { + "data": { + "domain": "Dom\u00e4nenname" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/el.json b/homeassistant/components/whois/translations/el.json new file mode 100644 index 00000000000000..b116200db57879 --- /dev/null +++ b/homeassistant/components/whois/translations/el.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "unexpected_response": "\u039c\u03b7 \u03b1\u03bd\u03b1\u03bc\u03b5\u03bd\u03cc\u03bc\u03b5\u03bd\u03b7 \u03b1\u03c0\u03ac\u03bd\u03c4\u03b7\u03c3\u03b7 \u03b1\u03c0\u03cc \u03c4\u03bf\u03bd \u03b4\u03b9\u03b1\u03ba\u03bf\u03bc\u03b9\u03c3\u03c4\u03ae whois", + "unknown_date_format": "\u0386\u03b3\u03bd\u03c9\u03c3\u03c4\u03b7 \u03bc\u03bf\u03c1\u03c6\u03ae \u03b7\u03bc\u03b5\u03c1\u03bf\u03bc\u03b7\u03bd\u03af\u03b1\u03c2 \u03c3\u03c4\u03b7\u03bd \u03b1\u03c0\u03cc\u03ba\u03c1\u03b9\u03c3\u03b7 \u03b4\u03b9\u03b1\u03ba\u03bf\u03bc\u03b9\u03c3\u03c4\u03ae whois", + "unknown_tld": "\u03a4\u03bf \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03bf TLD \u03b5\u03af\u03bd\u03b1\u03b9 \u03ac\u03b3\u03bd\u03c9\u03c3\u03c4\u03bf \u03ae \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03bf \u03c3\u03b5 \u03b1\u03c5\u03c4\u03ae\u03bd \u03c4\u03b7\u03bd \u03b5\u03bd\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7", + "whois_command_failed": "\u0397 \u03b5\u03bd\u03c4\u03bf\u03bb\u03ae Whois \u03b1\u03c0\u03ad\u03c4\u03c5\u03c7\u03b5: \u03b4\u03b5\u03bd \u03ae\u03c4\u03b1\u03bd \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03b1\u03bd\u03ac\u03ba\u03c4\u03b7\u03c3\u03b7 \u03c4\u03c9\u03bd \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03b9\u03ce\u03bd whois" + }, + "step": { + "user": { + "data": { + "domain": "\u039f\u03bd\u03bf\u03bc\u03b1 \u03c4\u03bf\u03bc\u03ad\u03b1" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/en.json b/homeassistant/components/whois/translations/en.json new file mode 100644 index 00000000000000..4000621ba063ed --- /dev/null +++ b/homeassistant/components/whois/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured" + }, + "error": { + "unexpected_response": "Unexpected response from whois server", + "unknown_date_format": "Unknown date format in whois server response", + "unknown_tld": "The given TLD is unknown or not available to this integration", + "whois_command_failed": "Whois command failed: could not retrieve whois information" + }, + "step": { + "user": { + "data": { + "domain": "Domain name" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/es.json b/homeassistant/components/whois/translations/es.json new file mode 100644 index 00000000000000..712c85865cd28b --- /dev/null +++ b/homeassistant/components/whois/translations/es.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "El servicio ya est\u00e1 configurado" + }, + "step": { + "user": { + "data": { + "domain": "Nombre de dominio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/et.json b/homeassistant/components/whois/translations/et.json new file mode 100644 index 00000000000000..c0f718a99d6822 --- /dev/null +++ b/homeassistant/components/whois/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Teenus on juba seadistatud" + }, + "error": { + "unexpected_response": "Ootamatu vastus WHOIS serverist", + "unknown_date_format": "Tundmatu kuup\u00e4evavorming whois serveri vastuses", + "unknown_tld": "Antud tippdomeen on tundmatu v\u00f5i pole selle sidumise jaoks saadaval", + "whois_command_failed": "Whois k\u00e4sk nurjus: whois'i teavet ei \u00f5nnestunud hankida" + }, + "step": { + "user": { + "data": { + "domain": "Domeeninimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/fr.json b/homeassistant/components/whois/translations/fr.json new file mode 100644 index 00000000000000..e45ecd615da958 --- /dev/null +++ b/homeassistant/components/whois/translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Service d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "unexpected_response": "R\u00e9ponse inattendue du serveur whois", + "unknown_date_format": "Format de date inconnu dans la r\u00e9ponse du serveur whois", + "unknown_tld": "Le TLD donn\u00e9 est inconnu ou n'est pas disponible pour cette int\u00e9gration", + "whois_command_failed": "\u00c9chec de la commande Whois\u00a0: impossible de r\u00e9cup\u00e9rer les informations whois" + }, + "step": { + "user": { + "data": { + "domain": "Nom de domaine" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/he.json b/homeassistant/components/whois/translations/he.json new file mode 100644 index 00000000000000..48a6eeeea33b15 --- /dev/null +++ b/homeassistant/components/whois/translations/he.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/hu.json b/homeassistant/components/whois/translations/hu.json new file mode 100644 index 00000000000000..85aad08e86795a --- /dev/null +++ b/homeassistant/components/whois/translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "unexpected_response": "V\u00e1ratlan v\u00e1lasz a whois szervert\u0151l", + "unknown_date_format": "Ismeretlen d\u00e1tumform\u00e1tum a whois szerver v\u00e1lasz\u00e1ban", + "unknown_tld": "A megadott TLD ismeretlen vagy nem el\u00e9rhet\u0151 az integr\u00e1ci\u00f3 sz\u00e1m\u00e1ra.", + "whois_command_failed": "Sikertelen whois parancs: nem siker\u00fclt lek\u00e9rni a whois inform\u00e1ci\u00f3kat" + }, + "step": { + "user": { + "data": { + "domain": "Domain n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/id.json b/homeassistant/components/whois/translations/id.json new file mode 100644 index 00000000000000..fa8cd415378a7e --- /dev/null +++ b/homeassistant/components/whois/translations/id.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Layanan sudah dikonfigurasi" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/it.json b/homeassistant/components/whois/translations/it.json new file mode 100644 index 00000000000000..ccc4a08cfd0e19 --- /dev/null +++ b/homeassistant/components/whois/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Il servizio \u00e8 gi\u00e0 configurato" + }, + "error": { + "unexpected_response": "Risposta inattesa dal server whois", + "unknown_date_format": "Formato della data sconosciuto nella risposta del server whois", + "unknown_tld": "Il TLD specificato \u00e8 sconosciuto o non disponibile per questa integrazione", + "whois_command_failed": "Comando Whois non riuscito: impossibile recuperare le informazioni whois" + }, + "step": { + "user": { + "data": { + "domain": "Nome di dominio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/ja.json b/homeassistant/components/whois/translations/ja.json new file mode 100644 index 00000000000000..7102e95a25d5f6 --- /dev/null +++ b/homeassistant/components/whois/translations/ja.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u30b5\u30fc\u30d3\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "domain": "\u30c9\u30e1\u30a4\u30f3\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/nl.json b/homeassistant/components/whois/translations/nl.json new file mode 100644 index 00000000000000..a6ad64881ef7d1 --- /dev/null +++ b/homeassistant/components/whois/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Service is al geconfigureerd" + }, + "error": { + "unexpected_response": "Onverwacht antwoord van whois-server", + "unknown_date_format": "Onbekende datumnotatie in whois-server antwoord", + "unknown_tld": "De opgegeven TLD is onbekend of niet beschikbaar voor deze integratie", + "whois_command_failed": "Whois-opdracht mislukt: kan whois-informatie niet ophalen" + }, + "step": { + "user": { + "data": { + "domain": "Domeinnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/no.json b/homeassistant/components/whois/translations/no.json new file mode 100644 index 00000000000000..ea14fb0826540f --- /dev/null +++ b/homeassistant/components/whois/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Tjenesten er allerede konfigurert" + }, + "error": { + "unexpected_response": "Uventet svar fra whois server", + "unknown_date_format": "Ukjent datoformat i whois-serversvar", + "unknown_tld": "Den gitte TLD er ukjent eller ikke tilgjengelig for denne integrasjonen", + "whois_command_failed": "Whois-kommando mislyktes: kunne ikke hente whois-informasjon" + }, + "step": { + "user": { + "data": { + "domain": "Domenenavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/pl.json b/homeassistant/components/whois/translations/pl.json new file mode 100644 index 00000000000000..621be0d70cc8e7 --- /dev/null +++ b/homeassistant/components/whois/translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana" + }, + "error": { + "unknown_date_format": "Nieznany format daty w odpowiedzi serwera whois", + "unknown_tld": "Podana domena TLD jest nieznana lub niedost\u0119pna dla tej integracji", + "whois_command_failed": "Komenda Whois nie powiod\u0142a si\u0119: nie mo\u017cna pobra\u0107 informacji whois" + }, + "step": { + "user": { + "data": { + "domain": "Nazwa domeny" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/ru.json b/homeassistant/components/whois/translations/ru.json new file mode 100644 index 00000000000000..b926fc3c12636d --- /dev/null +++ b/homeassistant/components/whois/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0441\u043b\u0443\u0436\u0431\u0430 \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." + }, + "error": { + "unexpected_response": "\u041d\u0435\u043e\u0436\u0438\u0434\u0430\u043d\u043d\u044b\u0439 \u043e\u0442\u0432\u0435\u0442 \u043e\u0442 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 whois.", + "unknown_date_format": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0439 \u0444\u043e\u0440\u043c\u0430\u0442 \u0434\u0430\u0442\u044b \u0432 \u043e\u0442\u0432\u0435\u0442\u0435 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 whois.", + "unknown_tld": "\u0414\u0430\u043d\u043d\u044b\u0439 TLD \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u0435\u043d \u0438\u043b\u0438 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438.", + "whois_command_failed": "\u0421\u0431\u043e\u0439 \u043a\u043e\u043c\u0430\u043d\u0434\u044b Whois: \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e whois." + }, + "step": { + "user": { + "data": { + "domain": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/sv.json b/homeassistant/components/whois/translations/sv.json new file mode 100644 index 00000000000000..c5d4a425a5bf2f --- /dev/null +++ b/homeassistant/components/whois/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "domain": "Dom\u00e4nnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/tr.json b/homeassistant/components/whois/translations/tr.json new file mode 100644 index 00000000000000..aa48f474c4febe --- /dev/null +++ b/homeassistant/components/whois/translations/tr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "unexpected_response": "Whois sunucusundan beklenmeyen yan\u0131t", + "unknown_date_format": "Whois sunucusu yan\u0131t\u0131nda bilinmeyen tarih bi\u00e7imi", + "unknown_tld": "Verilen TLD bilinmiyor veya bu entegrasyon i\u00e7in kullan\u0131lam\u0131yor", + "whois_command_failed": "Whois komutu ba\u015far\u0131s\u0131z oldu: whois bilgileri al\u0131namad\u0131" + }, + "step": { + "user": { + "data": { + "domain": "Alan ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whois/translations/zh-Hant.json b/homeassistant/components/whois/translations/zh-Hant.json new file mode 100644 index 00000000000000..ccfd6123254368 --- /dev/null +++ b/homeassistant/components/whois/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52d9\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "unexpected_response": "\u4f86\u81ea Whois \u4f3a\u670d\u5668\u672a\u9810\u671f\u56de\u61c9", + "unknown_date_format": "Whois \u4f3a\u670d\u5668\u56de\u61c9\u5305\u542b\u672a\u77e5\u8cc7\u6599\u683c\u5f0f", + "unknown_tld": "\u586b\u5beb TLD \u672a\u77e5\u6216\u7121\u6cd5\u4f7f\u7528\u65bc\u6b64\u6574\u5408", + "whois_command_failed": "Whois \u547d\u4ee4\u5931\u6557\uff1a\u7121\u6cd5\u53d6\u5f97 whois \u8cc7\u8a0a" + }, + "step": { + "user": { + "data": { + "domain": "\u7db2\u57df\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index a7f6b8a7b22793..ab5bda8dd1d961 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -6,7 +6,7 @@ from wiffi import WiffiTcpServer from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, CONF_TIMEOUT +from homeassistant.const import CONF_PORT, CONF_TIMEOUT, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry @@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor", "binary_sensor"] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index f6063b3c202cad..d0647b2529701e 100644 --- a/homeassistant/components/wiffi/binary_sensor.py +++ b/homeassistant/components/wiffi/binary_sensor.py @@ -1,14 +1,19 @@ """Binary sensor platform support for wiffi devices.""" - from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import WiffiEntity from .const import CREATE_ENTITY_SIGNAL -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up platform for a new integration. Called by the HA framework after async_forward_entry_setup has been called diff --git a/homeassistant/components/wiffi/config_flow.py b/homeassistant/components/wiffi/config_flow.py index f9456f25df2ce4..5087915181eb6e 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -38,6 +38,7 @@ async def async_step_user(self, user_input=None): return self._async_show_form() # received input from form or configuration.yaml + self._async_abort_entries_match(user_input) try: # try to start server to check whether port is in use diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index c16ae3c6aca5ab..e4c0597d1be875 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -1,17 +1,14 @@ """Sensor platform support for wiffi devices.""" - from homeassistant.components.sensor import ( - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, + SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE, PRESSURE_MBAR, TEMP_CELSIUS -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import WiffiEntity from .const import CREATE_ENTITY_SIGNAL @@ -25,10 +22,10 @@ # map to determine HA device class from wiffi's unit of measurement UOM_TO_DEVICE_CLASS_MAP = { - WIFFI_UOM_TEMP_CELSIUS: DEVICE_CLASS_TEMPERATURE, - WIFFI_UOM_PERCENT: DEVICE_CLASS_HUMIDITY, - WIFFI_UOM_MILLI_BAR: DEVICE_CLASS_PRESSURE, - WIFFI_UOM_LUX: DEVICE_CLASS_ILLUMINANCE, + WIFFI_UOM_TEMP_CELSIUS: SensorDeviceClass.TEMPERATURE, + WIFFI_UOM_PERCENT: SensorDeviceClass.HUMIDITY, + WIFFI_UOM_MILLI_BAR: SensorDeviceClass.PRESSURE, + WIFFI_UOM_LUX: SensorDeviceClass.ILLUMINANCE, } # map to convert wiffi unit of measurements to common HA uom's @@ -39,7 +36,11 @@ } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up platform for a new integration. Called by the HA framework after async_forward_entry_setup has been called @@ -74,9 +75,9 @@ def __init__(self, device, metric, options): self._value = metric.value if self._is_measurement_entity(): - self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_state_class = SensorStateClass.MEASUREMENT elif self._is_metered_entity(): - self._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._attr_state_class = SensorStateClass.TOTAL_INCREASING self.reset_expiration_date() diff --git a/homeassistant/components/wiffi/strings.json b/homeassistant/components/wiffi/strings.json index d4dc66972c768b..ebc5dd67a20197 100644 --- a/homeassistant/components/wiffi/strings.json +++ b/homeassistant/components/wiffi/strings.json @@ -10,6 +10,7 @@ }, "abort": { "addr_in_use": "Server port already in use.", + "already_configured": "Server port is already configured.", "start_server_failed": "Start server failed." } }, diff --git a/homeassistant/components/wiffi/translations/bg.json b/homeassistant/components/wiffi/translations/bg.json index f7644524c153ab..2e5c6026e0b58b 100644 --- a/homeassistant/components/wiffi/translations/bg.json +++ b/homeassistant/components/wiffi/translations/bg.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "\u041f\u043e\u0440\u0442\u0430 \u043d\u0430 \u0441\u044a\u0440\u0432\u044a\u0440\u0430 \u0432\u0435\u0447\u0435 \u0441\u0435 \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430.", + "already_configured": "\u041f\u043e\u0440\u0442\u044a\u0442 \u043d\u0430 \u0441\u044a\u0440\u0432\u044a\u0440\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d.", "start_server_failed": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0442\u0430\u0440\u0442\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0441\u044a\u0440\u0432\u044a\u0440\u0430." }, "step": { diff --git a/homeassistant/components/wiffi/translations/ca.json b/homeassistant/components/wiffi/translations/ca.json index 6fe2792888c51a..185bcaffd74292 100644 --- a/homeassistant/components/wiffi/translations/ca.json +++ b/homeassistant/components/wiffi/translations/ca.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "El port del servidor que ja est\u00e0 en us.", + "already_configured": "El port del servidor ja est\u00e0 configurat.", "start_server_failed": "Ha fallat l'inici del servidor." }, "step": { diff --git a/homeassistant/components/wiffi/translations/de.json b/homeassistant/components/wiffi/translations/de.json index c94122cac5e9de..101fed5f6a6061 100644 --- a/homeassistant/components/wiffi/translations/de.json +++ b/homeassistant/components/wiffi/translations/de.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "Server Port wird bereits genutzt", + "already_configured": "Server-Port ist bereits konfiguriert.", "start_server_failed": "Server starten fehlgeschlagen" }, "step": { diff --git a/homeassistant/components/wiffi/translations/el.json b/homeassistant/components/wiffi/translations/el.json new file mode 100644 index 00000000000000..ae15bb06d19607 --- /dev/null +++ b/homeassistant/components/wiffi/translations/el.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03b8\u03cd\u03c1\u03b1 \u03b4\u03b9\u03b1\u03ba\u03bf\u03bc\u03b9\u03c3\u03c4\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af." + } + }, + "options": { + "step": { + "init": { + "data": { + "timeout": "\u03a7\u03c1\u03bf\u03bd\u03b9\u03ba\u03cc \u03cc\u03c1\u03b9\u03bf (\u03bb\u03b5\u03c0\u03c4\u03ac)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wiffi/translations/en.json b/homeassistant/components/wiffi/translations/en.json index 046f37de2c749f..6732e5102dab3a 100644 --- a/homeassistant/components/wiffi/translations/en.json +++ b/homeassistant/components/wiffi/translations/en.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "Server port already in use.", + "already_configured": "Server port is already configured.", "start_server_failed": "Start server failed." }, "step": { diff --git a/homeassistant/components/wiffi/translations/es.json b/homeassistant/components/wiffi/translations/es.json index 6ab1f5c54079c4..392392e0f3112f 100644 --- a/homeassistant/components/wiffi/translations/es.json +++ b/homeassistant/components/wiffi/translations/es.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "El puerto del servidor ya est\u00e1 en uso.", + "already_configured": "El puerto del servidor ya est\u00e1 configurado.", "start_server_failed": "No se pudo iniciar el servidor." }, "step": { diff --git a/homeassistant/components/wiffi/translations/et.json b/homeassistant/components/wiffi/translations/et.json index d15b8d895f0345..fe3229574d0615 100644 --- a/homeassistant/components/wiffi/translations/et.json +++ b/homeassistant/components/wiffi/translations/et.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "Serveri port on juba kasutusel.", + "already_configured": "Serveri port on juba seadistatud.", "start_server_failed": "Serveri k\u00e4ivitamine nurjus." }, "step": { diff --git a/homeassistant/components/wiffi/translations/fr.json b/homeassistant/components/wiffi/translations/fr.json index 3d24f7791c0b6e..1126783a92dd74 100644 --- a/homeassistant/components/wiffi/translations/fr.json +++ b/homeassistant/components/wiffi/translations/fr.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "Port du serveur d\u00e9j\u00e0 utilis\u00e9.", + "already_configured": "Le port du serveur est d\u00e9j\u00e0 configur\u00e9.", "start_server_failed": "\u00c9chec du d\u00e9marrage du serveur." }, "step": { diff --git a/homeassistant/components/wiffi/translations/hu.json b/homeassistant/components/wiffi/translations/hu.json index 902fabcbc85580..be0cdf50de0de9 100644 --- a/homeassistant/components/wiffi/translations/hu.json +++ b/homeassistant/components/wiffi/translations/hu.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "A szerverport m\u00e1r haszn\u00e1latban van.", + "already_configured": "A kiszolg\u00e1l\u00f3 portja m\u00e1r be van \u00e1ll\u00edtva.", "start_server_failed": "A szerver ind\u00edt\u00e1sa nem siker\u00fclt." }, "step": { diff --git a/homeassistant/components/wiffi/translations/id.json b/homeassistant/components/wiffi/translations/id.json index 0022f83b0a1b38..18f5ab5ae27009 100644 --- a/homeassistant/components/wiffi/translations/id.json +++ b/homeassistant/components/wiffi/translations/id.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "Port server sudah digunakan.", + "already_configured": "Port server sudah dikonfigurasi.", "start_server_failed": "Gagal memulai server." }, "step": { diff --git a/homeassistant/components/wiffi/translations/it.json b/homeassistant/components/wiffi/translations/it.json index 054bcbc986226d..a3a2c3620fbc8e 100644 --- a/homeassistant/components/wiffi/translations/it.json +++ b/homeassistant/components/wiffi/translations/it.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "Porta del server gi\u00e0 in uso.", + "already_configured": "La porta del server \u00e8 gi\u00e0 configurata.", "start_server_failed": "Avvio del server non riuscito." }, "step": { @@ -9,7 +10,7 @@ "data": { "port": "Porta" }, - "title": "Configurare il server TCP per i dispositivi WIFFI" + "title": "Configura il server TCP per i dispositivi WIFFI" } } }, diff --git a/homeassistant/components/wiffi/translations/ja.json b/homeassistant/components/wiffi/translations/ja.json index ba9f63c6e7c8de..edcf8f231afbbe 100644 --- a/homeassistant/components/wiffi/translations/ja.json +++ b/homeassistant/components/wiffi/translations/ja.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "\u30b5\u30fc\u30d0\u30fc\u30dd\u30fc\u30c8\u306f\u3059\u3067\u306b\u4f7f\u7528\u3055\u308c\u3066\u3044\u307e\u3059\u3002", + "already_configured": "\u30b5\u30fc\u30d0\u30fc\u30dd\u30fc\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002", "start_server_failed": "\u30b5\u30fc\u30d0\u30fc\u306e\u8d77\u52d5\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002" }, "step": { diff --git a/homeassistant/components/wiffi/translations/nl.json b/homeassistant/components/wiffi/translations/nl.json index 966f9a18e416a9..67c82350b91bad 100644 --- a/homeassistant/components/wiffi/translations/nl.json +++ b/homeassistant/components/wiffi/translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "Serverpoort al in gebruik.", + "already_configured": "De serverpoort is al geconfigureerd.", "start_server_failed": "Start server is mislukt." }, "step": { diff --git a/homeassistant/components/wiffi/translations/no.json b/homeassistant/components/wiffi/translations/no.json index 06e8f3449e14c8..89f39e0b972766 100644 --- a/homeassistant/components/wiffi/translations/no.json +++ b/homeassistant/components/wiffi/translations/no.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "Serverport allerede i bruk.", + "already_configured": "Serverporten er allerede konfigurert.", "start_server_failed": "Startserveren mislyktes." }, "step": { diff --git a/homeassistant/components/wiffi/translations/pl.json b/homeassistant/components/wiffi/translations/pl.json index f9c2e2c39793f3..94ac2490a8a150 100644 --- a/homeassistant/components/wiffi/translations/pl.json +++ b/homeassistant/components/wiffi/translations/pl.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "Port serwera jest ju\u017c w u\u017cyciu", + "already_configured": "Port serwera jest ju\u017c skonfigurowany.", "start_server_failed": "Uruchomienie serwera nie powiod\u0142o si\u0119" }, "step": { diff --git a/homeassistant/components/wiffi/translations/ru.json b/homeassistant/components/wiffi/translations/ru.json index f703edbf23b548..3b72b703b9ec0a 100644 --- a/homeassistant/components/wiffi/translations/ru.json +++ b/homeassistant/components/wiffi/translations/ru.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "\u041f\u043e\u0440\u0442 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f.", + "already_configured": "\u041f\u043e\u0440\u0442 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d.", "start_server_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0441\u0435\u0440\u0432\u0435\u0440." }, "step": { diff --git a/homeassistant/components/wiffi/translations/tr.json b/homeassistant/components/wiffi/translations/tr.json index 281300caee3634..c1efc71bc1bdbb 100644 --- a/homeassistant/components/wiffi/translations/tr.json +++ b/homeassistant/components/wiffi/translations/tr.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "Sunucu ba\u011flant\u0131 noktas\u0131 zaten kullan\u0131l\u0131yor.", + "already_configured": "Sunucu ba\u011flant\u0131 noktas\u0131 zaten yap\u0131land\u0131r\u0131lm\u0131\u015f.", "start_server_failed": "Ba\u015flatma sunucusu ba\u015far\u0131s\u0131z oldu." }, "step": { diff --git a/homeassistant/components/wiffi/translations/zh-Hant.json b/homeassistant/components/wiffi/translations/zh-Hant.json index ea02e179337bae..81382d9f036e08 100644 --- a/homeassistant/components/wiffi/translations/zh-Hant.json +++ b/homeassistant/components/wiffi/translations/zh-Hant.json @@ -2,6 +2,7 @@ "config": { "abort": { "addr_in_use": "\u4f3a\u670d\u5668\u901a\u8a0a\u57e0\u5df2\u88ab\u4f7f\u7528\u3002", + "already_configured": "\u4f3a\u670d\u5668\u901a\u8a0a\u57e0\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002", "start_server_failed": "\u555f\u52d5\u4f3a\u670d\u5668\u5931\u6557\u3002" }, "step": { diff --git a/homeassistant/components/wilight/__init__.py b/homeassistant/components/wilight/__init__.py index c77814519a1c57..932ce1538bf4de 100644 --- a/homeassistant/components/wilight/__init__.py +++ b/homeassistant/components/wilight/__init__.py @@ -1,6 +1,7 @@ """The WiLight integration.""" from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.entity import DeviceInfo, Entity @@ -10,7 +11,7 @@ DOMAIN = "wilight" # List the platforms that you want to support. -PLATFORMS = ["cover", "fan", "light"] +PLATFORMS = [Platform.COVER, Platform.FAN, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py index 93c9a8c450307e..ad94c224518022 100644 --- a/homeassistant/components/wilight/cover.py +++ b/homeassistant/components/wilight/cover.py @@ -1,5 +1,4 @@ """Support for WiLight Cover.""" - from pywilight.const import ( COVER_V1, ITEM_COVER, @@ -14,13 +13,14 @@ from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, WiLightDevice async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up WiLight covers from a config entry.""" parent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index b549eb2f81328c..e3f3ab055f9a47 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -20,6 +20,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -33,8 +34,8 @@ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up WiLight lights from a config entry.""" parent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py index 0c7206be00cde8..3236b3b3851a23 100644 --- a/homeassistant/components/wilight/light.py +++ b/homeassistant/components/wilight/light.py @@ -1,5 +1,4 @@ """Support for WiLight lights.""" - from pywilight.const import ( ITEM_LIGHT, LIGHT_COLOR, @@ -17,6 +16,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, WiLightDevice @@ -43,8 +43,8 @@ def entities_from_discovered_wilight(hass, api_device): async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up WiLight lights from a config entry.""" parent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wilight/translations/fr.json b/homeassistant/components/wilight/translations/fr.json index 5d54070b4012ea..0fb00748e25c6c 100644 --- a/homeassistant/components/wilight/translations/fr.json +++ b/homeassistant/components/wilight/translations/fr.json @@ -5,7 +5,7 @@ "not_supported_device": "Ce WiLight n'est actuellement pas pris en charge", "not_wilight_device": "Cet appareil n'est pas WiLight" }, - "flow_title": "WiLight: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Voulez-vous configurer WiLight {name} ? \n\n Il prend en charge: {components}", diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index a7555d15bb9b04..c94c518709d6af 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -6,6 +6,7 @@ from wirelesstagpy import WirelessTags from wirelesstagpy.exceptions import WirelessTagsException +from homeassistant.components import persistent_notification from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, @@ -15,9 +16,11 @@ PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -124,7 +127,7 @@ def push_callback(tags_spec, event_spec): self.api.start_monitoring(push_callback) -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Wireless Sensor Tag component.""" conf = config[DOMAIN] username = conf.get(CONF_USERNAME) @@ -139,7 +142,8 @@ def setup(hass, config): hass.data[DOMAIN] = platform except (ConnectTimeout, HTTPError, WirelessTagsException) as ex: _LOGGER.error("Unable to connect to wirelesstag.net service: %s", str(ex)) - hass.components.persistent_notification.create( + persistent_notification.create( + hass, f"Error: {ex}
Please restart hass after fixing this.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index da901f31cd664e..dde1a22f6220e2 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -1,11 +1,15 @@ """Binary sensor support for Wireless Sensor Tags.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ( DOMAIN as WIRELESSTAG_DOMAIN, @@ -68,15 +72,20 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the platform for a WirelessTags.""" - platform = hass.data.get(WIRELESSTAG_DOMAIN) + platform = hass.data[WIRELESSTAG_DOMAIN] sensors = [] tags = platform.tags for tag in tags.values(): allowed_sensor_types = tag.supported_binary_events_types - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + for sensor_type in config[CONF_MONITORED_CONDITIONS]: if sensor_type in allowed_sensor_types: sensors.append(WirelessTagBinarySensor(platform, tag, sensor_type)) diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 8038b42bffe026..9cec276f26ad48 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -7,19 +7,17 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, -) -from homeassistant.core import callback +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_TAG_UPDATE, WirelessTagBaseSensor @@ -34,28 +32,28 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { SENSOR_TEMPERATURE: SensorEntityDescription( key=SENSOR_TEMPERATURE, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SENSOR_AMBIENT_TEMPERATURE: SensorEntityDescription( key=SENSOR_AMBIENT_TEMPERATURE, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SENSOR_HUMIDITY: SensorEntityDescription( key=SENSOR_HUMIDITY, - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), SENSOR_MOISTURE: SensorEntityDescription( key=SENSOR_MOISTURE, device_class=SENSOR_MOISTURE, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), SENSOR_LIGHT: SensorEntityDescription( key=SENSOR_LIGHT, - device_class=DEVICE_CLASS_ILLUMINANCE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ), } @@ -70,9 +68,14 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the sensor platform.""" - platform = hass.data.get(WIRELESSTAG_DOMAIN) + platform = hass.data[WIRELESSTAG_DOMAIN] sensors = [] tags = platform.tags for tag in tags.values(): diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index ca5391fdf969fa..a6e4a85559cd1d 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -9,7 +9,10 @@ SwitchEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as WIRELESSTAG_DOMAIN, WirelessTagBaseSensor @@ -47,9 +50,14 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up switches for a Wireless Sensor Tags.""" - platform = hass.data.get(WIRELESSTAG_DOMAIN) + platform = hass.data[WIRELESSTAG_DOMAIN] tags = platform.load_tags() monitored_conditions = config[CONF_MONITORED_CONDITIONS] diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 7132b3bff9dc62..701694e40e903a 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -9,17 +9,20 @@ from aiohttp.web import Request, Response import voluptuous as vol -from withings_api import WithingsAuth +from withings_api import AbstractWithingsApi, WithingsAuth from withings_api.common import NotifyAppli from homeassistant.components import webhook -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.webhook import ( async_unregister as async_unregister_webhook, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_WEBHOOK_ID, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later @@ -36,6 +39,7 @@ ) DOMAIN = const.DOMAIN +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { @@ -80,7 +84,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET], f"{WithingsAuth.URL}/oauth2_user/authorize2", - f"{WithingsAuth.URL}/oauth2/token", + f"{AbstractWithingsApi.URL}/v2/oauth2", ), ) @@ -143,12 +147,7 @@ def async_call_later_callback(now) -> None: # Start subscription check in the background, outside this component's setup. async_call_later(hass, 1, async_call_later_callback) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, BINARY_SENSOR_DOMAIN) - ) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, SENSOR_DOMAIN) - ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -162,8 +161,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await asyncio.gather( data_manager.async_unsubscribe_webhook(), - hass.config_entries.async_forward_entry_unload(entry, BINARY_SENSOR_DOMAIN), - hass.config_entries.async_forward_entry_unload(entry, SENSOR_DOMAIN), + hass.config_entries.async_unload_platforms(entry, PLATFORMS), ) async_remove_data_manager(hass, entry) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 25ed695e8ff935..b0af5051124a4e 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -2,8 +2,8 @@ from __future__ import annotations from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_OCCUPANCY, DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -29,7 +29,7 @@ async def async_setup_entry( class WithingsHealthBinarySensor(BaseWithingsSensor, BinarySensorEntity): """Implementation of a Withings sensor.""" - _attr_device_class = DEVICE_CLASS_OCCUPANCY + _attr_device_class = BinarySensorDeviceClass.OCCUPANCY @property def is_on(self) -> bool: diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 608f20a4fb368b..56f7f7cdf913cd 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -10,7 +10,7 @@ from http import HTTPStatus import logging import re -from typing import Any, Dict +from typing import Any from aiohttp.web import Response import requests @@ -63,7 +63,7 @@ ) DATA_UPDATED_SIGNAL = "withings_entity_state_updated" -MeasurementData = Dict[Measurement, Any] +MeasurementData = dict[Measurement, Any] class NotAuthenticatedError(HomeAssistantError): @@ -588,7 +588,7 @@ def __init__( update_method=self.async_subscribe_webhook, ) self.poll_data_update_coordinator = DataUpdateCoordinator[ - Dict[MeasureType, Any] + dict[MeasureType, Any] ]( hass, _LOGGER, @@ -1111,3 +1111,46 @@ def redirect_uri(self) -> str: """Return the redirect uri.""" url = get_url(self.hass, allow_internal=False, prefer_cloud=True) return f"{url}{AUTH_CALLBACK_PATH}" + + async def _token_request(self, data: dict) -> dict: + """Make a token request and adapt Withings API reply.""" + new_token = await super()._token_request(data) + # Withings API returns habitual token data under json key "body": + # { + # "status": [{integer} Withings API response status], + # "body": { + # "access_token": [{string} Your new access_token], + # "expires_in": [{integer} Access token expiry delay in seconds], + # "token_type": [{string] HTTP Authorization Header format: Bearer], + # "scope": [{string} Scopes the user accepted], + # "refresh_token": [{string} Your new refresh_token], + # "userid": [{string} The Withings ID of the user] + # } + # } + # so we copy that to token root. + if body := new_token.pop("body", None): + new_token.update(body) + return new_token + + async def async_resolve_external_data(self, external_data: Any) -> dict: + """Resolve the authorization code to tokens.""" + return await self._token_request( + { + "action": "requesttoken", + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + ) + + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh tokens.""" + new_token = await self._token_request( + { + "action": "requesttoken", + "grant_type": "refresh_token", + "client_id": self.client_id, + "refresh_token": token["refresh_token"], + } + ) + return {**token, **new_token} diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 29c1e162ed4fe9..b447973af97ccb 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -6,11 +6,12 @@ import voluptuous as vol from withings_api.common import AuthScope -from homeassistant.components.withings import const from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util import slugify +from . import const + class WithingsFlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=const.DOMAIN diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 0ca40d28440d40..f8753739519676 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -15,7 +15,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - entities = await async_create_entities( hass, entry, diff --git a/homeassistant/components/withings/translations/el.json b/homeassistant/components/withings/translations/el.json new file mode 100644 index 00000000000000..78528a8898b4ca --- /dev/null +++ b/homeassistant/components/withings/translations/el.json @@ -0,0 +1,19 @@ +{ + "config": { + "create_entry": { + "default": "\u0395\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03bc\u03b5 \u03c4\u03bf Withings." + }, + "flow_title": "{profile}", + "step": { + "profile": { + "data": { + "profile": "\u039f\u03bd\u03bf\u03bc\u03b1 \u03c0\u03c1\u03bf\u03c6\u03af\u03bb" + }, + "description": "\u0394\u03ce\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03bc\u03bf\u03bd\u03b1\u03b4\u03b9\u03ba\u03cc \u03cc\u03bd\u03bf\u03bc\u03b1 \u03c0\u03c1\u03bf\u03c6\u03af\u03bb \u03b3\u03b9\u03b1 \u03c4\u03b1 \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03b1 \u03b1\u03c5\u03c4\u03ac. \u03a3\u03c5\u03bd\u03ae\u03b8\u03c9\u03c2 \u03c0\u03c1\u03cc\u03ba\u03b5\u03b9\u03c4\u03b1\u03b9 \u03b3\u03b9\u03b1 \u03c4\u03bf \u03cc\u03bd\u03bf\u03bc\u03b1 \u03c4\u03bf\u03c5 \u03c0\u03c1\u03bf\u03c6\u03af\u03bb \u03c0\u03bf\u03c5 \u03b5\u03c0\u03b9\u03bb\u03ad\u03be\u03b1\u03c4\u03b5 \u03c3\u03c4\u03bf \u03c0\u03c1\u03bf\u03b7\u03b3\u03bf\u03cd\u03bc\u03b5\u03bd\u03bf \u03b2\u03ae\u03bc\u03b1." + }, + "reauth": { + "description": "\u03a4\u03bf \u03c0\u03c1\u03bf\u03c6\u03af\u03bb \"{profile}\" \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03c0\u03b9\u03c3\u03c4\u03bf\u03c0\u03bf\u03b9\u03b7\u03b8\u03b5\u03af \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03c3\u03c5\u03bd\u03b5\u03c7\u03af\u03c3\u03b5\u03b9 \u03bd\u03b1 \u03bb\u03b1\u03bc\u03b2\u03ac\u03bd\u03b5\u03b9 \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03b1 Withings." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/translations/fr.json b/homeassistant/components/withings/translations/fr.json index 9f675026022c17..a506d491a74b72 100644 --- a/homeassistant/components/withings/translations/fr.json +++ b/homeassistant/components/withings/translations/fr.json @@ -12,7 +12,7 @@ "error": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" }, - "flow_title": "Withings: {profile}", + "flow_title": "{profile}", "step": { "pick_implementation": { "title": "S\u00e9lectionner une m\u00e9thode d'authentification" diff --git a/homeassistant/components/withings/translations/it.json b/homeassistant/components/withings/translations/it.json index 77e29c71e0cc02..f97933593a0668 100644 --- a/homeassistant/components/withings/translations/it.json +++ b/homeassistant/components/withings/translations/it.json @@ -26,7 +26,7 @@ }, "reauth": { "description": "Il profilo \"{profile}\" deve essere autenticato nuovamente per continuare a ricevere i dati Withings.", - "title": "Autenticare nuovamente l'integrazione" + "title": "Autentica nuovamente l'integrazione" } } } diff --git a/homeassistant/components/withings/translations/ja.json b/homeassistant/components/withings/translations/ja.json index c409579fbc8f6d..bc70bf0c746cd0 100644 --- a/homeassistant/components/withings/translations/ja.json +++ b/homeassistant/components/withings/translations/ja.json @@ -21,7 +21,7 @@ "data": { "profile": "\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u540d" }, - "description": "\u3053\u306e\u30c7\u30fc\u30bf\u306b\u56fa\u6709\u306e\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u6307\u5b9a\u3057\u307e\u3059\u3002\u901a\u5e38\u3001\u3053\u308c\u306f\u524d\u306e\u624b\u9806\u3067\u9078\u629e\u3057\u305f\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u306e\u540d\u524d\u3067\u3059\u3002", + "description": "\u3053\u306e\u30c7\u30fc\u30bf\u306b\u30e6\u30cb\u30fc\u30af(\u4e00\u610f)\u306a\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u540d\u3092\u6307\u5b9a\u3057\u307e\u3059\u3002\u901a\u5e38\u3001\u3053\u308c\u306f\u524d\u306e\u624b\u9806\u3067\u9078\u629e\u3057\u305f\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u306e\u540d\u524d\u3067\u3059\u3002", "title": "\u30e6\u30fc\u30b6\u30fc\u30d7\u30ed\u30d5\u30a1\u30a4\u30eb\u3002" }, "reauth": { diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 659df1baad9e50..9c32f9becb8bc8 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -5,7 +5,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import DOMAIN, LOGGER from .coordinator import WLEDDataUpdateCoordinator PLATFORMS = ( @@ -24,14 +24,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = WLEDDataUpdateCoordinator(hass, entry=entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator - - # For backwards compat, set unique ID - if entry.unique_id is None: - hass.config_entries.async_update_entry( - entry, unique_id=coordinator.data.info.mac_address + if coordinator.data.info.leds.cct: + LOGGER.error( + "WLED device '%s' has a CCT channel, which is not supported by " + "this integration", + entry.title, ) + return False + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator # Set up all platforms for this device/entry. hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -44,8 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload WLED config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] # Ensure disconnected and cleanup stop sub diff --git a/homeassistant/components/wled/binary_sensor.py b/homeassistant/components/wled/binary_sensor.py index ce082691ffef28..1a8033701da6e9 100644 --- a/homeassistant/components/wled/binary_sensor.py +++ b/homeassistant/components/wled/binary_sensor.py @@ -6,8 +6,8 @@ BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -32,7 +32,7 @@ async def async_setup_entry( class WLEDUpdateBinarySensor(WLEDEntity, BinarySensorEntity): """Defines a WLED firmware binary sensor.""" - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_device_class = BinarySensorDeviceClass.UPDATE def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: diff --git a/homeassistant/components/wled/button.py b/homeassistant/components/wled/button.py index 191242fe0dc931..5b2e13911bcfc4 100644 --- a/homeassistant/components/wled/button.py +++ b/homeassistant/components/wled/button.py @@ -3,8 +3,8 @@ from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -32,7 +32,7 @@ class WLEDRestartButton(WLEDEntity, ButtonEntity): """Defines a WLED restart button.""" _attr_device_class = ButtonDeviceClass.RESTART - _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_entity_category = EntityCategory.CONFIG def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize the button entity.""" @@ -50,7 +50,7 @@ class WLEDUpdateButton(WLEDEntity, ButtonEntity): """Defines a WLED update button.""" _attr_device_class = ButtonDeviceClass.UPDATE - _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_entity_category = EntityCategory.CONFIG def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize the button entity.""" diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 485afef4f6ceae..99630f5781cecd 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -4,16 +4,11 @@ from typing import Any import voluptuous as vol -from wled import WLED, WLEDConnectionError +from wled import WLED, Device, WLEDConnectionError from homeassistant.components import zeroconf -from homeassistant.config_entries import ( - SOURCE_ZEROCONF, - ConfigEntry, - ConfigFlow, - OptionsFlow, -) -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -25,6 +20,8 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a WLED config flow.""" VERSION = 1 + discovered_host: str + discovered_device: Device @staticmethod @callback @@ -36,98 +33,89 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initiated by the user.""" - return await self._handle_config_flow(user_input) + errors = {} + + if user_input is not None: + try: + device = await self._async_get_device(user_input[CONF_HOST]) + except WLEDConnectionError: + errors["base"] = "cannot_connect" + else: + if device.info.leds.cct: + return self.async_abort(reason="cct_unsupported") + await self.async_set_unique_id(device.info.mac_address) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) + return self.async_create_entry( + title=device.info.name, + data={ + CONF_HOST: user_input[CONF_HOST], + }, + ) + else: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors or {}, + ) async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" + # Abort quick if the mac address is provided by discovery info + if mac := discovery_info.properties.get(CONF_MAC): + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info.host} + ) + + self.discovered_host = discovery_info.host + try: + self.discovered_device = await self._async_get_device(discovery_info.host) + except WLEDConnectionError: + return self.async_abort(reason="cannot_connect") + + if self.discovered_device.info.leds.cct: + return self.async_abort(reason="cct_unsupported") - # Hostname is format: wled-livingroom.local. - host = discovery_info.hostname.rstrip(".") - name, _ = host.rsplit(".") + await self.async_set_unique_id(self.discovered_device.info.mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) self.context.update( { - CONF_HOST: discovery_info.host, - CONF_NAME: name, - CONF_MAC: discovery_info.properties.get(CONF_MAC), - "title_placeholders": {"name": name}, + "title_placeholders": {"name": self.discovered_device.info.name}, + "configuration_url": f"http://{discovery_info.host}", } ) - - # Prepare configuration flow - return await self._handle_config_flow({}, True) + return await self.async_step_zeroconf_confirm() async def async_step_zeroconf_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initiated by zeroconf.""" - return await self._handle_config_flow(user_input) - - async def _handle_config_flow( - self, user_input: dict[str, Any] | None = None, prepare: bool = False - ) -> FlowResult: - """Config flow handler for WLED.""" - source = self.context.get("source") - - # Request user input, unless we are preparing discovery flow - if user_input is None and not prepare: - if source == SOURCE_ZEROCONF: - return self._show_confirm_dialog() - return self._show_setup_form() - - # if prepare is True, user_input can not be None. - assert user_input is not None - - if source == SOURCE_ZEROCONF: - user_input[CONF_HOST] = self.context.get(CONF_HOST) - user_input[CONF_MAC] = self.context.get(CONF_MAC) - - if user_input.get(CONF_MAC) is None or not prepare: - session = async_get_clientsession(self.hass) - wled = WLED(user_input[CONF_HOST], session=session) - try: - device = await wled.update() - except WLEDConnectionError: - if source == SOURCE_ZEROCONF: - return self.async_abort(reason="cannot_connect") - return self._show_setup_form({"base": "cannot_connect"}) - user_input[CONF_MAC] = device.info.mac_address - - # Check if already configured - await self.async_set_unique_id(user_input[CONF_MAC]) - self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) - - title = user_input[CONF_HOST] - if source == SOURCE_ZEROCONF: - title = self.context.get(CONF_NAME) - - if prepare: - return await self.async_step_zeroconf_confirm() - - return self.async_create_entry( - title=title, - data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]}, - ) - - def _show_setup_form(self, errors: dict | None = None) -> FlowResult: - """Show the setup form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required(CONF_HOST): str}), - errors=errors or {}, - ) + if user_input is not None: + return self.async_create_entry( + title=self.discovered_device.info.name, + data={ + CONF_HOST: self.discovered_host, + }, + ) - def _show_confirm_dialog(self, errors: dict | None = None) -> FlowResult: - """Show the confirm dialog to the user.""" - name = self.context.get(CONF_NAME) return self.async_show_form( step_id="zeroconf_confirm", - description_placeholders={"name": name}, - errors=errors or {}, + description_placeholders={"name": self.discovered_device.info.name}, ) + async def _async_get_device(self, host: str) -> Device: + """Get device information from WLED device.""" + session = async_get_clientsession(self.hass) + wled = WLED(host, session=session) + return await wled.update() + class WLEDOptionsFlowHandler(OptionsFlow): """Handle WLED options.""" diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 9dbd02d65c388e..a4cbaade8ba24b 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -113,6 +113,7 @@ async def _async_update_data(self) -> WLEDDevice: # If the device supports a WebSocket, try activating it. if ( device.info.websocket is not None + and device.info.leds.cct is not True and not self.wled.connected and not self.unsub ): diff --git a/homeassistant/components/wled/diagnostics.py b/homeassistant/components/wled/diagnostics.py new file mode 100644 index 00000000000000..c2820a7a13a0d7 --- /dev/null +++ b/homeassistant/components/wled/diagnostics.py @@ -0,0 +1,48 @@ +"""Diagnostics support for WLED.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import WLEDDataUpdateCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + data = { + "info": async_redact_data(coordinator.data.info.__dict__, "wifi"), + "state": coordinator.data.state.__dict__, + "effects": { + effect.effect_id: effect.name for effect in coordinator.data.effects + }, + "palettes": { + palette.palette_id: palette.name for palette in coordinator.data.palettes + }, + "playlists": { + playlist.playlist_id: { + "name": playlist.name, + "repeat": playlist.repeat, + "shuffle": playlist.shuffle, + "end": playlist.end.preset_id if playlist.end else None, + } + for playlist in coordinator.data.playlists + }, + "presets": { + preset.preset_id: { + "name": preset.name, + "quick_label": preset.quick_label, + "on": preset.on, + "transition": preset.transition, + } + for preset in coordinator.data.presets + }, + } + return data diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index b42c7b0a8b473e..b30e20810d90bd 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -2,7 +2,7 @@ from __future__ import annotations from functools import partial -from typing import Any, Tuple, cast +from typing import Any, cast from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -154,7 +154,7 @@ def rgb_color(self) -> tuple[int, int, int] | None: def rgbw_color(self) -> tuple[int, int, int, int] | None: """Return the color value.""" return cast( - Tuple[int, int, int, int], + tuple[int, int, int, int], self.coordinator.data.state.segments[self._segment].color_primary, ) diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index a99278a80c610e..eb99b8519a9bc5 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -3,7 +3,7 @@ "name": "WLED", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wled", - "requirements": ["wled==0.10.1"], + "requirements": ["wled==0.13.0"], "zeroconf": ["_wled._tcp.local."], "codeowners": ["@frenck"], "quality_scale": "platinum", diff --git a/homeassistant/components/wled/models.py b/homeassistant/components/wled/models.py index a71491daf7a419..c6f10daeff4607 100644 --- a/homeassistant/components/wled/models.py +++ b/homeassistant/components/wled/models.py @@ -1,4 +1,5 @@ """Models for WLED.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -15,10 +16,14 @@ class WLEDEntity(CoordinatorEntity): def device_info(self) -> DeviceInfo: """Return device information about this WLED device.""" return DeviceInfo( + connections={ + (CONNECTION_NETWORK_MAC, self.coordinator.data.info.mac_address) + }, identifiers={(DOMAIN, self.coordinator.data.info.mac_address)}, name=self.coordinator.data.info.name, manufacturer=self.coordinator.data.info.brand, model=self.coordinator.data.info.product, sw_version=str(self.coordinator.data.info.version), + hw_version=self.coordinator.data.info.architecture, configuration_url=f"http://{self.coordinator.wled.host}", ) diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 5167ee8a37ad8f..d551072e452a82 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -5,8 +5,8 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_INTENSITY, ATTR_SPEED, DOMAIN @@ -40,12 +40,18 @@ async def async_setup_entry( key=ATTR_SPEED, name="Speed", icon="mdi:speedometer", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, + step=1, + min_value=0, + max_value=255, ), NumberEntityDescription( key=ATTR_INTENSITY, name="Intensity", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, + step=1, + min_value=0, + max_value=255, ), ] @@ -53,10 +59,6 @@ async def async_setup_entry( class WLEDNumber(WLEDEntity, NumberEntity): """Defines a WLED speed number.""" - _attr_step = 1 - _attr_min_value = 0 - _attr_max_value = 255 - def __init__( self, coordinator: WLEDDataUpdateCoordinator, diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index d82f12cffd710e..e555b3422ce7ad 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -7,8 +7,8 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEVICE_CLASS_WLED_LIVE_OVERRIDE, DOMAIN @@ -49,7 +49,7 @@ class WLEDLiveOverrideSelect(WLEDEntity, SelectEntity): """Defined a WLED Live Override select.""" _attr_device_class = DEVICE_CLASS_WLED_LIVE_OVERRIDE - _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:theater" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -138,7 +138,7 @@ async def async_select_option(self, option: str) -> None: class WLEDPaletteSelect(WLEDEntity, SelectEntity): """Defines a WLED Palette select.""" - _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:palette-outline" _segment: int diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 0bde4107688bd3..b37238aef64f63 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -8,20 +8,20 @@ from wled import Device as WLEDDevice from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DATA_BYTES, ELECTRIC_CURRENT_MILLIAMPERE, - ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -51,21 +51,21 @@ class WLEDSensorEntityDescription( name="Estimated Current", native_unit_of_measurement=ELECTRIC_CURRENT_MILLIAMPERE, device_class=SensorDeviceClass.CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.leds.power, ), WLEDSensorEntityDescription( key="info_leds_count", name="LED Count", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.leds.count, ), WLEDSensorEntityDescription( key="info_leds_max_power", name="Max Current", native_unit_of_measurement=ELECTRIC_CURRENT_MILLIAMPERE, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.CURRENT, value_fn=lambda device: device.info.leds.max_power, ), @@ -73,7 +73,7 @@ class WLEDSensorEntityDescription( key="uptime", name="Uptime", device_class=SensorDeviceClass.TIMESTAMP, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: (utcnow() - timedelta(seconds=device.info.uptime)), ), @@ -82,8 +82,8 @@ class WLEDSensorEntityDescription( name="Free Memory", icon="mdi:memory", native_unit_of_measurement=DATA_BYTES, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: device.info.free_heap, ), @@ -92,7 +92,7 @@ class WLEDSensorEntityDescription( name="Wi-Fi Signal", icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.signal if device.info.wifi else None, ), @@ -101,7 +101,7 @@ class WLEDSensorEntityDescription( name="Wi-Fi RSSI", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.rssi if device.info.wifi else None, ), @@ -109,7 +109,7 @@ class WLEDSensorEntityDescription( key="wifi_channel", name="Wi-Fi Channel", icon="mdi:wifi", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.channel if device.info.wifi else None, ), @@ -117,7 +117,7 @@ class WLEDSensorEntityDescription( key="wifi_bssid", name="Wi-Fi BSSID", icon="mdi:wifi", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.bssid if device.info.wifi else None, ), diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 9717637fdbb3a7..ac1eb4046ddf44 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -18,7 +18,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cct_unsupported": "This WLED device uses CCT channels, which is not supported by this integration" } }, "options": { diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index a05a0ddaf08da6..e98b3494ad63a0 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -6,8 +6,8 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -54,7 +54,7 @@ class WLEDNightlightSwitch(WLEDEntity, SwitchEntity): """Defines a WLED nightlight switch.""" _attr_icon = "mdi:weather-night" - _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_entity_category = EntityCategory.CONFIG def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED nightlight switch.""" @@ -91,7 +91,7 @@ class WLEDSyncSendSwitch(WLEDEntity, SwitchEntity): """Defines a WLED sync send switch.""" _attr_icon = "mdi:upload-network-outline" - _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_entity_category = EntityCategory.CONFIG def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED sync send switch.""" @@ -124,7 +124,7 @@ class WLEDSyncReceiveSwitch(WLEDEntity, SwitchEntity): """Defines a WLED sync receive switch.""" _attr_icon = "mdi:download-network-outline" - _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_entity_category = EntityCategory.CONFIG def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED sync receive switch.""" @@ -157,7 +157,7 @@ class WLEDReverseSwitch(WLEDEntity, SwitchEntity): """Defines a WLED reverse effect switch.""" _attr_icon = "mdi:swap-horizontal-bold" - _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_entity_category = EntityCategory.CONFIG _segment: int def __init__(self, coordinator: WLEDDataUpdateCoordinator, segment: int) -> None: diff --git a/homeassistant/components/wled/translations/ca.json b/homeassistant/components/wled/translations/ca.json index c4adacf21c2161..743bf324b94e55 100644 --- a/homeassistant/components/wled/translations/ca.json +++ b/homeassistant/components/wled/translations/ca.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "El dispositiu ja est\u00e0 configurat", - "cannot_connect": "Ha fallat la connexi\u00f3" + "cannot_connect": "Ha fallat la connexi\u00f3", + "cct_unsupported": "Aquest dispositiu WLED utilitza canals CCT que no s\u00f3n compatibles amb aquesta integraci\u00f3" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3" diff --git a/homeassistant/components/wled/translations/de.json b/homeassistant/components/wled/translations/de.json index 01b0839ba32d79..76cc923f3d4f53 100644 --- a/homeassistant/components/wled/translations/de.json +++ b/homeassistant/components/wled/translations/de.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", - "cannot_connect": "Verbindung fehlgeschlagen" + "cannot_connect": "Verbindung fehlgeschlagen", + "cct_unsupported": "Dieses WLED-Ger\u00e4t verwendet CCT-Kan\u00e4le, die von dieser Integration nicht unterst\u00fctzt werden." }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" diff --git a/homeassistant/components/wled/translations/el.json b/homeassistant/components/wled/translations/el.json index 58012c1e4e38de..d931bc69d70ea0 100644 --- a/homeassistant/components/wled/translations/el.json +++ b/homeassistant/components/wled/translations/el.json @@ -1,10 +1,20 @@ { "config": { "abort": { - "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2" + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "cct_unsupported": "\u0391\u03c5\u03c4\u03ae \u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae WLED \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af \u03ba\u03b1\u03bd\u03ac\u03bb\u03b9\u03b1 CCT, \u03c4\u03b1 \u03bf\u03c0\u03bf\u03af\u03b1 \u03b4\u03b5\u03bd \u03c5\u03c0\u03bf\u03c3\u03c4\u03b7\u03c1\u03af\u03b6\u03bf\u03bd\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03b1\u03c5\u03c4\u03ae \u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7" }, "error": { "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2" + }, + "step": { + "user": { + "description": "\u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b5 \u03c4\u03bf WLED \u03c3\u03b1\u03c2 \u03b3\u03b9\u03b1 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 \u03bc\u03b5 \u03c4\u03bf Home Assistant." + }, + "zeroconf_confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c0\u03c1\u03bf\u03c3\u03b8\u03ad\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf WLED \u03bc\u03b5 \u03c4\u03bf \u03cc\u03bd\u03bf\u03bc\u03b1 `{name}` \u03c3\u03c4\u03bf Home Assistant;", + "title": "\u0391\u03bd\u03b1\u03ba\u03b1\u03bb\u03cd\u03c6\u03b8\u03b7\u03ba\u03b5 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae WLED" + } } } } \ No newline at end of file diff --git a/homeassistant/components/wled/translations/en.json b/homeassistant/components/wled/translations/en.json index a114d0218ca308..4cc3e12bf2a073 100644 --- a/homeassistant/components/wled/translations/en.json +++ b/homeassistant/components/wled/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Device is already configured", - "cannot_connect": "Failed to connect" + "cannot_connect": "Failed to connect", + "cct_unsupported": "This WLED device uses CCT channels, which is not supported by this integration" }, "error": { "cannot_connect": "Failed to connect" diff --git a/homeassistant/components/wled/translations/et.json b/homeassistant/components/wled/translations/et.json index b3fdec52961dcf..d45fdad2ddeebd 100644 --- a/homeassistant/components/wled/translations/et.json +++ b/homeassistant/components/wled/translations/et.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "See WLED seade on juba h\u00e4\u00e4lestatud.", - "cannot_connect": "\u00dchendamine nurjus" + "cannot_connect": "\u00dchendamine nurjus", + "cct_unsupported": "See WLED-seade kasutab CCT-kanaleid, mida see sidumine ei toeta." }, "error": { "cannot_connect": "\u00dchendamine nurjus" diff --git a/homeassistant/components/wled/translations/fr.json b/homeassistant/components/wled/translations/fr.json index 03255541ad9359..ac3197f3911720 100644 --- a/homeassistant/components/wled/translations/fr.json +++ b/homeassistant/components/wled/translations/fr.json @@ -2,12 +2,13 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "cannot_connect": "\u00c9chec de connexion" + "cannot_connect": "\u00c9chec de connexion", + "cct_unsupported": "Cet appareil WLED utilise des canaux CCT, qui ne sont pas pris en charge par cette int\u00e9gration" }, "error": { "cannot_connect": "\u00c9chec de connexion" }, - "flow_title": "WLED: {name}", + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/wled/translations/hu.json b/homeassistant/components/wled/translations/hu.json index 4c4223e75086ec..88dc16a9a553ce 100644 --- a/homeassistant/components/wled/translations/hu.json +++ b/homeassistant/components/wled/translations/hu.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "cct_unsupported": "Ez a WLED-eszk\u00f6z CCT-csatorn\u00e1kat haszn\u00e1l, amit ez az integr\u00e1ci\u00f3 nem t\u00e1mogat" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" diff --git a/homeassistant/components/wled/translations/it.json b/homeassistant/components/wled/translations/it.json index efdc761960a1c3..36227a1e9bce99 100644 --- a/homeassistant/components/wled/translations/it.json +++ b/homeassistant/components/wled/translations/it.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", - "cannot_connect": "Impossibile connettersi" + "cannot_connect": "Impossibile connettersi", + "cct_unsupported": "Questo dispositivo WLED utilizza i canali CCT, che non sono supportati da questa integrazione" }, "error": { "cannot_connect": "Impossibile connettersi" diff --git a/homeassistant/components/wled/translations/no.json b/homeassistant/components/wled/translations/no.json index a63871613ccb15..f161d24b41ec0e 100644 --- a/homeassistant/components/wled/translations/no.json +++ b/homeassistant/components/wled/translations/no.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "cannot_connect": "Tilkobling mislyktes" + "cannot_connect": "Tilkobling mislyktes", + "cct_unsupported": "Denne WLED-enheten bruker CCT-kanaler, som ikke st\u00f8ttes av denne integrasjonen" }, "error": { "cannot_connect": "Tilkobling mislyktes" diff --git a/homeassistant/components/wled/translations/pl.json b/homeassistant/components/wled/translations/pl.json index c4a2efc43a1555..b24f44f2055544 100644 --- a/homeassistant/components/wled/translations/pl.json +++ b/homeassistant/components/wled/translations/pl.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "cct_unsupported": "To urz\u0105dzenie WLED wykorzystuje kana\u0142y CCT, kt\u00f3re nie s\u0105 obs\u0142ugiwane przez t\u0119 integracj\u0119" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" diff --git a/homeassistant/components/wled/translations/ru.json b/homeassistant/components/wled/translations/ru.json index b43013b34e7cd3..fd7a4bf6d23f00 100644 --- a/homeassistant/components/wled/translations/ru.json +++ b/homeassistant/components/wled/translations/ru.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "cct_unsupported": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e WLED \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u043a\u0430\u043d\u0430\u043b\u044b CCT, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u044e\u0442\u0441\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0435\u0439." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." diff --git a/homeassistant/components/wled/translations/select.es.json b/homeassistant/components/wled/translations/select.es.json new file mode 100644 index 00000000000000..b7b345a5245460 --- /dev/null +++ b/homeassistant/components/wled/translations/select.es.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Apagado", + "1": "Encendido", + "2": "Hasta que el dispositivo se reinicie" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.fr.json b/homeassistant/components/wled/translations/select.fr.json new file mode 100644 index 00000000000000..47a1a07989b565 --- /dev/null +++ b/homeassistant/components/wled/translations/select.fr.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Inactif", + "1": "Actif", + "2": "Jusqu'au red\u00e9marrage de l'appareil" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.it.json b/homeassistant/components/wled/translations/select.it.json new file mode 100644 index 00000000000000..8cd961b9c87609 --- /dev/null +++ b/homeassistant/components/wled/translations/select.it.json @@ -0,0 +1,9 @@ +{ + "state": { + "wled__live_override": { + "0": "Spento", + "1": "Acceso", + "2": "Fino al riavvio del dispositivo" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/select.lt.json b/homeassistant/components/wled/translations/select.lt.json new file mode 100644 index 00000000000000..e617ff3e8d19f8 --- /dev/null +++ b/homeassistant/components/wled/translations/select.lt.json @@ -0,0 +1,8 @@ +{ + "state": { + "wled__live_override": { + "0": "I\u0161jungta", + "1": "\u012ejungta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/tr.json b/homeassistant/components/wled/translations/tr.json index 938fa8d7f69697..5c8b3b0f135fad 100644 --- a/homeassistant/components/wled/translations/tr.json +++ b/homeassistant/components/wled/translations/tr.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", - "cannot_connect": "Ba\u011flanma hatas\u0131" + "cannot_connect": "Ba\u011flanma hatas\u0131", + "cct_unsupported": "Bu WLED cihaz\u0131, bu entegrasyon taraf\u0131ndan desteklenmeyen CCT kanallar\u0131n\u0131 kullan\u0131r." }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131" @@ -11,7 +12,7 @@ "step": { "user": { "data": { - "host": "Ana bilgisayar" + "host": "Sunucu" }, "description": "WLED'inizi Home Assistant ile t\u00fcmle\u015ftirmek i\u00e7in ayarlay\u0131n." }, diff --git a/homeassistant/components/wled/translations/zh-Hant.json b/homeassistant/components/wled/translations/zh-Hant.json index b8c873b90a5d70..69f6476a7685ab 100644 --- a/homeassistant/components/wled/translations/zh-Hant.json +++ b/homeassistant/components/wled/translations/zh-Hant.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", - "cannot_connect": "\u9023\u7dda\u5931\u6557" + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "cct_unsupported": "\u6b64\u6574\u5408\u4e0d\u652f\u63f4\u6b64 WLED \u88dd\u7f6e\u6240\u4f7f\u7528\u4e4b CCT \u901a\u9053" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557" diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index e15a5225475d8b..19cafa89a13500 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -7,7 +7,7 @@ from wolf_smartset.wolf_client import FetchFailed, ParameterReadError, WolfClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 975ddbdd06837a..f1a94cbbe20e92 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -1,4 +1,6 @@ """The Wolf SmartSet sensors.""" +from __future__ import annotations + from wolf_smartset.models import ( HoursParameter, ListItemParameter, @@ -9,27 +11,28 @@ Temperature, ) -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - PRESSURE_BAR, - TEMP_CELSIUS, - TIME_HOURS, -) +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PRESSURE_BAR, TEMP_CELSIUS, TIME_HOURS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATOR, DEVICE_ID, DOMAIN, PARAMETERS, STATES -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up all entries for Wolf Platform.""" coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] parameters = hass.data[DOMAIN][config_entry.entry_id][PARAMETERS] device_id = hass.data[DOMAIN][config_entry.entry_id][DEVICE_ID] - entities = [] + entities: list[WolfLinkSensor] = [] for parameter in parameters: if isinstance(parameter, Temperature): entities.append(WolfLinkTemperature(coordinator, parameter, device_id)) @@ -106,7 +109,7 @@ class WolfLinkTemperature(WolfLinkSensor): @property def device_class(self): """Return the device_class.""" - return DEVICE_CLASS_TEMPERATURE + return SensorDeviceClass.TEMPERATURE @property def native_unit_of_measurement(self): @@ -120,7 +123,7 @@ class WolfLinkPressure(WolfLinkSensor): @property def device_class(self): """Return the device_class.""" - return DEVICE_CLASS_PRESSURE + return SensorDeviceClass.PRESSURE @property def native_unit_of_measurement(self): diff --git a/homeassistant/components/wolflink/strings.sensor.json b/homeassistant/components/wolflink/strings.sensor.json index 75c8199a117517..c197edb5ea8597 100644 --- a/homeassistant/components/wolflink/strings.sensor.json +++ b/homeassistant/components/wolflink/strings.sensor.json @@ -19,7 +19,7 @@ "ruhekontakt": "Rest contact", "vorspulen": "Entry rinsing", "zunden": "Ignition", - "stabilisierung": "Stablization", + "stabilisierung": "Stabilization", "ventilprufung": "Valve test", "nachspulen": "Post-flush", "softstart": "Soft start", diff --git a/homeassistant/components/wolflink/translations/it.json b/homeassistant/components/wolflink/translations/it.json index 6e88a6b3e29a3c..7d41f47cd2d93c 100644 --- a/homeassistant/components/wolflink/translations/it.json +++ b/homeassistant/components/wolflink/translations/it.json @@ -13,7 +13,7 @@ "data": { "device_name": "Dispositivo" }, - "title": "Selezionare il dispositivo WOLF" + "title": "Seleziona il dispositivo WOLF" }, "user": { "data": { diff --git a/homeassistant/components/wolflink/translations/sensor.el.json b/homeassistant/components/wolflink/translations/sensor.el.json new file mode 100644 index 00000000000000..75ab523afd2a84 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.el.json @@ -0,0 +1,15 @@ +{ + "state": { + "wolflink__state": { + "externe_deaktivierung": "\u0395\u03be\u03c9\u03c4\u03b5\u03c1\u03b9\u03ba\u03ae \u03b1\u03c0\u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7", + "fernschalter_ein": "\u0391\u03c0\u03bf\u03bc\u03b1\u03ba\u03c1\u03c5\u03c3\u03bc\u03ad\u03bd\u03b7 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u0395\u03bd\u03b5\u03c1\u03b3\u03ae", + "frost_heizkreis": "\u03a0\u03b1\u03b3\u03b5\u03c4\u03cc\u03c2 \u03c3\u03c4\u03bf \u03ba\u03cd\u03ba\u03bb\u03c9\u03bc\u03b1 \u03b8\u03ad\u03c1\u03bc\u03b1\u03bd\u03c3\u03b7\u03c2", + "frostschutz": "\u03a0\u03c1\u03bf\u03c3\u03c4\u03b1\u03c3\u03af\u03b1 \u03b1\u03c0\u03cc \u03c0\u03b1\u03b3\u03b5\u03c4\u03cc", + "gasdruck": "\u03a0\u03af\u03b5\u03c3\u03b7 \u03b1\u03b5\u03c1\u03af\u03bf\u03c5", + "gradienten_uberwachung": "\u03a0\u03b1\u03c1\u03b1\u03ba\u03bf\u03bb\u03bf\u03cd\u03b8\u03b7\u03c3\u03b7 \u03ba\u03bb\u03af\u03c3\u03b7\u03c2", + "heizbetrieb": "\u039b\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03b8\u03ad\u03c1\u03bc\u03b1\u03bd\u03c3\u03b7\u03c2", + "heizgerat_mit_speicher": "\u039b\u03ad\u03b2\u03b7\u03c4\u03b1\u03c2 \u03bc\u03b5 \u03ba\u03cd\u03bb\u03b9\u03bd\u03b4\u03c1\u03bf", + "heizung": "\u0398\u03ad\u03c1\u03bc\u03b1\u03bd\u03c3\u03b7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.en.json b/homeassistant/components/wolflink/translations/sensor.en.json index bd505e845aec22..eeb940d81fecf6 100644 --- a/homeassistant/components/wolflink/translations/sensor.en.json +++ b/homeassistant/components/wolflink/translations/sensor.en.json @@ -65,7 +65,7 @@ "sparen": "Economy", "spreizung_hoch": "dT too wide", "spreizung_kf": "Spread KF", - "stabilisierung": "Stablization", + "stabilisierung": "Stabilization", "standby": "Standby", "start": "Start", "storung": "Fault", diff --git a/homeassistant/components/wolflink/translations/sensor.et.json b/homeassistant/components/wolflink/translations/sensor.et.json index db6e5db23e697c..4ed9c342089f30 100644 --- a/homeassistant/components/wolflink/translations/sensor.et.json +++ b/homeassistant/components/wolflink/translations/sensor.et.json @@ -65,7 +65,7 @@ "sparen": "S\u00e4\u00e4sture\u017eiim", "spreizung_hoch": "temperatuurivahemik liiga suur", "spreizung_kf": "KF-i hajutamine", - "stabilisierung": "Rahunemine", + "stabilisierung": "Stabiliseerumine", "standby": "Ootel", "start": "K\u00e4ivitus", "storung": "Viga", diff --git a/homeassistant/components/wolflink/translations/sensor.id.json b/homeassistant/components/wolflink/translations/sensor.id.json index bcef8fe6d2cdf7..9e7932b2dd74f4 100644 --- a/homeassistant/components/wolflink/translations/sensor.id.json +++ b/homeassistant/components/wolflink/translations/sensor.id.json @@ -2,6 +2,7 @@ "state": { "wolflink__state": { "1_x_warmwasser": "1 x DHW", + "abgasklappe": "Katup saluran buang gas", "aktiviert": "Diaktifkan", "antilegionellenfunktion": "Fungsi Anti-legionella", "aus": "Dinonaktifkan", @@ -19,6 +20,12 @@ "ein": "Diaktifkan", "externe_deaktivierung": "Penonaktifan eksternal", "fernschalter_ein": "Kontrol jarak jauh diaktifkan", + "frostschutz": "Perlindungan beku", + "gasdruck": "Tekanan gas", + "glt_betrieb": "Mode BMS", + "gradienten_uberwachung": "Pemantauan gradien", + "heizbetrieb": "Mode pemanasan", + "heizgerat_mit_speicher": "Ketel dengan silinder", "heizung": "Memanaskan", "initialisierung": "Inisialisasi", "kalibration": "Kalibrasi", @@ -27,6 +34,8 @@ "kalibration_warmwasserbetrieb": "Kalibrasi DHW", "kaskadenbetrieb": "Operasi bertingkat", "kombibetrieb": "Mode kombi", + "kombigerat": "Ketel kombinasi", + "kombigerat_mit_solareinbindung": "Ketel kombinasi dengan integrasi tenaga surya", "mindest_kombizeit": "Waktu kombi minimum", "nur_heizgerat": "Hanya boiler", "parallelbetrieb": "Mode paralel", @@ -40,11 +49,15 @@ "solarbetrieb": "Mode surya", "sparbetrieb": "Mode ekonomi", "sparen": "Ekonomi", + "stabilisierung": "Stabilisasi", "standby": "Siaga", "start": "Mulai", "storung": "Kesalahan", + "telefonfernschalter": "Saklar jarak jauh per telepon", + "test": "Pengujian", "urlaubsmodus": "Mode liburan", - "ventilprufung": "Uji katup" + "ventilprufung": "Uji katup", + "zunden": "Pengapian" } } } \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.ja.json b/homeassistant/components/wolflink/translations/sensor.ja.json index 0b9fa47c9eab99..d2ba79d4b1941e 100644 --- a/homeassistant/components/wolflink/translations/sensor.ja.json +++ b/homeassistant/components/wolflink/translations/sensor.ja.json @@ -11,6 +11,8 @@ "at_frostschutz": "OT\u971c\u9632\u6b62", "aus": "\u7121\u52b9", "auto": "\u30aa\u30fc\u30c8", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", "automatik_aus": "\u81ea\u52d5\u30aa\u30d5", "automatik_ein": "\u81ea\u52d5\u30aa\u30f3", "bereit_keine_ladung": "\u6e96\u5099\u5b8c\u4e86\u3001\u8aad\u307f\u8fbc\u307f\u4e2d\u3067\u306f\u306a\u3044", @@ -20,6 +22,7 @@ "dhw_prior": "DHWPrior", "eco": "\u30a8\u30b3", "ein": "\u6709\u52b9", + "estrichtrocknung": "Screed drying", "externe_deaktivierung": "\u5916\u90e8\u306e\u975e\u30a2\u30af\u30c6\u30a3\u30d6\u5316", "fernschalter_ein": "\u30ea\u30e2\u30fc\u30c8\u5236\u5fa1\u304c\u6709\u52b9", "frost_heizkreis": "\u6696\u623f(\u52a0\u71b1)\u56de\u8def\u306e\u971c", @@ -36,8 +39,11 @@ "kalibration_heizbetrieb": "\u6696\u623f(\u52a0\u71b1)\u306e\u30ad\u30e3\u30ea\u30d6\u30ec\u30fc\u30b7\u30e7\u30f3", "kalibration_kombibetrieb": "\u30b3\u30f3\u30d3\u30e2\u30fc\u30c9 \u30ad\u30e3\u30ea\u30d6\u30ec\u30fc\u30b7\u30e7\u30f3", "kalibration_warmwasserbetrieb": "DHW\u30ad\u30e3\u30ea\u30d6\u30ec\u30fc\u30b7\u30e7\u30f3", + "kaskadenbetrieb": "Cascade\u64cd\u4f5c", "kombibetrieb": "\u30b3\u30f3\u30d3\u30e2\u30fc\u30c9", "kombigerat": "\u30b3\u30f3\u30d3 \u30dc\u30a4\u30e9\u30fc", + "kombigerat_mit_solareinbindung": "\u592a\u967d\u71b1\u5229\u7528\u306e\u30b3\u30f3\u30d3\u30dc\u30a4\u30e9\u30fc", + "mindest_kombizeit": "\u6700\u5c0fcombi time", "nachlauf_heizkreispumpe": "\u6696\u623f(\u52a0\u71b1)\u56de\u8def\u30dd\u30f3\u30d7\u306e\u4f5c\u52d5", "nachspulen": "\u30d5\u30e9\u30c3\u30b7\u30e5\u5f8c(Post-flush)", "nur_heizgerat": "\u30dc\u30a4\u30e9\u30fc\u306e\u307f", @@ -51,8 +57,8 @@ "rt_frostschutz": "RT\u971c\u9632\u6b62", "ruhekontakt": "\u6b8b\u308a\u306e\u9023\u7d61\u5148(Rest contact)", "schornsteinfeger": "\u6392\u51fa\u91cf\u30c6\u30b9\u30c8", - "smart_grid": "\u30b9\u30de\u30fc\u30c8\u30b0\u30ea\u30c3\u30c9", - "smart_home": "\u30b9\u30de\u30fc\u30c8\u30db\u30fc\u30e0", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", "softstart": "\u30bd\u30d5\u30c8\u30b9\u30bf\u30fc\u30c8", "solarbetrieb": "\u30bd\u30fc\u30e9\u30fc\u30e2\u30fc\u30c9", "sparbetrieb": "\u30a8\u30b3\u30ce\u30df\u30fc\u30e2\u30fc\u30c9", @@ -69,6 +75,7 @@ "tpw": "TPW", "urlaubsmodus": "\u30db\u30ea\u30c7\u30fc(\u4f11\u65e5)\u30e2\u30fc\u30c9", "ventilprufung": "\u30d0\u30eb\u30d6\u30c6\u30b9\u30c8", + "vorspulen": "\u30a8\u30f3\u30c8\u30ea\u30fc\u3092\u6d17\u3044\u6d41\u3059(rinsing)", "warmwasser": "DHW", "warmwasser_schnellstart": "DHW\u30af\u30a4\u30c3\u30af\u30b9\u30bf\u30fc\u30c8", "warmwasserbetrieb": "DHW\u30e2\u30fc\u30c9", diff --git a/homeassistant/components/wolflink/translations/sensor.nl.json b/homeassistant/components/wolflink/translations/sensor.nl.json index 304a4fd6f27993..a528dcf11c2066 100644 --- a/homeassistant/components/wolflink/translations/sensor.nl.json +++ b/homeassistant/components/wolflink/translations/sensor.nl.json @@ -65,7 +65,7 @@ "sparen": "Spaarstand", "spreizung_hoch": "dT te breed", "spreizung_kf": "Spreid KF", - "stabilisierung": "Stablisatie", + "stabilisierung": "Stabilisatie", "standby": "Stand-by", "start": "Start", "storung": "Fout", diff --git a/homeassistant/components/wolflink/translations/sensor.no.json b/homeassistant/components/wolflink/translations/sensor.no.json index 75aa91bcf9cc58..487548468bcd0d 100644 --- a/homeassistant/components/wolflink/translations/sensor.no.json +++ b/homeassistant/components/wolflink/translations/sensor.no.json @@ -65,7 +65,7 @@ "sparen": "\u00d8konomi", "spreizung_hoch": "dT for bred", "spreizung_kf": "Spre KF", - "stabilisierung": "Stablisering", + "stabilisierung": "Stabilisering", "standby": "Avventer", "start": "Start", "storung": "Feil", diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index fc726d56f0456c..c6ac7aceae16b1 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -1,4 +1,6 @@ """Sensor to indicate whether the current day is a workday.""" +from __future__ import annotations + from datetime import timedelta import logging from typing import Any @@ -8,7 +10,10 @@ from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity from homeassistant.const import CONF_NAME, WEEKDAYS +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt _LOGGER = logging.getLogger(__name__) @@ -61,16 +66,25 @@ def valid_country(value: Any) -> str: vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): vol.All( cv.ensure_list, [vol.In(ALLOWED_DAYS)] ), - vol.Optional(CONF_ADD_HOLIDAYS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_REMOVE_HOLIDAYS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ADD_HOLIDAYS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_REMOVE_HOLIDAYS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), } ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Workday sensor.""" - add_holidays = config.get(CONF_ADD_HOLIDAYS) - remove_holidays = config.get(CONF_REMOVE_HOLIDAYS) + add_holidays = config[CONF_ADD_HOLIDAYS] + remove_holidays = config[CONF_REMOVE_HOLIDAYS] country = config[CONF_COUNTRY] days_offset = config[CONF_OFFSET] excludes = config[CONF_EXCLUDES] diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index f75e25dd97eb8a..6140abf4f2aef8 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -2,7 +2,7 @@ "domain": "workday", "name": "Workday", "documentation": "https://www.home-assistant.io/integrations/workday", - "requirements": ["holidays==0.11.3.1"], + "requirements": ["holidays==0.12"], "codeowners": ["@fabaff"], "quality_scale": "internal", "iot_class": "local_polling" diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index 74da12f7f610e8..069ca5c55e7afd 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -1,9 +1,14 @@ """Support for showing the time in a different time zone.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_NAME, CONF_TIME_ZONE +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util CONF_TIME_FORMAT = "time_format" @@ -21,10 +26,15 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the World clock sensor.""" name = config.get(CONF_NAME) - time_zone = dt_util.get_time_zone(config.get(CONF_TIME_ZONE)) + time_zone = dt_util.get_time_zone(config[CONF_TIME_ZONE]) async_add_entities( [ diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py index 4d7a32605b0549..533328490c84ff 100644 --- a/homeassistant/components/worldtidesinfo/sensor.py +++ b/homeassistant/components/worldtidesinfo/sensor.py @@ -1,4 +1,6 @@ """Support for the worldtides.info API.""" +from __future__ import annotations + from datetime import timedelta import logging import time @@ -14,7 +16,10 @@ CONF_LONGITUDE, CONF_NAME, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -34,7 +39,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the WorldTidesInfo sensor.""" name = config.get(CONF_NAME) diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 98b7470b7a8fae..47617dc609e091 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -1,4 +1,6 @@ """Support for Worx Landroid mower.""" +from __future__ import annotations + import asyncio import logging @@ -8,8 +10,11 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT, PERCENTAGE +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -43,7 +48,12 @@ ] -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Worx Landroid sensors.""" for typ in ("battery", "state"): async_add_entities([WorxLandroidSensor(typ, config)]) diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index e0866f6f677678..309b9a6a75811b 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -1,4 +1,6 @@ """Support for Washington State Department of Transportation (WSDOT) data.""" +from __future__ import annotations + from datetime import datetime, timedelta, timezone from http import HTTPStatus import logging @@ -16,7 +18,10 @@ CONF_NAME, TIME_MINUTES, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -43,17 +48,22 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_TRAVEL_TIMES): [ + vol.Required(CONF_TRAVEL_TIMES): [ {vol.Required(CONF_ID): cv.string, vol.Optional(CONF_NAME): cv.string} ], } ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the WSDOT sensor.""" sensors = [] - for travel_time in config.get(CONF_TRAVEL_TIMES): + for travel_time in config[CONF_TRAVEL_TIMES]: name = travel_time.get(CONF_NAME) or travel_time.get(CONF_ID) sensors.append( WashingtonStateTravelTimeSensor( diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index 3943c081eef2a0..0676b249e1b354 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -1,4 +1,6 @@ """Support for X10 lights.""" +from __future__ import annotations + import logging from subprocess import STDOUT, CalledProcessError, check_output @@ -11,7 +13,10 @@ LightEntity, ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -38,7 +43,12 @@ def get_unit_status(code): return int(output.decode("utf-8")[0]) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the x10 Light platform.""" is_cm11a = True try: diff --git a/homeassistant/components/xbee/__init__.py b/homeassistant/components/xbee/__init__.py index 299d70529343dc..17d861d6432216 100644 --- a/homeassistant/components/xbee/__init__.py +++ b/homeassistant/components/xbee/__init__.py @@ -18,9 +18,11 @@ EVENT_HOMEASSISTANT_STOP, PERCENTAGE, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -58,7 +60,7 @@ ) -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the connection to the XBee Zigbee device.""" usb_device = config[DOMAIN].get(CONF_DEVICE, DEFAULT_DEVICE) baud = int(config[DOMAIN].get(CONF_BAUD, DEFAULT_BAUD)) diff --git a/homeassistant/components/xbee/binary_sensor.py b/homeassistant/components/xbee/binary_sensor.py index 01095822d1f57c..b1639085993176 100644 --- a/homeassistant/components/xbee/binary_sensor.py +++ b/homeassistant/components/xbee/binary_sensor.py @@ -1,7 +1,12 @@ """Support for Zigbee binary sensors.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import PLATFORM_SCHEMA, XBeeDigitalIn, XBeeDigitalInConfig from .const import CONF_ON_STATE, DOMAIN, STATES @@ -9,7 +14,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_ON_STATE): vol.In(STATES)}) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the XBee Zigbee binary sensor platform.""" zigbee_device = hass.data[DOMAIN] add_entities([XBeeBinarySensor(XBeeDigitalInConfig(config), zigbee_device)], True) diff --git a/homeassistant/components/xbee/light.py b/homeassistant/components/xbee/light.py index 859feee495bf52..93f5f866f2f327 100644 --- a/homeassistant/components/xbee/light.py +++ b/homeassistant/components/xbee/light.py @@ -1,7 +1,12 @@ """Support for XBee Zigbee lights.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.light import LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import PLATFORM_SCHEMA, XBeeDigitalOut, XBeeDigitalOutConfig from .const import CONF_ON_STATE, DEFAULT_ON_STATE, DOMAIN, STATES @@ -11,7 +16,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Create and add an entity based on the configuration.""" zigbee_device = hass.data[DOMAIN] add_entities([XBeeLight(XBeeDigitalOutConfig(config), zigbee_device)]) diff --git a/homeassistant/components/xbee/sensor.py b/homeassistant/components/xbee/sensor.py index 8dae25ad5e15fd..1d1a4b99705ddd 100644 --- a/homeassistant/components/xbee/sensor.py +++ b/homeassistant/components/xbee/sensor.py @@ -1,12 +1,17 @@ """Support for XBee Zigbee sensors.""" +from __future__ import annotations + from binascii import hexlify import logging import voluptuous as vol from xbee_helper.exceptions import ZigBeeException, ZigBeeTxFailure -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_TYPE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import CONF_TYPE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN, PLATFORM_SCHEMA, XBeeAnalogIn, XBeeAnalogInConfig, XBeeConfig @@ -25,14 +30,19 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the XBee Zigbee platform. Uses the 'type' config value to work out which type of Zigbee sensor we're dealing with and instantiates the relevant classes to handle it. """ zigbee_device = hass.data[DOMAIN] - typ = config.get(CONF_TYPE) + typ = config[CONF_TYPE] try: sensor_class, config_class = TYPE_CLASSES[typ] @@ -46,7 +56,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class XBeeTemperatureSensor(SensorEntity): """Representation of XBee Pro temperature sensor.""" - _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = TEMP_CELSIUS def __init__(self, config, device): diff --git a/homeassistant/components/xbee/switch.py b/homeassistant/components/xbee/switch.py index b97d9f315d572b..9cc25fbf7d2a55 100644 --- a/homeassistant/components/xbee/switch.py +++ b/homeassistant/components/xbee/switch.py @@ -1,7 +1,12 @@ """Support for XBee Zigbee switches.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import PLATFORM_SCHEMA, XBeeDigitalOut, XBeeDigitalOutConfig from .const import CONF_ON_STATE, DOMAIN, STATES @@ -9,7 +14,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_ON_STATE): vol.In(STATES)}) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the XBee Zigbee switch platform.""" zigbee_device = hass.data[DOMAIN] add_entities([XBeeSwitch(XBeeDigitalOutConfig(config), zigbee_device)]) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 2a09c465984215..0466d0191cf669 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -21,7 +21,7 @@ ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import ( aiohttp_client, @@ -48,7 +48,12 @@ extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["media_player", "remote", "binary_sensor", "sensor"] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.MEDIA_PLAYER, + Platform.REMOTE, + Platform.SENSOR, +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index 4965e9705d1fc4..592909f3aac552 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import ( async_get_registry as async_get_entity_registry, ) @@ -18,8 +19,8 @@ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Xbox Live friends.""" coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ "coordinator" diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index cdeb016d6040a6..4cc7ebda545a24 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -28,8 +28,11 @@ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ConsoleData, XboxUpdateCoordinator @@ -60,7 +63,9 @@ } -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Xbox media_player from a config entry.""" client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"] consoles: SmartglassConsoleList = hass.data[DOMAIN][entry.entry_id]["consoles"] diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py index 04f25c5f6323d3..897836e5c42eb3 100644 --- a/homeassistant/components/xbox/remote.py +++ b/homeassistant/components/xbox/remote.py @@ -20,14 +20,19 @@ DEFAULT_DELAY_SECS, RemoteEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ConsoleData, XboxUpdateCoordinator from .const import DOMAIN -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Xbox media_player from a config entry.""" client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"] consoles: SmartglassConsoleList = hass.data[DOMAIN][entry.entry_id]["consoles"] diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 854c0b007f6fa3..edcc4a8c135214 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -4,7 +4,9 @@ from functools import partial from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import ( async_get_registry as async_get_entity_registry, ) @@ -16,7 +18,11 @@ SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"] -async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Xbox Live friends.""" coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id][ "coordinator" diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index bbd44498daba54..f9283b459e9cfb 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -1,4 +1,6 @@ """Sensor for Xbox Live account status.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -7,9 +9,11 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -25,7 +29,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Xbox platform.""" api = Client(api_key=config[CONF_API_KEY]) entities = [] diff --git a/homeassistant/components/xeoma/camera.py b/homeassistant/components/xeoma/camera.py index 049b4bfcbc0f23..31b80618d9e6b6 100644 --- a/homeassistant/components/xeoma/camera.py +++ b/homeassistant/components/xeoma/camera.py @@ -8,7 +8,10 @@ from homeassistant.components.camera import PLATFORM_SCHEMA, Camera from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -41,7 +44,12 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Discover and setup Xeoma Cameras.""" host = config[CONF_HOST] diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index 016fe7dd2ba76a..15d92fb714de5c 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -9,7 +9,7 @@ from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -18,9 +18,12 @@ CONF_PORT, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -50,7 +53,12 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up a Xiaomi Camera.""" _LOGGER.debug("Received configuration for model %s", config[CONF_MODEL]) async_add_entities([XiaomiCamera(hass, config)]) @@ -65,7 +73,7 @@ def __init__(self, hass, config): self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS) self._last_image = None self._last_url = None - self._manager = hass.data[DATA_FFMPEG] + self._manager = get_ffmpeg_manager(hass) self._name = config[CONF_NAME] self.host = config[CONF_HOST] self.host.hass = hass diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index c1e38f64c5332b..c21f21e1f9b6f4 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -1,4 +1,6 @@ """Support for Xiaomi Mi routers.""" +from __future__ import annotations + from http import HTTPStatus import logging @@ -11,7 +13,9 @@ DeviceScanner, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -24,7 +28,7 @@ ) -def get_scanner(hass, config): +def get_scanner(hass: HomeAssistant, config: ConfigType) -> DeviceScanner | None: """Validate the configuration and return a Xiaomi Device Scanner.""" scanner = XiaomiDeviceScanner(config[DOMAIN]) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 7aff6ece0e1fa6..1857b6e83b21ed 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -5,7 +5,8 @@ import voluptuous as vol from xiaomi_gateway import XiaomiGateway, XiaomiGatewayDiscovery -from homeassistant import config_entries, core +from homeassistant.components import persistent_notification +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, @@ -15,13 +16,15 @@ CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP, + Platform, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import utcnow from .const import ( @@ -36,8 +39,15 @@ _LOGGER = logging.getLogger(__name__) -GATEWAY_PLATFORMS = ["binary_sensor", "sensor", "switch", "light", "cover", "lock"] -GATEWAY_PLATFORMS_NO_KEY = ["binary_sensor", "sensor"] +GATEWAY_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.COVER, + Platform.LIGHT, + Platform.LOCK, + Platform.SENSOR, + Platform.SWITCH, +] +GATEWAY_PLATFORMS_NO_KEY = [Platform.BINARY_SENSOR, Platform.SENSOR] ATTR_GW_MAC = "gw_mac" ATTR_RINGTONE_ID = "ringtone_id" @@ -66,10 +76,10 @@ ) -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Xiaomi component.""" - def play_ringtone_service(call): + def play_ringtone_service(call: ServiceCall) -> None: """Service to play ringtone through Gateway.""" ring_id = call.data.get(ATTR_RINGTONE_ID) gateway = call.data.get(ATTR_GW_MAC) @@ -81,22 +91,23 @@ def play_ringtone_service(call): gateway.write_to_hub(gateway.sid, **kwargs) - def stop_ringtone_service(call): + def stop_ringtone_service(call: ServiceCall) -> None: """Service to stop playing ringtone on Gateway.""" gateway = call.data.get(ATTR_GW_MAC) gateway.write_to_hub(gateway.sid, mid=10000) - def add_device_service(call): + def add_device_service(call: ServiceCall) -> None: """Service to add a new sub-device within the next 30 seconds.""" gateway = call.data.get(ATTR_GW_MAC) gateway.write_to_hub(gateway.sid, join_permission="yes") - hass.components.persistent_notification.async_create( + persistent_notification.async_create( + hass, "Join permission enabled for 30 seconds! " "Please press the pairing button of the new device once.", title="Xiaomi Aqara Gateway", ) - def remove_device_service(call): + def remove_device_service(call: ServiceCall) -> None: """Service to remove a sub-device from the gateway.""" device_id = call.data.get(ATTR_DEVICE_ID) gateway = call.data.get(ATTR_GW_MAC) @@ -129,9 +140,7 @@ def remove_device_service(call): return True -async def async_setup_entry( - hass: core.HomeAssistant, entry: config_entries.ConfigEntry -): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the xiaomi aqara components from a config entry.""" hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].setdefault(GATEWAYS_KEY, {}) @@ -191,9 +200,7 @@ def stop_xiaomi(event): return True -async def async_unload_entry( - hass: core.HomeAssistant, entry: config_entries.ConfigEntry -): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if entry.data[CONF_KEY] is not None: platforms = GATEWAY_PLATFORMS diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 41a99426c670ba..ae4059728fe6b6 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -2,12 +2,14 @@ import logging from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_OPENING, + BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.restore_state import RestoreEntity from . import XiaomiDevice from .const import DOMAIN, GATEWAYS_KEY @@ -26,7 +28,11 @@ ATTR_DENSITY = "Density" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Perform the setup for Xiaomi devices.""" entities = [] gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] @@ -176,6 +182,11 @@ def extra_state_attributes(self): attrs.update(super().extra_state_attributes) return attrs + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self._state = False + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" if DENSITY in data: @@ -227,6 +238,11 @@ def _async_set_no_motion(self, now): self._state = False self.async_write_ha_state() + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self._state = False + def parse_data(self, data, raw_data): """Parse data sent by gateway. @@ -288,7 +304,7 @@ def parse_data(self, data, raw_data): return True -class XiaomiDoorSensor(XiaomiBinarySensor): +class XiaomiDoorSensor(XiaomiBinarySensor, RestoreEntity): """Representation of a XiaomiDoorSensor.""" def __init__(self, device, xiaomi_hub, config_entry): @@ -303,7 +319,7 @@ def __init__(self, device, xiaomi_hub, config_entry): "Door Window Sensor", xiaomi_hub, data_key, - DEVICE_CLASS_OPENING, + BinarySensorDeviceClass.OPENING, config_entry, ) @@ -314,6 +330,15 @@ def extra_state_attributes(self): attrs.update(super().extra_state_attributes) return attrs + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state is None: + return + + self._state = state.state == "on" + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" self._should_poll = False @@ -353,10 +378,15 @@ def __init__(self, device, xiaomi_hub, config_entry): "Water Leak Sensor", xiaomi_hub, data_key, - DEVICE_CLASS_MOISTURE, + BinarySensorDeviceClass.MOISTURE, config_entry, ) + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self._state = False + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" self._should_poll = False @@ -395,6 +425,11 @@ def extra_state_attributes(self): attrs.update(super().extra_state_attributes) return attrs + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self._state = False + def parse_data(self, data, raw_data): """Parse data sent by gateway.""" if DENSITY in data: diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index db41a4d719aa04..422d9b21e0d759 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -1,5 +1,8 @@ """Support for Xiaomi curtain.""" from homeassistant.components.cover import ATTR_POSITION, CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import XiaomiDevice from .const import DOMAIN, GATEWAYS_KEY @@ -10,7 +13,11 @@ DATA_KEY_PROTO_V2 = "curtain_status" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Perform the setup for Xiaomi devices.""" entities = [] gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index 4064df5f259a77..637055144dba9a 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -10,6 +10,9 @@ SUPPORT_COLOR, LightEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util from . import XiaomiDevice @@ -18,7 +21,11 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Perform the setup for Xiaomi devices.""" entities = [] gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index b7167452b65fdb..e21967a9f06d61 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -1,7 +1,9 @@ """Support for Xiaomi Aqara locks.""" from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from . import XiaomiDevice @@ -17,7 +19,11 @@ UNLOCK_MAINTAIN_TIME = 5 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Perform the setup for Xiaomi devices.""" entities = [] gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index c3f5cf4dacc164..9c295c3fe0aba5 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -3,21 +3,22 @@ import logging -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, LIGHT_LUX, PERCENTAGE, POWER_WATT, PRESSURE_HPA, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import XiaomiDevice from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY, POWER_MODELS @@ -28,27 +29,27 @@ "temperature": SensorEntityDescription( key="temperature", native_unit_of_measurement=TEMP_CELSIUS, - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, ), "humidity": SensorEntityDescription( key="humidity", native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, ), "illumination": SensorEntityDescription( key="illumination", native_unit_of_measurement="lm", - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=SensorDeviceClass.ILLUMINANCE, ), "lux": SensorEntityDescription( key="lux", native_unit_of_measurement=LIGHT_LUX, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=SensorDeviceClass.ILLUMINANCE, ), "pressure": SensorEntityDescription( key="pressure", native_unit_of_measurement=PRESSURE_HPA, - device_class=DEVICE_CLASS_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, ), "bed_activity": SensorEntityDescription( key="bed_activity", @@ -58,7 +59,7 @@ "load_power": SensorEntityDescription( key="load_power", native_unit_of_measurement=POWER_WATT, - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, ), "final_tilt_angle": SensorEntityDescription( key="final_tilt_angle", @@ -72,7 +73,11 @@ } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Perform the setup for Xiaomi devices.""" entities = [] gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] @@ -185,7 +190,7 @@ class XiaomiBatterySensor(XiaomiDevice, SensorEntity): """Representation of a XiaomiSensor.""" _attr_native_unit_of_measurement = PERCENTAGE - _attr_device_class = DEVICE_CLASS_BATTERY + _attr_device_class = SensorDeviceClass.BATTERY def parse_data(self, data, raw_data): """Parse data sent by gateway.""" diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index 139a7a57dbc167..86acd4100a2a84 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -2,6 +2,9 @@ import logging from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import XiaomiDevice from .const import DOMAIN, GATEWAYS_KEY @@ -21,7 +24,11 @@ IN_USE = "inuse" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Perform the setup for Xiaomi devices.""" entities = [] gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] diff --git a/homeassistant/components/xiaomi_aqara/translations/el.json b/homeassistant/components/xiaomi_aqara/translations/el.json new file mode 100644 index 00000000000000..d35fc5a2b082dc --- /dev/null +++ b/homeassistant/components/xiaomi_aqara/translations/el.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "not_xiaomi_aqara": "\u0394\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03c0\u03cd\u03bb\u03b7 Xiaomi Aqara, \u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c0\u03bf\u03c5 \u03b1\u03bd\u03b1\u03ba\u03b1\u03bb\u03cd\u03c6\u03b8\u03b7\u03ba\u03b5 \u03b4\u03b5\u03bd \u03c4\u03b1\u03b9\u03c1\u03b9\u03ac\u03b6\u03b5\u03b9 \u03bc\u03b5 \u03c4\u03b9\u03c2 \u03b3\u03bd\u03c9\u03c3\u03c4\u03ad\u03c2 \u03c0\u03cd\u03bb\u03b5\u03c2" + }, + "error": { + "discovery_error": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03b1\u03bd\u03b1\u03ba\u03ac\u03bb\u03c5\u03c8\u03b7\u03c2 \u03bc\u03b9\u03b1\u03c2 \u03c0\u03cd\u03bb\u03b7\u03c2 Xiaomi Aqara, \u03b4\u03bf\u03ba\u03b9\u03bc\u03ac\u03c3\u03c4\u03b5 \u03bd\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03b5\u03c4\u03b5 \u03c4\u03b7\u03bd IP \u03c4\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 \u03c0\u03bf\u03c5 \u03b5\u03ba\u03c4\u03b5\u03bb\u03b5\u03af \u03c4\u03bf HomeAssistant \u03c9\u03c2 \u03b4\u03b9\u03b5\u03c0\u03b1\u03c6\u03ae", + "invalid_host": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf \u03cc\u03bd\u03bf\u03bc\u03b1 \u03ba\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03bf\u03cd \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae \u03ae \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IP, \u03b2\u03bb. https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", + "invalid_interface": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03b7 \u03b4\u03b9\u03b5\u03c0\u03b1\u03c6\u03ae \u03b4\u03b9\u03ba\u03c4\u03cd\u03bf\u03c5", + "invalid_key": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf \u03ba\u03bb\u03b5\u03b9\u03b4\u03af \u03c0\u03cd\u03bb\u03b7\u03c2", + "invalid_mac": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 Mac" + }, + "flow_title": "{name}", + "step": { + "select": { + "description": "\u0395\u03ba\u03c4\u03b5\u03bb\u03ad\u03c3\u03c4\u03b5 \u03be\u03b1\u03bd\u03ac \u03c4\u03b7 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 \u03b5\u03ac\u03bd \u03b8\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c3\u03c5\u03bd\u03b4\u03ad\u03c3\u03b5\u03c4\u03b5 \u03b5\u03c0\u03b9\u03c0\u03bb\u03ad\u03bf\u03bd \u03c0\u03cd\u03bb\u03b5\u03c2", + "title": "\u0395\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c0\u03cd\u03bb\u03b7 Xiaomi Aqara \u03c0\u03bf\u03c5 \u03b8\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c3\u03c5\u03bd\u03b4\u03ad\u03c3\u03b5\u03c4\u03b5" + }, + "settings": { + "data": { + "key": "\u03a4\u03bf \u03ba\u03bb\u03b5\u03b9\u03b4\u03af \u03c4\u03b7\u03c2 \u03c0\u03cd\u03bb\u03b7\u03c2 \u03c3\u03b1\u03c2", + "name": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c4\u03b7\u03c2 \u03c0\u03cd\u03bb\u03b7\u03c2" + }, + "description": "\u03a4\u03bf \u03ba\u03bb\u03b5\u03b9\u03b4\u03af (\u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2) \u03bc\u03c0\u03bf\u03c1\u03b5\u03af \u03bd\u03b1 \u03b1\u03bd\u03b1\u03ba\u03c4\u03b7\u03b8\u03b5\u03af \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03ce\u03bd\u03c4\u03b1\u03c2 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03c3\u03b5\u03bc\u03b9\u03bd\u03ac\u03c1\u03b9\u03bf: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. \u0395\u03ac\u03bd \u03b4\u03b5\u03bd \u03b4\u03bf\u03b8\u03b5\u03af \u03c4\u03bf \u03ba\u03bb\u03b5\u03b9\u03b4\u03af, \u03bc\u03cc\u03bd\u03bf \u03bf\u03b9 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b5\u03c2 \u03b8\u03b1 \u03b5\u03af\u03bd\u03b1\u03b9 \u03c0\u03c1\u03bf\u03c3\u03b2\u03ac\u03c3\u03b9\u03bc\u03bf\u03b9.", + "title": "\u03a0\u03cd\u03bb\u03b7 Xiaomi Aqara, \u03c0\u03c1\u03bf\u03b1\u03b9\u03c1\u03b5\u03c4\u03b9\u03ba\u03ad\u03c2 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2" + }, + "user": { + "data": { + "interface": "\u0397 \u03b4\u03b9\u03b5\u03c0\u03b1\u03c6\u03ae \u03b4\u03b9\u03ba\u03c4\u03cd\u03bf\u03c5 \u03c0\u03c1\u03bf\u03c2 \u03c7\u03c1\u03ae\u03c3\u03b7", + "mac": "\u0394\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 Mac (\u03c0\u03c1\u03bf\u03b1\u03b9\u03c1\u03b5\u03c4\u03b9\u03ba\u03cc)" + }, + "description": "\u03a3\u03c5\u03bd\u03b4\u03b5\u03b8\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd \u03c0\u03cd\u03bb\u03b7 Xiaomi Aqara Gateway, \u03b5\u03ac\u03bd \u03bf\u03b9 \u03b4\u03b9\u03b5\u03c5\u03b8\u03cd\u03bd\u03c3\u03b5\u03b9\u03c2 IP \u03ba\u03b1\u03b9 MAC \u03c0\u03b1\u03c1\u03b1\u03bc\u03ad\u03bd\u03bf\u03c5\u03bd \u03ba\u03b5\u03bd\u03ad\u03c2, \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b1\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03b7 \u03b1\u03bd\u03b1\u03ba\u03ac\u03bb\u03c5\u03c8\u03b7.", + "title": "\u03a0\u03cd\u03bb\u03b7 Xiaomi Aqara" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_aqara/translations/es.json b/homeassistant/components/xiaomi_aqara/translations/es.json index 1d45b456611b30..04da1a8caf5c8f 100644 --- a/homeassistant/components/xiaomi_aqara/translations/es.json +++ b/homeassistant/components/xiaomi_aqara/translations/es.json @@ -7,7 +7,7 @@ }, "error": { "discovery_error": "No se pudo descubrir un Xiaomi Aqara Gateway, intenta utilizar la IP del dispositivo que ejecuta HomeAssistant como interfaz", - "invalid_host": "Direcci\u00f3n IP no v\u00e1lida", + "invalid_host": "Nombre de host o direcci\u00f3n IP no v\u00e1lidos, consulte https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", "invalid_interface": "Interfaz de red inv\u00e1lida", "invalid_key": "Clave del gateway inv\u00e1lida", "invalid_mac": "Direcci\u00f3n Mac no v\u00e1lida" diff --git a/homeassistant/components/xiaomi_aqara/translations/fr.json b/homeassistant/components/xiaomi_aqara/translations/fr.json index f6ca4af9d3e666..31779ba80b5e6b 100644 --- a/homeassistant/components/xiaomi_aqara/translations/fr.json +++ b/homeassistant/components/xiaomi_aqara/translations/fr.json @@ -12,7 +12,7 @@ "invalid_key": "Cl\u00e9 de passerelle non valide", "invalid_mac": "Adresse MAC non valide" }, - "flow_title": "Passerelle Xiaomi Aqara: {nom}", + "flow_title": "{name}", "step": { "select": { "data": { diff --git a/homeassistant/components/xiaomi_aqara/translations/it.json b/homeassistant/components/xiaomi_aqara/translations/it.json index a730a2e24d94ee..27330c1124274e 100644 --- a/homeassistant/components/xiaomi_aqara/translations/it.json +++ b/homeassistant/components/xiaomi_aqara/translations/it.json @@ -6,7 +6,7 @@ "not_xiaomi_aqara": "Non \u00e8 un Gateway Xiaomi Aqara, il dispositivo scoperto non corrisponde ai gateway noti" }, "error": { - "discovery_error": "Impossibile individuare un gateway Xiaomi Aqara, provare a utilizzare l'IP del dispositivo che esegue HomeAssistant come interfaccia", + "discovery_error": "Impossibile individuare un gateway Xiaomi Aqara, prova a utilizzare l'IP del dispositivo che esegue HomeAssistant come interfaccia", "invalid_host": "Nome host o indirizzo IP non valido, vedere https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", "invalid_interface": "Interfaccia di rete non valida", "invalid_key": "Chiave gateway non valida", @@ -19,7 +19,7 @@ "select_ip": "Indirizzo IP" }, "description": "Esegui di nuovo la configurazione se desideri connettere gateway aggiuntivi", - "title": "Selezionare il Gateway Xiaomi Aqara che si desidera collegare" + "title": "Seleziona il Gateway Xiaomi Aqara che si desidera collegare" }, "settings": { "data": { @@ -35,7 +35,7 @@ "interface": "L'interfaccia di rete da utilizzare", "mac": "Indirizzo Mac (opzionale)" }, - "description": "Connettiti al tuo Xiaomi Aqara Gateway, se gli indirizzi IP e MAC sono lasciati vuoti, verr\u00e0 utilizzato il rilevamento automatico", + "description": "Connettiti al tuo Xiaomi Aqara Gateway, se gli indirizzi IP e MAC sono lasciati vuoti, sar\u00e0 utilizzato il rilevamento automatico", "title": "Xiaomi Aqara Gateway" } } diff --git a/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json b/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json index 4fe5decd858da6..dcc7b76a0c4b6d 100644 --- a/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json +++ b/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json @@ -9,7 +9,7 @@ "discovery_error": "\u63a2\u7d22\u5c0f\u7c73 Aqara \u7db2\u95dc\u5931\u6557\uff0c\u8acb\u5617\u8a66\u4f7f\u7528\u57f7\u884c Home Assistant \u88dd\u7f6e\u7684 IP \u4f5c\u70ba\u4ecb\u9762", "invalid_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u7a31\u6216 IP \u4f4d\u5740\uff0c\u8acb\u53c3\u95b1 https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", "invalid_interface": "\u7db2\u8def\u4ecb\u9762\u7121\u6548", - "invalid_key": "\u7db2\u95dc\u5bc6\u9470\u7121\u6548", + "invalid_key": "\u7db2\u95dc\u91d1\u9470\u7121\u6548", "invalid_mac": "\u7121\u6548\u7684 Mac \u4f4d\u5740" }, "flow_title": "{name}", @@ -23,10 +23,10 @@ }, "settings": { "data": { - "key": "\u7db2\u95dc\u5bc6\u9470", + "key": "\u7db2\u95dc\u91d1\u9470", "name": "\u7db2\u95dc\u540d\u7a31" }, - "description": "\u5bc6\u9470\uff08\u5bc6\u78bc\uff09\u53d6\u5f97\u8acb\u53c3\u8003\u4e0b\u65b9\u6559\u5b78\uff1ahttps://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz\u3002\u5047\u5982\u672a\u63d0\u4f9b\u5bc6\u9470\u3001\u5247\u50c5\u6703\u6536\u5230\u611f\u6e2c\u5668\u88dd\u7f6e\u7684\u8cc7\u8a0a", + "description": "\u91d1\u9470\uff08\u5bc6\u78bc\uff09\u53d6\u5f97\u8acb\u53c3\u8003\u4e0b\u65b9\u6559\u5b78\uff1ahttps://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz\u3002\u5047\u5982\u672a\u63d0\u4f9b\u91d1\u9470\u3001\u5247\u50c5\u6703\u6536\u5230\u611f\u6e2c\u5668\u88dd\u7f6e\u7684\u8cc7\u8a0a", "title": "\u5c0f\u7c73 Aqara \u7db2\u95dc\u9078\u9805\u8a2d\u5b9a" }, "user": { diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 62935f5e38b907..63fc31722535ba 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -32,9 +32,9 @@ ) from miio.gateway.gateway import GatewayException -from homeassistant import config_entries, core -from homeassistant.const import CONF_HOST, CONF_TOKEN -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -78,20 +78,32 @@ POLLING_TIMEOUT_SEC = 10 UPDATE_INTERVAL = timedelta(seconds=15) -GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] -SWITCH_PLATFORMS = ["switch"] -FAN_PLATFORMS = ["binary_sensor", "fan", "number", "select", "sensor", "switch"] +GATEWAY_PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] +SWITCH_PLATFORMS = [Platform.SWITCH] +FAN_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.FAN, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] HUMIDIFIER_PLATFORMS = [ - "binary_sensor", - "humidifier", - "number", - "select", - "sensor", - "switch", + Platform.BINARY_SENSOR, + Platform.HUMIDIFIER, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, ] -LIGHT_PLATFORMS = ["light"] -VACUUM_PLATFORMS = ["binary_sensor", "sensor", "vacuum"] -AIR_MONITOR_PLATFORMS = ["air_quality", "sensor"] +LIGHT_PLATFORMS = [Platform.LIGHT] +VACUUM_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.VACUUM] +AIR_MONITOR_PLATFORMS = [Platform.AIR_QUALITY, Platform.SENSOR] MODEL_TO_CLASS_MAP = { MODEL_FAN_1C: Fan1C, @@ -103,9 +115,7 @@ } -async def async_setup_entry( - hass: core.HomeAssistant, entry: config_entries.ConfigEntry -): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Xiaomi Miio components from a config entry.""" hass.data.setdefault(DOMAIN, {}) if entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: @@ -266,8 +276,8 @@ async def execute_update(): async def async_create_miio_device_and_coordinator( - hass: core.HomeAssistant, entry: config_entries.ConfigEntry -): + hass: HomeAssistant, entry: ConfigEntry +) -> None: """Set up a data coordinator and one miio device to service multiple entities.""" model: str = entry.data[CONF_MODEL] host = entry.data[CONF_HOST] @@ -358,9 +368,7 @@ async def async_create_miio_device_and_coordinator( await coordinator.async_config_entry_first_refresh() -async def async_setup_gateway_entry( - hass: core.HomeAssistant, entry: config_entries.ConfigEntry -): +async def async_setup_gateway_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Set up the Xiaomi Gateway component from a config entry.""" host = entry.data[CONF_HOST] token = entry.data[CONF_TOKEN] @@ -383,8 +391,6 @@ async def async_setup_gateway_entry( raise ConfigEntryNotReady() from error gateway_info = gateway.gateway_info - gateway_model = f"{gateway_info.model}-{gateway_info.hardware_version}" - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -392,8 +398,9 @@ async def async_setup_gateway_entry( identifiers={(DOMAIN, gateway_id)}, manufacturer="Xiaomi", name=name, - model=gateway_model, + model=gateway_info.model, sw_version=gateway_info.firmware_version, + hw_version=gateway_info.hardware_version, ) def update_data(): @@ -434,9 +441,7 @@ async def async_update_data(): ) -async def async_setup_device_entry( - hass: core.HomeAssistant, entry: config_entries.ConfigEntry -): +async def async_setup_device_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Xiaomi Miio device component from a config entry.""" platforms = get_platforms(entry) await async_create_miio_device_and_coordinator(hass, entry) @@ -451,9 +456,7 @@ async def async_setup_device_entry( return True -async def async_unload_entry( - hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry -): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" platforms = get_platforms(config_entry) @@ -467,8 +470,6 @@ async def async_unload_entry( return unload_ok -async def update_listener( - hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry -): +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 271beae131c9c2..fdc62076c258c6 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -4,7 +4,10 @@ from miio import AirQualityMonitor, AirQualityMonitorCGDN1, DeviceException from homeassistant.components.air_quality import AirQualityEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CONF_DEVICE, @@ -236,7 +239,11 @@ def particulate_matter_10(self): } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Xiaomi Air Quality from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index 1fccbcf8056bab..f7dbf60f63aac6 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -1,5 +1,4 @@ """Support for Xiomi Gateway alarm control panels.""" - from functools import partial import logging @@ -9,12 +8,15 @@ SUPPORT_ALARM_ARM_AWAY, AlarmControlPanelEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_GATEWAY, DOMAIN @@ -25,7 +27,11 @@ XIAOMI_STATE_ARMING_VALUE = "oning" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Xiaomi Gateway Alarm from a config entry.""" entities = [] gateway = hass.data[DOMAIN][config_entry.entry_id][CONF_GATEWAY] diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 26fa82b7df2985..83bea4be9b1f2b 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -6,14 +6,14 @@ import logging from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, - DEVICE_CLASS_PLUG, - DEVICE_CLASS_PROBLEM, + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import VacuumCoordinatorDataAttributes from .const import ( @@ -56,21 +56,21 @@ class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): key=ATTR_NO_WATER, name="Water Tank Empty", icon="mdi:water-off-outline", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), XiaomiMiioBinarySensorDescription( key=ATTR_WATER_TANK_DETACHED, name="Water Tank", icon="mdi:car-coolant-level", - device_class=DEVICE_CLASS_CONNECTIVITY, + device_class=BinarySensorDeviceClass.CONNECTIVITY, value=lambda value: not value, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), XiaomiMiioBinarySensorDescription( key=ATTR_POWERSUPPLY_ATTACHED, name="Power Supply", - device_class=DEVICE_CLASS_PLUG, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PLUG, + entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -83,8 +83,8 @@ class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): icon="mdi:square-rounded", parent_key=VacuumCoordinatorDataAttributes.status, entity_registry_enabled_default=True, - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, ), ATTR_WATER_BOX_ATTACHED: XiaomiMiioBinarySensorDescription( key=ATTR_WATER_BOX_ATTACHED, @@ -92,8 +92,8 @@ class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): icon="mdi:water", parent_key=VacuumCoordinatorDataAttributes.status, entity_registry_enabled_default=True, - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, ), ATTR_WATER_SHORTAGE: XiaomiMiioBinarySensorDescription( key=ATTR_WATER_SHORTAGE, @@ -101,8 +101,8 @@ class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): icon="mdi:water", parent_key=VacuumCoordinatorDataAttributes.status, entity_registry_enabled_default=True, - device_class=DEVICE_CLASS_PROBLEM, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), } @@ -114,8 +114,8 @@ class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): icon="mdi:square-rounded", parent_key=VacuumCoordinatorDataAttributes.status, entity_registry_enabled_default=True, - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, ), } @@ -160,7 +160,11 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities): async_add_entities(entities) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Xiaomi sensor from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 8c83c8015b2015..b361e8ba1b3069 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -166,7 +166,10 @@ class SetupException(Exception): "philips.light.candle2", "philips.light.downlight", ] -MODELS_LIGHT_MONO = ["philips.light.mono1"] +MODELS_LIGHT_MONO = [ + "philips.light.mono1", + "philips.light.hbulb", +] # Model lists MODELS_GATEWAY = ["lumi.gateway", "lumi.acpartner"] diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index a6c1c7e5a28d61..3f819d7ab7d577 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -1,4 +1,6 @@ """Support for Xiaomi Mi WiFi Repeater 2.""" +from __future__ import annotations + import logging from miio import DeviceException, WifiRepeater @@ -10,7 +12,9 @@ DeviceScanner, ) from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -22,7 +26,7 @@ ) -def get_scanner(hass, config): +def get_scanner(hass: HomeAssistant, config: ConfigType) -> DeviceScanner | None: """Return a Xiaomi MiIO device scanner.""" scanner = None host = config[DOMAIN][CONF_HOST] diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 07ec46132707ea..a12a8a6063bb50 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -24,9 +24,11 @@ SUPPORT_SET_SPEED, FanEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -172,7 +174,11 @@ } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Fan from a config entry.""" entities = [] @@ -222,7 +228,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append(entity) - async def async_service_handler(service): + async def async_service_handler(service: ServiceCall) -> None: """Map services to methods on XiaomiAirPurifier.""" method = SERVICE_TO_METHOD[service.service] params = { diff --git a/homeassistant/components/xiaomi_miio/gateway.py b/homeassistant/components/xiaomi_miio/gateway.py index af6ef2e29a5161..27f426ff9d62b9 100644 --- a/homeassistant/components/xiaomi_miio/gateway.py +++ b/homeassistant/components/xiaomi_miio/gateway.py @@ -160,6 +160,7 @@ def device_info(self) -> DeviceInfo: name=self._sub_device.name, model=self._sub_device.model, sw_version=self._sub_device.firmware_version, + hw_version=self._sub_device.zigbee_model, ) @property diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 9896bf8f0ead3a..7c3110e190ad70 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -6,13 +6,12 @@ from miio.airhumidifier_miot import OperationMode as AirhumidifierMiotOperationMode from miio.airhumidifier_mjjsq import OperationMode as AirhumidifierMjjsqOperationMode -from homeassistant.components.humidifier import HumidifierEntity -from homeassistant.components.humidifier.const import ( - DEVICE_CLASS_HUMIDIFIER, - SUPPORT_MODES, -) +from homeassistant.components.humidifier import HumidifierDeviceClass, HumidifierEntity +from homeassistant.components.humidifier.const import SUPPORT_MODES +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import percentage_to_ranged_value from .const import ( @@ -58,7 +57,11 @@ ] -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Humidifier from a config entry.""" if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: return @@ -105,7 +108,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): """Representation of a generic Xiaomi humidifier device.""" - _attr_device_class = DEVICE_CLASS_HUMIDIFIER + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER _attr_supported_features = SUPPORT_MODES def __init__(self, name, device, entry, unique_id, coordinator): diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 03722380a692b3..cae7bacaba4708 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -24,9 +24,12 @@ SUPPORT_COLOR_TEMP, LightEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color, dt from .const import ( @@ -109,7 +112,11 @@ } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Xiaomi light from a config entry.""" entities = [] @@ -187,7 +194,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) return - async def async_service_handler(service): + async def async_service_handler(service: ServiceCall) -> None: """Map services to methods on Xiaomi Philips Lights.""" method = SERVICE_TO_METHOD.get(service.service) params = { diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index eacc39db560a25..8939200d1073c1 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -5,9 +5,12 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.components.number.const import DOMAIN as PLATFORM_DOMAIN -from homeassistant.const import DEGREE, ENTITY_CATEGORY_CONFIG, TIME_MINUTES -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE, TIME_MINUTES +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CONF_DEVICE, @@ -84,9 +87,6 @@ class XiaomiMiioNumberDescription(NumberEntityDescription): """A class that describes number entities.""" - min_value: float | None = None - max_value: float | None = None - step: float | None = None available_with_device_off: bool = True method: str | None = None @@ -111,7 +111,7 @@ class OscillationAngleValues: step=10, available_with_device_off=False, method="async_set_motor_speed", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), FEATURE_SET_FAVORITE_LEVEL: XiaomiMiioNumberDescription( key=ATTR_FAVORITE_LEVEL, @@ -121,7 +121,7 @@ class OscillationAngleValues: max_value=17, step=1, method="async_set_favorite_level", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), FEATURE_SET_FAN_LEVEL: XiaomiMiioNumberDescription( key=ATTR_FAN_LEVEL, @@ -131,7 +131,7 @@ class OscillationAngleValues: max_value=3, step=1, method="async_set_fan_level", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), FEATURE_SET_VOLUME: XiaomiMiioNumberDescription( key=ATTR_VOLUME, @@ -141,7 +141,7 @@ class OscillationAngleValues: max_value=100, step=1, method="async_set_volume", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), FEATURE_SET_OSCILLATION_ANGLE: XiaomiMiioNumberDescription( key=ATTR_OSCILLATION_ANGLE, @@ -152,7 +152,7 @@ class OscillationAngleValues: max_value=120, step=1, method="async_set_oscillation_angle", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), FEATURE_SET_DELAY_OFF_COUNTDOWN: XiaomiMiioNumberDescription( key=ATTR_DELAY_OFF_COUNTDOWN, @@ -163,7 +163,7 @@ class OscillationAngleValues: max_value=480, step=1, method="async_set_delay_off_countdown", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), FEATURE_SET_LED_BRIGHTNESS: XiaomiMiioNumberDescription( key=ATTR_LED_BRIGHTNESS, @@ -173,7 +173,7 @@ class OscillationAngleValues: max_value=100, step=1, method="async_set_led_brightness", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), FEATURE_SET_LED_BRIGHTNESS_LEVEL: XiaomiMiioNumberDescription( key=ATTR_LED_BRIGHTNESS_LEVEL, @@ -183,7 +183,7 @@ class OscillationAngleValues: max_value=8, step=1, method="async_set_led_brightness_level", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), FEATURE_SET_FAVORITE_RPM: XiaomiMiioNumberDescription( key=ATTR_FAVORITE_RPM, @@ -194,7 +194,7 @@ class OscillationAngleValues: max_value=2200, step=10, method="async_set_favorite_rpm", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), } @@ -232,7 +232,11 @@ class OscillationAngleValues: } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Selectors from a config entry.""" entities = [] if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: @@ -289,9 +293,6 @@ def __init__(self, name, device, entry, unique_id, coordinator, description): """Initialize the generic Xiaomi attribute selector.""" super().__init__(name, device, entry, unique_id, coordinator) - self._attr_min_value = description.min_value - self._attr_max_value = description.max_value - self._attr_step = description.step self._attr_value = self._extract_value_from_attribute( coordinator.data, description.key ) diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py index 5428d8a7bde4fe..199f5dd6c5d929 100644 --- a/homeassistant/components/xiaomi_miio/remote.py +++ b/homeassistant/components/xiaomi_miio/remote.py @@ -1,4 +1,6 @@ """Support for the Xiaomi IR Remote (Chuangmi IR).""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -7,6 +9,7 @@ from miio import ChuangmiIr, DeviceException import voluptuous as vol +from homeassistant.components import persistent_notification from homeassistant.components.remote import ( ATTR_DELAY_SECS, ATTR_NUM_REPEATS, @@ -21,8 +24,11 @@ CONF_TIMEOUT, CONF_TOKEN, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow from .const import SERVICE_LEARN, SERVICE_SET_REMOTE_LED_OFF, SERVICE_SET_REMOTE_LED_ON @@ -58,7 +64,12 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Xiaomi IR Remote (Chuangmi IR) platform.""" host = config[CONF_HOST] token = config[CONF_TOKEN] @@ -128,8 +139,8 @@ async def async_service_learn_handler(entity, service): if "code" in message and message["code"]: log_msg = "Received command is: {}".format(message["code"]) _LOGGER.info(log_msg) - hass.components.persistent_notification.async_create( - log_msg, title="Xiaomi Miio Remote" + persistent_notification.async_create( + hass, log_msg, title="Xiaomi Miio Remote" ) return @@ -139,8 +150,8 @@ async def async_service_learn_handler(entity, service): await asyncio.sleep(1) _LOGGER.error("Timeout. No infrared command captured") - hass.components.persistent_notification.async_create( - "Timeout. No infrared command captured", title="Xiaomi Miio Remote" + persistent_notification.async_create( + hass, "Timeout. No infrared command captured", title="Xiaomi Miio Remote" ) platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index ec1be6f321925f..a0ff320e228f23 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -11,8 +11,10 @@ from miio.fan import LedBrightness as FanLedBrightness from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import ENTITY_CATEGORY_CONFIG -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CONF_DEVICE, @@ -63,12 +65,16 @@ class XiaomiMiioSelectDescription(SelectEntityDescription): icon="mdi:brightness-6", device_class="xiaomi_miio__led_brightness", options=("bright", "dim", "off"), - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Selectors from a config entry.""" if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: return diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index ccf55a04e171da..d6d1c8500ed41e 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -15,11 +15,12 @@ ) from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( AREA_SQUARE_METERS, ATTR_BATTERY_LEVEL, @@ -28,17 +29,6 @@ CONCENTRATION_PARTS_PER_MILLION, CONF_HOST, CONF_TOKEN, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO2, - DEVICE_CLASS_GAS, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_PM25, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, - ENTITY_CATEGORY_DIAGNOSTIC, LIGHT_LUX, PERCENTAGE, POWER_WATT, @@ -48,7 +38,9 @@ TIME_SECONDS, VOLUME_CUBIC_METERS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from . import VacuumCoordinatorDataAttributes @@ -123,6 +115,8 @@ ATTR_DND_END = "end" ATTR_LAST_CLEAN_TIME = "duration" ATTR_LAST_CLEAN_AREA = "area" +ATTR_STATUS_CLEAN_TIME = "clean_time" +ATTR_STATUS_CLEAN_AREA = "clean_area" ATTR_LAST_CLEAN_START = "start" ATTR_LAST_CLEAN_END = "end" ATTR_CLEAN_HISTORY_TOTAL_DURATION = "total_duration" @@ -148,137 +142,137 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): key=ATTR_TEMPERATURE, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), ATTR_HUMIDITY: XiaomiMiioSensorDescription( key=ATTR_HUMIDITY, name="Humidity", native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), ATTR_PRESSURE: XiaomiMiioSensorDescription( key=ATTR_PRESSURE, name="Pressure", native_unit_of_measurement=PRESSURE_HPA, - device_class=DEVICE_CLASS_PRESSURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), ATTR_LOAD_POWER: XiaomiMiioSensorDescription( key=ATTR_LOAD_POWER, name="Load Power", native_unit_of_measurement=POWER_WATT, - device_class=DEVICE_CLASS_POWER, + device_class=SensorDeviceClass.POWER, ), ATTR_WATER_LEVEL: XiaomiMiioSensorDescription( key=ATTR_WATER_LEVEL, name="Water Level", native_unit_of_measurement=PERCENTAGE, icon="mdi:water-check", - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), ATTR_ACTUAL_SPEED: XiaomiMiioSensorDescription( key=ATTR_ACTUAL_SPEED, name="Actual Speed", native_unit_of_measurement="rpm", icon="mdi:fast-forward", - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), ATTR_MOTOR_SPEED: XiaomiMiioSensorDescription( key=ATTR_MOTOR_SPEED, name="Motor Speed", native_unit_of_measurement="rpm", icon="mdi:fast-forward", - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), ATTR_MOTOR2_SPEED: XiaomiMiioSensorDescription( key=ATTR_MOTOR2_SPEED, name="Second Motor Speed", native_unit_of_measurement="rpm", icon="mdi:fast-forward", - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), ATTR_USE_TIME: XiaomiMiioSensorDescription( key=ATTR_USE_TIME, name="Use Time", native_unit_of_measurement=TIME_SECONDS, icon="mdi:progress-clock", - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), ATTR_ILLUMINANCE: XiaomiMiioSensorDescription( key=ATTR_ILLUMINANCE, name="Illuminance", native_unit_of_measurement=UNIT_LUMEN, - device_class=DEVICE_CLASS_ILLUMINANCE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ), ATTR_ILLUMINANCE_LUX: XiaomiMiioSensorDescription( key=ATTR_ILLUMINANCE, name="Illuminance", native_unit_of_measurement=LIGHT_LUX, - device_class=DEVICE_CLASS_ILLUMINANCE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ), ATTR_AIR_QUALITY: XiaomiMiioSensorDescription( key=ATTR_AIR_QUALITY, native_unit_of_measurement="AQI", icon="mdi:cloud", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ATTR_PM25: XiaomiMiioSensorDescription( key=ATTR_AQI, name="PM2.5", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - device_class=DEVICE_CLASS_PM25, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, ), ATTR_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( key=ATTR_FILTER_LIFE_REMAINING, name="Filter Life Remaining", native_unit_of_measurement=PERCENTAGE, icon="mdi:air-filter", - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, attributes=("filter_type",), - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), ATTR_FILTER_USE: XiaomiMiioSensorDescription( key=ATTR_FILTER_HOURS_USED, name="Filter Use", native_unit_of_measurement=TIME_HOURS, icon="mdi:clock-outline", - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), ATTR_CARBON_DIOXIDE: XiaomiMiioSensorDescription( key=ATTR_CARBON_DIOXIDE, name="Carbon Dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - device_class=DEVICE_CLASS_CO2, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, ), ATTR_PURIFY_VOLUME: XiaomiMiioSensorDescription( key=ATTR_PURIFY_VOLUME, name="Purify Volume", native_unit_of_measurement=VOLUME_CUBIC_METERS, - device_class=DEVICE_CLASS_GAS, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), ATTR_BATTERY: XiaomiMiioSensorDescription( key=ATTR_BATTERY, name="Battery", native_unit_of_measurement=PERCENTAGE, - device_class=DEVICE_CLASS_BATTERY, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), } @@ -408,35 +402,35 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): key=ATTR_DND_START, icon="mdi:minus-circle-off", name="DnD Start", - device_class=DEVICE_CLASS_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, parent_key=VacuumCoordinatorDataAttributes.dnd_status, entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"dnd_{ATTR_DND_END}": XiaomiMiioSensorDescription( key=ATTR_DND_END, icon="mdi:minus-circle-off", name="DnD End", - device_class=DEVICE_CLASS_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, parent_key=VacuumCoordinatorDataAttributes.dnd_status, entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"last_clean_{ATTR_LAST_CLEAN_START}": XiaomiMiioSensorDescription( key=ATTR_LAST_CLEAN_START, icon="mdi:clock-time-twelve", name="Last Clean Start", - device_class=DEVICE_CLASS_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, parent_key=VacuumCoordinatorDataAttributes.last_clean_details, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"last_clean_{ATTR_LAST_CLEAN_END}": XiaomiMiioSensorDescription( key=ATTR_LAST_CLEAN_END, icon="mdi:clock-time-twelve", - device_class=DEVICE_CLASS_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, parent_key=VacuumCoordinatorDataAttributes.last_clean_details, name="Last Clean End", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"last_clean_{ATTR_LAST_CLEAN_TIME}": XiaomiMiioSensorDescription( native_unit_of_measurement=TIME_SECONDS, @@ -444,7 +438,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): key=ATTR_LAST_CLEAN_TIME, parent_key=VacuumCoordinatorDataAttributes.last_clean_details, name="Last Clean Duration", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"last_clean_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription( native_unit_of_measurement=AREA_SQUARE_METERS, @@ -452,7 +446,23 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): key=ATTR_LAST_CLEAN_AREA, parent_key=VacuumCoordinatorDataAttributes.last_clean_details, name="Last Clean Area", - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, + ), + f"current_{ATTR_STATUS_CLEAN_TIME}": XiaomiMiioSensorDescription( + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-sand", + key=ATTR_STATUS_CLEAN_TIME, + parent_key=VacuumCoordinatorDataAttributes.status, + name="Current Clean Duration", + entity_category=EntityCategory.DIAGNOSTIC, + ), + f"current_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription( + native_unit_of_measurement=AREA_SQUARE_METERS, + icon="mdi:texture-box", + key=ATTR_STATUS_CLEAN_AREA, + parent_key=VacuumCoordinatorDataAttributes.status, + entity_category=EntityCategory.DIAGNOSTIC, + name="Current Clean Area", ), f"clean_history_{ATTR_CLEAN_HISTORY_TOTAL_DURATION}": XiaomiMiioSensorDescription( native_unit_of_measurement=TIME_SECONDS, @@ -461,7 +471,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): parent_key=VacuumCoordinatorDataAttributes.clean_history_status, name="Total duration", entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"clean_history_{ATTR_CLEAN_HISTORY_TOTAL_AREA}": XiaomiMiioSensorDescription( native_unit_of_measurement=AREA_SQUARE_METERS, @@ -470,17 +480,17 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): parent_key=VacuumCoordinatorDataAttributes.clean_history_status, name="Total Clean Area", entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"clean_history_{ATTR_CLEAN_HISTORY_COUNT}": XiaomiMiioSensorDescription( native_unit_of_measurement="", icon="mdi:counter", - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, key=ATTR_CLEAN_HISTORY_COUNT, parent_key=VacuumCoordinatorDataAttributes.clean_history_status, name="Total Clean Count", entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"clean_history_{ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT}": XiaomiMiioSensorDescription( native_unit_of_measurement="", @@ -490,7 +500,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): parent_key=VacuumCoordinatorDataAttributes.clean_history_status, name="Total Dust Collection Count", entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"consumable_{ATTR_CONSUMABLE_STATUS_MAIN_BRUSH_LEFT}": XiaomiMiioSensorDescription( native_unit_of_measurement=TIME_SECONDS, @@ -499,7 +509,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): parent_key=VacuumCoordinatorDataAttributes.consumable_status, name="Main Brush Left", entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"consumable_{ATTR_CONSUMABLE_STATUS_SIDE_BRUSH_LEFT}": XiaomiMiioSensorDescription( native_unit_of_measurement=TIME_SECONDS, @@ -508,7 +518,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): parent_key=VacuumCoordinatorDataAttributes.consumable_status, name="Side Brush Left", entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"consumable_{ATTR_CONSUMABLE_STATUS_FILTER_LEFT}": XiaomiMiioSensorDescription( native_unit_of_measurement=TIME_SECONDS, @@ -517,7 +527,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): parent_key=VacuumCoordinatorDataAttributes.consumable_status, name="Filter Left", entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), f"consumable_{ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT}": XiaomiMiioSensorDescription( native_unit_of_measurement=TIME_SECONDS, @@ -526,7 +536,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): parent_key=VacuumCoordinatorDataAttributes.consumable_status, name="Sensor Dirty Left", entity_registry_enabled_default=False, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + entity_category=EntityCategory.DIAGNOSTIC, ), } @@ -560,7 +570,11 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities): async_add_entities(entities) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Xiaomi sensor from a config entry.""" entities = [] @@ -700,7 +714,7 @@ def _determine_native_value(self): ) if ( - self.device_class == DEVICE_CLASS_TIMESTAMP + self.device_class == SensorDeviceClass.TIMESTAMP and native_value is not None and (native_datetime := dt_util.parse_datetime(str(native_value))) is not None diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index ab825e2485d149..bd6482e891b7c4 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -11,20 +11,22 @@ import voluptuous as vol from homeassistant.components.switch import ( - DEVICE_CLASS_SWITCH, + SwitchDeviceClass, SwitchEntity, SwitchEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, ATTR_TEMPERATURE, CONF_HOST, CONF_TOKEN, - ENTITY_CATEGORY_CONFIG, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CONF_DEVICE, @@ -206,7 +208,7 @@ class XiaomiMiioSwitchDescription(SwitchEntityDescription): icon="mdi:volume-high", method_on="async_set_buzzer_on", method_off="async_set_buzzer_off", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), XiaomiMiioSwitchDescription( key=ATTR_CHILD_LOCK, @@ -215,7 +217,7 @@ class XiaomiMiioSwitchDescription(SwitchEntityDescription): icon="mdi:lock", method_on="async_set_child_lock_on", method_off="async_set_child_lock_off", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), XiaomiMiioSwitchDescription( key=ATTR_DRY, @@ -224,7 +226,7 @@ class XiaomiMiioSwitchDescription(SwitchEntityDescription): icon="mdi:hair-dryer", method_on="async_set_dry_on", method_off="async_set_dry_off", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), XiaomiMiioSwitchDescription( key=ATTR_CLEAN, @@ -234,7 +236,7 @@ class XiaomiMiioSwitchDescription(SwitchEntityDescription): method_on="async_set_clean_on", method_off="async_set_clean_off", available_with_device_off=False, - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), XiaomiMiioSwitchDescription( key=ATTR_LED, @@ -243,7 +245,7 @@ class XiaomiMiioSwitchDescription(SwitchEntityDescription): icon="mdi:led-outline", method_on="async_set_led_on", method_off="async_set_led_off", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), XiaomiMiioSwitchDescription( key=ATTR_LEARN_MODE, @@ -252,7 +254,7 @@ class XiaomiMiioSwitchDescription(SwitchEntityDescription): icon="mdi:school-outline", method_on="async_set_learn_mode_on", method_off="async_set_learn_mode_off", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), XiaomiMiioSwitchDescription( key=ATTR_AUTO_DETECT, @@ -260,7 +262,7 @@ class XiaomiMiioSwitchDescription(SwitchEntityDescription): name="Auto Detect", method_on="async_set_auto_detect_on", method_off="async_set_auto_detect_off", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), XiaomiMiioSwitchDescription( key=ATTR_IONIZER, @@ -269,12 +271,16 @@ class XiaomiMiioSwitchDescription(SwitchEntityDescription): icon="mdi:shimmer", method_on="async_set_ionizer_on", method_off="async_set_ionizer_off", - entity_category=ENTITY_CATEGORY_CONFIG, + entity_category=EntityCategory.CONFIG, ), ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the switch from a config entry.""" model = config_entry.data[CONF_MODEL] if model in (*MODELS_HUMIDIFIER, *MODELS_FAN): @@ -405,7 +411,7 @@ async def async_setup_other_entry(hass, config_entry, async_add_entities): model, ) - async def async_service_handler(service): + async def async_service_handler(service: ServiceCall) -> None: """Map services to methods on XiaomiPlugGenericSwitch.""" method = SERVICE_TO_METHOD.get(service.service) params = { @@ -621,7 +627,7 @@ async def async_set_ionizer_off(self) -> bool: class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity): """Representation of a XiaomiGatewaySwitch.""" - _attr_device_class = DEVICE_CLASS_SWITCH + _attr_device_class = SwitchDeviceClass.SWITCH def __init__(self, coordinator, sub_device, entry, variable): """Initialize the XiaomiSensor.""" diff --git a/homeassistant/components/xiaomi_miio/translations/el.json b/homeassistant/components/xiaomi_miio/translations/el.json new file mode 100644 index 00000000000000..c5c8d79c281d18 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/el.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "no_device_selected": "\u0394\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03b5\u03c0\u03b9\u03bb\u03b5\u03b3\u03b5\u03af \u03ba\u03b1\u03bc\u03af\u03b1 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae, \u03c0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b5\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 \u03bc\u03af\u03b1 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae.", + "unknown_device": "\u03a4\u03bf \u03bc\u03bf\u03bd\u03c4\u03ad\u03bb\u03bf \u03c4\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b3\u03bd\u03c9\u03c3\u03c4\u03cc, \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 \u03bc\u03b5 \u03c4\u03b7 \u03c7\u03c1\u03ae\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03c1\u03bf\u03ae\u03c2 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c9\u03bd." + }, + "step": { + "device": { + "data": { + "model": "\u039c\u03bf\u03bd\u03c4\u03ad\u03bb\u03bf \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 (\u03a0\u03c1\u03bf\u03b1\u03b9\u03c1\u03b5\u03c4\u03b9\u03ba\u03cc)", + "name": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c4\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, + "description": "\u0398\u03b1 \u03c7\u03c1\u03b5\u03b9\u03b1\u03c3\u03c4\u03b5\u03af\u03c4\u03b5 \u03c4\u03bf\u03bd 32 \u03c7\u03b1\u03c1\u03b1\u03ba\u03c4\u03ae\u03c1\u03b5\u03c2 \u0394\u03b9\u03b1\u03ba\u03c1\u03b9\u03c4\u03b9\u03ba\u03cc API, \u03b1\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u03b3\u03b9\u03b1 \u03bf\u03b4\u03b7\u03b3\u03af\u03b5\u03c2. \u039b\u03ac\u03b2\u03b5\u03c4\u03b5 \u03c5\u03c0\u03cc\u03c8\u03b7 \u03cc\u03c4\u03b9 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u0394\u03b9\u03b1\u03ba\u03c1\u03b9\u03c4\u03b9\u03ba\u03cc API \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03b9\u03b1\u03c6\u03bf\u03c1\u03b5\u03c4\u03b9\u03ba\u03cc \u03b1\u03c0\u03cc \u03c4\u03bf \u03ba\u03bb\u03b5\u03b9\u03b4\u03af \u03c0\u03bf\u03c5 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 Xiaomi Aqara.\u0398\u03b1 \u03c7\u03c1\u03b5\u03b9\u03b1\u03c3\u03c4\u03b5\u03af\u03c4\u03b5 \u03c4\u03bf\u03bd 32 \u03c7\u03b1\u03c1\u03b1\u03ba\u03c4\u03ae\u03c1\u03b5\u03c2 \u0394\u03b9\u03b1\u03ba\u03c1\u03b9\u03c4\u03b9\u03ba\u03cc API, \u03b1\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u03b3\u03b9\u03b1 \u03bf\u03b4\u03b7\u03b3\u03af\u03b5\u03c2. \u039b\u03ac\u03b2\u03b5\u03c4\u03b5 \u03c5\u03c0\u03cc\u03c8\u03b7 \u03cc\u03c4\u03b9 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u0394\u03b9\u03b1\u03ba\u03c1\u03b9\u03c4\u03b9\u03ba\u03cc API \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03b9\u03b1\u03c6\u03bf\u03c1\u03b5\u03c4\u03b9\u03ba\u03cc \u03b1\u03c0\u03cc \u03c4\u03bf \u03ba\u03bb\u03b5\u03b9\u03b4\u03af \u03c0\u03bf\u03c5 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03b7\u03bd \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 Xiaomi Aqara.", + "title": "\u03a3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7 \u03c3\u03b5 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae Xiaomi Miio \u03ae \u03c0\u03cd\u03bb\u03b7 Xiaomi" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/es.json b/homeassistant/components/xiaomi_miio/translations/es.json index d70445d2aa5ea1..ae59404a793679 100644 --- a/homeassistant/components/xiaomi_miio/translations/es.json +++ b/homeassistant/components/xiaomi_miio/translations/es.json @@ -13,7 +13,8 @@ "cloud_login_error": "No se ha podido iniciar sesi\u00f3n en Xioami Miio Cloud, comprueba las credenciales.", "cloud_no_devices": "No se han encontrado dispositivos en esta cuenta de Xiaomi Miio.", "no_device_selected": "No se ha seleccionado ning\u00fan dispositivo, por favor, seleccione un dispositivo.", - "unknown_device": "No se conoce el modelo del dispositivo, no se puede configurar el dispositivo mediante el flujo de configuraci\u00f3n." + "unknown_device": "No se conoce el modelo del dispositivo, no se puede configurar el dispositivo mediante el flujo de configuraci\u00f3n.", + "wrong_token": "Error de suma de comprobaci\u00f3n, token err\u00f3neo" }, "flow_title": "Xiaomi Miio: {name}", "step": { diff --git a/homeassistant/components/xiaomi_miio/translations/fr.json b/homeassistant/components/xiaomi_miio/translations/fr.json index 65e7f90d253a13..2a9d8d65c7797f 100644 --- a/homeassistant/components/xiaomi_miio/translations/fr.json +++ b/homeassistant/components/xiaomi_miio/translations/fr.json @@ -16,7 +16,7 @@ "unknown_device": "Le mod\u00e8le d'appareil n'est pas connu, impossible de configurer l'appareil \u00e0 l'aide du flux de configuration.", "wrong_token": "Erreur de somme de contr\u00f4le, jeton incorrect" }, - "flow_title": "Xiaomi Miio: {name}", + "flow_title": "{name}", "step": { "cloud": { "data": { diff --git a/homeassistant/components/xiaomi_miio/translations/he.json b/homeassistant/components/xiaomi_miio/translations/he.json index 69d47597c5f8bd..4bb0251d6cbc2f 100644 --- a/homeassistant/components/xiaomi_miio/translations/he.json +++ b/homeassistant/components/xiaomi_miio/translations/he.json @@ -13,7 +13,8 @@ "cloud_login_error": "\u05dc\u05d0 \u05d4\u05d9\u05ea\u05d4 \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05e2\u05e0\u05df \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05de\u05d9\u05d5, \u05e0\u05d0 \u05dc\u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea \u05d4\u05d0\u05d9\u05e9\u05d5\u05e8\u05d9\u05dd.", "cloud_no_devices": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d1\u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05e2\u05e0\u05df \u05d4\u05d6\u05d4 \u05e9\u05dc \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05de\u05d9\u05d5.", "no_device_selected": "\u05dc\u05d0 \u05e0\u05d1\u05d7\u05e8 \u05d4\u05ea\u05e7\u05df, \u05e0\u05d0 \u05d1\u05d7\u05e8 \u05d4\u05ea\u05e7\u05df \u05d0\u05d7\u05d3.", - "unknown_device": "\u05d3\u05d2\u05dd \u05d4\u05d4\u05ea\u05e7\u05df \u05d0\u05d9\u05e0\u05d5 \u05d9\u05d3\u05d5\u05e2, \u05d0\u05d9\u05df \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d6\u05e8\u05d9\u05de\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4." + "unknown_device": "\u05d3\u05d2\u05dd \u05d4\u05d4\u05ea\u05e7\u05df \u05d0\u05d9\u05e0\u05d5 \u05d9\u05d3\u05d5\u05e2, \u05d0\u05d9\u05df \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d6\u05e8\u05d9\u05de\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4.", + "wrong_token": "\u05e9\u05d2\u05d9\u05d0\u05ea \u05d1\u05d3\u05d9\u05e7\u05ea \u05e1\u05d9\u05db\u05d5\u05dd, \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05e9\u05d2\u05d5\u05d9" }, "flow_title": "{name}", "step": { diff --git a/homeassistant/components/xiaomi_miio/translations/it.json b/homeassistant/components/xiaomi_miio/translations/it.json index df69b9a27364f1..35bdecea0822d1 100644 --- a/homeassistant/components/xiaomi_miio/translations/it.json +++ b/homeassistant/components/xiaomi_miio/translations/it.json @@ -12,9 +12,9 @@ "cloud_credentials_incomplete": "Credenziali cloud incomplete, inserisci nome utente, password e paese", "cloud_login_error": "Impossibile accedere a Xioami Miio Cloud, controlla le credenziali.", "cloud_no_devices": "Nessun dispositivo trovato in questo account cloud Xiaomi Miio.", - "no_device_selected": "Nessun dispositivo selezionato, selezionare un dispositivo.", + "no_device_selected": "Nessun dispositivo selezionato, seleziona un dispositivo.", "unknown_device": "Il modello del dispositivo non \u00e8 noto, non \u00e8 possibile configurare il dispositivo utilizzando il flusso di configurazione.", - "wrong_token": "Errore di checksum, token errato" + "wrong_token": "Errore del codice di controllo, token errato" }, "flow_title": "{name}", "step": { @@ -42,7 +42,7 @@ "name": "Nome del dispositivo", "token": "Token API" }, - "description": "Avrai bisogno dei 32 caratteri della Token API, vedi https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token per istruzioni. Tieni presente che questa Token API \u00e8 diversa dalla chiave utilizzata dall'integrazione Xiaomi Aqara.", + "description": "Avrai bisogno dei 32 caratteri del Token API, vedi https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token per istruzioni. Tieni presente che questo Token API \u00e8 diverso dalla chiave utilizzata dall'integrazione Xiaomi Aqara.", "title": "Connettiti a un dispositivo Xiaomi Miio o Xiaomi Gateway" }, "gateway": { @@ -51,7 +51,7 @@ "name": "Nome del Gateway", "token": "Token API" }, - "description": "E' necessaria la Token API di 32 caratteri, vedere https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token per le istruzioni. Notare che questa Token API \u00e8 differente dalla chiave usata dall'integrazione di Xiaomi Aqara.", + "description": "\u00c8 necessario il Token API di 32 caratteri, vedi https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token per le istruzioni. Nota che questo Token API \u00e8 diverso dalla chiave usata dall'integrazione di Xiaomi Aqara.", "title": "Connessione a un Xiaomi Gateway " }, "manual": { @@ -63,8 +63,8 @@ "title": "Connettiti a un dispositivo Xiaomi Miio o Xiaomi Gateway" }, "reauth_confirm": { - "description": "L'integrazione di Xiaomi Miio deve riautenticare il tuo account per aggiornare i token o aggiungere credenziali cloud mancanti.", - "title": "Autenticare nuovamente l'integrazione" + "description": "L'integrazione di Xiaomi Miio deve autenticare nuovamente il tuo account per aggiornare i token o aggiungere credenziali cloud mancanti.", + "title": "Autentica nuovamente l'integrazione" }, "select": { "data": { @@ -77,7 +77,7 @@ "data": { "gateway": "Connettiti a un Xiaomi Gateway" }, - "description": "Selezionare a quale dispositivo si desidera collegare.", + "description": "Seleziona a quale dispositivo desideri collegarti.", "title": "Xiaomi Miio" } } @@ -91,7 +91,7 @@ "data": { "cloud_subdevices": "Usa il cloud per connettere i sottodispositivi" }, - "description": "Specificare le impostazioni opzionali", + "description": "Specifica le impostazioni opzionali", "title": "Xiaomi Miio" } } diff --git a/homeassistant/components/xiaomi_miio/translations/ja.json b/homeassistant/components/xiaomi_miio/translations/ja.json index e9b6c7a6cc9fd2..44c6f7f149e0bf 100644 --- a/homeassistant/components/xiaomi_miio/translations/ja.json +++ b/homeassistant/components/xiaomi_miio/translations/ja.json @@ -63,7 +63,7 @@ "title": "Xiaomi Miio\u30c7\u30d0\u30a4\u30b9\u307e\u305f\u306f\u3001Xiaomi Gateway\u306b\u63a5\u7d9a" }, "reauth_confirm": { - "description": "Xiaomi Miio\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001\u30c8\u30fc\u30af\u30f3\u3092\u66f4\u65b0\u3057\u305f\u308a\u3001\u4e0d\u8db3\u3057\u3066\u3044\u308b\u30af\u30e9\u30a6\u30c9\u306e\u8cc7\u683c\u60c5\u5831\u3092\u8ffd\u52a0\u3059\u308b\u305f\u3081\u306b\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", + "description": "Xiaomi Miio\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u3067\u306f\u3001\u30c8\u30fc\u30af\u30f3\u3092\u66f4\u65b0\u3057\u305f\u308a\u3001\u4e0d\u8db3\u3057\u3066\u3044\u308b\u30af\u30e9\u30a6\u30c9\u306e\u8a8d\u8a3c\u60c5\u5831\u3092\u8ffd\u52a0\u3059\u308b\u305f\u3081\u306b\u3001\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u518d\u8a8d\u8a3c\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002", "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" }, "select": { diff --git a/homeassistant/components/xiaomi_miio/translations/zh-Hans.json b/homeassistant/components/xiaomi_miio/translations/zh-Hans.json index c3eb4affc4c004..02519414492e2b 100644 --- a/homeassistant/components/xiaomi_miio/translations/zh-Hans.json +++ b/homeassistant/components/xiaomi_miio/translations/zh-Hans.json @@ -4,7 +4,8 @@ "already_configured": "\u8bbe\u5907\u5df2\u7ecf\u914d\u7f6e\u8fc7\u4e86", "already_in_progress": "\u6b64\u5c0f\u7c73\u8bbe\u5907\u7684\u914d\u7f6e\u6d41\u7a0b\u5df2\u5728\u8fdb\u884c\u4e2d\u3002", "incomplete_info": "\u8bbe\u5907\u4fe1\u606f\u4e0d\u5b8c\u6574\uff0c\u672a\u63d0\u4f9b IP \u6216 token\u3002", - "not_xiaomi_miio": "Xiaomi Miio \u6682\u672a\u9002\u914d\u8be5\u8bbe\u5907\u3002" + "not_xiaomi_miio": "Xiaomi Miio \u6682\u672a\u9002\u914d\u8be5\u8bbe\u5907\u3002", + "reauth_successful": "\u91cd\u65b0\u8ba4\u8bc1\u6210\u529f" }, "error": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25", @@ -60,7 +61,8 @@ "description": "\u60a8\u9700\u8981\u83b7\u53d6\u4e00\u4e2a 32 \u4f4d\u7684 API Token\uff0c\u8bf7\u53c2\u8003 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u4e2d\u63d0\u5230\u7684\u65b9\u6cd5\u83b7\u53d6\u8be5\u4fe1\u606f\u3002\u8bf7\u6ce8\u610f\uff0c\u8be5 API Token \u4e0d\u540c\u4e8e \"Xiaomi Aqara\" \u96c6\u6210\u6240\u4f7f\u7528\u7684\u5bc6\u94a5\u3002" }, "reauth_confirm": { - "description": "\u5c0f\u7c73 Miio \u96c6\u6210\u9700\u8981\u91cd\u65b0\u9a8c\u8bc1\u60a8\u7684\u5e10\u6237\uff0c\u4ee5\u4fbf\u66f4\u65b0 token \u6216\u6dfb\u52a0\u4e22\u5931\u7684\u4e91\u7aef\u51ed\u636e\u3002" + "description": "\u5c0f\u7c73 Miio \u96c6\u6210\u9700\u8981\u91cd\u65b0\u9a8c\u8bc1\u60a8\u7684\u5e10\u6237\uff0c\u4ee5\u4fbf\u66f4\u65b0 token \u6216\u6dfb\u52a0\u4e22\u5931\u7684\u4e91\u7aef\u51ed\u636e\u3002", + "title": "\u4f7f\u96c6\u6210\u91cd\u65b0\u8fdb\u884c\u8eab\u4efd\u8ba4\u8bc1" }, "select": { "data": { diff --git a/homeassistant/components/xiaomi_miio/translations/zh-Hant.json b/homeassistant/components/xiaomi_miio/translations/zh-Hant.json index db9db466f4e66c..2812f91be7e737 100644 --- a/homeassistant/components/xiaomi_miio/translations/zh-Hant.json +++ b/homeassistant/components/xiaomi_miio/translations/zh-Hant.json @@ -59,7 +59,7 @@ "host": "IP \u4f4d\u5740", "token": "API \u6b0a\u6756" }, - "description": "\u5c07\u9700\u8981\u8f38\u5165 32 \u4f4d\u5b57\u5143 API \u6b0a\u6756\uff0c\u8acb\u53c3\u95b1 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u4ee5\u7372\u5f97\u7372\u53d6\u5bc6\u9470\u7684\u6559\u5b78\u3002\u8acb\u6ce8\u610f\uff1a\u6b64 API \u6b0a\u6756\u8207\u5c0f\u7c73 Aqara \u6574\u5408\u6240\u4f7f\u7528\u4e4b\u5bc6\u9470\u4e0d\u540c\u3002", + "description": "\u5c07\u9700\u8981\u8f38\u5165 32 \u4f4d\u5b57\u5143 API \u6b0a\u6756\uff0c\u8acb\u53c3\u95b1 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u4ee5\u7372\u5f97\u7372\u53d6\u91d1\u9470\u7684\u6559\u5b78\u3002\u8acb\u6ce8\u610f\uff1a\u6b64 API \u6b0a\u6756\u8207\u5c0f\u7c73 Aqara \u6574\u5408\u6240\u4f7f\u7528\u4e4b\u91d1\u9470\u4e0d\u540c\u3002", "title": "\u9023\u7dda\u81f3\u5c0f\u7c73 MIIO \u88dd\u7f6e\u6216\u5c0f\u7c73\u7db2\u95dc" }, "reauth_confirm": { diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 2362fcf8996f1c..a6fa6c399eb135 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -26,8 +26,10 @@ SUPPORT_STOP, StateVacuumEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_utc from . import VacuumCoordinatorData @@ -98,7 +100,11 @@ } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Xiaomi vacuum cleaner robot from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_tv/media_player.py b/homeassistant/components/xiaomi_tv/media_player.py index 6c0a55f787d07c..095eee571e59e2 100644 --- a/homeassistant/components/xiaomi_tv/media_player.py +++ b/homeassistant/components/xiaomi_tv/media_player.py @@ -1,4 +1,6 @@ """Add support for the Xiaomi TVs.""" +from __future__ import annotations + import logging import pymitv @@ -11,7 +13,10 @@ SUPPORT_VOLUME_STEP, ) from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType DEFAULT_NAME = "Xiaomi TV" @@ -28,7 +33,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Xiaomi TV platform.""" # If a hostname is set. Discovery is skipped. diff --git a/homeassistant/components/xs1/__init__.py b/homeassistant/components/xs1/__init__.py index 1d65b2bcfd1e74..67452ce94269ce 100644 --- a/homeassistant/components/xs1/__init__.py +++ b/homeassistant/components/xs1/__init__.py @@ -11,10 +11,13 @@ CONF_PORT, CONF_SSL, CONF_USERNAME, + Platform, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -38,7 +41,7 @@ extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["climate", "sensor", "switch"] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] # Lock used to limit the amount of concurrent update requests # as the XS1 Gateway can only handle a very @@ -46,7 +49,7 @@ UPDATE_LOCK = asyncio.Lock() -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up XS1 integration.""" _LOGGER.debug("Initializing XS1") diff --git a/homeassistant/components/xs1/climate.py b/homeassistant/components/xs1/climate.py index 238fe2b83428cf..c05ce7e24f6cb1 100644 --- a/homeassistant/components/xs1/climate.py +++ b/homeassistant/components/xs1/climate.py @@ -1,4 +1,6 @@ """Support for XS1 climate devices.""" +from __future__ import annotations + from xs1_api_client.api_constants import ActuatorType from homeassistant.components.climate import ClimateEntity @@ -7,6 +9,9 @@ SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS, XS1DeviceEntity @@ -16,7 +21,12 @@ SUPPORT_HVAC = [HVAC_MODE_HEAT] -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the XS1 thermostat platform.""" actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS] sensors = hass.data[COMPONENT_DOMAIN][SENSORS] diff --git a/homeassistant/components/xs1/sensor.py b/homeassistant/components/xs1/sensor.py index ed022f5b9e792a..4855c8b8dcf635 100644 --- a/homeassistant/components/xs1/sensor.py +++ b/homeassistant/components/xs1/sensor.py @@ -1,12 +1,22 @@ """Support for XS1 sensors.""" +from __future__ import annotations + from xs1_api_client.api_constants import ActuatorType from homeassistant.components.sensor import SensorEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS, XS1DeviceEntity -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the XS1 sensor platform.""" sensors = hass.data[COMPONENT_DOMAIN][SENSORS] actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS] diff --git a/homeassistant/components/xs1/switch.py b/homeassistant/components/xs1/switch.py index a69c8e965eb5ca..0f40678986b7c5 100644 --- a/homeassistant/components/xs1/switch.py +++ b/homeassistant/components/xs1/switch.py @@ -1,13 +1,22 @@ """Support for XS1 switches.""" +from __future__ import annotations from xs1_api_client.api_constants import ActuatorType -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, XS1DeviceEntity -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the XS1 switch platform.""" actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS] @@ -21,7 +30,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(switch_entities) -class XS1SwitchEntity(XS1DeviceEntity, ToggleEntity): +class XS1SwitchEntity(XS1DeviceEntity, SwitchEntity): """Representation of a XS1 switch actuator.""" @property diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 87bfb2b86d735f..626c7d0b206e2c 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN, LOGGER, PLATFORMS +from .const import COORDINATOR, DOMAIN, LOGGER, PLATFORMS from .coordinator import YaleDataUpdateCoordinator @@ -22,16 +22,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = { - "coordinator": coordinator, + COORDINATOR: coordinator, } hass.config_entries.async_setup_platforms(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) LOGGER.debug("Loaded entry for %s", title) return True +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 4011e7dfbdc025..0348676904e499 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -1,7 +1,15 @@ """Support for Yale Alarm.""" from __future__ import annotations +from typing import TYPE_CHECKING + import voluptuous as vol +from yalesmartalarmclient.const import ( + YALE_STATE_ARM_FULL, + YALE_STATE_ARM_PARTIAL, + YALE_STATE_DISARM, +) +from yalesmartalarmclient.exceptions import AuthenticationError, UnknownError from homeassistant.components.alarm_control_panel import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, @@ -14,13 +22,11 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, - ConfigType, - DiscoveryInfoType, -) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -78,51 +84,109 @@ async def async_setup_entry( class YaleAlarmDevice(CoordinatorEntity, AlarmControlPanelEntity): """Represent a Yale Smart Alarm.""" + coordinator: YaleDataUpdateCoordinator + + _attr_code_arm_required = False + _attr_supported_features = SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None: """Initialize the Yale Alarm Device.""" super().__init__(coordinator) - self._attr_name: str = coordinator.entry.data[CONF_NAME] + self._attr_name = coordinator.entry.data[CONF_NAME] self._attr_unique_id = coordinator.entry.entry_id - self._identifier: str = coordinator.entry.data[CONF_USERNAME] - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - return DeviceInfo( - identifiers={(DOMAIN, self._identifier)}, + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.entry.data[CONF_USERNAME])}, manufacturer=MANUFACTURER, model=MODEL, - name=str(self.name), + name=self._attr_name, ) - @property - def state(self): - """Return the state of the device.""" - return STATE_MAP.get(self.coordinator.data["alarm"]) - - @property - def available(self): - """Return if entity is available.""" - return STATE_MAP.get(self.coordinator.data["alarm"]) is not None + async def async_alarm_disarm(self, code=None) -> None: + """Send disarm command.""" + if TYPE_CHECKING: + assert self.coordinator.yale, "Connection to API is missing" + + try: + alarm_state = await self.hass.async_add_executor_job( + self.coordinator.yale.disarm + ) + except ( + AuthenticationError, + ConnectionError, + TimeoutError, + UnknownError, + ) as error: + raise HomeAssistantError( + f"Could not verify disarmed for {self._attr_name}: {error}" + ) from error + + LOGGER.debug("Alarm disarmed: %s", alarm_state) + if alarm_state: + self.coordinator.data["alarm"] = YALE_STATE_DISARM + self.async_write_ha_state() + return + raise HomeAssistantError("Could not disarm, check system ready for disarming.") + + async def async_alarm_arm_home(self, code=None) -> None: + """Send arm home command.""" + if TYPE_CHECKING: + assert self.coordinator.yale, "Connection to API is missing" + + try: + alarm_state = await self.hass.async_add_executor_job( + self.coordinator.yale.arm_partial + ) + except ( + AuthenticationError, + ConnectionError, + TimeoutError, + UnknownError, + ) as error: + raise HomeAssistantError( + f"Could not verify armed home for {self._attr_name}: {error}" + ) from error + + LOGGER.debug("Alarm armed home: %s", alarm_state) + if alarm_state: + self.coordinator.data["alarm"] = YALE_STATE_ARM_PARTIAL + self.async_write_ha_state() + return + raise HomeAssistantError("Could not arm home, check system ready for arming.") + + async def async_alarm_arm_away(self, code=None) -> None: + """Send arm away command.""" + if TYPE_CHECKING: + assert self.coordinator.yale, "Connection to API is missing" + + try: + alarm_state = await self.hass.async_add_executor_job( + self.coordinator.yale.arm_full + ) + except ( + AuthenticationError, + ConnectionError, + TimeoutError, + UnknownError, + ) as error: + raise HomeAssistantError( + f"Could not verify armed away for {self._attr_name}: {error}" + ) from error + + LOGGER.debug("Alarm armed away: %s", alarm_state) + if alarm_state: + self.coordinator.data["alarm"] = YALE_STATE_ARM_FULL + self.async_write_ha_state() + return + raise HomeAssistantError("Could not arm away, check system ready for arming.") @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return False + def available(self) -> bool: + """Return True if alarm is available.""" + if STATE_MAP.get(self.coordinator.data["alarm"]) is None: + return False + return super().available @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY - - def alarm_disarm(self, code=None): - """Send disarm command.""" - self.coordinator.yale.disarm() - - def alarm_arm_home(self, code=None): - """Send arm home command.""" - self.coordinator.yale.arm_partial() - - def alarm_arm_away(self, code=None): - """Send arm away command.""" - self.coordinator.yale.arm_full() + def state(self) -> StateType: + """Return the state of the alarm.""" + return STATE_MAP.get(self.coordinator.data["alarm"]) diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py new file mode 100644 index 00000000000000..b017c4e33e389b --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -0,0 +1,39 @@ +"""Binary sensors for Yale Alarm.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import COORDINATOR, DOMAIN +from .coordinator import YaleDataUpdateCoordinator +from .entity import YaleEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Yale binary sensor entry.""" + + coordinator: YaleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + COORDINATOR + ] + + async_add_entities( + YaleBinarySensor(coordinator, data) for data in coordinator.data["door_windows"] + ) + + +class YaleBinarySensor(YaleEntity, BinarySensorEntity): + """Representation of a Yale binary sensor.""" + + _attr_device_class = BinarySensorDeviceClass.DOOR + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.coordinator.data["sensor_map"][self._attr_unique_id] == "open" diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 7538c6e40ca0db..1567f22be4471c 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -1,14 +1,27 @@ """Adds config flow for Yale Smart Alarm integration.""" from __future__ import annotations +from typing import Any + import voluptuous as vol -from yalesmartalarmclient.client import AuthenticationError, YaleSmartAlarmClient +from yalesmartalarmclient.client import YaleSmartAlarmClient +from yalesmartalarmclient.exceptions import AuthenticationError, UnknownError -from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from .const import CONF_AREA_ID, DEFAULT_AREA_ID, DEFAULT_NAME, DOMAIN, LOGGER +from .const import ( + CONF_AREA_ID, + CONF_LOCK_CODE_DIGITS, + DEFAULT_AREA_ID, + DEFAULT_LOCK_CODE_DIGITS, + DEFAULT_NAME, + DOMAIN, + LOGGER, +) DATA_SCHEMA = vol.Schema( { @@ -27,12 +40,18 @@ ) -class YaleConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Yale integration.""" VERSION = 1 - entry: config_entries.ConfigEntry + entry: ConfigEntry + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> YaleOptionsFlowHandler: + """Get the options flow for this handler.""" + return YaleOptionsFlowHandler(config_entry) async def async_step_import(self, config: dict): """Import a configuration from config.yaml.""" @@ -61,24 +80,24 @@ async def async_step_reauth_confirm(self, user_input=None): ) except AuthenticationError as error: LOGGER.error("Authentication failed. Check credentials %s", error) - return self.async_show_form( - step_id="reauth_confirm", - data_schema=DATA_SCHEMA, - errors={"base": "invalid_auth"}, - ) - - existing_entry = await self.async_set_unique_id(username) - if existing_entry: - self.hass.config_entries.async_update_entry( - existing_entry, - data={ - **self.entry.data, - CONF_USERNAME: username, - CONF_PASSWORD: password, - }, - ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") + errors = {"base": "invalid_auth"} + except (ConnectionError, TimeoutError, UnknownError) as error: + LOGGER.error("Connection to API failed %s", error) + errors = {"base": "cannot_connect"} + + if not errors: + existing_entry = await self.async_set_unique_id(username) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, + data={ + **self.entry.data, + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") return self.async_show_form( step_id="reauth_confirm", @@ -102,27 +121,71 @@ async def async_step_user(self, user_input=None): ) except AuthenticationError as error: LOGGER.error("Authentication failed. Check credentials %s", error) - return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={"base": "invalid_auth"}, - ) + errors = {"base": "invalid_auth"} + except (ConnectionError, TimeoutError, UnknownError) as error: + LOGGER.error("Connection to API failed %s", error) + errors = {"base": "cannot_connect"} - await self.async_set_unique_id(username) - self._abort_if_unique_id_configured() + if not errors: + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured() - return self.async_create_entry( - title=username, - data={ - CONF_USERNAME: username, - CONF_PASSWORD: password, - CONF_NAME: name, - CONF_AREA_ID: area, - }, - ) + return self.async_create_entry( + title=username, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_NAME: name, + CONF_AREA_ID: area, + }, + ) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors, ) + + +class YaleOptionsFlowHandler(OptionsFlow): + """Handle Yale options.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize Yale options flow.""" + self.entry = entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Yale options.""" + errors = {} + + if user_input: + if len(user_input.get(CONF_CODE, "")) not in [ + 0, + user_input[CONF_LOCK_CODE_DIGITS], + ]: + errors["base"] = "code_format_mismatch" + else: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_CODE, + description={ + "suggested_value": self.entry.options.get(CONF_CODE) + }, + ): str, + vol.Optional( + CONF_LOCK_CODE_DIGITS, + default=self.entry.options.get( + CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS + ), + ): int, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/yale_smart_alarm/const.py b/homeassistant/components/yale_smart_alarm/const.py index cbb96579f4f90b..0628e6aceb40fb 100644 --- a/homeassistant/components/yale_smart_alarm/const.py +++ b/homeassistant/components/yale_smart_alarm/const.py @@ -11,11 +11,14 @@ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + Platform, ) CONF_AREA_ID = "area_id" +CONF_LOCK_CODE_DIGITS = "lock_code_digits" DEFAULT_NAME = "Yale Smart Alarm" DEFAULT_AREA_ID = "1" +DEFAULT_LOCK_CODE_DIGITS = 4 MANUFACTURER = "Yale" MODEL = "main" @@ -30,7 +33,7 @@ ATTR_ONLINE = "online" ATTR_STATUS = "status" -PLATFORMS = ["alarm_control_panel"] +PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.LOCK] STATE_MAP = { YALE_STATE_DISARM: STATE_ALARM_DISARMED, diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 3cef1876e3a032..2d476f920f9627 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -3,17 +3,14 @@ from datetime import timedelta -import requests -from yalesmartalarmclient.client import AuthenticationError, YaleSmartAlarmClient +from yalesmartalarmclient.client import YaleSmartAlarmClient +from yalesmartalarmclient.exceptions import AuthenticationError, UnknownError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import ( - ConfigEntryAuthFailed, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER @@ -45,7 +42,6 @@ async def _async_update_data(self) -> dict: if device["type"] == "device_type.door_lock": lock_status_str = device["minigw_lock_status"] lock_status = int(str(lock_status_str or 0), 16) - jammed = (lock_status & 48) == 48 closed = (lock_status & 16) == 16 locked = (lock_status & 1) == 1 if not lock_status and "device_status.lock" in state: @@ -58,17 +54,6 @@ async def _async_update_data(self) -> dict: device["_state2"] = "unknown" locks.append(device) continue - if ( - lock_status - and ( - "device_status.lock" in state or "device_status.unlock" in state - ) - and jammed - ): - device["_state"] = "jammed" - device["_state2"] = "closed" - locks.append(device) - continue if ( lock_status and ( @@ -120,12 +105,19 @@ async def _async_update_data(self) -> dict: door_windows.append(device) continue + _sensor_map = { + contact["address"]: contact["_state"] for contact in door_windows + } + _lock_map = {lock["address"]: lock["_state"] for lock in locks} + return { "alarm": updates["arm_status"], "locks": locks, "door_windows": door_windows, "status": updates["status"], "online": updates["online"], + "sensor_map": _sensor_map, + "lock_map": _lock_map, } def get_updates(self) -> dict: @@ -138,9 +130,7 @@ def get_updates(self) -> dict: ) except AuthenticationError as error: raise ConfigEntryAuthFailed from error - except requests.HTTPError as error: - if error.response.status_code == 401: - raise ConfigEntryAuthFailed from error + except (ConnectionError, TimeoutError, UnknownError) as error: raise UpdateFailed from error try: @@ -151,11 +141,7 @@ def get_updates(self) -> dict: except AuthenticationError as error: raise ConfigEntryAuthFailed from error - except requests.HTTPError as error: - if error.response.status_code == 401: - raise ConfigEntryAuthFailed from error - raise UpdateFailed from error - except requests.RequestException as error: + except (ConnectionError, TimeoutError, UnknownError) as error: raise UpdateFailed from error return { diff --git a/homeassistant/components/yale_smart_alarm/entity.py b/homeassistant/components/yale_smart_alarm/entity.py new file mode 100644 index 00000000000000..318989a018caf8 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/entity.py @@ -0,0 +1,27 @@ +"""Base class for yale_smart_alarm entity.""" + +from homeassistant.const import CONF_USERNAME +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER, MODEL +from .coordinator import YaleDataUpdateCoordinator + + +class YaleEntity(CoordinatorEntity, Entity): + """Base implementation for Yale device.""" + + coordinator: YaleDataUpdateCoordinator + + def __init__(self, coordinator: YaleDataUpdateCoordinator, data: dict) -> None: + """Initialize an Yale device.""" + super().__init__(coordinator) + self._attr_name: str = data["name"] + self._attr_unique_id: str = data["address"] + self._attr_device_info: DeviceInfo = DeviceInfo( + name=self._attr_name, + manufacturer=MANUFACTURER, + model=MODEL, + identifiers={(DOMAIN, data["address"])}, + via_device=(DOMAIN, self.coordinator.entry.data[CONF_USERNAME]), + ) diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py new file mode 100644 index 00000000000000..a7231d78dcef7a --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -0,0 +1,123 @@ +"""Lock for Yale Alarm.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from yalesmartalarmclient.exceptions import AuthenticationError, UnknownError + +from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_CODE, CONF_CODE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_LOCK_CODE_DIGITS, + COORDINATOR, + DEFAULT_LOCK_CODE_DIGITS, + DOMAIN, + LOGGER, +) +from .coordinator import YaleDataUpdateCoordinator +from .entity import YaleEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Yale lock entry.""" + + coordinator: YaleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + COORDINATOR + ] + code_format = entry.options.get(CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS) + + async_add_entities( + YaleDoorlock(coordinator, data, code_format) + for data in coordinator.data["locks"] + ) + + +class YaleDoorlock(YaleEntity, LockEntity): + """Representation of a Yale doorlock.""" + + def __init__( + self, coordinator: YaleDataUpdateCoordinator, data: dict, code_format: int + ) -> None: + """Initialize the Yale Lock Device.""" + super().__init__(coordinator, data) + self._attr_code_format = f"^\\d{code_format}$" + + async def async_unlock(self, **kwargs) -> None: + """Send unlock command.""" + if TYPE_CHECKING: + assert self.coordinator.yale, "Connection to API is missing" + + code = kwargs.get(ATTR_CODE, self.coordinator.entry.options.get(CONF_CODE)) + + if not code: + raise HomeAssistantError( + f"No code provided, {self._attr_name} not unlocked" + ) + + try: + get_lock = await self.hass.async_add_executor_job( + self.coordinator.yale.lock_api.get, self._attr_name + ) + lock_state = await self.hass.async_add_executor_job( + self.coordinator.yale.lock_api.open_lock, + get_lock, + code, + ) + except ( + AuthenticationError, + ConnectionError, + TimeoutError, + UnknownError, + ) as error: + raise HomeAssistantError( + f"Could not verify unlocking for {self._attr_name}: {error}" + ) from error + + LOGGER.debug("Door unlock: %s", lock_state) + if lock_state: + self.coordinator.data["lock_map"][self._attr_unique_id] = "unlocked" + self.async_write_ha_state() + return + raise HomeAssistantError("Could not unlock, check system ready for unlocking") + + async def async_lock(self, **kwargs) -> None: + """Send lock command.""" + if TYPE_CHECKING: + assert self.coordinator.yale, "Connection to API is missing" + + try: + get_lock = await self.hass.async_add_executor_job( + self.coordinator.yale.lock_api.get, self._attr_name + ) + lock_state = await self.hass.async_add_executor_job( + self.coordinator.yale.lock_api.close_lock, + get_lock, + ) + except ( + AuthenticationError, + ConnectionError, + TimeoutError, + UnknownError, + ) as error: + raise HomeAssistantError( + f"Could not verify unlocking for {self._attr_name}: {error}" + ) from error + + LOGGER.debug("Door unlock: %s", lock_state) + if lock_state: + self.coordinator.data["lock_map"][self._attr_unique_id] = "unlocked" + self.async_write_ha_state() + return + raise HomeAssistantError("Could not unlock, check system ready for unlocking") + + @property + def is_locked(self) -> bool | None: + """Return true if the lock is locked.""" + return self.coordinator.data["lock_map"][self._attr_unique_id] == "locked" diff --git a/homeassistant/components/yale_smart_alarm/manifest.json b/homeassistant/components/yale_smart_alarm/manifest.json index a61a18889903db..6bc3846ea67a6f 100644 --- a/homeassistant/components/yale_smart_alarm/manifest.json +++ b/homeassistant/components/yale_smart_alarm/manifest.json @@ -2,7 +2,7 @@ "domain": "yale_smart_alarm", "name": "Yale Smart Living", "documentation": "https://www.home-assistant.io/integrations/yale_smart_alarm", - "requirements": ["yalesmartalarmclient==0.3.4"], + "requirements": ["yalesmartalarmclient==0.3.7"], "codeowners": ["@gjohansson-ST"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index cec588a3cc8ee1..5258e681c05a37 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -5,7 +5,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { "user": { @@ -21,9 +22,22 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "name": "[%key:common::config_flow::data::name%]", - "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]" + "area_id": "Area ID" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "code": "Default code for locks, used if none is given", + "lock_code_digits": "Number of digits in PIN code for locks" } } + }, + "error": { + "code_format_mismatch": "The code does not match the required number of digits" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/bg.json b/homeassistant/components/yale_smart_alarm/translations/bg.json index b4e9a017081960..ecab0c9bc29524 100644 --- a/homeassistant/components/yale_smart_alarm/translations/bg.json +++ b/homeassistant/components/yale_smart_alarm/translations/bg.json @@ -1,9 +1,11 @@ { "config": { "abort": { - "already_configured": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + "already_configured": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" }, "step": { diff --git a/homeassistant/components/yale_smart_alarm/translations/ca.json b/homeassistant/components/yale_smart_alarm/translations/ca.json index 6e14f2d6e20ee3..e5dd15b877a575 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ca.json +++ b/homeassistant/components/yale_smart_alarm/translations/ca.json @@ -5,6 +5,7 @@ "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" }, "step": { @@ -25,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "El codi no cont\u00e9 el nombre de d\u00edgits adequat" + }, + "step": { + "init": { + "data": { + "code": "Codi predeterminat per als panys, en cas que no se'n configuri cap", + "lock_code_digits": "Nombre de d\u00edgits del codi PIN dels panys" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/cs.json b/homeassistant/components/yale_smart_alarm/translations/cs.json index 70947657e4def0..22b8fdc4228139 100644 --- a/homeassistant/components/yale_smart_alarm/translations/cs.json +++ b/homeassistant/components/yale_smart_alarm/translations/cs.json @@ -1,9 +1,11 @@ { "config": { "abort": { - "already_configured": "\u00da\u010det je ji\u017e nastaven" + "already_configured": "\u00da\u010det je ji\u017e nastaven", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" }, "step": { diff --git a/homeassistant/components/yale_smart_alarm/translations/de.json b/homeassistant/components/yale_smart_alarm/translations/de.json index 6050bafa645436..8f24160663f330 100644 --- a/homeassistant/components/yale_smart_alarm/translations/de.json +++ b/homeassistant/components/yale_smart_alarm/translations/de.json @@ -5,12 +5,13 @@ "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { + "cannot_connect": "Verbindung fehlgeschlagen", "invalid_auth": "Ung\u00fcltige Authentifizierung" }, "step": { "reauth_confirm": { "data": { - "area_id": "Bereichs-ID", + "area_id": "Area ID", "name": "Name", "password": "Passwort", "username": "Benutzername" @@ -25,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "Der Code entspricht nicht der erforderlichen Stellenzahl" + }, + "step": { + "init": { + "data": { + "code": "Standardcode f\u00fcr Schl\u00f6sser. Wird verwendet, wenn keiner angegeben ist", + "lock_code_digits": "Anzahl der Ziffern im PIN-Code f\u00fcr Schl\u00f6sser" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/el.json b/homeassistant/components/yale_smart_alarm/translations/el.json index 676d0889008490..6a8ad33c53bc51 100644 --- a/homeassistant/components/yale_smart_alarm/translations/el.json +++ b/homeassistant/components/yale_smart_alarm/translations/el.json @@ -7,5 +7,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "\u039f \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03b4\u03b5\u03bd \u03b1\u03bd\u03c4\u03b9\u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af \u03c3\u03c4\u03bf\u03bd \u03b1\u03c0\u03b1\u03b9\u03c4\u03bf\u03cd\u03bc\u03b5\u03bd\u03bf \u03b1\u03c1\u03b9\u03b8\u03bc\u03cc \u03c8\u03b7\u03c6\u03af\u03c9\u03bd" + }, + "step": { + "init": { + "data": { + "code": "\u03a0\u03c1\u03bf\u03b5\u03c0\u03b9\u03bb\u03b5\u03b3\u03bc\u03ad\u03bd\u03bf\u03c2 \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03b3\u03b9\u03b1 \u03ba\u03bb\u03b5\u03b9\u03b4\u03b1\u03c1\u03b9\u03ad\u03c2, \u03c0\u03bf\u03c5 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b5\u03ac\u03bd \u03b4\u03b5\u03bd \u03b4\u03af\u03bd\u03b5\u03c4\u03b1\u03b9 \u03ba\u03b1\u03bd\u03ad\u03bd\u03b1\u03c2", + "lock_code_digits": "\u0391\u03c1\u03b9\u03b8\u03bc\u03cc\u03c2 \u03c8\u03b7\u03c6\u03af\u03c9\u03bd \u03c3\u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc PIN \u03b3\u03b9\u03b1 \u03ba\u03bb\u03b5\u03b9\u03b4\u03b1\u03c1\u03b9\u03ad\u03c2" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/en.json b/homeassistant/components/yale_smart_alarm/translations/en.json index e198b0329b9111..d710f4a217729f 100644 --- a/homeassistant/components/yale_smart_alarm/translations/en.json +++ b/homeassistant/components/yale_smart_alarm/translations/en.json @@ -5,6 +5,7 @@ "reauth_successful": "Re-authentication was successful" }, "error": { + "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication" }, "step": { @@ -25,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "The code does not match the required number of digits" + }, + "step": { + "init": { + "data": { + "code": "Default code for locks, used if none is given", + "lock_code_digits": "Number of digits in PIN code for locks" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/es.json b/homeassistant/components/yale_smart_alarm/translations/es.json index 178b8209af7284..4df58fda1b7f1b 100644 --- a/homeassistant/components/yale_smart_alarm/translations/es.json +++ b/homeassistant/components/yale_smart_alarm/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "La cuenta ya ha sido configurada" + "already_configured": "La cuenta ya ha sido configurada", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" }, "error": { "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" @@ -24,5 +25,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "El c\u00f3digo no coincide con el n\u00famero de d\u00edgitos requerido" + }, + "step": { + "init": { + "data": { + "code": "C\u00f3digo predeterminado para cerraduras, utilizado si no se proporciona ninguno", + "lock_code_digits": "N\u00famero de d\u00edgitos del c\u00f3digo PIN de las cerraduras" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/et.json b/homeassistant/components/yale_smart_alarm/translations/et.json index dd55b1ebd7d822..fd63eec8cb9970 100644 --- a/homeassistant/components/yale_smart_alarm/translations/et.json +++ b/homeassistant/components/yale_smart_alarm/translations/et.json @@ -5,6 +5,7 @@ "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { + "cannot_connect": "\u00dchendamine nurjus", "invalid_auth": "Tuvastamine nurjus" }, "step": { @@ -25,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "Koodi numbrite arv on vale" + }, + "step": { + "init": { + "data": { + "code": "Lukkude vaikekood kui kood on m\u00e4\u00e4ramata", + "lock_code_digits": "Lukkude PIN-koodi numbrite arv" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/fr.json b/homeassistant/components/yale_smart_alarm/translations/fr.json index c2cf20086e2e70..50ad7f4b9ca4ac 100644 --- a/homeassistant/components/yale_smart_alarm/translations/fr.json +++ b/homeassistant/components/yale_smart_alarm/translations/fr.json @@ -1,9 +1,11 @@ { "config": { "abort": { - "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { + "cannot_connect": "Impossible de se connecter", "invalid_auth": "Authentification invalide" }, "step": { @@ -24,5 +26,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "lock_code_digits": "Nombre de chiffres dans le code PIN pour les serrures" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/he.json b/homeassistant/components/yale_smart_alarm/translations/he.json index 41f5d4493bff6e..baf747a16ef1bf 100644 --- a/homeassistant/components/yale_smart_alarm/translations/he.json +++ b/homeassistant/components/yale_smart_alarm/translations/he.json @@ -1,9 +1,11 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" }, "step": { diff --git a/homeassistant/components/yale_smart_alarm/translations/hu.json b/homeassistant/components/yale_smart_alarm/translations/hu.json index 6845e245f2d235..028f2cd6f0f119 100644 --- a/homeassistant/components/yale_smart_alarm/translations/hu.json +++ b/homeassistant/components/yale_smart_alarm/translations/hu.json @@ -5,6 +5,7 @@ "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, "step": { @@ -25,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "A k\u00f3d nem rendelkezik a sz\u00fcks\u00e9ges sz\u00e1mjegyekkel" + }, + "step": { + "init": { + "data": { + "code": "A z\u00e1rak alap\u00e9rtelmezett k\u00f3dja, ha nincs m\u00e1sik megadva", + "lock_code_digits": "Sz\u00e1mjegyek sz\u00e1ma a z\u00e1rak PIN-k\u00f3dj\u00e1ban" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/id.json b/homeassistant/components/yale_smart_alarm/translations/id.json index 37b88ddc68c074..86e54e111bde80 100644 --- a/homeassistant/components/yale_smart_alarm/translations/id.json +++ b/homeassistant/components/yale_smart_alarm/translations/id.json @@ -5,6 +5,7 @@ "reauth_successful": "Autentikasi ulang berhasil" }, "error": { + "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid" }, "step": { diff --git a/homeassistant/components/yale_smart_alarm/translations/it.json b/homeassistant/components/yale_smart_alarm/translations/it.json index 2f510e46396857..af6b89f045a199 100644 --- a/homeassistant/components/yale_smart_alarm/translations/it.json +++ b/homeassistant/components/yale_smart_alarm/translations/it.json @@ -1,9 +1,11 @@ { "config": { "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato" + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { + "cannot_connect": "Impossibile connettersi", "invalid_auth": "Autenticazione non valida" }, "step": { @@ -24,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "Il codice non corrisponde al numero di cifre richiesto" + }, + "step": { + "init": { + "data": { + "code": "Codice predefinito per le serrature, utilizzato se non ne viene fornito alcuno", + "lock_code_digits": "Numero di cifre nel codice PIN per le serrature" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/ja.json b/homeassistant/components/yale_smart_alarm/translations/ja.json index 53d868fe35195f..261c1775931b17 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ja.json +++ b/homeassistant/components/yale_smart_alarm/translations/ja.json @@ -5,6 +5,7 @@ "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" }, "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c" }, "step": { @@ -25,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "\u30b3\u30fc\u30c9\u304c\u5fc5\u8981\u306a\u6841\u6570\u3068\u4e00\u81f4\u3057\u3066\u3044\u307e\u305b\u3093" + }, + "step": { + "init": { + "data": { + "code": "\u30ed\u30c3\u30af\u306e\u30c7\u30d5\u30a9\u30eb\u30c8\u30b3\u30fc\u30c9\u3001\u4f55\u3082\u6307\u5b9a\u3055\u308c\u3066\u3044\u306a\u3044\u5834\u5408\u306b\u4f7f\u7528\u3055\u308c\u307e\u3059", + "lock_code_digits": "\u30ed\u30c3\u30af\u7528PIN\u30b3\u30fc\u30c9\u306e\u6841\u6570" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/nl.json b/homeassistant/components/yale_smart_alarm/translations/nl.json index bf2f3409e4245f..cdde7efe151676 100644 --- a/homeassistant/components/yale_smart_alarm/translations/nl.json +++ b/homeassistant/components/yale_smart_alarm/translations/nl.json @@ -5,6 +5,7 @@ "reauth_successful": "Herauthenticatie was succesvol" }, "error": { + "cannot_connect": "Kan geen verbinding maken", "invalid_auth": "Ongeldige authenticatie" }, "step": { @@ -25,5 +26,17 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "De code komt niet overeen met het vereiste aantal cijfers" + }, + "step": { + "init": { + "data": { + "code": "Standaardcode voor sloten, gebruikt als er geen is opgegeven" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/no.json b/homeassistant/components/yale_smart_alarm/translations/no.json index 4d306dc3cad28d..579f61f2d71de7 100644 --- a/homeassistant/components/yale_smart_alarm/translations/no.json +++ b/homeassistant/components/yale_smart_alarm/translations/no.json @@ -5,12 +5,13 @@ "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { + "cannot_connect": "Tilkobling mislyktes", "invalid_auth": "Ugyldig godkjenning" }, "step": { "reauth_confirm": { "data": { - "area_id": "Omr\u00e5de -ID", + "area_id": "Omr\u00e5de-ID", "name": "Navn", "password": "Passord", "username": "Brukernavn" @@ -25,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "Koden samsvarer ikke med det n\u00f8dvendige antallet sifre" + }, + "step": { + "init": { + "data": { + "code": "Standardkode for l\u00e5ser, brukes hvis ingen er oppgitt", + "lock_code_digits": "Antall sifre i PIN-kode for l\u00e5ser" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/pl.json b/homeassistant/components/yale_smart_alarm/translations/pl.json index b409b7026c14d9..31897a0d3c9866 100644 --- a/homeassistant/components/yale_smart_alarm/translations/pl.json +++ b/homeassistant/components/yale_smart_alarm/translations/pl.json @@ -1,9 +1,11 @@ { "config": { "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane" + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { + "cannot_connect": "Nie uda\u0142o si\u0119 po\u0142\u0105czy\u0107", "invalid_auth": "Niepoprawne uwierzytelnienie" }, "step": { @@ -24,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "Kod PIN nie odpowiada wymaganej liczbie cyfr" + }, + "step": { + "init": { + "data": { + "code": "Domy\u015blny kod dla zamk\u00f3w. U\u017cywany, je\u015bli nie podano \u017cadnego.", + "lock_code_digits": "Liczba cyfr w kodzie PIN dla zamk\u00f3w" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/pt-BR.json b/homeassistant/components/yale_smart_alarm/translations/pt-BR.json new file mode 100644 index 00000000000000..b9580fd103fc93 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/pt-BR.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "cannot_connect": "Falha na conex\u00e3o" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/ru.json b/homeassistant/components/yale_smart_alarm/translations/ru.json index 4a9132c7546f03..b982aa8cea6890 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ru.json +++ b/homeassistant/components/yale_smart_alarm/translations/ru.json @@ -5,6 +5,7 @@ "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." }, "step": { @@ -25,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "\u041a\u043e\u0434 \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u043c\u0443 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0443 \u0446\u0438\u0444\u0440." + }, + "step": { + "init": { + "data": { + "code": "\u041a\u043e\u0434 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0434\u043b\u044f \u0437\u0430\u043c\u043a\u043e\u0432 (\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f, \u0435\u0441\u043b\u0438 \u043d\u0435 \u0443\u043a\u0430\u0437\u0430\u043d)", + "lock_code_digits": "\u041a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0446\u0438\u0444\u0440 \u0432 PIN-\u043a\u043e\u0434\u0435 \u0434\u043b\u044f \u0437\u0430\u043c\u043a\u043e\u0432" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/tr.json b/homeassistant/components/yale_smart_alarm/translations/tr.json index 24b3744016074f..5cc52dfc37273b 100644 --- a/homeassistant/components/yale_smart_alarm/translations/tr.json +++ b/homeassistant/components/yale_smart_alarm/translations/tr.json @@ -5,25 +5,39 @@ "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama" }, "step": { "reauth_confirm": { "data": { - "area_id": "Alan Kodu", + "area_id": "Alan Kimli\u011fi", "name": "Ad", - "password": "\u015eifre", + "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" } }, "user": { "data": { "area_id": "Alan Kodu", - "name": "\u0130sim", - "password": "\u015eifre", + "name": "Ad", + "password": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" } } } + }, + "options": { + "error": { + "code_format_mismatch": "Kod, gerekli basamak say\u0131s\u0131yla e\u015fle\u015fmiyor" + }, + "step": { + "init": { + "data": { + "code": "Kilitler i\u00e7in varsay\u0131lan kod, hi\u00e7biri verilmezse kullan\u0131l\u0131r", + "lock_code_digits": "Kilitler i\u00e7in PIN kodundaki hane say\u0131s\u0131" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json b/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json index 5d7c14b07b2fb7..a5b7f43b6c49d3 100644 --- a/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json +++ b/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json @@ -5,6 +5,7 @@ "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" }, "step": { @@ -25,5 +26,18 @@ } } } + }, + "options": { + "error": { + "code_format_mismatch": "\u9580\u9396\u78bc\u8207\u6240\u9700\u6578\u5b57\u6578\u4e0d\u7b26\u5408" + }, + "step": { + "init": { + "data": { + "code": "\u9810\u8a2d\u9580\u9396\u78bc\uff0c\u65bc\u672a\u63d0\u4f9b\u6642\u4f7f\u7528", + "lock_code_digits": "\u9580\u9396 PIN \u78bc\u6578\u5b57\u6578" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index 4bf830ed68d08e..4a816b99acaa43 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -31,7 +31,9 @@ STATE_ON, STATE_PLAYING, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -99,7 +101,9 @@ class YamahaConfigInfo: """Configuration Info for Yamaha Receivers.""" - def __init__(self, config: ConfigType, discovery_info: DiscoveryInfoType) -> None: + def __init__( + self, config: ConfigType, discovery_info: DiscoveryInfoType | None + ) -> None: """Initialize the Configuration Info for Yamaha Receiver.""" self.name = config.get(CONF_NAME) self.host = config.get(CONF_HOST) @@ -138,9 +142,13 @@ def _discovery(config_info): return receivers -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Yamaha platform.""" - # Keep track of configured receivers so that we don't end up # discovering a receiver dynamically that we have static config # for. Map each device from its zone_id . @@ -153,7 +161,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entities = [] for receiver in receivers: - if receiver.zone in config_info.zone_ignore: + if config_info.zone_ignore and receiver.zone in config_info.zone_ignore: continue entity = YamahaDevice( diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 194168b2eee3cb..d984aaceb96f9f 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -10,7 +10,7 @@ from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac @@ -30,7 +30,7 @@ ENTITY_CATEGORY_MAPPING, ) -PLATFORMS = ["media_player", "number"] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT] _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) @@ -199,6 +199,17 @@ def device_info(self) -> DeviceInfo: return device_info + async def async_added_to_hass(self): + """Run when this Entity has been added to HA.""" + await super().async_added_to_hass() + # All entities should register callbacks to update HA when their state changes + self.coordinator.musiccast.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """Entity being removed from hass.""" + await super().async_will_remove_from_hass() + self.coordinator.musiccast.remove_callback(self.async_write_ha_state) + class MusicCastCapabilityEntity(MusicCastDeviceEntity): """Base Entity type for all capabilities.""" @@ -216,17 +227,6 @@ def __init__( super().__init__(name=capability.name, icon="", coordinator=coordinator) self._attr_entity_category = ENTITY_CATEGORY_MAPPING.get(capability.entity_type) - async def async_added_to_hass(self): - """Run when this Entity has been added to HA.""" - await super().async_added_to_hass() - # All capability based entities should register callbacks to update HA when their state changes - self.coordinator.musiccast.register_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self): - """Entity being removed from hass.""" - await super().async_added_to_hass() - self.coordinator.musiccast.remove_callback(self.async_write_ha_state) - @property def unique_id(self) -> str: """Return the unique ID for this entity.""" diff --git a/homeassistant/components/yamaha_musiccast/const.py b/homeassistant/components/yamaha_musiccast/const.py index 5384cc566943aa..21b7de8218460c 100644 --- a/homeassistant/components/yamaha_musiccast/const.py +++ b/homeassistant/components/yamaha_musiccast/const.py @@ -9,11 +9,7 @@ REPEAT_MODE_OFF, REPEAT_MODE_ONE, ) -from homeassistant.const import ( - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, - ENTITY_CATEGORY_SYSTEM, -) +from homeassistant.helpers.entity import EntityCategory DOMAIN = "yamaha_musiccast" @@ -51,8 +47,18 @@ } ENTITY_CATEGORY_MAPPING = { - EntityType.CONFIG: ENTITY_CATEGORY_CONFIG, + EntityType.CONFIG: EntityCategory.CONFIG, EntityType.REGULAR: None, - EntityType.DIAGNOSTIC: ENTITY_CATEGORY_DIAGNOSTIC, - EntityType.SYSTEM: ENTITY_CATEGORY_SYSTEM, + EntityType.DIAGNOSTIC: EntityCategory.DIAGNOSTIC, +} + +DEVICE_CLASS_MAPPING = { + "DIMMER": "yamaha_musiccast__dimmer", + "zone_SLEEP": "yamaha_musiccast__zone_sleep", + "zone_TONE_CONTROL_mode": "yamaha_musiccast__zone_tone_control_mode", + "zone_SURR_DECODER_TYPE": "yamaha_musiccast__zone_surr_decoder_type", + "zone_EQUALIZER_mode": "yamaha_musiccast__zone_equalizer_mode", + "zone_LINK_AUDIO_QUALITY": "yamaha_musiccast__zone_link_audio_quality", + "zone_LINK_CONTROL": "yamaha_musiccast__zone_link_control", + "zone_LINK_AUDIO_DELAY": "yamaha_musiccast__zone_link_audio_delay", } diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 329fa2354d5b3e..7d07d57fc289bd 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "requirements": [ - "aiomusiccast==0.14.2" + "aiomusiccast==0.14.3" ], "ssdp": [ { diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index b1d0bdcd2e9e84..bcd1a0a2c1ffe9 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -49,7 +49,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import uuid from . import MusicCastDataUpdateCoordinator, MusicCastDeviceEntity @@ -87,7 +87,7 @@ async def async_setup_platform( hass: HomeAssistant, - config, + config: ConfigType, async_add_devices: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: @@ -162,7 +162,6 @@ async def async_added_to_hass(self): await super().async_added_to_hass() self.coordinator.entities.append(self) # Sensors should also register callbacks to HA when their state changes - self.coordinator.musiccast.register_callback(self.async_write_ha_state) self.coordinator.musiccast.register_group_update_callback( self.update_all_mc_entities ) @@ -173,7 +172,6 @@ async def async_will_remove_from_hass(self): await super().async_will_remove_from_hass() self.coordinator.entities.remove(self) # The opposite of async_added_to_hass. Remove any registered call backs here. - self.coordinator.musiccast.remove_callback(self.async_write_ha_state) self.coordinator.musiccast.remove_group_update_callback( self.update_all_mc_entities ) diff --git a/homeassistant/components/yamaha_musiccast/number.py b/homeassistant/components/yamaha_musiccast/number.py index daef8bacd12677..2648359f7686e3 100644 --- a/homeassistant/components/yamaha_musiccast/number.py +++ b/homeassistant/components/yamaha_musiccast/number.py @@ -3,15 +3,12 @@ from aiomusiccast.capabilities import NumberSetter from homeassistant.components.number import NumberEntity -from homeassistant.components.yamaha_musiccast import ( - DOMAIN, - MusicCastCapabilityEntity, - MusicCastDataUpdateCoordinator, -) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DOMAIN, MusicCastCapabilityEntity, MusicCastDataUpdateCoordinator + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py new file mode 100644 index 00000000000000..d57d1c07f68d7a --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -0,0 +1,62 @@ +"""The select entities for musiccast.""" +from __future__ import annotations + +from aiomusiccast.capabilities import OptionSetter + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN, MusicCastCapabilityEntity, MusicCastDataUpdateCoordinator +from .const import DEVICE_CLASS_MAPPING + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MusicCast select entities based on a config entry.""" + coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + select_entities = [] + + for capability in coordinator.data.capabilities: + if isinstance(capability, OptionSetter): + select_entities.append(SelectableCapapility(coordinator, capability)) + + for zone, data in coordinator.data.zones.items(): + for capability in data.capabilities: + if isinstance(capability, OptionSetter): + select_entities.append( + SelectableCapapility(coordinator, capability, zone) + ) + + async_add_entities(select_entities) + + +class SelectableCapapility(MusicCastCapabilityEntity, SelectEntity): + """Representation of a MusicCast Select entity.""" + + capability: OptionSetter + + async def async_select_option(self, option: str) -> None: + """Select the given option.""" + value = {val: key for key, val in self.capability.options.items()}[option] + await self.capability.set(value) + + @property + def device_class(self) -> str | None: + """Return the device class, to identify the entity for translations.""" + return DEVICE_CLASS_MAPPING.get(self.capability.id) + + @property + def options(self): + """Return the list possible options.""" + return list(self.capability.options.values()) + + @property + def current_option(self): + """Return the currently selected option.""" + return self.capability.options.get(self.capability.current) diff --git a/homeassistant/components/yamaha_musiccast/strings.select.json b/homeassistant/components/yamaha_musiccast/strings.select.json new file mode 100644 index 00000000000000..59c763017bf810 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/strings.select.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Auto" + }, + "yamaha_musiccast__zone_sleep": { + "off": "Off", + "30 min": "30 Minutes", + "60 min": "60 Minutes", + "90 min": "90 Minutes", + "120 min": "120 Minutes" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "manual": "Manual", + "auto": "Auto", + "bypass": "Bypass" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "toggle": "Toggle", + "auto": "Auto", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", + "dolby_pl2x_music": "Dolby ProLogic 2x Music", + "dolby_pl2x_game": "Dolby ProLogic 2x Game", + "dolby_surround": "Dolby Surround", + "dts_neural_x": "DTS Neural:X", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Music" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "manual": "Manual", + "auto": "Auto", + "bypass": "Bypass" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Compressed", + "uncompressed": "Uncompressed" + }, + "yamaha_musiccast__zone_link_control": { + "standard": "Standard", + "speed": "Speed", + "stability": "Stability" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync_on": "Audio Synchronization On", + "audio_sync_off": "Audio Synchronization Off", + "balanced": "Balanced", + "lip_sync": "Lip Synchronization", + "audio_sync": "Audio Synchronization" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/es.json b/homeassistant/components/yamaha_musiccast/translations/es.json index 46f8a02f33dc47..a83d97db703e18 100644 --- a/homeassistant/components/yamaha_musiccast/translations/es.json +++ b/homeassistant/components/yamaha_musiccast/translations/es.json @@ -14,7 +14,7 @@ }, "user": { "data": { - "host": "Anfitri\u00f3n" + "host": "Host" }, "description": "Configura MusicCast para integrarse con Home Assistant." } diff --git a/homeassistant/components/yamaha_musiccast/translations/select.bg.json b/homeassistant/components/yamaha_musiccast/translations/select.bg.json new file mode 100644 index 00000000000000..cee1a2a5a7cc7e --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.bg.json @@ -0,0 +1,38 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e", + "bypass": "\u0411\u0430\u0439\u043f\u0430\u0441", + "manual": "\u0420\u044a\u0447\u043d\u043e" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "\u0421\u043a\u043e\u0440\u043e\u0441\u0442" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 \u043c\u0438\u043d\u0443\u0442\u0438", + "30 min": "30 \u043c\u0438\u043d\u0443\u0442\u0438", + "60 min": "60 \u043c\u0438\u043d\u0443\u0442\u0438", + "90 min": "90 \u043c\u0438\u043d\u0443\u0442\u0438", + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Game", + "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", + "dolby_pl2x_music": "Dolby ProLogic 2x Music", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Music", + "dts_neural_x": "DTS Neural:X" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e", + "bypass": "\u0411\u0430\u0439\u043f\u0430\u0441", + "manual": "\u0420\u044a\u0447\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.ca.json b/homeassistant/components/yamaha_musiccast/translations/select.ca.json new file mode 100644 index 00000000000000..cca1e3dd75ae65 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.ca.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Auto" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Auto", + "bypass": "Pont", + "manual": "Manual" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Sincronitzaci\u00f3 d'\u00e0udio", + "audio_sync_off": "Sincronitzaci\u00f3 d'\u00e0udio OFF", + "audio_sync_on": "Sincronitzaci\u00f3 d'\u00e0udio ON", + "balanced": "Equilibrat", + "lip_sync": "Sincronitzaci\u00f3 Lip" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Amb compressi\u00f3", + "uncompressed": "Sense compressi\u00f3" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Velocitat", + "stability": "Estabilitat", + "standard": "Est\u00e0ndard" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 minuts", + "30 min": "30 minuts", + "60 min": "60 minuts", + "90 min": "90 minuts", + "off": "OFF" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Auto", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Game", + "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", + "dolby_pl2x_music": "Dolby ProLogic 2x Music", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Music", + "dts_neural_x": "DTS Neural:X", + "toggle": "Commuta" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Auto", + "bypass": "Pont", + "manual": "Manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.cs.json b/homeassistant/components/yamaha_musiccast/translations/select.cs.json new file mode 100644 index 00000000000000..c416256dd99239 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.cs.json @@ -0,0 +1,12 @@ +{ + "state": { + "yamaha_musiccast__zone_surr_decoder_type": { + "toggle": "P\u0159epnout" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Automaticky", + "bypass": "Bypass", + "manual": "Manu\u00e1ln\u00ed" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.de.json b/homeassistant/components/yamaha_musiccast/translations/select.de.json new file mode 100644 index 00000000000000..e21b228c2a3617 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.de.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Automatisch" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Automatisch", + "bypass": "Bypass", + "manual": "Manuell" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Audio-Synchronisation", + "audio_sync_off": "Audio-Synchronisation Aus", + "audio_sync_on": "Audio-Synchronisation Ein", + "balanced": "Ausgeglichen", + "lip_sync": "Lippensynchronisation" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Komprimiert", + "uncompressed": "Unkomprimiert" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Geschwindigkeit", + "stability": "Stabilit\u00e4t", + "standard": "Standard" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 Minuten", + "30 min": "30 Minuten", + "60 min": "60 Minuten", + "90 min": "90 Minuten", + "off": "Aus" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Automatisch", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Game", + "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", + "dolby_pl2x_music": "Dolby ProLogic 2x Music", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Music", + "dts_neural_x": "DTS Neural:X", + "toggle": "Umschalten" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Automatisch", + "bypass": "Bypass", + "manual": "Manuell" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.en.json b/homeassistant/components/yamaha_musiccast/translations/select.en.json new file mode 100644 index 00000000000000..9f0fc18cd4c2c0 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.en.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Auto" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Auto", + "bypass": "Bypass", + "manual": "Manual" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Audio Synchronization", + "audio_sync_off": "Audio Synchronization Off", + "audio_sync_on": "Audio Synchronization On", + "balanced": "Balanced", + "lip_sync": "Lip Synchronization" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Compressed", + "uncompressed": "Uncompressed" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Speed", + "stability": "Stability", + "standard": "Standard" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 Minutes", + "30 min": "30 Minutes", + "60 min": "60 Minutes", + "90 min": "90 Minutes", + "off": "Off" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Auto", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Game", + "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", + "dolby_pl2x_music": "Dolby ProLogic 2x Music", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Music", + "dts_neural_x": "DTS Neural:X", + "toggle": "Toggle" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Auto", + "bypass": "Bypass", + "manual": "Manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.es.json b/homeassistant/components/yamaha_musiccast/translations/select.es.json new file mode 100644 index 00000000000000..f97bea3e3eb980 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.es.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Autom\u00e1tico" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Autom\u00e1tico", + "bypass": "Derivaci\u00f3n", + "manual": "Manual" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Sincronizaci\u00f3n de audio", + "audio_sync_off": "Sincronizaci\u00f3n de audio desactivada", + "audio_sync_on": "Sincronizaci\u00f3n de audio activada", + "balanced": "Equilibrado", + "lip_sync": "Sincronizaci\u00f3n de labios" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Comprimido", + "uncompressed": "Sin comprimir" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Velocidad", + "stability": "Estabilidad", + "standard": "Est\u00e1ndar" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 minutos", + "30 min": "30 minutos", + "60 min": "60 minutos", + "90 min": "90 minutos", + "off": "Apagado" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Autom\u00e1tico", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Juego Dolby ProLogic 2x", + "dolby_pl2x_movie": "Pel\u00edcula Dolby ProLogic 2x", + "dolby_pl2x_music": "M\u00fasica Dolby ProLogic 2x", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "Cine DTS Neo:6", + "dts_neo6_music": "M\u00fasica DTS Neo:6", + "dts_neural_x": "DTS Neural: X", + "toggle": "Alternar" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Autom\u00e1tico", + "bypass": "Derivaci\u00f3n", + "manual": "Manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.et.json b/homeassistant/components/yamaha_musiccast/translations/select.et.json new file mode 100644 index 00000000000000..79430ce0c3e2bc --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.et.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Automaatne" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Automaatne", + "bypass": "M\u00f6\u00f6daviik", + "manual": "K\u00e4sitsi" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Heli s\u00fcnkroonimine", + "audio_sync_off": "Heli s\u00fcnkroonimine v\u00e4ljas", + "audio_sync_on": "Heli s\u00fcnkroonimine sees", + "balanced": "Tasakaalustatud", + "lip_sync": "Huulte s\u00fcnkroonimine" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Tihendatud", + "uncompressed": "Tihendamata" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Kiirus", + "stability": "Stabiilne", + "standard": "Standartne" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 minutit", + "30 min": "30 minutit", + "60 min": "60 minutit", + "90 min": "90 minutit", + "off": "V\u00e4ljas" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Automaatne", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Game", + "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", + "dolby_pl2x_music": "Dolby ProLogic 2x Music", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Music", + "dts_neural_x": "DTS Neural:X", + "toggle": "Muuda olekut" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Automaatne", + "bypass": "M\u00f6\u00f6daviik", + "manual": "K\u00e4sitsi" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.fr.json b/homeassistant/components/yamaha_musiccast/translations/select.fr.json new file mode 100644 index 00000000000000..f8ce2da31a7aef --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.fr.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Auto" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Auto", + "bypass": "Bypass personnalis\u00e9", + "manual": "Manuel" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Synchronisation audio", + "audio_sync_off": "Synchronisation audio d\u00e9sactiv\u00e9e", + "audio_sync_on": "Synchronisation audio activ\u00e9e", + "balanced": "\u00c9quilibr\u00e9", + "lip_sync": "Synchronisation audio" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Compress\u00e9", + "uncompressed": "Non compress\u00e9" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Vitesse", + "stability": "Stabilit\u00e9", + "standard": "Standard" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 Minutes", + "30 min": "30 Minutes", + "60 min": "60 Minutes", + "90 min": "90 Minutes", + "off": "\u00c9teint" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Auto", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Jeu Dolby ProLogic 2x", + "dolby_pl2x_movie": "Film Dolby ProLogic 2x", + "dolby_pl2x_music": "Musique Dolby ProLogic 2x", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cin\u00e9ma", + "dts_neo6_music": "DTS Neo:6 Musique", + "dts_neural_x": "DTS Neural:X", + "toggle": "Permuter" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Auto", + "bypass": "Bypass personnalis\u00e9", + "manual": "Manuel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.hu.json b/homeassistant/components/yamaha_musiccast/translations/select.hu.json new file mode 100644 index 00000000000000..7ff67f3ddf3234 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.hu.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Automatikus" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Automatikus", + "bypass": "Kihagy\u00e1s", + "manual": "Manu\u00e1lis" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Audi\u00f3 szinkroniz\u00e1l\u00e1s", + "audio_sync_off": "Audi\u00f3 szinkroniz\u00e1l\u00e1s kikapcsolva", + "audio_sync_on": "Audi\u00f3 szinkroniz\u00e1l\u00e1s bekapcsolva", + "balanced": "Kiegyens\u00falyozott", + "lip_sync": "K\u00e9p-hang k\u00e9sleltet\u00e9s" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "T\u00f6m\u00f6r\u00edtve", + "uncompressed": "T\u00f6m\u00f6r\u00edtetlen" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Sebess\u00e9g", + "stability": "Stabilit\u00e1s", + "standard": "Standard" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 perc", + "30 min": "30 perc", + "60 min": "60 perc", + "90 min": "90 perc", + "off": "Ki" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Automatikus", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x J\u00e1t\u00e9k", + "dolby_pl2x_movie": "Dolby ProLogic 2x Film", + "dolby_pl2x_music": "Dolby ProLogic 2x Zene", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Mozi", + "dts_neo6_music": "DTS Neo:6 Zene", + "dts_neural_x": "DTS Neural:X", + "toggle": "Kapcsol\u00e1s" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Automatikus", + "bypass": "Kihagy\u00e1s", + "manual": "Manu\u00e1lis" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.id.json b/homeassistant/components/yamaha_musiccast/translations/select.id.json new file mode 100644 index 00000000000000..3bcdb6f7650ecd --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.id.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Otomatis" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Otomatis", + "bypass": "Pintas", + "manual": "Manual" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Sinkronisasi Audio", + "audio_sync_off": "Sinkronisasi Audio Mati", + "audio_sync_on": "Sinkronisasi Audio Nyala", + "balanced": "Seimbang", + "lip_sync": "Sinkronisasi Bibir" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Terkompresi", + "uncompressed": "Tidak terkompresi" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Kecepatan", + "stability": "Stabilitas", + "standard": "Standar" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 Menit", + "30 min": "30 Menit", + "60 min": "60 Menit", + "90 min": "90 Menit", + "off": "Mati" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Otomatis", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Game", + "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", + "dolby_pl2x_music": "Dolby ProLogic 2x Music", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Music", + "dts_neural_x": "DTS Neural:X", + "toggle": "Alihkan" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Otomatis", + "bypass": "Pintas", + "manual": "Manual" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.it.json b/homeassistant/components/yamaha_musiccast/translations/select.it.json new file mode 100644 index 00000000000000..0640173398f622 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.it.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Automatico" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Automatico", + "bypass": "Bypass", + "manual": "Manuale" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Sincronizzazione audio", + "audio_sync_off": "Sincronizzazione audio disattivata", + "audio_sync_on": "Sincronizzazione audio attivata", + "balanced": "Bilanciato", + "lip_sync": "Sincronizzazione labiale" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Compresso", + "uncompressed": "Non compresso" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Velocit\u00e0", + "stability": "Stabilit\u00e0", + "standard": "Standard" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 minuti", + "30 min": "30 minuti", + "60 min": "60 minuti", + "90 min": "90 minuti", + "off": "Spento" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Automatico", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Gioco", + "dolby_pl2x_movie": "Dolby ProLogic 2x Film", + "dolby_pl2x_music": "Dolby ProLogic 2x Musica", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Musica", + "dts_neural_x": "DTS Neural:X", + "toggle": "Attiva/disattiva" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Automatico", + "bypass": "Bypass", + "manual": "Manuale" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.ja.json b/homeassistant/components/yamaha_musiccast/translations/select.ja.json new file mode 100644 index 00000000000000..c698e6fe13e056 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.ja.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "\u30aa\u30fc\u30c8" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "\u30aa\u30fc\u30c8", + "bypass": "\u30d0\u30a4\u30d1\u30b9", + "manual": "\u30de\u30cb\u30e5\u30a2\u30eb" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "\u30aa\u30fc\u30c7\u30a3\u30aa\u540c\u671f", + "audio_sync_off": "\u30aa\u30fc\u30c7\u30a3\u30aa\u540c\u671f \u30aa\u30d5", + "audio_sync_on": "\u30aa\u30fc\u30c7\u30a3\u30aa\u540c\u671f \u30aa\u30f3", + "balanced": "\u30d0\u30e9\u30f3\u30b9", + "lip_sync": "\u30ea\u30c3\u30d7\u30b7\u30f3\u30af" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "\u5727\u7e2e", + "uncompressed": "\u975e\u5727\u7e2e" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "\u901f\u5ea6", + "stability": "\u5b89\u5b9a\u6027", + "standard": "\u30b9\u30bf\u30f3\u30c0\u30fc\u30c9" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120\u5206", + "30 min": "30\u5206", + "60 min": "60\u5206", + "90 min": "90\u5206", + "off": "\u30aa\u30d5" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "\u30aa\u30fc\u30c8", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Game", + "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", + "dolby_pl2x_music": "Dolby ProLogic 2x Music", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Music", + "dts_neural_x": "DTS Neural:X", + "toggle": "\u30c8\u30b0\u30eb" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "\u30aa\u30fc\u30c8", + "bypass": "\u30d0\u30a4\u30d1\u30b9", + "manual": "\u30de\u30cb\u30e5\u30a2\u30eb" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.lt.json b/homeassistant/components/yamaha_musiccast/translations/select.lt.json new file mode 100644 index 00000000000000..28f2a709a97e4d --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.lt.json @@ -0,0 +1,29 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Automatinis" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Automatinis", + "manual": "Rankinis" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Greitis", + "standard": "Standartas" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 minu\u010di\u0173", + "30 min": "30 minu\u010di\u0173", + "60 min": "60 minu\u010di\u0173", + "90 min": "90 minu\u010di\u0173", + "off": "I\u0161jungta" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Automatinis" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Automatinis", + "manual": "Rankinis" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.nl.json b/homeassistant/components/yamaha_musiccast/translations/select.nl.json new file mode 100644 index 00000000000000..05dc828c0f45a4 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.nl.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Auto" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Auto", + "bypass": "Omzeilen", + "manual": "Handmatig" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Audiosynchronisatie", + "audio_sync_off": "Audiosynchronisatie Uit", + "audio_sync_on": "Audiosynchronisatie Aan", + "balanced": "Gebalanceerd", + "lip_sync": "Lipsynchronisatie" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Gecomprimeerd", + "uncompressed": "Ongecomprimeerd" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Snelheid", + "stability": "Stabiliteit", + "standard": "Standaard" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 minuten", + "30 min": "30 minuten", + "60 min": "60 minuten", + "90 min": "90 minuten", + "off": "Uit" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Auto", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Game", + "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", + "dolby_pl2x_music": "Dolby ProLogic 2x Music", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Cinema", + "dts_neo6_music": "DTS Neo:6 Music", + "dts_neural_x": "DTS Neural:X", + "toggle": "Toggle" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Auto", + "bypass": "Omzeilen", + "manual": "Handmatig" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.no.json b/homeassistant/components/yamaha_musiccast/translations/select.no.json new file mode 100644 index 00000000000000..3007e1b881b9b2 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.no.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Auto" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Auto", + "bypass": "Bypass", + "manual": "Manuell" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Lydsynkronisering", + "audio_sync_off": "Lydsynkronisering av", + "audio_sync_on": "Lydsynkronisering p\u00e5", + "balanced": "Balansert", + "lip_sync": "Leppesynkronisering" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Komprimert", + "uncompressed": "Ukomprimert" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Hastighet", + "stability": "Stabilitet", + "standard": "Standard" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 minutter", + "30 min": "30 minutter", + "60 min": "60 minutter", + "90 min": "90 minutter", + "off": "Av" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Auto", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x-spill", + "dolby_pl2x_movie": "Dolby ProLogic 2x film", + "dolby_pl2x_music": "Dolby ProLogic 2x musikk", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 kino", + "dts_neo6_music": "DTS Neo:6 musikk", + "dts_neural_x": "DTS Neural:X", + "toggle": "Veksle" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Auto", + "bypass": "Bypass", + "manual": "Manuell" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.pl.json b/homeassistant/components/yamaha_musiccast/translations/select.pl.json new file mode 100644 index 00000000000000..a6e9bde7c4f606 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.pl.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Automatyczny" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Automatycznie", + "bypass": "Pomijanie", + "manual": "R\u0119cznie" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Synchronizacja d\u017awi\u0119ku", + "audio_sync_off": "Synchronizacja d\u017awi\u0119ku wy\u0142\u0105czona", + "audio_sync_on": "Synchronizacja d\u017awi\u0119ku w\u0142\u0105czona", + "balanced": "Zr\u00f3wnowa\u017cone", + "lip_sync": "Synchronizacja ust" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Skompresowane", + "uncompressed": "Nieskompresowane" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "Pr\u0119dko\u015b\u0107", + "stability": "Stabilno\u015b\u0107", + "standard": "Normalnie" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 minut", + "30 min": "30 minut", + "60 min": "60 minut", + "90 min": "90 minut", + "off": "Wy\u0142\u0105czone" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Automatycznie", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x (Gra)", + "dolby_pl2x_movie": "Dolby ProLogic 2x (Film)", + "dolby_pl2x_music": "Dolby ProLogic 2x (Muzyka)", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 (Kino)", + "dts_neo6_music": "DTS Neo:6 (Muzyka)", + "dts_neural_x": "DTS Neural:X", + "toggle": "Prze\u0142\u0105cz" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Automatyczna", + "bypass": "Pomijanie", + "manual": "R\u0119czna" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.ru.json b/homeassistant/components/yamaha_musiccast/translations/select.ru.json new file mode 100644 index 00000000000000..aa65bfa41468fc --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.ru.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438", + "bypass": "\u0411\u0430\u0439\u043f\u0430\u0441", + "manual": "\u0420\u0443\u0447\u043d\u043e\u0439" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "\u0421\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u044f \u0437\u0432\u0443\u043a\u0430", + "audio_sync_off": "\u0421\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u044f \u0437\u0432\u0443\u043a\u0430 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430", + "audio_sync_on": "\u0421\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u044f \u0437\u0432\u0443\u043a\u0430 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0430", + "balanced": "\u0421\u0431\u0430\u043b\u0430\u043d\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439", + "lip_sync": "\u0421\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u044f \u0433\u0443\u0431" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "\u0421\u0436\u0430\u0442\u044b\u0439", + "uncompressed": "\u041d\u0435\u0441\u0436\u0430\u0442\u044b\u0439" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "\u0421\u043a\u043e\u0440\u043e\u0441\u0442\u044c", + "stability": "\u0421\u0442\u0430\u0431\u0438\u043b\u044c\u043d\u043e\u0441\u0442\u044c", + "standard": "\u0421\u0442\u0430\u043d\u0434\u0430\u0440\u0442" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 \u043c\u0438\u043d\u0443\u0442", + "30 min": "30 \u043c\u0438\u043d\u0443\u0442", + "60 min": "60 \u043c\u0438\u043d\u0443\u0442", + "90 min": "90 \u043c\u0438\u043d\u0443\u0442", + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x \u0418\u0433\u0440\u0430", + "dolby_pl2x_movie": "Dolby ProLogic 2x \u0424\u0438\u043b\u044c\u043c", + "dolby_pl2x_music": "Dolby ProLogic 2x \u041c\u0443\u0437\u044b\u043a\u0430", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 \u041a\u0438\u043d\u043e\u0442\u0435\u0430\u0442\u0440", + "dts_neo6_music": "DTS Neo:6 \u041c\u0443\u0437\u044b\u043a\u0430", + "dts_neural_x": "DTS Neural:X", + "toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438", + "bypass": "\u0411\u0430\u0439\u043f\u0430\u0441", + "manual": "\u0420\u0443\u0447\u043d\u043e\u0439" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.tr.json b/homeassistant/components/yamaha_musiccast/translations/select.tr.json new file mode 100644 index 00000000000000..4033d44be25646 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.tr.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Otomatik" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Otomatik", + "bypass": "Atlatma", + "manual": "Manuel" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "Ses Senkronizasyonu", + "audio_sync_off": "Ses Senkronizasyonu Kapal\u0131", + "audio_sync_on": "Ses Senkronizasyonu A\u00e7\u0131k", + "balanced": "Dengeli", + "lip_sync": "Dudak Senkronizasyonu" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "S\u0131k\u0131\u015ft\u0131r\u0131lm\u0131\u015f", + "uncompressed": "S\u0131k\u0131\u015ft\u0131r\u0131lmam\u0131\u015f" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "H\u0131z", + "stability": "Stabilite", + "standard": "Standart" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 Dakika", + "30 min": "30 Dakika", + "60 min": "60 Dakika", + "90 min": "90 Dakika", + "off": "Kapal\u0131" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "Otomatik", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x Oyun", + "dolby_pl2x_movie": "Dolby ProLogic 2x Film", + "dolby_pl2x_music": "Dolby ProLogic 2x M\u00fczik", + "dolby_surround": "Dolby Surround", + "dts_neo6_cinema": "DTS Neo:6 Sinema", + "dts_neo6_music": "DTS Neo:6 M\u00fczik", + "dts_neural_x": "DTS Neural:X", + "toggle": "De\u011fi\u015ftir" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "Otomatik", + "bypass": "Atlatma", + "manual": "Manuel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.zh-Hans.json b/homeassistant/components/yamaha_musiccast/translations/select.zh-Hans.json new file mode 100644 index 00000000000000..8d2b3bbd88c0e6 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.zh-Hans.json @@ -0,0 +1,12 @@ +{ + "state": { + "yamaha_musiccast__zone_sleep": { + "90 min": "90 \u5206\u949f", + "off": "\u5173" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "\u81ea\u52a8", + "toggle": "\u5207\u6362" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/select.zh-Hant.json b/homeassistant/components/yamaha_musiccast/translations/select.zh-Hant.json new file mode 100644 index 00000000000000..b7d9051069e84f --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.zh-Hant.json @@ -0,0 +1,52 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "\u81ea\u52d5" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "\u81ea\u52d5", + "bypass": "\u5ffd\u7565", + "manual": "\u624b\u52d5" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync": "\u97f3\u6548\u540c\u6b65", + "audio_sync_off": "\u97f3\u6548\u540c\u6b65\u95dc\u9589", + "audio_sync_on": "\u97f3\u6548\u540c\u6b65\u958b\u555f", + "balanced": "\u5e73\u8861", + "lip_sync": "\u5507\u5f62\u540c\u6b65" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "\u58d3\u7e2e", + "uncompressed": "\u672a\u58d3\u7e2e" + }, + "yamaha_musiccast__zone_link_control": { + "speed": "\u6548\u80fd", + "stability": "\u7a69\u5b9a", + "standard": "\u6a19\u6e96" + }, + "yamaha_musiccast__zone_sleep": { + "120 min": "120 \u5206\u9418", + "30 min": "30 \u5206\u9418", + "60 min": "60 \u5206\u9418", + "90 min": "90 \u5206\u9418", + "off": "\u95dc\u9589" + }, + "yamaha_musiccast__zone_surr_decoder_type": { + "auto": "\u81ea\u52d5", + "dolby_pl": "Dolby ProLogic", + "dolby_pl2x_game": "Dolby ProLogic 2x \u904a\u6232\u6a21\u5f0f", + "dolby_pl2x_movie": "Dolby ProLogic 2x \u96fb\u5f71\u6a21\u5f0f", + "dolby_pl2x_music": "Dolby ProLogic 2x \u97f3\u6a02\u6a21\u5f0f", + "dolby_surround": "Dolby \u74b0\u7e5e\u97f3\u6548", + "dts_neo6_cinema": "DTS Neo:6 \u5287\u5834\u6a21\u5f0f", + "dts_neo6_music": "DTS Neo:6 \u97f3\u6a02\u6a21\u5f0f", + "dts_neural_x": "DTS Neural:X", + "toggle": "\u958b\u95dc" + }, + "yamaha_musiccast__zone_tone_control_mode": { + "auto": "\u81ea\u52d5", + "bypass": "\u5ffd\u7565", + "manual": "\u624b\u52d5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/tr.json b/homeassistant/components/yamaha_musiccast/translations/tr.json index 37be5646212481..955ef6646c4bd9 100644 --- a/homeassistant/components/yamaha_musiccast/translations/tr.json +++ b/homeassistant/components/yamaha_musiccast/translations/tr.json @@ -14,7 +14,7 @@ }, "user": { "data": { - "host": "Ana bilgisayar" + "host": "Sunucu" }, "description": "Home Assistant ile entegre etmek i\u00e7in MusicCast'i kurun." } diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 724fca14725285..3fdca47ef02f6b 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -1,4 +1,5 @@ """Service for obtaining information about closer bus from Transport Yandex Service.""" +from __future__ import annotations from datetime import timedelta import logging @@ -6,10 +7,17 @@ from aioymaps import CaptchaError, YandexMapsRequester import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, DEVICE_CLASS_TIMESTAMP +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, +) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -35,7 +43,12 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Yandex transport sensor.""" stop_id = config[CONF_STOP_ID] name = config[CONF_NAME] @@ -140,7 +153,7 @@ def native_value(self): @property def device_class(self): """Return the device class.""" - return DEVICE_CLASS_TIMESTAMP + return SensorDeviceClass.TIMESTAMP @property def name(self): diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 89eb910f942393..cd312715b9dbf4 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -5,6 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN from .entity import YeelightEntity @@ -13,7 +14,9 @@ async def async_setup_entry( - hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Yeelight from a config entry.""" device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE] diff --git a/homeassistant/components/yeelight/const.py b/homeassistant/components/yeelight/const.py index 2b494bf9ef282d..28b5591dcbf2cd 100644 --- a/homeassistant/components/yeelight/const.py +++ b/homeassistant/components/yeelight/const.py @@ -2,6 +2,8 @@ from datetime import timedelta +from homeassistant.const import Platform + DOMAIN = "yeelight" @@ -100,4 +102,4 @@ ] -PLATFORMS = ["binary_sensor", "light"] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.LIGHT] diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 75735beed34944..84b76d98658487 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -40,6 +40,7 @@ from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later import homeassistant.util.color as color_util from homeassistant.util.color import ( @@ -276,7 +277,9 @@ async def _async_wrap(self, *args, **kwargs): async def async_setup_entry( - hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Yeelight from a config entry.""" custom_effects = _parse_custom_effects(hass.data[DOMAIN][DATA_CUSTOM_EFFECTS]) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index c46878c0ef3597..5320b8023e9c52 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,8 +2,8 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.8", "async-upnp-client==0.23.1"], - "codeowners": ["@rytilahti", "@zewelor", "@shenxn", "@starkillerOG"], + "requirements": ["yeelight==0.7.8", "async-upnp-client==0.23.4"], + "codeowners": ["@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], "quality_scale": "platinum", diff --git a/homeassistant/components/yeelight/translations/el.json b/homeassistant/components/yeelight/translations/el.json index 611f84a2ba4859..cfd4d626b5a670 100644 --- a/homeassistant/components/yeelight/translations/el.json +++ b/homeassistant/components/yeelight/translations/el.json @@ -3,7 +3,11 @@ "error": { "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2" }, + "flow_title": "{model} {id} ({host})", "step": { + "discovery_confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {model} ({host});" + }, "pick_device": { "data": { "device": "\u03a3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae" diff --git a/homeassistant/components/yeelight/translations/es.json b/homeassistant/components/yeelight/translations/es.json index c58d863fa1396d..bc9f26f665ab3e 100644 --- a/homeassistant/components/yeelight/translations/es.json +++ b/homeassistant/components/yeelight/translations/es.json @@ -21,7 +21,7 @@ "data": { "host": "Host" }, - "description": "Si dejas la direcci\u00f3n IP vac\u00eda, se usar\u00e1 descubrimiento para encontrar dispositivos." + "description": "Si dejas el host vac\u00edo, se usar\u00e1 descubrimiento para encontrar dispositivos." } } }, diff --git a/homeassistant/components/yeelight/translations/it.json b/homeassistant/components/yeelight/translations/it.json index ce34523bb615df..7022a016ce8bad 100644 --- a/homeassistant/components/yeelight/translations/it.json +++ b/homeassistant/components/yeelight/translations/it.json @@ -21,7 +21,7 @@ "data": { "host": "Host" }, - "description": "Se lasci l'host vuoto, il rilevamento verr\u00e0 utilizzato per trovare i dispositivi." + "description": "Se lasci l'host vuoto, il rilevamento sar\u00e0 utilizzato per trovare i dispositivi." } } }, @@ -35,7 +35,7 @@ "transition": "Tempo di transizione (ms)", "use_music_mode": "Abilita la modalit\u00e0 musica" }, - "description": "Se lasci il modello vuoto, verr\u00e0 rilevato automaticamente." + "description": "Se lasci il modello vuoto, sar\u00e0 rilevato automaticamente." } } } diff --git a/homeassistant/components/yeelight/translations/zh-Hans.json b/homeassistant/components/yeelight/translations/zh-Hans.json index 43fb1d9fe25e90..36add653d1de8d 100644 --- a/homeassistant/components/yeelight/translations/zh-Hans.json +++ b/homeassistant/components/yeelight/translations/zh-Hans.json @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "\u578b\u53f7\uff08\u53ef\u9009\uff09", + "model": "\u578b\u53f7", "nightlight_switch": "\u4f7f\u7528\u591c\u5149\u5f00\u5173", "save_on_change": "\u4fdd\u5b58\u66f4\u6539\u72b6\u6001", "transition": "\u8fc7\u6e21\u65f6\u95f4\uff08\u6beb\u79d2\uff09", diff --git a/homeassistant/components/yeelightsunflower/light.py b/homeassistant/components/yeelightsunflower/light.py index 00ea467c0d1660..a682032124325f 100644 --- a/homeassistant/components/yeelightsunflower/light.py +++ b/homeassistant/components/yeelightsunflower/light.py @@ -1,4 +1,6 @@ """Support for Yeelight Sunflower color bulbs (not Yeelight Blue or WiFi).""" +from __future__ import annotations + import logging import voluptuous as vol @@ -13,7 +15,10 @@ LightEntity, ) from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -23,14 +28,19 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string}) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Yeelight Sunflower Light platform.""" host = config.get(CONF_HOST) hub = yeelightsunflower.Hub(host) if not hub.available: _LOGGER.error("Could not connect to Yeelight Sunflower hub") - return False + return add_entities(SunflowerBulb(light) for light in hub.get_lights()) diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index 91dfaab38bf2b6..0537c268aa42d4 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -9,7 +9,7 @@ from homeassistant.components import ffmpeg from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -18,9 +18,12 @@ CONF_PORT, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -46,7 +49,12 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up a Yi Camera.""" async_add_entities([YiCamera(hass, config)], True) @@ -60,7 +68,7 @@ def __init__(self, hass, config): self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS) self._last_image = None self._last_url = None - self._manager = hass.data[DATA_FFMPEG] + self._manager = get_ffmpeg_manager(hass) self._name = config[CONF_NAME] self._is_on = True self.host = config[CONF_HOST] diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index 0980e4510287e2..3339cdccd36d64 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -6,14 +6,14 @@ from youless_api import YoulessAPI from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -PLATFORMS = ["sensor"] +PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 355d4f831270ae..19e9c635dce017 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -4,18 +4,13 @@ from youless_api.youless_sensor import YoulessSensor from homeassistant.components.sensor import ( - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, + SensorStateClass, ) -from homeassistant.components.youless import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_GAS, - DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, POWER_WATT, VOLUME_CUBIC_METERS, @@ -29,6 +24,8 @@ DataUpdateCoordinator, ) +from . import DOMAIN + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -42,9 +39,13 @@ async def async_setup_entry( async_add_entities( [ GasSensor(coordinator, device), - PowerMeterSensor(coordinator, device, "low", STATE_CLASS_TOTAL_INCREASING), - PowerMeterSensor(coordinator, device, "high", STATE_CLASS_TOTAL_INCREASING), - PowerMeterSensor(coordinator, device, "total", STATE_CLASS_TOTAL), + PowerMeterSensor( + coordinator, device, "low", SensorStateClass.TOTAL_INCREASING + ), + PowerMeterSensor( + coordinator, device, "high", SensorStateClass.TOTAL_INCREASING + ), + PowerMeterSensor(coordinator, device, "total", SensorStateClass.TOTAL), CurrentPowerSensor(coordinator, device), DeliveryMeterSensor(coordinator, device, "low"), DeliveryMeterSensor(coordinator, device, "high"), @@ -102,8 +103,8 @@ class GasSensor(YoulessBaseSensor): """The Youless gas sensor.""" _attr_native_unit_of_measurement = VOLUME_CUBIC_METERS - _attr_device_class = DEVICE_CLASS_GAS - _attr_state_class = STATE_CLASS_TOTAL_INCREASING + _attr_device_class = SensorDeviceClass.GAS + _attr_state_class = SensorStateClass.TOTAL_INCREASING def __init__(self, coordinator: DataUpdateCoordinator, device: str) -> None: """Instantiate a gas sensor.""" @@ -121,8 +122,8 @@ class CurrentPowerSensor(YoulessBaseSensor): """The current power usage sensor.""" _attr_native_unit_of_measurement = POWER_WATT - _attr_device_class = DEVICE_CLASS_POWER - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, coordinator: DataUpdateCoordinator, device: str) -> None: """Instantiate the usage meter.""" @@ -140,8 +141,8 @@ class DeliveryMeterSensor(YoulessBaseSensor): """The Youless delivery meter value sensor.""" _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - _attr_device_class = DEVICE_CLASS_ENERGY - _attr_state_class = STATE_CLASS_TOTAL_INCREASING + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING def __init__( self, coordinator: DataUpdateCoordinator, device: str, dev_type: str @@ -166,15 +167,15 @@ class PowerMeterSensor(YoulessBaseSensor): """The Youless low meter value sensor.""" _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - _attr_device_class = DEVICE_CLASS_ENERGY - _attr_state_class = STATE_CLASS_TOTAL_INCREASING + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING def __init__( self, coordinator: DataUpdateCoordinator, device: str, dev_type: str, - state_class: str, + state_class: SensorStateClass, ) -> None: """Instantiate a power meter sensor.""" super().__init__( @@ -198,8 +199,8 @@ class ExtraMeterSensor(YoulessBaseSensor): """The Youless extra meter value sensor (s0).""" _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - _attr_device_class = DEVICE_CLASS_ENERGY - _attr_state_class = STATE_CLASS_TOTAL_INCREASING + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING def __init__( self, coordinator: DataUpdateCoordinator, device: str, dev_type: str @@ -224,8 +225,8 @@ class ExtraMeterPowerSensor(YoulessBaseSensor): """The Youless extra meter power value sensor (s0).""" _attr_native_unit_of_measurement = POWER_WATT - _attr_device_class = DEVICE_CLASS_POWER - _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT def __init__( self, coordinator: DataUpdateCoordinator, device: str, dev_type: str diff --git a/homeassistant/components/youless/translations/tr.json b/homeassistant/components/youless/translations/tr.json index afa9c9323f2f55..fcb766d8e9f650 100644 --- a/homeassistant/components/youless/translations/tr.json +++ b/homeassistant/components/youless/translations/tr.json @@ -6,7 +6,7 @@ "step": { "user": { "data": { - "host": "Ana bilgisayar", + "host": "Sunucu", "name": "Ad" } } diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 82807c4aceefd0..21c3edd56bf74a 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -23,13 +23,14 @@ STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import event as event_helper, state as state_helper import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, ) +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -65,7 +66,7 @@ ) -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Zabbix component.""" conf = config[DOMAIN] diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index 36207842285df1..6d1b0b186d1e50 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -1,4 +1,6 @@ """Support for Zabbix sensors.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -6,7 +8,10 @@ from homeassistant.components import zabbix from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -30,13 +35,18 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Zabbix sensor platform.""" - sensors = [] + sensors: list[ZabbixTriggerCountSensor] = [] if not (zapi := hass.data[zabbix.DOMAIN]): _LOGGER.error("Zabbix integration hasn't been loaded? zapi is None") - return False + return _LOGGER.info("Connected to Zabbix API Version %s", zapi.api_version()) @@ -51,27 +61,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if not hostids: # We need hostids _LOGGER.error("If using 'individual', must specify hostids") - return False + return for hostid in hostids: _LOGGER.debug("Creating Zabbix Sensor: %s", str(hostid)) - sensor = ZabbixSingleHostTriggerCountSensor(zapi, [hostid], name) - sensors.append(sensor) + sensors.append(ZabbixSingleHostTriggerCountSensor(zapi, [hostid], name)) else: if not hostids: # Single sensor that provides the total count of triggers. _LOGGER.debug("Creating Zabbix Sensor") - sensor = ZabbixTriggerCountSensor(zapi, name) + sensors.append(ZabbixTriggerCountSensor(zapi, name)) else: # Single sensor that sums total issues for all hosts _LOGGER.debug("Creating Zabbix Sensor group: %s", str(hostids)) - sensor = ZabbixMultipleHostTriggerCountSensor(zapi, hostids, name) - sensors.append(sensor) + sensors.append( + ZabbixMultipleHostTriggerCountSensor(zapi, hostids, name) + ) + else: # Single sensor that provides the total count of triggers. _LOGGER.debug("Creating Zabbix Sensor") - sensor = ZabbixTriggerCountSensor(zapi) - sensors.append(sensor) + sensors.append(ZabbixTriggerCountSensor(zapi)) add_entities(sensors) diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 054646800a9132..87a1175b7cd064 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -8,13 +8,17 @@ import json import logging import os -from typing import Type, Union +from typing import Union from aiohttp.hdrs import USER_AGENT import requests import voluptuous as vol -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( AREA_SQUARE_METERS, ATTR_ATTRIBUTION, @@ -23,7 +27,6 @@ CONF_MONITORED_CONDITIONS, CONF_NAME, DEGREE, - DEVICE_CLASS_TEMPERATURE, LENGTH_METERS, PERCENTAGE, PRESSURE_HPA, @@ -31,7 +34,10 @@ TEMP_CELSIUS, __version__, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -47,7 +53,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") -DTypeT = Union[Type[int], Type[float], Type[str]] +DTypeT = Union[type[int], type[float], type[str]] @dataclass @@ -124,7 +130,7 @@ class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin key="temperature", name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, col_heading=f"T {TEMP_CELSIUS}", dtype=float, ), @@ -139,7 +145,7 @@ class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin key="dewpoint", name="Dew Point", native_unit_of_measurement=TEMP_CELSIUS, - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, col_heading=f"TP {TEMP_CELSIUS}", dtype=float, ), @@ -195,7 +201,12 @@ class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the ZAMG sensor platform.""" name = config[CONF_NAME] latitude = config.get(CONF_LATITUDE, hass.config.latitude) @@ -210,14 +221,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): CONF_STATION_ID, station_id, ) - return False + return probe = ZamgData(station_id=station_id) try: probe.update() except (ValueError, TypeError) as err: _LOGGER.error("Received error from ZAMG: %s", err) - return False + return monitored_conditions = config[CONF_MONITORED_CONDITIONS] add_entities( diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index c1a0ab62cc52ea..6a5d7ccdf81701 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -1,4 +1,6 @@ """Sensor for data from Austrian Zentralanstalt für Meteorologie.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -13,7 +15,10 @@ WeatherEntity, ) from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType # Reuse data and API logic from the sensor implementation from .sensor import ( @@ -40,7 +45,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the ZAMG weather platform.""" name = config.get(CONF_NAME) latitude = config.get(CONF_LATITUDE, hass.config.latitude) @@ -55,14 +65,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): CONF_STATION_ID, station_id, ) - return False + return probe = ZamgData(station_id=station_id) try: probe.update() except (ValueError, TypeError) as err: _LOGGER.error("Received error from ZAMG: %s", err) - return False + return add_entities([ZamgWeather(probe, name)], True) diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py index 30776eabbb3625..0a4392ff8556b0 100644 --- a/homeassistant/components/zengge/light.py +++ b/homeassistant/components/zengge/light.py @@ -1,4 +1,6 @@ """Support for Zengge lights.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -15,7 +17,10 @@ LightEntity, ) from homeassistant.const import CONF_DEVICES, CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -29,7 +34,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Zengge platform.""" lights = [] for address, device_config in config[CONF_DEVICES].items(): diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 50db346451feb7..1dc70cde610d26 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import contextlib from contextlib import suppress from dataclasses import dataclass import fnmatch @@ -32,7 +33,13 @@ from homeassistant.helpers.frame import report from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass +from homeassistant.loader import ( + Integration, + async_get_homekit, + async_get_integration, + async_get_zeroconf, + bind_hass, +) from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf from .usage import install_multiple_zeroconf_catcher @@ -48,17 +55,9 @@ "_hap._udp.local.", ] -# Keys we support matching against in properties that are always matched in -# upper case. ex: ZeroconfServiceInfo.properties["macaddress"] -UPPER_MATCH_PROPS = {"macaddress"} -# Keys we support matching against in properties that are always matched in -# lower case. ex: ZeroconfServiceInfo.properties["model"] -LOWER_MATCH_PROPS = {"manufacturer", "model"} # Top level keys we support matching against in properties that are always matched in # lower case. ex: ZeroconfServiceInfo.name LOWER_MATCH_ATTRS = {"name"} -# Everything we support matching -ALL_MATCHERS = UPPER_MATCH_PROPS | LOWER_MATCH_PROPS | LOWER_MATCH_ATTRS CONF_DEFAULT_INTERFACE = "default_interface" CONF_IPV6 = "ipv6" @@ -75,10 +74,11 @@ # Dns label max length MAX_NAME_LEN = 63 +ATTR_PROPERTIES: Final = "properties" + # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] ATTR_PROPERTIES_ID: Final = "id" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( @@ -107,22 +107,18 @@ class ZeroconfServiceInfo(BaseServiceInfo): name: str properties: dict[str, Any] - # Used to prevent log flooding. To be removed in 2022.6 - _warning_logged: bool = False - def __getitem__(self, name: str) -> Any: """ Enable method for compatibility reason. Deprecated, and will be removed in version 2022.6. """ - if not self._warning_logged: - report( - f"accessed discovery_info['{name}'] instead of discovery_info.{name}; this will fail in version 2022.6", - exclude_integrations={DOMAIN}, - error_if_core=False, - ) - self._warning_logged = True + report( + f"accessed discovery_info['{name}'] instead of discovery_info.{name}; " + "this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) return getattr(self, name) def get(self, name: str, default: Any = None) -> Any: @@ -131,13 +127,12 @@ def get(self, name: str, default: Any = None) -> Any: Deprecated, and will be removed in version 2022.6. """ - if not self._warning_logged: - report( - f"accessed discovery_info.get('{name}') instead of discovery_info.{name}; this will fail in version 2022.6", - exclude_integrations={DOMAIN}, - error_if_core=False, - ) - self._warning_logged = True + report( + f"accessed discovery_info.get('{name}') instead of discovery_info.{name}; " + "this will fail in version 2022.6", + exclude_integrations={DOMAIN}, + error_if_core=False, + ) if hasattr(self, name): return getattr(self, name) return default @@ -326,18 +321,42 @@ async def _async_register_hass_zc_service( await aio_zc.async_register_service(info, allow_name_change=True) -def _match_against_data(matcher: dict[str, str], match_data: dict[str, str]) -> bool: +def _match_against_data( + matcher: dict[str, str | dict[str, str]], match_data: dict[str, str] +) -> bool: """Check a matcher to ensure all values in match_data match.""" + for key in LOWER_MATCH_ATTRS: + if key not in matcher: + continue + if key not in match_data: + return False + match_val = matcher[key] + assert isinstance(match_val, str) + if not fnmatch.fnmatch(match_data[key], match_val): + return False + return True + + +def _match_against_props(matcher: dict[str, str], props: dict[str, str]) -> bool: + """Check a matcher to ensure all values in props.""" return not any( key - for key in ALL_MATCHERS - if key in matcher - and ( - key not in match_data or not fnmatch.fnmatch(match_data[key], matcher[key]) - ) + for key in matcher + if key not in props or not fnmatch.fnmatch(props[key].lower(), matcher[key]) ) +def is_homekit_paired(props: dict[str, Any]) -> bool: + """Check properties to see if a device is homekit paired.""" + if HOMEKIT_PAIRED_STATUS_FLAG not in props: + return False + with contextlib.suppress(ValueError): + # 0 means paired and not discoverable by iOS clients) + return int(props[HOMEKIT_PAIRED_STATUS_FLAG]) == 0 + # If we cannot tell, we assume its not paired + return False + + class ZeroconfDiscovery: """Discovery via zeroconf.""" @@ -345,7 +364,7 @@ def __init__( self, hass: HomeAssistant, zeroconf: HaZeroconf, - zeroconf_types: dict[str, list[dict[str, str]]], + zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]], homekit_models: dict[str, str], ipv6: bool, ) -> None: @@ -415,11 +434,12 @@ async def _process_service_update( props: dict[str, str] = info.properties # If we can handle it as a HomeKit discovery, we do that here. - if service_type in HOMEKIT_TYPES: - if domain := async_get_homekit_discovery_domain(self.homekit_models, props): - discovery_flow.async_create_flow( - self.hass, domain, {"source": config_entries.SOURCE_HOMEKIT}, info - ) + if service_type in HOMEKIT_TYPES and ( + domain := async_get_homekit_discovery_domain(self.homekit_models, props) + ): + discovery_flow.async_create_flow( + self.hass, domain, {"source": config_entries.SOURCE_HOMEKIT}, info + ) # Continue on here as homekit_controller # still needs to get updates on devices # so it can see when the 'c#' field is updated. @@ -427,36 +447,43 @@ async def _process_service_update( # We only send updates to homekit_controller # if the device is already paired in order to avoid # offering a second discovery for the same device - if domain and HOMEKIT_PAIRED_STATUS_FLAG in props: - try: - # 0 means paired and not discoverable by iOS clients) - if int(props[HOMEKIT_PAIRED_STATUS_FLAG]): - return - except ValueError: - # HomeKit pairing status unknown - # likely bad homekit data + if not is_homekit_paired(props): + integration: Integration = await async_get_integration( + self.hass, domain + ) + # Since we prefer local control, if the integration that is being discovered + # is cloud AND the homekit device is UNPAIRED we still want to discovery it. + # + # As soon as the device becomes paired, the config flow will be dismissed + # in the event the user does not want to pair with Home Assistant. + # + if not integration.iot_class or not integration.iot_class.startswith( + "cloud" + ): return match_data: dict[str, str] = {} for key in LOWER_MATCH_ATTRS: attr_value: str = getattr(info, key) match_data[key] = attr_value.lower() - for key in UPPER_MATCH_PROPS: - if key in props: - match_data[key] = props[key].upper() - for key in LOWER_MATCH_PROPS: - if key in props: - match_data[key] = props[key].lower() # Not all homekit types are currently used for discovery # so not all service type exist in zeroconf_types for matcher in self.zeroconf_types.get(service_type, []): - if len(matcher) > 1 and not _match_against_data(matcher, match_data): - continue - + if len(matcher) > 1: + if not _match_against_data(matcher, match_data): + continue + if ATTR_PROPERTIES in matcher: + matcher_props = matcher[ATTR_PROPERTIES] + assert isinstance(matcher_props, dict) + if not _match_against_props(matcher_props, props): + continue + + matcher_domain = matcher["domain"] + assert isinstance(matcher_domain, str) discovery_flow.async_create_flow( self.hass, - matcher["domain"], + matcher_domain, {"source": config_entries.SOURCE_ZEROCONF}, info, ) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 16a8a8ff26e157..6f6a56774d7357 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.38.1"], + "requirements": ["zeroconf==0.38.3"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/components/zerproc/__init__.py b/homeassistant/components/zerproc/__init__.py index ec48fb90345f53..e8cc6962a0bd59 100644 --- a/homeassistant/components/zerproc/__init__.py +++ b/homeassistant/components/zerproc/__init__.py @@ -1,14 +1,15 @@ """Zerproc lights integration.""" - from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN -PLATFORMS = ["light"] +PLATFORMS = [Platform.LIGHT] -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Zerproc platform.""" hass.async_create_task( hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}) diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 36e13a780d9668..3c6b7c7186de70 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -1,4 +1,6 @@ """Support for zestimate data from zillow.com.""" +from __future__ import annotations + from datetime import timedelta import logging @@ -8,7 +10,10 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) _RESOURCE = "http://www.zillow.com/webservice/GetZestimate.htm" @@ -40,7 +45,12 @@ SCAN_INTERVAL = timedelta(minutes=30) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Zestimate sensor.""" name = config.get(CONF_NAME) properties = config[CONF_ZPID] diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index d6578be775f49c..1d5656a1b8d094 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -1,5 +1,4 @@ """Support for Zigbee Home Automation devices.""" - import asyncio import logging @@ -7,11 +6,13 @@ from zhaquirks import setup as setup_quirks from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH -from homeassistant import config_entries, const as ha_const +from homeassistant import const as ha_const +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from . import api from .core import ZHAGateway @@ -27,7 +28,6 @@ CONF_ZIGPY, DATA_ZHA, DATA_ZHA_CONFIG, - DATA_ZHA_DISPATCHERS, DATA_ZHA_GATEWAY, DATA_ZHA_PLATFORM_LOADED, DATA_ZHA_SHUTDOWN_TASK, @@ -72,7 +72,7 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up ZHA from config.""" hass.data[DATA_ZHA] = {} @@ -83,7 +83,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up ZHA. Will automatically load components to support devices found on the network. @@ -101,7 +101,6 @@ async def async_setup_entry(hass, config_entry): zha_gateway = ZHAGateway(hass, config, config_entry) await zha_gateway.async_initialize() - zha_data[DATA_ZHA_DISPATCHERS] = [] zha_data[DATA_ZHA_PLATFORM_LOADED] = [] for platform in PLATFORMS: coro = hass.config_entries.async_forward_entry_setup(config_entry, platform) @@ -131,7 +130,7 @@ async def async_zha_shutdown(event): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].shutdown() await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].async_update_device_storage() @@ -139,10 +138,6 @@ async def async_unload_entry(hass, config_entry): GROUP_PROBE.cleanup() api.async_unload_api(hass) - dispatchers = hass.data[DATA_ZHA].get(DATA_ZHA_DISPATCHERS, []) - for unsub_dispatcher in dispatchers: - unsub_dispatcher() - # our components don't have unload methods so no need to look at return values await asyncio.gather( *( @@ -167,9 +162,7 @@ async def async_load_entities(hass: HomeAssistant) -> None: async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) -async def async_migrate_entry( - hass: HomeAssistant, config_entry: config_entries.ConfigEntry -): +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 1ba5f1b73f872c..17dc47ebefa246 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -4,7 +4,6 @@ from zigpy.zcl.clusters.security import IasAce from homeassistant.components.alarm_control_panel import ( - DOMAIN, FORMAT_TEXT, SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, @@ -12,16 +11,18 @@ SUPPORT_ALARM_TRIGGER, AlarmControlPanelEntity, ) -from homeassistant.components.zha.core.typing import ZhaDeviceType +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, + Platform, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.channels.security import ( @@ -35,15 +36,17 @@ CONF_ALARM_FAILED_TRIES, CONF_ALARM_MASTER_CODE, DATA_ZHA, - DATA_ZHA_DISPATCHERS, SIGNAL_ADD_ENTITIES, ZHA_ALARM_OPTIONS, ) from .core.helpers import async_get_zha_config_value from .core.registries import ZHA_ENTITIES +from .core.typing import ZhaDeviceType from .entity import ZhaEntity -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +STRICT_MATCH = functools.partial( + ZHA_ENTITIES.strict_match, Platform.ALARM_CONTROL_PANEL +) IAS_ACE_STATE_MAP = { IasAce.PanelStatus.Panel_Disarmed: STATE_ALARM_DISARMED, @@ -54,9 +57,13 @@ } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Zigbee Home Automation alarm control panel from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.ALARM_CONTROL_PANEL] unsub = async_dispatcher_connect( hass, @@ -65,7 +72,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): discovery.async_add_entities, async_add_entities, entities_to_create ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) @STRICT_MATCH(channel_names=CHANNEL_IAS_ACE) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 48e70c86c1f6a2..86dad9d6bd0b36 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -14,7 +14,7 @@ from homeassistant.components import websocket_api from homeassistant.const import ATTR_COMMAND, ATTR_NAME -from homeassistant.core import callback +from homeassistant.core import ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -945,7 +945,7 @@ def async_load_api(hass): zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] application_controller = zha_gateway.application_controller - async def permit(service): + async def permit(service: ServiceCall) -> None: """Allow devices to join this network.""" duration = service.data[ATTR_DURATION] ieee = service.data.get(ATTR_IEEE) @@ -976,7 +976,7 @@ async def permit(service): DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] ) - async def remove(service): + async def remove(service: ServiceCall) -> None: """Remove a node from the network.""" ieee = service.data[ATTR_IEEE] zha_gateway: ZhaGatewayType = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] @@ -994,7 +994,7 @@ async def remove(service): DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] ) - async def set_zigbee_cluster_attributes(service): + async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: """Set zigbee attribute for cluster on zha entity.""" ieee = service.data.get(ATTR_IEEE) endpoint_id = service.data.get(ATTR_ENDPOINT_ID) @@ -1041,7 +1041,7 @@ async def set_zigbee_cluster_attributes(service): schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], ) - async def issue_zigbee_cluster_command(service): + async def issue_zigbee_cluster_command(service: ServiceCall) -> None: """Issue command on zigbee cluster on zha entity.""" ieee = service.data.get(ATTR_IEEE) endpoint_id = service.data.get(ATTR_ENDPOINT_ID) @@ -1092,7 +1092,7 @@ async def issue_zigbee_cluster_command(service): schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], ) - async def issue_zigbee_group_command(service): + async def issue_zigbee_group_command(service: ServiceCall) -> None: """Issue command on zigbee cluster on a zigbee group.""" group_id = service.data.get(ATTR_GROUP) cluster_id = service.data.get(ATTR_CLUSTER_ID) @@ -1138,7 +1138,7 @@ def _get_ias_wd_channel(zha_device): } return cluster_channels.get(CHANNEL_IAS_WD) - async def warning_device_squawk(service): + async def warning_device_squawk(service: ServiceCall) -> None: """Issue the squawk command for an IAS warning device.""" ieee = service.data[ATTR_IEEE] mode = service.data.get(ATTR_WARNING_DEVICE_MODE) @@ -1177,7 +1177,7 @@ async def warning_device_squawk(service): schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], ) - async def warning_device_warn(service): + async def warning_device_warn(service: ServiceCall) -> None: """Issue the warning command for an IAS warning device.""" ieee = service.data[ATTR_IEEE] mode = service.data.get(ATTR_WARNING_DEVICE_MODE) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index e6f03a8a848ed9..730748d74aeb5d 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,20 +2,14 @@ import functools from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_GAS, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_MOVING, - DEVICE_CLASS_OCCUPANCY, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_VIBRATION, - DOMAIN, + BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.const import STATE_ON -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( @@ -25,7 +19,6 @@ CHANNEL_ON_OFF, CHANNEL_ZONE, DATA_ZHA, - DATA_ZHA_DISPATCHERS, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -34,20 +27,25 @@ # Zigbee Cluster Library Zone Type to Home Assistant device class CLASS_MAPPING = { - 0x000D: DEVICE_CLASS_MOTION, - 0x0015: DEVICE_CLASS_OPENING, - 0x0028: DEVICE_CLASS_SMOKE, - 0x002A: DEVICE_CLASS_MOISTURE, - 0x002B: DEVICE_CLASS_GAS, - 0x002D: DEVICE_CLASS_VIBRATION, + 0x000D: BinarySensorDeviceClass.MOTION, + 0x0015: BinarySensorDeviceClass.OPENING, + 0x0028: BinarySensorDeviceClass.SMOKE, + 0x002A: BinarySensorDeviceClass.MOISTURE, + 0x002B: BinarySensorDeviceClass.GAS, + 0x002D: BinarySensorDeviceClass.VIBRATION, } -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.BINARY_SENSOR) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BINARY_SENSOR) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Zigbee Home Automation binary sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.BINARY_SENSOR] unsub = async_dispatcher_connect( hass, @@ -56,20 +54,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): discovery.async_add_entities, async_add_entities, entities_to_create ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) class BinarySensor(ZhaEntity, BinarySensorEntity): """ZHA BinarySensor.""" SENSOR_ATTR = None - DEVICE_CLASS = None def __init__(self, unique_id, zha_device, channels, **kwargs): """Initialize the ZHA binary sensor.""" super().__init__(unique_id, zha_device, channels, **kwargs) self._channel = channels[0] - self._device_class = self.DEVICE_CLASS async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -91,11 +87,6 @@ def is_on(self) -> bool: return False return self._state - @property - def device_class(self) -> str: - """Return device class from component DEVICE_CLASSES.""" - return self._device_class - @callback def async_set_state(self, attr_id, attr_name, value): """Set the state.""" @@ -113,20 +104,20 @@ async def async_update(self): self._state = attr_value -@STRICT_MATCH(channel_names=CHANNEL_ACCELEROMETER) +@MULTI_MATCH(channel_names=CHANNEL_ACCELEROMETER) class Accelerometer(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "acceleration" - DEVICE_CLASS = DEVICE_CLASS_MOVING + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOVING -@STRICT_MATCH(channel_names=CHANNEL_OCCUPANCY) +@MULTI_MATCH(channel_names=CHANNEL_OCCUPANCY) class Occupancy(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "occupancy" - DEVICE_CLASS = DEVICE_CLASS_OCCUPANCY + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY @STRICT_MATCH(channel_names=CHANNEL_ON_OFF) @@ -134,10 +125,10 @@ class Opening(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "on_off" - DEVICE_CLASS = DEVICE_CLASS_OPENING + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING -@STRICT_MATCH(channel_names=CHANNEL_BINARY_INPUT) +@MULTI_MATCH(channel_names=CHANNEL_BINARY_INPUT) class BinaryInput(BinarySensor): """ZHA BinarySensor.""" @@ -160,10 +151,10 @@ class Motion(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "on_off" - DEVICE_CLASS = DEVICE_CLASS_MOTION + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOTION -@STRICT_MATCH(channel_names=CHANNEL_ZONE) +@MULTI_MATCH(channel_names=CHANNEL_ZONE) class IASZone(BinarySensor): """ZHA IAS BinarySensor.""" diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py new file mode 100644 index 00000000000000..90148ba42f39f5 --- /dev/null +++ b/homeassistant/components/zha/button.py @@ -0,0 +1,105 @@ +"""Support for ZHA button.""" +from __future__ import annotations + +import abc +import functools +import logging +from typing import Any + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .core import discovery +from .core.const import CHANNEL_IDENTIFY, DATA_ZHA, SIGNAL_ADD_ENTITIES +from .core.registries import ZHA_ENTITIES +from .core.typing import ChannelType, ZhaDeviceType +from .entity import ZhaEntity + +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BUTTON) +DEFAULT_DURATION = 5 # seconds + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation button from config entry.""" + entities_to_create = hass.data[DATA_ZHA][Platform.BUTTON] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + update_before_add=False, + ), + ) + config_entry.async_on_unload(unsub) + + +class ZHAButton(ZhaEntity, ButtonEntity): + """Defines a ZHA button.""" + + _command_name: str = None + + def __init__( + self, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> None: + """Init this button.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._channel: ChannelType = channels[0] + + @abc.abstractmethod + def get_args(self) -> list[Any]: + """Return the arguments to use in the command.""" + + async def async_press(self) -> None: + """Send out a update command.""" + command = getattr(self._channel, self._command_name) + arguments = self.get_args() + await command(*arguments) + + +@MULTI_MATCH(channel_names=CHANNEL_IDENTIFY) +class ZHAIdentifyButton(ZHAButton): + """Defines a ZHA identify button.""" + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> ZhaEntity | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + if ZHA_ENTITIES.prevent_entity_creation( + Platform.BUTTON, zha_device.ieee, CHANNEL_IDENTIFY + ): + return None + return cls(unique_id, zha_device, channels, **kwargs) + + _attr_device_class: ButtonDeviceClass = ButtonDeviceClass.UPDATE + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _command_name = "identify" + + def get_args(self) -> list[Any]: + """Return the arguments to use in the command.""" + + return [DEFAULT_DURATION] diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index d57fb21b4a3d1b..65de5fd04cc796 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -7,10 +7,11 @@ from __future__ import annotations from datetime import datetime, timedelta -import enum import functools from random import randint +from zigpy.zcl.clusters.hvac import Fan as F, Thermostat as T + from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( ATTR_HVAC_MODE, @@ -21,7 +22,6 @@ CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, - DOMAIN, FAN_AUTO, FAN_ON, HVAC_MODE_COOL, @@ -40,9 +40,16 @@ SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) -from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, TEMP_CELSIUS -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_TENTHS, + TEMP_CELSIUS, + Platform, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.dt as dt_util @@ -51,9 +58,9 @@ CHANNEL_FAN, CHANNEL_THERMOSTAT, DATA_ZHA, - DATA_ZHA_DISPATCHERS, PRESET_COMPLEX, PRESET_SCHEDULE, + PRESET_TEMP_MANUAL, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -73,31 +80,10 @@ ATTR_UNOCCP_COOL_SETPT = "unoccupied_cooling_setpoint" -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) -MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, DOMAIN) +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.CLIMATE) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.CLIMATE) RUNNING_MODE = {0x00: HVAC_MODE_OFF, 0x03: HVAC_MODE_COOL, 0x04: HVAC_MODE_HEAT} - -class ThermostatFanMode(enum.IntEnum): - """Fan channel enum for thermostat Fans.""" - - OFF = 0x00 - ON = 0x04 - AUTO = 0x05 - - -class RunningState(enum.IntFlag): - """ZCL Running state enum.""" - - HEAT = 0x0001 - COOL = 0x0002 - FAN = 0x0004 - HEAT_STAGE_2 = 0x0008 - COOL_STAGE_2 = 0x0010 - FAN_STAGE_2 = 0x0020 - FAN_STAGE_3 = 0x0040 - - SEQ_OF_OPERATION = { 0x00: (HVAC_MODE_OFF, HVAC_MODE_COOL), # cooling only 0x01: (HVAC_MODE_OFF, HVAC_MODE_COOL), # cooling with reheat @@ -111,48 +97,37 @@ class RunningState(enum.IntFlag): 0x07: (HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF), # centralite specific } - -class SystemMode(enum.IntEnum): - """ZCL System Mode attribute enum.""" - - OFF = 0x00 - HEAT_COOL = 0x01 - COOL = 0x03 - HEAT = 0x04 - AUX_HEAT = 0x05 - PRE_COOL = 0x06 - FAN_ONLY = 0x07 - DRY = 0x08 - SLEEP = 0x09 - - HVAC_MODE_2_SYSTEM = { - HVAC_MODE_OFF: SystemMode.OFF, - HVAC_MODE_HEAT_COOL: SystemMode.HEAT_COOL, - HVAC_MODE_COOL: SystemMode.COOL, - HVAC_MODE_HEAT: SystemMode.HEAT, - HVAC_MODE_FAN_ONLY: SystemMode.FAN_ONLY, - HVAC_MODE_DRY: SystemMode.DRY, + HVAC_MODE_OFF: T.SystemMode.Off, + HVAC_MODE_HEAT_COOL: T.SystemMode.Auto, + HVAC_MODE_COOL: T.SystemMode.Cool, + HVAC_MODE_HEAT: T.SystemMode.Heat, + HVAC_MODE_FAN_ONLY: T.SystemMode.Fan_only, + HVAC_MODE_DRY: T.SystemMode.Dry, } SYSTEM_MODE_2_HVAC = { - SystemMode.OFF: HVAC_MODE_OFF, - SystemMode.HEAT_COOL: HVAC_MODE_HEAT_COOL, - SystemMode.COOL: HVAC_MODE_COOL, - SystemMode.HEAT: HVAC_MODE_HEAT, - SystemMode.AUX_HEAT: HVAC_MODE_HEAT, - SystemMode.PRE_COOL: HVAC_MODE_COOL, # this is 'precooling'. is it the same? - SystemMode.FAN_ONLY: HVAC_MODE_FAN_ONLY, - SystemMode.DRY: HVAC_MODE_DRY, - SystemMode.SLEEP: HVAC_MODE_OFF, + T.SystemMode.Off: HVAC_MODE_OFF, + T.SystemMode.Auto: HVAC_MODE_HEAT_COOL, + T.SystemMode.Cool: HVAC_MODE_COOL, + T.SystemMode.Heat: HVAC_MODE_HEAT, + T.SystemMode.Emergency_Heating: HVAC_MODE_HEAT, + T.SystemMode.Pre_cooling: HVAC_MODE_COOL, # this is 'precooling'. is it the same? + T.SystemMode.Fan_only: HVAC_MODE_FAN_ONLY, + T.SystemMode.Dry: HVAC_MODE_DRY, + T.SystemMode.Sleep: HVAC_MODE_OFF, } ZCL_TEMP = 100 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.CLIMATE] unsub = async_dispatcher_connect( hass, SIGNAL_ADD_ENTITIES, @@ -160,10 +135,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): discovery.async_add_entities, async_add_entities, entities_to_create ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) -@MULTI_MATCH(channel_names=CHANNEL_THERMOSTAT, aux_channels=CHANNEL_FAN) +@MULTI_MATCH( + channel_names=CHANNEL_THERMOSTAT, + aux_channels=CHANNEL_FAN, + stop_on_match_group=CHANNEL_THERMOSTAT, +) class Thermostat(ZhaEntity, ClimateEntity): """Representation of a ZHA Thermostat device.""" @@ -220,7 +199,9 @@ def fan_mode(self) -> str | None: return FAN_AUTO if self._thrm.running_state & ( - RunningState.FAN | RunningState.FAN_STAGE_2 | RunningState.FAN_STAGE_3 + T.RunningState.Fan_State_On + | T.RunningState.Fan_2nd_Stage_On + | T.RunningState.Fan_3rd_Stage_On ): return FAN_ON return FAN_AUTO @@ -246,18 +227,25 @@ def hvac_action(self) -> str | None: def _rm_rs_action(self) -> str | None: """Return the current HVAC action based on running mode and running state.""" - running_mode = self._thrm.running_mode - if running_mode == SystemMode.HEAT: + if (running_state := self._thrm.running_state) is None: + return None + if running_state & ( + T.RunningState.Heat_State_On | T.RunningState.Heat_2nd_Stage_On + ): return CURRENT_HVAC_HEAT - if running_mode == SystemMode.COOL: + if running_state & ( + T.RunningState.Cool_State_On | T.RunningState.Cool_2nd_Stage_On + ): return CURRENT_HVAC_COOL - - running_state = self._thrm.running_state - if running_state and running_state & ( - RunningState.FAN | RunningState.FAN_STAGE_2 | RunningState.FAN_STAGE_3 + if running_state & ( + T.RunningState.Fan_State_On + | T.RunningState.Fan_2nd_Stage_On + | T.RunningState.Fan_3rd_Stage_On ): return CURRENT_HVAC_FAN - if self.hvac_mode != HVAC_MODE_OFF and running_mode == SystemMode.OFF: + if running_state & T.RunningState.Idle: + return CURRENT_HVAC_IDLE + if self.hvac_mode != HVAC_MODE_OFF: return CURRENT_HVAC_IDLE return CURRENT_HVAC_OFF @@ -418,9 +406,9 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: return if fan_mode == FAN_ON: - mode = ThermostatFanMode.ON + mode = F.FanMode.On else: - mode = ThermostatFanMode.AUTO + mode = F.FanMode.Auto await self._fan.async_set_speed(mode) @@ -517,7 +505,7 @@ async def async_preset_handler(self, preset: str, enable: bool = False) -> bool: @MULTI_MATCH( channel_names={CHANNEL_THERMOSTAT, "sinope_manufacturer_specific"}, manufacturers="Sinope Technologies", - stop_on_match=True, + stop_on_match_group=CHANNEL_THERMOSTAT, ) class SinopeTechnologiesThermostat(Thermostat): """Sinope Technologies Thermostat.""" @@ -532,6 +520,27 @@ def __init__(self, unique_id, zha_device, channels, **kwargs): self._supported_flags |= SUPPORT_PRESET_MODE self._manufacturer_ch = self.cluster_channels["sinope_manufacturer_specific"] + @property + def _rm_rs_action(self) -> str | None: + """Return the current HVAC action based on running mode and running state.""" + + running_mode = self._thrm.running_mode + if running_mode == T.SystemMode.Heat: + return CURRENT_HVAC_HEAT + if running_mode == T.SystemMode.Cool: + return CURRENT_HVAC_COOL + + running_state = self._thrm.running_state + if running_state and running_state & ( + T.RunningState.Fan_State_On + | T.RunningState.Fan_2nd_Stage_On + | T.RunningState.Fan_3rd_Stage_On + ): + return CURRENT_HVAC_FAN + if self.hvac_mode != HVAC_MODE_OFF and running_mode == T.SystemMode.Off: + return CURRENT_HVAC_IDLE + return CURRENT_HVAC_OFF + @callback def _async_update_time(self, timestamp=None) -> None: """Update thermostat's time display.""" @@ -570,37 +579,18 @@ async def async_preset_handler_away(self, is_away: bool = False) -> bool: channel_names=CHANNEL_THERMOSTAT, aux_channels=CHANNEL_FAN, manufacturers="Zen Within", - stop_on_match=True, + stop_on_match_group=CHANNEL_THERMOSTAT, ) class ZenWithinThermostat(Thermostat): """Zen Within Thermostat implementation.""" - @property - def _rm_rs_action(self) -> str | None: - """Return the current HVAC action based on running mode and running state.""" - - if (running_state := self._thrm.running_state) is None: - return None - if running_state & (RunningState.HEAT | RunningState.HEAT_STAGE_2): - return CURRENT_HVAC_HEAT - if running_state & (RunningState.COOL | RunningState.COOL_STAGE_2): - return CURRENT_HVAC_COOL - if running_state & ( - RunningState.FAN | RunningState.FAN_STAGE_2 | RunningState.FAN_STAGE_3 - ): - return CURRENT_HVAC_FAN - - if self.hvac_mode != HVAC_MODE_OFF: - return CURRENT_HVAC_IDLE - return CURRENT_HVAC_OFF - @MULTI_MATCH( channel_names=CHANNEL_THERMOSTAT, aux_channels=CHANNEL_FAN, manufacturers="Centralite", models={"3157100", "3157100-E"}, - stop_on_match=True, + stop_on_match_group=CHANNEL_THERMOSTAT, ) class CentralitePearl(ZenWithinThermostat): """Centralite Pearl Thermostat implementation.""" @@ -612,7 +602,6 @@ class CentralitePearl(ZenWithinThermostat): "_TZE200_ckud7u2l", "_TZE200_ywdxldoj", "_TZE200_cwnjrr72", - "_TZE200_b6wax7g0", "_TZE200_2atgpdho", "_TZE200_pvvbommb", "_TZE200_4eeyebrt", @@ -696,3 +685,78 @@ async def async_preset_handler(self, preset: str, enable: bool = False) -> bool: ) return False + + +@STRICT_MATCH( + channel_names=CHANNEL_THERMOSTAT, + manufacturers={ + "_TZE200_b6wax7g0", + }, +) +class BecaThermostat(Thermostat): + """Beca Thermostat implementation.""" + + def __init__(self, unique_id, zha_device, channels, **kwargs): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._presets = [ + PRESET_NONE, + PRESET_AWAY, + PRESET_SCHEDULE, + PRESET_ECO, + PRESET_BOOST, + PRESET_TEMP_MANUAL, + ] + self._supported_flags |= SUPPORT_PRESET_MODE + + @property + def hvac_modes(self) -> tuple[str, ...]: + """Return only the heat mode, because the device can't be turned off.""" + return (HVAC_MODE_HEAT,) + + async def async_attribute_updated(self, record): + """Handle attribute update from device.""" + if record.attr_name == "operation_preset": + if record.value == 0: + self._preset = PRESET_AWAY + if record.value == 1: + self._preset = PRESET_SCHEDULE + if record.value == 2: + self._preset = PRESET_NONE + if record.value == 4: + self._preset = PRESET_ECO + if record.value == 5: + self._preset = PRESET_BOOST + if record.value == 7: + self._preset = PRESET_TEMP_MANUAL + await super().async_attribute_updated(record) + + async def async_preset_handler(self, preset: str, enable: bool = False) -> bool: + """Set the preset mode.""" + mfg_code = self._zha_device.manufacturer_code + if not enable: + return await self._thrm.write_attributes( + {"operation_preset": 2}, manufacturer=mfg_code + ) + if preset == PRESET_AWAY: + return await self._thrm.write_attributes( + {"operation_preset": 0}, manufacturer=mfg_code + ) + if preset == PRESET_SCHEDULE: + return await self._thrm.write_attributes( + {"operation_preset": 1}, manufacturer=mfg_code + ) + if preset == PRESET_ECO: + return await self._thrm.write_attributes( + {"operation_preset": 4}, manufacturer=mfg_code + ) + if preset == PRESET_BOOST: + return await self._thrm.write_attributes( + {"operation_preset": 5}, manufacturer=mfg_code + ) + if preset == PRESET_TEMP_MANUAL: + return await self._thrm.write_attributes( + {"operation_preset": 7}, manufacturer=mfg_code + ) + + return False diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 3661d3b17d95b3..d60c38c69a60b5 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from typing import Any, Dict +from typing import Any import zigpy.zcl.clusters.closures @@ -32,7 +32,7 @@ typing as zha_typing, ) -ChannelsDict = Dict[str, zha_typing.ChannelType] +ChannelsDict = dict[str, zha_typing.ChannelType] class Channels: diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index b6981b4cd74845..f79000d0646afb 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -103,7 +103,6 @@ def __init__( ) -> None: """Initialize ZigbeeChannel.""" self._generic_id = f"channel_0x{cluster.cluster_id:04x}" - self._channel_name = getattr(cluster, "ep_attribute", self._generic_id) self._ch_pool = ch_pool self._cluster = cluster self._id = f"{ch_pool.id}:0x{cluster.cluster_id:04x}" @@ -117,6 +116,7 @@ def __init__( self.value_attribute = attr self._status = ChannelStatus.CREATED self._cluster.add_listener(self) + self.data_cache = {} @property def id(self) -> str: @@ -141,7 +141,7 @@ def cluster(self): @property def name(self) -> str: """Return friendly name.""" - return self._channel_name + return self.cluster.ep_attribute or self._generic_id @property def status(self): diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index d0216436ba2f78..89d750465b8e75 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -18,6 +18,8 @@ REPORT_CONFIG_BATTERY_SAVE, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_IMMEDIATE, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, @@ -170,6 +172,13 @@ class Commissioning(ZigbeeChannel): class DeviceTemperature(ZigbeeChannel): """Device Temperature channel.""" + REPORT_CONFIG = [ + { + "attr": "current_temperature", + "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), + } + ] + @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.GreenPowerProxy.cluster_id) class GreenPowerProxy(ZigbeeChannel): diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 813f268bbe52dc..9216b6dd6006e0 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -7,23 +7,13 @@ import bellows.zigbee.application import voluptuous as vol from zigpy.config import CONF_DEVICE_PATH # noqa: F401 # pylint: disable=unused-import +import zigpy.types as t import zigpy_deconz.zigbee.application import zigpy_xbee.zigbee.application import zigpy_zigate.zigbee.application import zigpy_znp.zigbee.application -from homeassistant.components.alarm_control_panel import DOMAIN as ALARM -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR -from homeassistant.components.climate import DOMAIN as CLIMATE -from homeassistant.components.cover import DOMAIN as COVER -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER -from homeassistant.components.fan import DOMAIN as FAN -from homeassistant.components.light import DOMAIN as LIGHT -from homeassistant.components.lock import DOMAIN as LOCK -from homeassistant.components.number import DOMAIN as NUMBER -from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.components.siren import DOMAIN as SIREN -from homeassistant.components.switch import DOMAIN as SWITCH +from homeassistant.const import Platform import homeassistant.helpers.config_validation as cv from .typing import CALLABLE_T @@ -80,6 +70,7 @@ CHANNEL_BASIC = "basic" CHANNEL_COLOR = "light_color" CHANNEL_COVER = "window_covering" +CHANNEL_DEVICE_TEMPERATURE = "device_temperature" CHANNEL_DOORLOCK = "door_lock" CHANNEL_ELECTRICAL_MEASUREMENT = "electrical_measurement" CHANNEL_EVENT_RELAY = "event_relay" @@ -111,18 +102,20 @@ CLUSTER_TYPE_OUT = "out" PLATFORMS = ( - ALARM, - BINARY_SENSOR, - CLIMATE, - COVER, - DEVICE_TRACKER, - FAN, - LIGHT, - LOCK, - NUMBER, - SENSOR, - SIREN, - SWITCH, + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.DEVICE_TRACKER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, ) CONF_ALARM_MASTER_CODE = "alarm_master_code" @@ -176,7 +169,6 @@ DATA_ZHA_CONFIG = "config" 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" DATA_ZHA_SHUTDOWN_TASK = "zha_shutdown_task" @@ -220,8 +212,9 @@ POWER_MAINS_POWERED = "Mains" POWER_BATTERY_OR_UNKNOWN = "Battery or Unknown" -PRESET_SCHEDULE = "schedule" -PRESET_COMPLEX = "complex" +PRESET_SCHEDULE = "Schedule" +PRESET_COMPLEX = "Complex" +PRESET_TEMP_MANUAL = "Temporary manual" ZHA_ALARM_OPTIONS = "zha_alarm_options" ZHA_OPTIONS = "zha_options" @@ -398,3 +391,10 @@ def description(self) -> str: EFFECT_OKAY = 0x02 EFFECT_DEFAULT_VARIANT = 0x00 + + +class Strobe(t.enum8): + """Strobe enum.""" + + No_Strobe = 0x00 + Strobe = 0x01 diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 82e2b85173eb52..e8b38fb9699a0f 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -341,6 +341,9 @@ def async_update_sw_build_id(self, sw_version: int): ) async def _check_available(self, *_): + # don't flip the availability state of the coordinator + if self.is_coordinator: + return if self.last_seen is None: self.update_available(False) return diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 780d7bc384b237..26323793e1346b 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -17,6 +17,7 @@ from .. import ( # noqa: F401 pylint: disable=unused-import, alarm_control_panel, binary_sensor, + button, climate, cover, device_tracker, @@ -24,6 +25,7 @@ light, lock, number, + select, sensor, siren, switch, @@ -66,6 +68,7 @@ def discover_entities(self, channel_pool: zha_typing.ChannelPoolType) -> None: self.discover_by_device_type(channel_pool) self.discover_multi_entities(channel_pool) self.discover_by_cluster_id(channel_pool) + zha_regs.ZHA_ENTITIES.clean_up() @callback def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None: diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index f624ef9289d0d1..1480469ce2cf28 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -4,32 +4,21 @@ import collections from collections.abc import Callable import dataclasses -from typing import Dict, List import attr from zigpy import zcl import zigpy.profiles.zha import zigpy.profiles.zll +from zigpy.types.named import EUI64 -from homeassistant.components.alarm_control_panel import DOMAIN as ALARM -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR -from homeassistant.components.climate import DOMAIN as CLIMATE -from homeassistant.components.cover import DOMAIN as COVER -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER -from homeassistant.components.fan import DOMAIN as FAN -from homeassistant.components.light import DOMAIN as LIGHT -from homeassistant.components.lock import DOMAIN as LOCK -from homeassistant.components.number import DOMAIN as NUMBER -from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.components.siren import DOMAIN as SIREN -from homeassistant.components.switch import DOMAIN as SWITCH +from homeassistant.const import Platform # importing channels updates registries from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import from .decorators import CALLABLE_T, DictRegistry, SetRegistry from .typing import ChannelType -GROUP_ENTITY_DOMAINS = [LIGHT, SWITCH, FAN] +GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN] PHILLIPS_REMOTE_CLUSTER = 0xFC00 SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 @@ -64,34 +53,15 @@ 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, - VOC_LEVEL_CLUSTER: SENSOR, - zcl.clusters.closures.DoorLock.cluster_id: LOCK, - zcl.clusters.closures.WindowCovering.cluster_id: COVER, - zcl.clusters.general.BinaryInput.cluster_id: BINARY_SENSOR, - zcl.clusters.general.AnalogInput.cluster_id: SENSOR, - zcl.clusters.general.AnalogOutput.cluster_id: NUMBER, - zcl.clusters.general.MultistateInput.cluster_id: SENSOR, - zcl.clusters.general.OnOff.cluster_id: SWITCH, - zcl.clusters.general.PowerConfiguration.cluster_id: SENSOR, - zcl.clusters.hvac.Fan.cluster_id: FAN, - zcl.clusters.measurement.CarbonDioxideConcentration.cluster_id: SENSOR, - zcl.clusters.measurement.CarbonMonoxideConcentration.cluster_id: SENSOR, - zcl.clusters.measurement.FormaldehydeConcentration.cluster_id: SENSOR, - 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.SoilMoisture.cluster_id: SENSOR, - zcl.clusters.measurement.LeafWetness.cluster_id: SENSOR, - zcl.clusters.measurement.TemperatureMeasurement.cluster_id: SENSOR, - zcl.clusters.security.IasZone.cluster_id: BINARY_SENSOR, + zcl.clusters.general.AnalogOutput.cluster_id: Platform.NUMBER, + zcl.clusters.general.MultistateInput.cluster_id: Platform.SENSOR, + zcl.clusters.general.OnOff.cluster_id: Platform.SWITCH, + zcl.clusters.hvac.Fan.cluster_id: Platform.FAN, } SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = { - zcl.clusters.general.OnOff.cluster_id: BINARY_SENSOR, - zcl.clusters.security.IasAce.cluster_id: ALARM, + zcl.clusters.general.OnOff.cluster_id: Platform.BINARY_SENSOR, + zcl.clusters.security.IasAce.cluster_id: Platform.ALARM_CONTROL_PANEL, } BINDABLE_CLUSTERS = SetRegistry() @@ -99,31 +69,31 @@ DEVICE_CLASS = { zigpy.profiles.zha.PROFILE_ID: { - SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE: DEVICE_TRACKER, - zigpy.profiles.zha.DeviceType.THERMOSTAT: CLIMATE, - zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT: LIGHT, - zigpy.profiles.zha.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT, - zigpy.profiles.zha.DeviceType.DIMMABLE_BALLAST: LIGHT, - zigpy.profiles.zha.DeviceType.DIMMABLE_LIGHT: LIGHT, - zigpy.profiles.zha.DeviceType.DIMMABLE_PLUG_IN_UNIT: LIGHT, - zigpy.profiles.zha.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT, - zigpy.profiles.zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: COVER, - zigpy.profiles.zha.DeviceType.ON_OFF_BALLAST: SWITCH, - zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT: LIGHT, - zigpy.profiles.zha.DeviceType.ON_OFF_PLUG_IN_UNIT: SWITCH, - zigpy.profiles.zha.DeviceType.SHADE: COVER, - zigpy.profiles.zha.DeviceType.SMART_PLUG: SWITCH, - zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: ALARM, - zigpy.profiles.zha.DeviceType.IAS_WARNING_DEVICE: SIREN, + SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE: Platform.DEVICE_TRACKER, + zigpy.profiles.zha.DeviceType.THERMOSTAT: Platform.CLIMATE, + zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.COLOR_TEMPERATURE_LIGHT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.DIMMABLE_BALLAST: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.DIMMABLE_LIGHT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.DIMMABLE_PLUG_IN_UNIT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.EXTENDED_COLOR_LIGHT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: Platform.COVER, + zigpy.profiles.zha.DeviceType.ON_OFF_BALLAST: Platform.SWITCH, + zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT: Platform.LIGHT, + zigpy.profiles.zha.DeviceType.ON_OFF_PLUG_IN_UNIT: Platform.SWITCH, + zigpy.profiles.zha.DeviceType.SHADE: Platform.COVER, + zigpy.profiles.zha.DeviceType.SMART_PLUG: Platform.SWITCH, + zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: Platform.ALARM_CONTROL_PANEL, + zigpy.profiles.zha.DeviceType.IAS_WARNING_DEVICE: Platform.SIREN, }, zigpy.profiles.zll.PROFILE_ID: { - zigpy.profiles.zll.DeviceType.COLOR_LIGHT: LIGHT, - zigpy.profiles.zll.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT, - zigpy.profiles.zll.DeviceType.DIMMABLE_LIGHT: LIGHT, - zigpy.profiles.zll.DeviceType.DIMMABLE_PLUGIN_UNIT: LIGHT, - zigpy.profiles.zll.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT, - zigpy.profiles.zll.DeviceType.ON_OFF_LIGHT: LIGHT, - zigpy.profiles.zll.DeviceType.ON_OFF_PLUGIN_UNIT: SWITCH, + zigpy.profiles.zll.DeviceType.COLOR_LIGHT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.COLOR_TEMPERATURE_LIGHT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.DIMMABLE_LIGHT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.DIMMABLE_PLUGIN_UNIT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.EXTENDED_COLOR_LIGHT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.ON_OFF_LIGHT: Platform.LIGHT, + zigpy.profiles.zll.DeviceType.ON_OFF_PLUGIN_UNIT: Platform.SWITCH, }, } DEVICE_CLASS = collections.defaultdict(dict, DEVICE_CLASS) @@ -147,12 +117,10 @@ def set_or_callable(value): class MatchRule: """Match a ZHA Entity to a channel name or generic id.""" - channel_names: Callable | set[str] | str = attr.ib( - factory=frozenset, converter=set_or_callable - ) - generic_ids: Callable | set[str] | str = attr.ib( + channel_names: set[str] | str = attr.ib( factory=frozenset, converter=set_or_callable ) + generic_ids: set[str] | str = attr.ib(factory=frozenset, converter=set_or_callable) manufacturers: Callable | set[str] | str = attr.ib( factory=frozenset, converter=set_or_callable ) @@ -162,8 +130,6 @@ class MatchRule: aux_channels: Callable | set[str] | str = attr.ib( factory=frozenset, converter=set_or_callable ) - # for multi entities, stop further processing on a match for a component - stop_on_match: bool = attr.ib(default=False) @property def weight(self) -> int: @@ -249,21 +215,23 @@ class EntityClassAndChannels: claimed_channel: list[ChannelType] -RegistryDictType = Dict[str, Dict[MatchRule, CALLABLE_T]] -MultiRegistryDictType = Dict[str, Dict[MatchRule, List[CALLABLE_T]]] -GroupRegistryDictType = Dict[str, CALLABLE_T] - - class ZHAEntityRegistry: """Channel to ZHA Entity mapping.""" def __init__(self): """Initialize Registry instance.""" - self._strict_registry: RegistryDictType = collections.defaultdict(dict) - self._multi_entity_registry: MultiRegistryDictType = collections.defaultdict( - lambda: collections.defaultdict(list) + self._strict_registry: dict[ + str, dict[MatchRule, CALLABLE_T] + ] = collections.defaultdict(dict) + self._multi_entity_registry: dict[ + str, dict[int | str | None, dict[MatchRule, list[CALLABLE_T]]] + ] = collections.defaultdict( + lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) - self._group_registry: GroupRegistryDictType = {} + self._group_registry: dict[str, CALLABLE_T] = {} + self.single_device_matches: dict[ + Platform, dict[EUI64, list[str]] + ] = collections.defaultdict(lambda: collections.defaultdict(list)) def get_entity( self, @@ -287,23 +255,22 @@ def get_multi_entity( manufacturer: str, model: str, channels: list[ChannelType], - components: set | None = None, ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ChannelType]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) all_claimed: set[ChannelType] = set() - for component in components or self._multi_entity_registry: - matches = self._multi_entity_registry[component] - sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) - for match in sorted_matches: - if match.strict_matched(manufacturer, model, channels): - claimed = match.claim_channels(channels) - for ent_class in self._multi_entity_registry[component][match]: - ent_n_channels = EntityClassAndChannels(ent_class, claimed) - result[component].append(ent_n_channels) - all_claimed |= set(claimed) - if match.stop_on_match: - break + for component, stop_match_groups in self._multi_entity_registry.items(): + for stop_match_grp, matches in stop_match_groups.items(): + sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) + for match in sorted_matches: + if match.strict_matched(manufacturer, model, channels): + claimed = match.claim_channels(channels) + for ent_class in stop_match_groups[stop_match_grp][match]: + ent_n_channels = EntityClassAndChannels(ent_class, claimed) + result[component].append(ent_n_channels) + all_claimed |= set(claimed) + if stop_match_grp: + break return result, list(all_claimed) @@ -314,8 +281,8 @@ def get_group_entity(self, component: str) -> CALLABLE_T: def strict_match( self, component: str, - channel_names: Callable | set[str] | str = None, - generic_ids: Callable | set[str] | str = None, + channel_names: set[str] | str = None, + generic_ids: set[str] | str = None, manufacturers: Callable | set[str] | str = None, models: Callable | set[str] | str = None, aux_channels: Callable | set[str] | str = None, @@ -339,12 +306,12 @@ def decorator(zha_ent: CALLABLE_T) -> CALLABLE_T: def multipass_match( self, component: str, - channel_names: Callable | set[str] | str = None, - generic_ids: Callable | set[str] | str = None, + channel_names: set[str] | str = None, + generic_ids: set[str] | str = None, manufacturers: Callable | set[str] | str = None, models: Callable | set[str] | str = None, aux_channels: Callable | set[str] | str = None, - stop_on_match: bool = False, + stop_on_match_group: int | str | None = None, ) -> Callable[[CALLABLE_T], CALLABLE_T]: """Decorate a loose match rule.""" @@ -354,7 +321,6 @@ def multipass_match( manufacturers, models, aux_channels, - stop_on_match, ) def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T: @@ -362,7 +328,10 @@ def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T: All non empty fields of a match rule must match. """ - self._multi_entity_registry[component][rule].append(zha_entity) + # group the rules by channels + self._multi_entity_registry[component][stop_on_match_group][rule].append( + zha_entity + ) return zha_entity return decorator @@ -377,5 +346,20 @@ def decorator(zha_ent: CALLABLE_T) -> CALLABLE_T: return decorator + def prevent_entity_creation(self, platform: Platform, ieee: EUI64, key: str): + """Return True if the entity should not be created.""" + platform_restrictions = self.single_device_matches[platform] + device_restrictions = platform_restrictions[ieee] + if key in device_restrictions: + return True + device_restrictions.append(key) + return False + + def clean_up(self) -> None: + """Clean up post discovery.""" + self.single_device_matches: dict[ + Platform, dict[EUI64, list[str]] + ] = collections.defaultdict(lambda: collections.defaultdict(list)) + ZHA_ENTITIES = ZHAEntityRegistry() diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py index 62a797d9fd5cad..7e5cce8fec55a1 100644 --- a/homeassistant/components/zha/core/typing.py +++ b/homeassistant/components/zha/core/typing.py @@ -26,20 +26,17 @@ ZigpyZdoType = zigpy.zdo.ZDO if TYPE_CHECKING: - from homeassistant.components.zha.core import channels - import homeassistant.components.zha.core.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.core.group import homeassistant.components.zha.entity + from . import channels, device, gateway, group + from .channels import base as base_channels + ChannelType = base_channels.ZigbeeChannel ChannelsType = channels.Channels ChannelPoolType = channels.ChannelPool ClientChannelType = base_channels.ClientChannel ZDOChannelType = base_channels.ZDOChannel - ZhaDeviceType = homeassistant.components.zha.core.device.ZHADevice + ZhaDeviceType = device.ZHADevice ZhaEntityType = homeassistant.components.zha.entity.ZhaEntity - ZhaGatewayType = homeassistant.components.zha.core.gateway.ZHAGateway - ZhaGroupType = homeassistant.components.zha.core.group.ZHAGroup + ZhaGatewayType = gateway.ZHAGateway + ZhaGroupType = group.ZHAGroup diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 71c5dcca908a71..9f62d4b9c02db9 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -10,14 +10,20 @@ from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, - DEVICE_CLASS_DAMPER, - DEVICE_CLASS_SHADE, - DOMAIN, + CoverDeviceClass, CoverEntity, ) -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + Platform, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( @@ -26,7 +32,6 @@ CHANNEL_ON_OFF, CHANNEL_SHADE, DATA_ZHA, - DATA_ZHA_DISPATCHERS, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, @@ -37,12 +42,16 @@ _LOGGER = logging.getLogger(__name__) -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.COVER) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Zigbee Home Automation cover from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.COVER] unsub = async_dispatcher_connect( hass, @@ -51,10 +60,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): discovery.async_add_entities, async_add_entities, entities_to_create ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) -@STRICT_MATCH(channel_names=CHANNEL_COVER) +@MULTI_MATCH(channel_names=CHANNEL_COVER) class ZhaCover(ZhaEntity, CoverEntity): """Representation of a ZHA cover.""" @@ -173,11 +182,11 @@ async def async_get_state(self, from_cache=True): self._state = None -@STRICT_MATCH(channel_names={CHANNEL_LEVEL, CHANNEL_ON_OFF, CHANNEL_SHADE}) +@MULTI_MATCH(channel_names={CHANNEL_LEVEL, CHANNEL_ON_OFF, CHANNEL_SHADE}) class Shade(ZhaEntity, CoverEntity): """ZHA Shade.""" - _attr_device_class = DEVICE_CLASS_SHADE + _attr_device_class = CoverDeviceClass.SHADE def __init__( self, @@ -280,13 +289,13 @@ async def async_stop_cover(self, **kwargs) -> None: return -@STRICT_MATCH( +@MULTI_MATCH( channel_names={CHANNEL_LEVEL, CHANNEL_ON_OFF}, manufacturers="Keen Home Inc" ) class KeenVent(Shade): """Keen vent cover.""" - _attr_device_class = DEVICE_CLASS_DAMPER + _attr_device_class = CoverDeviceClass.DAMPER async def async_open_cover(self, **kwargs): """Open the cover.""" diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index ffb37e33b0fcc3..c08491ab782dca 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -1,17 +1,22 @@ """Support for the ZHA platform.""" +from __future__ import annotations + import functools import time -from homeassistant.components.device_tracker import DOMAIN, SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( CHANNEL_POWER_CONFIGURATION, DATA_ZHA, - DATA_ZHA_DISPATCHERS, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -19,12 +24,16 @@ from .entity import ZhaEntity from .sensor import Battery -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.DEVICE_TRACKER) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Zigbee Home Automation device tracker from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.DEVICE_TRACKER] unsub = async_dispatcher_connect( hass, @@ -33,7 +42,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): discovery.async_add_entities, async_add_entities, entities_to_create ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) @STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) @@ -97,3 +106,19 @@ def battery_level(self): Percentage from 0-100. """ return self._battery_level + + @property + def device_info( # pylint: disable=overridden-final-method + self, + ) -> DeviceInfo | None: + """Return device info.""" + # We opt ZHA device tracker back into overriding this method because + # it doesn't track IP-based devices. + # Call Super because ScannerEntity overrode it. + return super(ZhaEntity, self).device_info + + @property + def unique_id(self) -> str | None: + """Return unique ID.""" + # Call Super because ScannerEntity overrode it. + return super(ZhaEntity, self).unique_id diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 80697c704bfacf..0b7f95efb6408a 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -41,7 +41,7 @@ class BaseZhaEntity(LogMixin, entity.Entity): """A base class for ZHA entities.""" - _unique_id_suffix: str | None = None + unique_id_suffix: str | None = None def __init__(self, unique_id: str, zha_device: ZhaDeviceType, **kwargs) -> None: """Init ZHA entity.""" @@ -49,8 +49,8 @@ def __init__(self, unique_id: str, zha_device: ZhaDeviceType, **kwargs) -> None: self._force_update: bool = False self._should_poll: bool = False self._unique_id: str = unique_id - if self._unique_id_suffix: - self._unique_id += f"-{self._unique_id_suffix}" + if self.unique_id_suffix: + self._unique_id += f"-{self.unique_id_suffix}" self._state: Any = None self._extra_state_attributes: dict[str, Any] = {} self._zha_device: ZhaDeviceType = zha_device @@ -154,7 +154,7 @@ def __init_subclass__(cls, id_suffix: str | None = None, **kwargs) -> None: """ super().__init_subclass__(**kwargs) if id_suffix: - cls._unique_id_suffix = id_suffix + cls.unique_id_suffix = id_suffix def __init__( self, @@ -166,11 +166,10 @@ def __init__( """Init ZHA entity.""" super().__init__(unique_id, zha_device, **kwargs) ieeetail = "".join([f"{o:02x}" for o in zha_device.ieee[:4]]) - ch_names = [ch.cluster.ep_attribute for ch in channels] - ch_names = ", ".join(sorted(ch_names)) + ch_names = ", ".join(sorted(ch.name for ch in channels)) self._name: str = f"{zha_device.name} {ieeetail} {ch_names}" - if self._unique_id_suffix: - self._name += f" {self._unique_id_suffix}" + if self.unique_id_suffix: + self._name += f" {self.unique_id_suffix}" self.cluster_channels: dict[str, ChannelType] = {} for channel in channels: self.cluster_channels[channel.name] = channel diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 0176cf1ea3b915..10b64caf9746d8 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -11,14 +11,15 @@ from homeassistant.components.fan import ( ATTR_PERCENTAGE, ATTR_PRESET_MODE, - DOMAIN, SUPPORT_SET_SPEED, FanEntity, NotValidPresetModeError, ) -from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import State, callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( int_states_in_range, percentage_to_ranged_value, @@ -26,13 +27,7 @@ ) from .core import discovery -from .core.const import ( - CHANNEL_FAN, - DATA_ZHA, - DATA_ZHA_DISPATCHERS, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, -) +from .core.const import CHANNEL_FAN, DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -53,13 +48,17 @@ DEFAULT_ON_PERCENTAGE = 50 -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) -GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, DOMAIN) +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.FAN) +GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.FAN) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Zigbee Home Automation fan from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.FAN] unsub = async_dispatcher_connect( hass, @@ -71,7 +70,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): update_before_add=False, ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) class BaseFan(FanEntity): diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 9398d0fde17699..6855db2257287b 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -30,13 +30,20 @@ SUPPORT_FLASH, SUPPORT_TRANSITION, ) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_UNAVAILABLE -from homeassistant.core import State, callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util @@ -47,7 +54,6 @@ CHANNEL_ON_OFF, CONF_DEFAULT_LIGHT_TRANSITION, DATA_ZHA, - DATA_ZHA_DISPATCHERS, EFFECT_BLINK, EFFECT_BREATHE, EFFECT_DEFAULT_VARIANT, @@ -77,8 +83,8 @@ FLASH_EFFECTS = {light.FLASH_SHORT: EFFECT_BLINK, light.FLASH_LONG: EFFECT_BREATHE} UNSUPPORTED_ATTRIBUTE = 0x86 -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, light.DOMAIN) -GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, light.DOMAIN) +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.LIGHT) +GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT) PARALLEL_UPDATES = 0 SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" @@ -100,9 +106,13 @@ class LightColorMode(enum.IntEnum): COLOR_TEMP = 0x02 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Zigbee Home Automation light from config entry.""" - entities_to_create = hass.data[DATA_ZHA][light.DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.LIGHT] unsub = async_dispatcher_connect( hass, @@ -111,7 +121,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): discovery.async_add_entities, async_add_entities, entities_to_create ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) class BaseLight(LogMixin, light.LightEntity): diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 99f6230de0b19c..341cfcebf682d7 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -4,13 +4,10 @@ import voluptuous as vol from zigpy.zcl.foundation import Status -from homeassistant.components.lock import ( - DOMAIN, - STATE_LOCKED, - STATE_UNLOCKED, - LockEntity, -) -from homeassistant.core import callback +from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -18,7 +15,6 @@ from .core.const import ( CHANNEL_DOORLOCK, DATA_ZHA, - DATA_ZHA_DISPATCHERS, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -27,7 +23,7 @@ # The first state is Zigbee 'Not fully locked' STATE_LIST = [STATE_UNLOCKED, STATE_LOCKED, STATE_UNLOCKED] -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.LOCK) VALUE_TO_STATE = dict(enumerate(STATE_LIST)) @@ -37,9 +33,13 @@ SERVICE_CLEAR_LOCK_USER_CODE = "clear_lock_user_code" -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: """Set up the Zigbee Home Automation Door Lock from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.LOCK] unsub = async_dispatcher_connect( hass, @@ -48,7 +48,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): discovery.async_add_entities, async_add_entities, entities_to_create ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) platform = entity_platform.async_get_current_platform() @@ -86,7 +86,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -@STRICT_MATCH(channel_names=CHANNEL_DOORLOCK) +@MULTI_MATCH(channel_names=CHANNEL_DOORLOCK) class ZhaDoorLock(ZhaEntity, LockEntity): """Representation of a ZHA lock.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 960bb55e0041ac..dfc1ffba538a78 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -6,22 +6,63 @@ "requirements": [ "bellows==0.29.0", "pyserial==3.5", - "pyserial-asyncio==0.5", - "zha-quirks==0.0.65", + "pyserial-asyncio==0.6", + "zha-quirks==0.0.66", "zigpy-deconz==0.14.0", - "zigpy==0.42.0", + "zigpy==0.43.0", "zigpy-xbee==0.14.0", "zigpy-zigate==0.7.3", - "zigpy-znp==0.6.4" + "zigpy-znp==0.7.0" ], "usb": [ - {"vid":"10C4","pid":"EA60","description":"*2652*","known_devices":["slae.sh cc2652rb stick"]}, - {"vid":"10C4","pid":"EA60","description":"*tubeszb*","known_devices":["TubesZB Coordinator"]}, - {"vid":"1A86","pid":"7523","description":"*tubeszb*","known_devices":["TubesZB Coordinator"]}, - {"vid":"1A86","pid":"7523","description":"*zigstar*","known_devices":["ZigStar Coordinators"]}, - {"vid":"1CF1","pid":"0030","description":"*conbee*","known_devices":["Conbee II"]}, - {"vid":"10C4","pid":"8A2A","description":"*zigbee*","known_devices":["Nortek HUSBZB-1"]}, - {"vid":"10C4","pid":"8B34","description":"*bv 2010/10*","known_devices":["Bitron Video AV2010/10"]} + { + "vid": "10C4", + "pid": "EA60", + "description": "*2652*", + "known_devices": ["slae.sh cc2652rb stick"] + }, + { + "vid": "10C4", + "pid": "EA60", + "description": "*sonoff*plus*", + "known_devices": ["sonoff zigbee dongle plus"] + }, + { + "vid": "10C4", + "pid": "EA60", + "description": "*tubeszb*", + "known_devices": ["TubesZB Coordinator"] + }, + { + "vid": "1A86", + "pid": "7523", + "description": "*tubeszb*", + "known_devices": ["TubesZB Coordinator"] + }, + { + "vid": "1A86", + "pid": "7523", + "description": "*zigstar*", + "known_devices": ["ZigStar Coordinators"] + }, + { + "vid": "1CF1", + "pid": "0030", + "description": "*conbee*", + "known_devices": ["Conbee II"] + }, + { + "vid": "10C4", + "pid": "8A2A", + "description": "*zigbee*", + "known_devices": ["Nortek HUSBZB-1"] + }, + { + "vid": "10C4", + "pid": "8B34", + "description": "*bv 2010/10*", + "known_devices": ["Bitron Video AV2010/10"] + } ], "codeowners": ["@dmulcahey", "@adminiuga"], "zeroconf": [ diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index b4772e51742681..3c5a374e588eba 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -2,15 +2,17 @@ import functools import logging -from homeassistant.components.number import DOMAIN, NumberEntity -from homeassistant.core import callback +from homeassistant.components.number import NumberEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( CHANNEL_ANALOG_OUTPUT, DATA_ZHA, - DATA_ZHA_DISPATCHERS, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -19,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.NUMBER) UNITS = { @@ -233,9 +235,13 @@ } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Zigbee Home Automation Analog Output from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.NUMBER] unsub = async_dispatcher_connect( hass, @@ -247,7 +253,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): update_before_add=False, ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) @STRICT_MATCH(channel_names=CHANNEL_ANALOG_OUTPUT) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py new file mode 100644 index 00000000000000..ff76023d96d82a --- /dev/null +++ b/homeassistant/components/zha/select.py @@ -0,0 +1,133 @@ +"""Support for ZHA controls using the select platform.""" +from __future__ import annotations + +from enum import Enum +import functools + +from zigpy.zcl.clusters.security import IasWd + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENTITY_CATEGORY_CONFIG, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .core import discovery +from .core.const import CHANNEL_IAS_WD, DATA_ZHA, SIGNAL_ADD_ENTITIES, Strobe +from .core.registries import ZHA_ENTITIES +from .core.typing import ChannelType, ZhaDeviceType +from .entity import ZhaEntity + +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SELECT) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Zigbee Home Automation siren from config entry.""" + entities_to_create = hass.data[DATA_ZHA][Platform.SELECT] + + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, + async_add_entities, + entities_to_create, + update_before_add=False, + ), + ) + config_entry.async_on_unload(unsub) + + +class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): + """Representation of a ZHA select entity.""" + + _attr_entity_category = ENTITY_CATEGORY_CONFIG + _enum: Enum = None + + def __init__( + self, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> None: + """Init this select entity.""" + self._attr_name = self._enum.__name__ + self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] + self._channel: ChannelType = channels[0] + super().__init__(unique_id, zha_device, channels, **kwargs) + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + option = self._channel.data_cache.get(self._attr_name) + if option is None: + return None + return option.name.replace("_", " ") + + async def async_select_option(self, option: str | int) -> None: + """Change the selected option.""" + self._channel.data_cache[self._attr_name] = self._enum[option.replace(" ", "_")] + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + if last_state := await self.async_get_last_state(): + self.async_restore_last_state(last_state) + + @callback + def async_restore_last_state(self, last_state) -> None: + """Restore previous state.""" + if last_state.state and last_state.state != STATE_UNKNOWN: + self._channel.data_cache[self._attr_name] = self._enum[ + last_state.state.replace(" ", "_") + ] + + +class ZHANonZCLSelectEntity(ZHAEnumSelectEntity): + """Representation of a ZHA select entity with no ZCL interaction.""" + + @property + def available(self) -> bool: + """Return entity availability.""" + return True + + +@MULTI_MATCH(channel_names=CHANNEL_IAS_WD) +class ZHADefaultToneSelectEntity( + ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.WarningMode.__name__ +): + """Representation of a ZHA default siren tone select entity.""" + + _enum: Enum = IasWd.Warning.WarningMode + + +@MULTI_MATCH(channel_names=CHANNEL_IAS_WD) +class ZHADefaultSirenLevelSelectEntity( + ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.SirenLevel.__name__ +): + """Representation of a ZHA default siren level select entity.""" + + _enum: Enum = IasWd.Warning.SirenLevel + + +@MULTI_MATCH(channel_names=CHANNEL_IAS_WD) +class ZHADefaultStrobeLevelSelectEntity( + ZHANonZCLSelectEntity, id_suffix=IasWd.StrobeLevel.__name__ +): + """Representation of a ZHA default siren strobe level select entity.""" + + _enum: Enum = IasWd.StrobeLevel + + +@MULTI_MATCH(channel_names=CHANNEL_IAS_WD) +class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity, id_suffix=Strobe.__name__): + """Representation of a ZHA default siren strobe select entity.""" + + _enum: Enum = Strobe diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 567d2a6065eba2..eb79634695fe06 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -13,26 +13,15 @@ CURRENT_HVAC_OFF, ) from homeassistant.components.sensor import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - DOMAIN, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_ENERGY, ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, @@ -51,17 +40,20 @@ VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, VOLUME_GALLONS, VOLUME_LITERS, + Platform, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .core import discovery from .core.const import ( CHANNEL_ANALOG_INPUT, + CHANNEL_BASIC, + CHANNEL_DEVICE_TEMPERATURE, CHANNEL_ELECTRICAL_MEASUREMENT, - CHANNEL_FAN, CHANNEL_HUMIDITY, CHANNEL_ILLUMINANCE, CHANNEL_LEAF_WETNESS, @@ -72,7 +64,6 @@ CHANNEL_TEMPERATURE, CHANNEL_THERMOSTAT, DATA_ZHA, - DATA_ZHA_DISPATCHERS, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -99,8 +90,8 @@ } CHANNEL_ST_HUMIDITY_CLUSTER = f"channel_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) -MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, DOMAIN) +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SENSOR) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SENSOR) async def async_setup_entry( @@ -109,7 +100,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.SENSOR] unsub = async_dispatcher_connect( hass, @@ -121,7 +112,7 @@ async def async_setup_entry( update_before_add=False, ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) class Sensor(ZhaEntity, SensorEntity): @@ -129,10 +120,8 @@ class Sensor(ZhaEntity, SensorEntity): SENSOR_ATTR: int | str | None = None _decimals: int = 1 - _device_class: str | None = None _divisor: int = 1 _multiplier: int = 1 - _state_class: str | None = None _unit: str | None = None def __init__( @@ -171,16 +160,6 @@ async def async_added_to_hass(self) -> None: self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) - @property - def device_class(self) -> str: - """Return device class from component DEVICE_CLASSES.""" - return self._device_class - - @property - def state_class(self) -> str | None: - """Return the state class of this entity, from STATE_CLASSES, if any.""" - return self._state_class - @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity.""" @@ -209,27 +188,32 @@ def formatter(self, value: int) -> int | float: return round(float(value * self._multiplier) / self._divisor) -@STRICT_MATCH( +@MULTI_MATCH( channel_names=CHANNEL_ANALOG_INPUT, manufacturers="LUMI", models={"lumi.plug", "lumi.plug.maus01", "lumi.plug.mmeu01"}, + stop_on_match_group=CHANNEL_ANALOG_INPUT, +) +@MULTI_MATCH( + channel_names=CHANNEL_ANALOG_INPUT, + manufacturers="Digi", + stop_on_match_group=CHANNEL_ANALOG_INPUT, ) -@STRICT_MATCH(channel_names=CHANNEL_ANALOG_INPUT, manufacturers="Digi") class AnalogInput(Sensor): """Sensor that displays analog input values.""" SENSOR_ATTR = "present_value" -@STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) +@MULTI_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 - _state_class = STATE_CLASS_MEASUREMENT + _attr_device_class: SensorDeviceClass = SensorDeviceClass.BATTERY + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _unit = PERCENTAGE - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_entity_category = EntityCategory.DIAGNOSTIC @classmethod def create_entity( @@ -277,8 +261,8 @@ class ElectricalMeasurement(Sensor): """Active power measurement.""" SENSOR_ATTR = "active_power" - _device_class = DEVICE_CLASS_POWER - _state_class = STATE_CLASS_MEASUREMENT + _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _unit = POWER_WATT _div_mul_prefix = "ac_power" @@ -323,7 +307,7 @@ class ElectricalMeasurementApparentPower( """Apparent power measurement.""" SENSOR_ATTR = "apparent_power" - _device_class = DEVICE_CLASS_POWER + _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER _unit = POWER_VOLT_AMPERE _div_mul_prefix = "ac_power" @@ -338,7 +322,7 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_curr """RMS current measurement.""" SENSOR_ATTR = "rms_current" - _device_class = DEVICE_CLASS_CURRENT + _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _unit = ELECTRIC_CURRENT_AMPERE _div_mul_prefix = "ac_current" @@ -353,7 +337,7 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_volt """RMS Voltage measurement.""" SENSOR_ATTR = "rms_voltage" - _device_class = DEVICE_CLASS_CURRENT + _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _unit = ELECTRIC_POTENTIAL_VOLT _div_mul_prefix = "ac_voltage" @@ -363,46 +347,49 @@ def should_poll(self) -> bool: return False -@STRICT_MATCH(generic_ids=CHANNEL_ST_HUMIDITY_CLUSTER) -@STRICT_MATCH(channel_names=CHANNEL_HUMIDITY) +@MULTI_MATCH( + generic_ids=CHANNEL_ST_HUMIDITY_CLUSTER, stop_on_match_group=CHANNEL_HUMIDITY +) +@MULTI_MATCH(channel_names=CHANNEL_HUMIDITY, stop_on_match_group=CHANNEL_HUMIDITY) class Humidity(Sensor): """Humidity sensor.""" SENSOR_ATTR = "measured_value" - _device_class = DEVICE_CLASS_HUMIDITY + _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _divisor = 100 - _state_class = STATE_CLASS_MEASUREMENT _unit = PERCENTAGE -@STRICT_MATCH(channel_names=CHANNEL_SOIL_MOISTURE) +@MULTI_MATCH(channel_names=CHANNEL_SOIL_MOISTURE) class SoilMoisture(Sensor): """Soil Moisture sensor.""" SENSOR_ATTR = "measured_value" - _device_class = DEVICE_CLASS_HUMIDITY + _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _divisor = 100 - _state_class = STATE_CLASS_MEASUREMENT _unit = PERCENTAGE -@STRICT_MATCH(channel_names=CHANNEL_LEAF_WETNESS) +@MULTI_MATCH(channel_names=CHANNEL_LEAF_WETNESS) class LeafWetness(Sensor): """Leaf Wetness sensor.""" SENSOR_ATTR = "measured_value" - _device_class = DEVICE_CLASS_HUMIDITY + _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _divisor = 100 - _state_class = STATE_CLASS_MEASUREMENT _unit = PERCENTAGE -@STRICT_MATCH(channel_names=CHANNEL_ILLUMINANCE) +@MULTI_MATCH(channel_names=CHANNEL_ILLUMINANCE) class Illuminance(Sensor): """Illuminance Sensor.""" SENSOR_ATTR = "measured_value" - _device_class = DEVICE_CLASS_ILLUMINANCE + _attr_device_class: SensorDeviceClass = SensorDeviceClass.ILLUMINANCE + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _unit = LIGHT_LUX @staticmethod @@ -416,8 +403,8 @@ class SmartEnergyMetering(Sensor): """Metering sensor.""" SENSOR_ATTR: int | str = "instantaneous_demand" - _device_class: str | None = DEVICE_CLASS_POWER - _state_class: str | None = STATE_CLASS_MEASUREMENT + _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT unit_of_measure_map = { 0x00: POWER_WATT, @@ -460,8 +447,8 @@ class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered") """Smart Energy Metering summation sensor.""" SENSOR_ATTR: int | str = "current_summ_delivered" - _device_class: str | None = DEVICE_CLASS_ENERGY - _state_class: str = STATE_CLASS_TOTAL_INCREASING + _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENERGY + _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING unit_of_measure_map = { 0x00: ENERGY_KILO_WATT_HOUR, @@ -488,82 +475,105 @@ def formatter(self, value: int) -> int | float: return round(cooked, 3) -@STRICT_MATCH(channel_names=CHANNEL_PRESSURE) +@MULTI_MATCH(channel_names=CHANNEL_PRESSURE) class Pressure(Sensor): """Pressure sensor.""" SENSOR_ATTR = "measured_value" - _device_class = DEVICE_CLASS_PRESSURE + _attr_device_class: SensorDeviceClass = SensorDeviceClass.PRESSURE + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 - _state_class = STATE_CLASS_MEASUREMENT _unit = PRESSURE_HPA -@STRICT_MATCH(channel_names=CHANNEL_TEMPERATURE) +@MULTI_MATCH(channel_names=CHANNEL_TEMPERATURE) class Temperature(Sensor): """Temperature Sensor.""" SENSOR_ATTR = "measured_value" - _device_class = DEVICE_CLASS_TEMPERATURE + _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _divisor = 100 - _state_class = STATE_CLASS_MEASUREMENT _unit = TEMP_CELSIUS -@STRICT_MATCH(channel_names="carbon_dioxide_concentration") +@MULTI_MATCH(channel_names=CHANNEL_DEVICE_TEMPERATURE) +class DeviceTemperature(Sensor): + """Device Temperature Sensor.""" + + SENSOR_ATTR = "current_temperature" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _divisor = 100 + _unit = TEMP_CELSIUS + _attr_entity_category = EntityCategory.DIAGNOSTIC + + +@MULTI_MATCH(channel_names="carbon_dioxide_concentration") class CarbonDioxideConcentration(Sensor): """Carbon Dioxide Concentration sensor.""" SENSOR_ATTR = "measured_value" - _device_class = DEVICE_CLASS_CO2 + _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO2 + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_PARTS_PER_MILLION -@STRICT_MATCH(channel_names="carbon_monoxide_concentration") +@MULTI_MATCH(channel_names="carbon_monoxide_concentration") class CarbonMonoxideConcentration(Sensor): """Carbon Monoxide Concentration sensor.""" SENSOR_ATTR = "measured_value" - _device_class = DEVICE_CLASS_CO + _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_PARTS_PER_MILLION -@STRICT_MATCH(generic_ids="channel_0x042e") -@STRICT_MATCH(channel_names="voc_level") +@MULTI_MATCH(generic_ids="channel_0x042e", stop_on_match_group="voc_level") +@MULTI_MATCH(channel_names="voc_level", stop_on_match_group="voc_level") class VOCLevel(Sensor): """VOC Level sensor.""" SENSOR_ATTR = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER -@STRICT_MATCH(channel_names="voc_level", models="lumi.airmonitor.acn01") +@MULTI_MATCH( + channel_names="voc_level", + models="lumi.airmonitor.acn01", + stop_on_match_group="voc_level", +) class PPBVOCLevel(Sensor): """VOC Level sensor.""" SENSOR_ATTR = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 _multiplier = 1 _unit = CONCENTRATION_PARTS_PER_BILLION -@STRICT_MATCH(channel_names="formaldehyde_concentration") +@MULTI_MATCH(channel_names="formaldehyde_concentration") class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" SENSOR_ATTR = "measured_value" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_PARTS_PER_MILLION -@MULTI_MATCH(channel_names=CHANNEL_THERMOSTAT) +@MULTI_MATCH(channel_names=CHANNEL_THERMOSTAT, stop_on_match_group=CHANNEL_THERMOSTAT) class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): """Thermostat HVAC action sensor.""" @@ -596,10 +606,21 @@ def native_value(self) -> str | None: def _rm_rs_action(self) -> str | None: """Return the current HVAC action based on running mode and running state.""" - running_mode = self._channel.running_mode - if running_mode == self._channel.RunningMode.Heat: + if (running_state := self._channel.running_state) is None: + return None + + rs_heat = ( + self._channel.RunningState.Heat_State_On + | self._channel.RunningState.Heat_2nd_Stage_On + ) + if running_state & rs_heat: return CURRENT_HVAC_HEAT - if running_mode == self._channel.RunningMode.Cool: + + rs_cool = ( + self._channel.RunningState.Cool_State_On + | self._channel.RunningState.Cool_2nd_Stage_On + ) + if running_state & rs_cool: return CURRENT_HVAC_COOL running_state = self._channel.running_state @@ -609,10 +630,12 @@ def _rm_rs_action(self) -> str | None: | self._channel.RunningState.Fan_3rd_Stage_On ): return CURRENT_HVAC_FAN - if ( - self._channel.system_mode != self._channel.SystemMode.Off - and running_mode == self._channel.SystemMode.Off - ): + + running_state = self._channel.running_state + if running_state and running_state & self._channel.RunningState.Idle: + return CURRENT_HVAC_IDLE + + if self._channel.system_mode != self._channel.SystemMode.Off: return CURRENT_HVAC_IDLE return CURRENT_HVAC_OFF @@ -638,39 +661,21 @@ def async_set_state(self, *args, **kwargs) -> None: @MULTI_MATCH( - channel_names=CHANNEL_THERMOSTAT, - aux_channels=CHANNEL_FAN, - manufacturers="Centralite", - models={"3157100", "3157100-E"}, - stop_on_match=True, -) -@MULTI_MATCH( - channel_names=CHANNEL_THERMOSTAT, - manufacturers="Zen Within", - stop_on_match=True, + channel_names={CHANNEL_THERMOSTAT}, + manufacturers="Sinope Technologies", + stop_on_match_group=CHANNEL_THERMOSTAT, ) -class ZenHVACAction(ThermostatHVACAction): - """Zen Within Thermostat HVAC Action.""" +class SinopeHVACAction(ThermostatHVACAction): + """Sinope Thermostat HVAC action sensor.""" @property def _rm_rs_action(self) -> str | None: """Return the current HVAC action based on running mode and running state.""" - if (running_state := self._channel.running_state) is None: - return None - - rs_heat = ( - self._channel.RunningState.Heat_State_On - | self._channel.RunningState.Heat_2nd_Stage_On - ) - if running_state & rs_heat: + running_mode = self._channel.running_mode + if running_mode == self._channel.RunningMode.Heat: return CURRENT_HVAC_HEAT - - rs_cool = ( - self._channel.RunningState.Cool_State_On - | self._channel.RunningState.Cool_2nd_Stage_On - ) - if running_state & rs_cool: + if running_mode == self._channel.RunningMode.Cool: return CURRENT_HVAC_COOL running_state = self._channel.running_state @@ -680,7 +685,51 @@ def _rm_rs_action(self) -> str | None: | self._channel.RunningState.Fan_3rd_Stage_On ): return CURRENT_HVAC_FAN - - if self._channel.system_mode != self._channel.SystemMode.Off: + if ( + self._channel.system_mode != self._channel.SystemMode.Off + and running_mode == self._channel.SystemMode.Off + ): return CURRENT_HVAC_IDLE return CURRENT_HVAC_OFF + + +@MULTI_MATCH(channel_names=CHANNEL_BASIC) +class RSSISensor(Sensor, id_suffix="rssi"): + """RSSI sensor for a device.""" + + _state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _device_class: SensorDeviceClass = SensorDeviceClass.SIGNAL_STRENGTH + _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> ZhaEntity | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + key = f"{CHANNEL_BASIC}_{cls.unique_id_suffix}" + if ZHA_ENTITIES.prevent_entity_creation(Platform.SENSOR, zha_device.ieee, key): + return None + return cls(unique_id, zha_device, channels, **kwargs) + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + return getattr(self._zha_device.device, self.unique_id_suffix) + + @property + def should_poll(self) -> bool: + """Poll the entity for current state.""" + return True + + +@MULTI_MATCH(channel_names=CHANNEL_BASIC) +class LQISensor(RSSISensor, id_suffix="lqi"): + """LQI sensor for a device.""" diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index 75f527cfcf16fd..5ba83dbef12ed1 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -5,9 +5,10 @@ import functools from typing import Any +from zigpy.zcl.clusters.security import IasWd as WD + from homeassistant.components.siren import ( ATTR_DURATION, - DOMAIN, SUPPORT_DURATION, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, @@ -20,6 +21,7 @@ SUPPORT_VOLUME_SET, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,13 +41,15 @@ WARNING_DEVICE_MODE_POLICE_PANIC, WARNING_DEVICE_MODE_STOP, WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_NO, + Strobe, ) from .core.registries import ZHA_ENTITIES from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SIREN) DEFAULT_DURATION = 5 # seconds @@ -55,7 +59,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.SIREN] unsub = async_dispatcher_connect( hass, @@ -70,7 +74,7 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -@STRICT_MATCH(channel_names=CHANNEL_IAS_WD) +@MULTI_MATCH(channel_names=CHANNEL_IAS_WD) class ZHASiren(ZhaEntity, SirenEntity): """Representation of a ZHA siren.""" @@ -107,9 +111,27 @@ async def async_turn_on(self, **kwargs: Any) -> None: if self._off_listener: self._off_listener() self._off_listener = None - siren_tone = WARNING_DEVICE_MODE_EMERGENCY + tone_cache = self._channel.data_cache.get(WD.Warning.WarningMode.__name__) + siren_tone = ( + tone_cache.value + if tone_cache is not None + else WARNING_DEVICE_MODE_EMERGENCY + ) siren_duration = DEFAULT_DURATION - siren_level = WARNING_DEVICE_SOUND_HIGH + level_cache = self._channel.data_cache.get(WD.Warning.SirenLevel.__name__) + siren_level = ( + level_cache.value if level_cache is not None else WARNING_DEVICE_SOUND_HIGH + ) + strobe_cache = self._channel.data_cache.get(Strobe.__name__) + should_strobe = ( + strobe_cache.value if strobe_cache is not None else Strobe.No_Strobe + ) + strobe_level_cache = self._channel.data_cache.get(WD.StrobeLevel.__name__) + strobe_level = ( + strobe_level_cache.value + if strobe_level_cache is not None + else WARNING_DEVICE_STROBE_HIGH + ) if (duration := kwargs.get(ATTR_DURATION)) is not None: siren_duration = duration if (tone := kwargs.get(ATTR_TONE)) is not None: @@ -117,7 +139,12 @@ async def async_turn_on(self, **kwargs: Any) -> None: if (level := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: siren_level = int(level) await self._channel.issue_start_warning( - mode=siren_tone, warning_duration=siren_duration, siren_level=siren_level + mode=siren_tone, + warning_duration=siren_duration, + siren_level=siren_level, + strobe=should_strobe, + strobe_duty_cycle=50 if should_strobe else 0, + strobe_intensity=strobe_level, ) self._attr_is_on = True self._off_listener = async_call_later( diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 75254f631b9d7f..29fb08b9bc0676 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -7,29 +7,34 @@ from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status -from homeassistant.components.switch import DOMAIN, SwitchEntity -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE -from homeassistant.core import State, callback +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import ( CHANNEL_ON_OFF, DATA_ZHA, - DATA_ZHA_DISPATCHERS, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) -GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, DOMAIN) +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SWITCH) +GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.SWITCH) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up the Zigbee Home Automation switch from config entry.""" - entities_to_create = hass.data[DATA_ZHA][DOMAIN] + entities_to_create = hass.data[DATA_ZHA][Platform.SWITCH] unsub = async_dispatcher_connect( hass, @@ -38,7 +43,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): discovery.async_add_entities, async_add_entities, entities_to_create ), ) - hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + config_entry.async_on_unload(unsub) class BaseSwitch(SwitchEntity): diff --git a/homeassistant/components/zha/translations/el.json b/homeassistant/components/zha/translations/el.json index 0df3306e84a5da..2a58de56c32e1d 100644 --- a/homeassistant/components/zha/translations/el.json +++ b/homeassistant/components/zha/translations/el.json @@ -7,7 +7,93 @@ "step": { "confirm": { "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name} ;" + }, + "pick_radio": { + "data": { + "radio_type": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03ba\u03b5\u03c1\u03b1\u03af\u03b1\u03c2" + }, + "description": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae \u03b5\u03bd\u03cc\u03c2 \u03c4\u03cd\u03c0\u03bf\u03c5 \u03ba\u03b5\u03c1\u03b1\u03af\u03b1\u03c2 Zigbee", + "title": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03ba\u03b5\u03c1\u03b1\u03af\u03b1\u03c2" + }, + "port_config": { + "data": { + "baudrate": "\u03c4\u03b1\u03c7\u03cd\u03c4\u03b7\u03c4\u03b1 \u03b8\u03cd\u03c1\u03b1\u03c2", + "flow_control": "\u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c1\u03bf\u03ae\u03c2 \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03c9\u03bd", + "path": "\u0394\u03b9\u03b1\u03b4\u03c1\u03bf\u03bc\u03ae \u03c3\u03b5\u03b9\u03c1\u03b9\u03b1\u03ba\u03ae\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, + "description": "\u0395\u03b9\u03c3\u03b1\u03b3\u03c9\u03b3\u03ae \u03c3\u03c5\u03b3\u03ba\u03b5\u03ba\u03c1\u03b9\u03bc\u03ad\u03bd\u03c9\u03bd \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c9\u03bd \u03b8\u03cd\u03c1\u03b1\u03c2", + "title": "\u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03b9\u03c2" + }, + "user": { + "data": { + "path": "\u0394\u03b9\u03b1\u03b4\u03c1\u03bf\u03bc\u03ae \u03c3\u03b5\u03b9\u03c1\u03b9\u03b1\u03ba\u03ae\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, + "description": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae \u03c3\u03b5\u03b9\u03c1\u03b9\u03b1\u03ba\u03ae\u03c2 \u03b8\u03cd\u03c1\u03b1\u03c2 \u03b3\u03b9\u03b1 \u03ba\u03b5\u03c1\u03b1\u03af\u03b1 Zigbee" } } + }, + "config_panel": { + "zha_alarm_options": { + "alarm_arm_requires_code": "\u0391\u03c0\u03b1\u03b9\u03c4\u03b5\u03af\u03c4\u03b1\u03b9 \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03b3\u03b9\u03b1 \u03b5\u03bd\u03ad\u03c1\u03b3\u03b5\u03b9\u03b5\u03c2 \u03bf\u03c0\u03bb\u03b9\u03c3\u03bc\u03bf\u03cd", + "alarm_failed_tries": "\u039f \u03b1\u03c1\u03b9\u03b8\u03bc\u03cc\u03c2 \u03c4\u03c9\u03bd \u03b4\u03b9\u03b1\u03b4\u03bf\u03c7\u03b9\u03ba\u03ce\u03bd \u03b1\u03c0\u03bf\u03c4\u03c5\u03c7\u03b7\u03bc\u03ad\u03bd\u03c9\u03bd \u03ba\u03b1\u03c4\u03b1\u03c7\u03c9\u03c1\u03ae\u03c3\u03b5\u03c9\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03bf\u03cd \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 \u03c3\u03c5\u03bd\u03b1\u03b3\u03b5\u03c1\u03bc\u03bf\u03cd", + "alarm_master_code": "\u039a\u03cd\u03c1\u03b9\u03bf\u03c2 \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03b3\u03b9\u03b1 \u03c4\u03bf\u03bd \u03c0\u03af\u03bd\u03b1\u03ba\u03b1 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c3\u03c5\u03bd\u03b1\u03b3\u03b5\u03c1\u03bc\u03bf\u03cd", + "title": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03c2 \u03c0\u03af\u03bd\u03b1\u03ba\u03b1 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c3\u03c5\u03bd\u03b1\u03b3\u03b5\u03c1\u03bc\u03bf\u03cd" + }, + "zha_options": { + "default_light_transition": "\u03a0\u03c1\u03bf\u03b5\u03c0\u03b9\u03bb\u03b5\u03b3\u03bc\u03ad\u03bd\u03bf\u03c2 \u03c7\u03c1\u03cc\u03bd\u03bf\u03c2 \u03bc\u03b5\u03c4\u03ac\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c6\u03c9\u03c4\u03cc\u03c2 (\u03b4\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1)", + "enable_identify_on_join": "\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 \u03b5\u03c6\u03ad \u03b1\u03bd\u03b1\u03b3\u03bd\u03ce\u03c1\u03b9\u03c3\u03b7\u03c2 \u03cc\u03c4\u03b1\u03bd \u03bf\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ad\u03c2 \u03c3\u03c5\u03bd\u03b4\u03ad\u03bf\u03bd\u03c4\u03b1\u03b9 \u03c3\u03c4\u03bf \u03b4\u03af\u03ba\u03c4\u03c5\u03bf", + "title": "\u039a\u03b1\u03b8\u03bf\u03bb\u03b9\u03ba\u03ad\u03c2 \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03c2" + } + }, + "device_automation": { + "action_type": { + "squawk": "\u039a\u03b1\u03ba\u03ac\u03c1\u03b9\u03c3\u03bc\u03b1", + "warn": "\u03a0\u03c1\u03bf\u03b5\u03b9\u03b4\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7" + }, + "trigger_subtype": { + "both_buttons": "\u039a\u03b1\u03b9 \u03c4\u03b1 \u03b4\u03cd\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03b9\u03ac", + "button_1": "\u03a0\u03c1\u03ce\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af", + "button_2": "\u0394\u03b5\u03cd\u03c4\u03b5\u03c1\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af", + "button_3": "\u03a4\u03c1\u03af\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af", + "button_4": "\u03a4\u03ad\u03c4\u03b1\u03c1\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af", + "button_5": "\u03a0\u03ad\u03bc\u03c0\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af", + "button_6": "\u0388\u03ba\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af", + "close": "\u039a\u03bb\u03b5\u03af\u03c3\u03b9\u03bc\u03bf", + "dim_down": "\u039c\u03b5\u03af\u03c9\u03c3\u03b7 \u03ad\u03bd\u03c4\u03b1\u03c3\u03b7\u03c2", + "dim_up": "\u0391\u03cd\u03be\u03b7\u03c3\u03b7 \u03ad\u03bd\u03c4\u03b1\u03c3\u03b7\u03c2", + "face_1": "\u03bc\u03b5 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03b7 \u03c4\u03b7\u03bd \u03cc\u03c8\u03b7 1", + "face_2": "\u03bc\u03b5 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03b7 \u03c4\u03b7\u03bd \u03cc\u03c8\u03b7 2", + "face_3": "\u03bc\u03b5 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03b7 \u03c4\u03b7\u03bd \u03cc\u03c8\u03b7 3", + "face_4": "\u03bc\u03b5 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03b7 \u03c4\u03b7\u03bd \u03cc\u03c8\u03b7 4", + "face_5": "\u03bc\u03b5 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03b7 \u03c4\u03b7\u03bd \u03cc\u03c8\u03b7 5", + "face_6": "\u03bc\u03b5 \u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03b7 \u03c4\u03b7\u03bd \u03cc\u03c8\u03b7 6", + "face_any": "\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 \u03bc\u03b5 \u03bf\u03c0\u03bf\u03b9\u03b1\u03b4\u03ae\u03c0\u03bf\u03c4\u03b5/\u03ba\u03b1\u03b8\u03bf\u03c1\u03b9\u03c3\u03bc\u03ad\u03bd\u03b7 \u03cc\u03c8\u03b7(\u03b5\u03c2)", + "left": "\u0391\u03c1\u03b9\u03c3\u03c4\u03b5\u03c1\u03ac", + "open": "\u0386\u03bd\u03bf\u03b9\u03b3\u03bc\u03b1", + "right": "\u0394\u03b5\u03be\u03b9\u03ac", + "turn_off": "\u0391\u03c0\u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7", + "turn_on": "\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7" + }, + "trigger_type": { + "device_dropped": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c0\u03b5\u03c3\u03b5", + "device_rotated": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c0\u03b5\u03c1\u03b9\u03c3\u03c4\u03c1\u03ac\u03c6\u03b7\u03ba\u03b5 \"{subtype}\"", + "device_shaken": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b1\u03bd\u03b1\u03ba\u03b9\u03bd\u03ae\u03b8\u03b7\u03ba\u03b5", + "remote_button_alt_double_press": "\u03a4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \"{subtype}\" \u03c0\u03b1\u03c4\u03ae\u03b8\u03b7\u03ba\u03b5 \u03b4\u03b9\u03c0\u03bb\u03ac (\u0395\u03bd\u03b1\u03bb\u03bb\u03b1\u03ba\u03c4\u03b9\u03ba\u03ae \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1)", + "remote_button_alt_long_press": "\u03a4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \"{subtype}\" \u03c0\u03b1\u03c4\u03ae\u03b8\u03b7\u03ba\u03b5 \u03c3\u03c5\u03bd\u03b5\u03c7\u03ce\u03c2 (\u0395\u03bd\u03b1\u03bb\u03bb\u03b1\u03ba\u03c4\u03b9\u03ba\u03ae \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1)", + "remote_button_alt_long_release": "\u03a4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \"{\u03c3\u03b8\u03b2\u03c4\u03c5\u03c0\u03b5}\" \u03b1\u03c0\u03b5\u03bb\u03b5\u03c5\u03b8\u03b5\u03c1\u03ce\u03b8\u03b7\u03ba\u03b5 \u03bc\u03b5\u03c4\u03ac \u03b1\u03c0\u03cc \u03c0\u03b1\u03c1\u03b1\u03c4\u03b5\u03c4\u03b1\u03bc\u03ad\u03bd\u03bf \u03c0\u03ac\u03c4\u03b7\u03bc\u03b1 (\u0395\u03bd\u03b1\u03bb\u03bb\u03b1\u03ba\u03c4\u03b9\u03ba\u03ae \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1)", + "remote_button_alt_quadruple_press": "\u03a4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \"{subtype}\" \u03c0\u03b1\u03c4\u03ae\u03b8\u03b7\u03ba\u03b5 \u03c4\u03b5\u03c4\u03c1\u03b1\u03c0\u03bb\u03ac (\u0395\u03bd\u03b1\u03bb\u03bb\u03b1\u03ba\u03c4\u03b9\u03ba\u03ae \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1)", + "remote_button_alt_quintuple_press": "\u03a4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \"{subtype}\" \u03c0\u03b1\u03c4\u03ae\u03b8\u03b7\u03ba\u03b5 \u03c0\u03b5\u03bd\u03c4\u03b1\u03c0\u03bb\u03ac (\u0395\u03bd\u03b1\u03bb\u03bb\u03b1\u03ba\u03c4\u03b9\u03ba\u03ae \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1)", + "remote_button_alt_short_press": "\u03a4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \"{subtype}\" \u03c0\u03b1\u03c4\u03ae\u03b8\u03b7\u03ba\u03b5 (\u0395\u03bd\u03b1\u03bb\u03bb\u03b1\u03ba\u03c4\u03b9\u03ba\u03ae \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1)", + "remote_button_alt_short_release": "\u03a4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \"{subtype}\" \u03b1\u03c0\u03b5\u03bb\u03b5\u03c5\u03b8\u03b5\u03c1\u03ce\u03b8\u03b7\u03ba\u03b5 (\u0395\u03bd\u03b1\u03bb\u03bb\u03b1\u03ba\u03c4\u03b9\u03ba\u03ae \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1)", + "remote_button_alt_triple_press": "\u03a4\u03c1\u03b9\u03c0\u03bb\u03cc \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \"{subtype}\" (\u0395\u03bd\u03b1\u03bb\u03bb\u03b1\u03ba\u03c4\u03b9\u03ba\u03ae \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1)", + "remote_button_double_press": "\u0394\u03b9\u03c0\u03bb\u03cc \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03c4\u03bf\u03c5 \"{subtype}\"", + "remote_button_long_press": "\u03a4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03c4\u03bf\u03c5 \"{subtype}\" \u03c0\u03b1\u03c4\u03ae\u03b8\u03b7\u03ba\u03b5 \u03c0\u03b1\u03c1\u03b1\u03c4\u03b5\u03c4\u03b1\u03bc\u03ad\u03bd\u03b1", + "remote_button_long_release": "\u03a4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03c4\u03bf\u03c5 \"{subtype}\" \u03b1\u03c0\u03b5\u03bb\u03b5\u03c5\u03b8\u03b5\u03c1\u03ce\u03b8\u03b7\u03ba\u03b5 \u03bc\u03b5\u03c4\u03ac \u03b1\u03c0\u03cc \u03c0\u03b1\u03c1\u03b1\u03c4\u03b5\u03c4\u03b1\u03bc\u03ad\u03bd\u03bf \u03c0\u03ac\u03c4\u03b7\u03bc\u03b1", + "remote_button_quadruple_press": "\u03a4\u03b5\u03c4\u03c1\u03b1\u03c0\u03bb\u03cc \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03c4\u03bf\u03c5 \"{subtype}\"", + "remote_button_quintuple_press": "\u03a0\u03b5\u03bd\u03c4\u03b1\u03c0\u03bb\u03cc \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03c4\u03bf\u03c5 \"{subtype}\"", + "remote_button_short_press": "\u03a0\u03b1\u03c4\u03ae\u03b8\u03b7\u03ba\u03b5 \u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03c4\u03bf\u03c5 \"{subtype}\"", + "remote_button_short_release": "\u0391\u03c6\u03ad\u03b8\u03b7\u03ba\u03b5 \u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \u03c4\u03bf\u03c5 \"{subtype}\"", + "remote_button_triple_press": "\u03a4\u03c1\u03b9\u03c0\u03bb\u03cc \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03ba\u03bf\u03c5\u03bc\u03c0\u03af \"{subtype}\"" + } } } \ No newline at end of file diff --git a/homeassistant/components/zha/translations/es.json b/homeassistant/components/zha/translations/es.json index f04614f1f722ef..36ab27c53b591d 100644 --- a/homeassistant/components/zha/translations/es.json +++ b/homeassistant/components/zha/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_zha_device": "Este dispositivo no es un dispositivo zha", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "usb_probe_failed": "No se ha podido sondear el dispositivo usb" }, @@ -9,6 +10,9 @@ }, "flow_title": "ZHA: {name}", "step": { + "confirm": { + "description": "\u00bfQuieres configurar {name} ?" + }, "pick_radio": { "data": { "radio_type": "Tipo de Radio" diff --git a/homeassistant/components/zha/translations/fr.json b/homeassistant/components/zha/translations/fr.json index c8b930a14d59e6..fdb3a8476fe358 100644 --- a/homeassistant/components/zha/translations/fr.json +++ b/homeassistant/components/zha/translations/fr.json @@ -8,7 +8,7 @@ "error": { "cannot_connect": "\u00c9chec de connexion" }, - "flow_title": "ZHA: {name}", + "flow_title": "{name}", "step": { "confirm": { "description": "Voulez-vous configurer {name} ?" diff --git a/homeassistant/components/zha/translations/it.json b/homeassistant/components/zha/translations/it.json index ba2427ebd4ccc0..884c6429cadc09 100644 --- a/homeassistant/components/zha/translations/it.json +++ b/homeassistant/components/zha/translations/it.json @@ -33,15 +33,15 @@ "data": { "path": "Percorso del dispositivo seriale" }, - "description": "Selezionare la porta seriale per la radio Zigbee", + "description": "Seleziona la porta seriale per la radio Zigbee", "title": "ZHA" } } }, "config_panel": { "zha_alarm_options": { - "alarm_arm_requires_code": "Codice necessario per le azioni di armamento", - "alarm_failed_tries": "Il numero di inserimenti consecutivi di codici falliti per attivare un allarme", + "alarm_arm_requires_code": "Codice necessario per le azioni di attivazione", + "alarm_failed_tries": "Il numero di inserimenti consecutivi di codici non validi per attivare un allarme", "alarm_master_code": "Codice principale per i pannelli di controllo degli allarmi", "title": "Opzioni del pannello di controllo degli allarmi" }, @@ -68,7 +68,7 @@ "button_6": "Sesto pulsante", "close": "Chiudere", "dim_down": "Diminuire luminosit\u00e0", - "dim_up": "Aumentare luminosit\u00e0", + "dim_up": "Aumenta luminosit\u00e0", "face_1": "con faccia 1 attivata", "face_2": "con faccia 2 attivata", "face_3": "con faccia 3 attivata", diff --git a/homeassistant/components/zha/translations/ja.json b/homeassistant/components/zha/translations/ja.json index 35e8933220ae49..636df6f047e667 100644 --- a/homeassistant/components/zha/translations/ja.json +++ b/homeassistant/components/zha/translations/ja.json @@ -46,6 +46,8 @@ "title": "\u30a2\u30e9\u30fc\u30e0 \u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30d1\u30cd\u30eb\u306e\u30aa\u30d7\u30b7\u30e7\u30f3" }, "zha_options": { + "consider_unavailable_battery": "(\u79d2)\u5f8c\u306b\u30d0\u30c3\u30c6\u30ea\u30fc\u99c6\u52d5\u306e\u30c7\u30d0\u30a4\u30b9\u304c\u4f7f\u7528\u3067\u304d\u306a\u304f\u306a\u308b\u3068\u898b\u306a\u3059", + "consider_unavailable_mains": "(\u79d2)\u5f8c\u306b\u4e3b\u96fb\u6e90\u304c\u30c7\u30d0\u30a4\u30b9\u304b\u3089\u4f7f\u7528\u3067\u304d\u306a\u304f\u306a\u308b\u3068\u898b\u306a\u3059", "default_light_transition": "\u30c7\u30d5\u30a9\u30eb\u30c8\u306e\u30e9\u30a4\u30c8\u9077\u79fb\u6642\u9593(\u79d2)", "enable_identify_on_join": "\u30c7\u30d0\u30a4\u30b9\u304c\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u306b\u53c2\u52a0\u3059\u308b\u969b\u306b\u3001\u8b58\u5225\u52b9\u679c\u3092\u6709\u52b9\u306b\u3059\u308b", "title": "\u30b0\u30ed\u30fc\u30d0\u30eb\u30aa\u30d7\u30b7\u30e7\u30f3" @@ -53,7 +55,8 @@ }, "device_automation": { "action_type": { - "squawk": "\u30b9\u30b3\u30fc\u30af(Squawk)" + "squawk": "\u30b9\u30b3\u30fc\u30af(Squawk)", + "warn": "Warn" }, "trigger_subtype": { "both_buttons": "\u4e21\u65b9\u306e\u30dc\u30bf\u30f3", @@ -66,6 +69,13 @@ "close": "\u30af\u30ed\u30fc\u30ba", "dim_down": "\u8584\u6697\u304f\u3059\u308b", "dim_up": "\u5fae\u304b\u306b\u660e\u308b\u304f\u3059\u308b", + "face_1": "\u30d5\u30a7\u30a4\u30b91\u304c\u30a2\u30af\u30c6\u30a3\u30d6\u306b\u306a\u3063\u3066\u3044\u308b", + "face_2": "\u30d5\u30a7\u30a4\u30b92\u304c\u30a2\u30af\u30c6\u30a3\u30d6\u306b\u306a\u3063\u3066\u3044\u308b", + "face_3": "\u30d5\u30a7\u30a4\u30b93\u304c\u30a2\u30af\u30c6\u30a3\u30d6\u306b\u306a\u3063\u3066\u3044\u308b", + "face_4": "\u30d5\u30a7\u30a4\u30b94\u304c\u30a2\u30af\u30c6\u30a3\u30d6\u306b\u306a\u3063\u3066\u3044\u308b", + "face_5": "\u30d5\u30a7\u30a4\u30b95\u304c\u30a2\u30af\u30c6\u30a3\u30d6\u306b\u306a\u3063\u3066\u3044\u308b", + "face_6": "\u30d5\u30a7\u30a4\u30b96\u304c\u30a2\u30af\u30c6\u30a3\u30d6\u306b\u306a\u3063\u3066\u3044\u308b", + "face_any": "\u4efb\u610f/\u6307\u5b9a\u3055\u308c\u305f\u30d5\u30a7\u30a4\u30b9\u304c\u30a2\u30af\u30c6\u30a3\u30d6\u306b\u306a\u3063\u3066\u3044\u308b", "left": "\u5de6", "open": "\u30aa\u30fc\u30d7\u30f3", "right": "\u53f3", @@ -73,24 +83,29 @@ "turn_on": "\u30aa\u30f3\u306b\u3059\u308b" }, "trigger_type": { + "device_dropped": "\u30c7\u30d0\u30a4\u30b9dropped", "device_flipped": "\u30c7\u30d0\u30a4\u30b9\u304c\u53cd\u8ee2\u3057\u307e\u3057\u305f \"{subtype}\"", "device_knocked": "\u30c7\u30d0\u30a4\u30b9\u304c\u30ce\u30c3\u30af\u3055\u308c\u307e\u3057\u305f \"{subtype}\"", "device_offline": "\u30c7\u30d0\u30a4\u30b9\u304c\u30aa\u30d5\u30e9\u30a4\u30f3", - "device_rotated": "\u30c7\u30d0\u30a4\u30b9\u304c\u56de\u8ee2\u3057\u307e\u3057\u305f \"{subtype}\"", + "device_rotated": "\u30c7\u30d0\u30a4\u30b9\u304c\u56de\u8ee2\u3057\u305f \"{subtype}\"", "device_shaken": "\u30c7\u30d0\u30a4\u30b9\u304c\u63fa\u308c\u308b", "device_slid": "\u30c7\u30d0\u30a4\u30b9 \u30b9\u30e9\u30a4\u30c9 \"{subtype}\"", "device_tilted": "\u30c7\u30d0\u30a4\u30b9\u304c\u50be\u3044\u3066\u3044\u308b", "remote_button_alt_double_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u30c0\u30d6\u30eb\u30af\u30ea\u30c3\u30af(\u4ee3\u66ff\u30e2\u30fc\u30c9)", "remote_button_alt_long_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u62bc\u3057\u7d9a\u3051\u308b(\u4ee3\u66ff\u30e2\u30fc\u30c9)", + "remote_button_alt_long_release": "\u9577\u62bc\u3057\u3059\u308b\u3068 \"{subtype}\" \u30dc\u30bf\u30f3\u304c\u30ea\u30ea\u30fc\u30b9\u3055\u308c\u308b(\u4ee3\u66ff\u30e2\u30fc\u30c9)", "remote_button_alt_quadruple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30924\u56de(quadruple)\u30af\u30ea\u30c3\u30af(\u4ee3\u66ff\u30e2\u30fc\u30c9)", "remote_button_alt_quintuple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30925\u56de(quintuple)\u30af\u30ea\u30c3\u30af(\u4ee3\u66ff\u30e2\u30fc\u30c9)", "remote_button_alt_short_press": "\"{subtype}\" \u62bc\u3057\u7d9a\u3051\u308b(\u4ee3\u66ff\u30e2\u30fc\u30c9)", + "remote_button_alt_short_release": "\"{subtype}\" \u30dc\u30bf\u30f3\u304c\u30ea\u30ea\u30fc\u30b9\u3055\u308c\u307e\u3057\u305f(\u4ee3\u66ff\u30e2\u30fc\u30c9)", "remote_button_alt_triple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30923\u56de\u30af\u30ea\u30c3\u30af(\u4ee3\u66ff\u30e2\u30fc\u30c9)", "remote_button_double_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u30c0\u30d6\u30eb\u30af\u30ea\u30c3\u30af", "remote_button_long_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u3092\u62bc\u3057\u7d9a\u3051\u308b", + "remote_button_long_release": "\u9577\u62bc\u3057\u3059\u308b\u3068 \"{subtype}\" \u30dc\u30bf\u30f3\u304c\u30ea\u30ea\u30fc\u30b9\u3055\u308c\u308b", "remote_button_quadruple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30924\u56de(quadruple)\u30af\u30ea\u30c3\u30af", "remote_button_quintuple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30925\u56de(quintuple)\u30af\u30ea\u30c3\u30af", "remote_button_short_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u304c\u62bc\u3055\u308c\u307e\u3057\u305f\u3002", + "remote_button_short_release": "\"{subtype}\" \u30dc\u30bf\u30f3\u304c\u30ea\u30ea\u30fc\u30b9\u3055\u308c\u307e\u3057\u305f", "remote_button_triple_press": "\"{subtype}\" \u30dc\u30bf\u30f3\u30923\u56de\u30af\u30ea\u30c3\u30af" } } diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index ad7b81dfe7b607..6c04775d233b29 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -1,4 +1,6 @@ """Support for ZhongHong HVAC Controller.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -23,11 +25,14 @@ EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -71,7 +76,12 @@ } -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the ZhongHong HVAC platform.""" host = config.get(CONF_HOST) diff --git a/homeassistant/components/ziggo_mediabox_xl/media_player.py b/homeassistant/components/ziggo_mediabox_xl/media_player.py index d3493f0dc35652..59e6e56e2c5cac 100644 --- a/homeassistant/components/ziggo_mediabox_xl/media_player.py +++ b/homeassistant/components/ziggo_mediabox_xl/media_player.py @@ -1,4 +1,6 @@ """Support for interface with a Ziggo Mediabox XL.""" +from __future__ import annotations + import logging import socket @@ -22,7 +24,10 @@ STATE_PAUSED, STATE_PLAYING, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -43,18 +48,22 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Ziggo Mediabox XL platform.""" hass.data[DATA_KNOWN_DEVICES] = known_devices = set() # Is this a manual configuration? - if config.get(CONF_HOST) is not None: - host = config.get(CONF_HOST) + if (host := config.get(CONF_HOST)) is not None: name = config.get(CONF_NAME) manual_config = True elif discovery_info is not None: - host = discovery_info.get("host") + host = discovery_info["host"] name = discovery_info.get("name") manual_config = False else: diff --git a/homeassistant/components/zodiac/__init__.py b/homeassistant/components/zodiac/__init__.py index c19b7a45ac2423..35d4d2eefbfb0c 100644 --- a/homeassistant/components/zodiac/__init__.py +++ b/homeassistant/components/zodiac/__init__.py @@ -1,6 +1,7 @@ """The zodiac component.""" import voluptuous as vol +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType @@ -15,6 +16,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the zodiac component.""" - hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) + hass.async_create_task( + async_load_platform(hass, Platform.SENSOR, DOMAIN, {}, config) + ) return True diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index d4474d793ab258..dd327acbf7582d 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -284,7 +284,6 @@ def __init__(self, config: dict) -> None: """Initialize the zone.""" self._config = config self.editable = True - self._attrs: dict | None = None self._generate_attrs() @classmethod @@ -315,11 +314,6 @@ def icon(self) -> str | None: """Return the icon if any.""" return self._config.get(CONF_ICON) - @property - def extra_state_attributes(self) -> dict | None: - """Return the state attributes of the zone.""" - return self._attrs - @property def should_poll(self) -> bool: """Zone does not poll.""" @@ -336,7 +330,7 @@ async def async_update_config(self, config: dict) -> None: @callback def _generate_attrs(self) -> None: """Generate new attrs based on config.""" - self._attrs = { + self._attr_extra_state_attributes = { ATTR_LATITUDE: self._config[CONF_LATITUDE], ATTR_LONGITUDE: self._config[CONF_LONGITUDE], ATTR_RADIUS: self._config[CONF_RADIUS], diff --git a/homeassistant/components/zone/translations/el.json b/homeassistant/components/zone/translations/el.json new file mode 100644 index 00000000000000..c71e66f6434af5 --- /dev/null +++ b/homeassistant/components/zone/translations/el.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "name_exists": "\u03a4\u03bf \u03cc\u03bd\u03bf\u03bc\u03b1 \u03c5\u03c0\u03ac\u03c1\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7" + }, + "step": { + "init": { + "data": { + "icon": "\u0395\u03b9\u03ba\u03bf\u03bd\u03af\u03b4\u03b9\u03bf" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/translations/is.json b/homeassistant/components/zone/translations/is.json new file mode 100644 index 00000000000000..392551912ec3fa --- /dev/null +++ b/homeassistant/components/zone/translations/is.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "init": { + "data": { + "latitude": "Breiddargr\u00e1\u00f0a", + "longitude": "Lengdargr\u00e1\u00f0a", + "name": "Heiti" + } + } + }, + "title": "Sv\u00e6\u00f0i" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index 373727e3f4df90..a008a30007abe4 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -1,4 +1,6 @@ """Offer zone automation rules.""" +import logging + import voluptuous as vol from homeassistant.const import ( @@ -25,6 +27,8 @@ EVENT_LEAVE = "leave" DEFAULT_EVENT = EVENT_ENTER +_LOGGER = logging.getLogger(__name__) + _EVENT_DESCRIPTION = {EVENT_ENTER: "entering", EVENT_LEAVE: "leaving"} _TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( @@ -76,6 +80,14 @@ def zone_automation_listener(zone_event): return zone_state = hass.states.get(zone_entity_id) + if not zone_state: + _LOGGER.warning( + "Automation '%s' is referencing non-existing zone '%s' in a zone trigger", + automation_info["name"], + zone_entity_id, + ) + return + from_match = condition.zone(hass, zone_state, from_s) if from_s else False to_match = condition.zone(hass, zone_state, to_s) if to_s else False diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index c631406b0e34f5..5e9c881af85763 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -13,9 +13,12 @@ CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, + Platform, ) +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -50,7 +53,7 @@ ) -def setup(hass, config): +def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ZoneMinder component.""" hass.data[DOMAIN] = {} @@ -74,7 +77,7 @@ def setup(hass, config): success = zm_client.login() and success - def set_active_state(call): + def set_active_state(call: ServiceCall) -> None: """Set the ZoneMinder run state to the given state name.""" zm_id = call.data[ATTR_ID] state_name = call.data[ATTR_NAME] @@ -92,7 +95,7 @@ def set_active_state(call): ) hass.async_create_task( - async_load_platform(hass, "binary_sensor", DOMAIN, {}, config) + async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) ) return success diff --git a/homeassistant/components/zoneminder/binary_sensor.py b/homeassistant/components/zoneminder/binary_sensor.py index 73d6877ef2d7f8..21f4588555c200 100644 --- a/homeassistant/components/zoneminder/binary_sensor.py +++ b/homeassistant/components/zoneminder/binary_sensor.py @@ -1,19 +1,28 @@ """Support for ZoneMinder binary sensors.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_CONNECTIVITY, + BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as ZONEMINDER_DOMAIN -async def async_setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the ZoneMinder binary sensor platform.""" sensors = [] for host_name, zm_client in hass.data[ZONEMINDER_DOMAIN].items(): sensors.append(ZMAvailabilitySensor(host_name, zm_client)) add_entities(sensors) - return True class ZMAvailabilitySensor(BinarySensorEntity): @@ -38,7 +47,7 @@ def is_on(self): @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_CONNECTIVITY + return BinarySensorDeviceClass.CONNECTIVITY def update(self): """Update the state of this sensor (availability of ZoneMinder).""" diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index 0f9f5e2f679e07..70e9548414eede 100644 --- a/homeassistant/components/zoneminder/camera.py +++ b/homeassistant/components/zoneminder/camera.py @@ -1,4 +1,6 @@ """Support for ZoneMinder camera streaming.""" +from __future__ import annotations + import logging from homeassistant.components.mjpeg.camera import ( @@ -8,13 +10,21 @@ filter_urllib3_logging, ) from homeassistant.const import CONF_NAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as ZONEMINDER_DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the ZoneMinder cameras.""" filter_urllib3_logging() cameras = [] diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 90c5f8d78eb8cb..53ed16c037ba98 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -12,7 +12,10 @@ SensorEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as ZONEMINDER_DOMAIN @@ -59,12 +62,17 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the ZoneMinder sensor platform.""" include_archived = config[CONF_INCLUDE_ARCHIVED] monitored_conditions = config[CONF_MONITORED_CONDITIONS] - sensors = [] + sensors: list[SensorEntity] = [] for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): if not (monitors := zm_client.get_monitors()): _LOGGER.warning("Could not fetch any monitors from ZoneMinder") diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index b7ba3f48f10b34..bd7f55915d1cf3 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -1,4 +1,6 @@ """Support for ZoneMinder switches.""" +from __future__ import annotations + import logging import voluptuous as vol @@ -6,7 +8,10 @@ from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as ZONEMINDER_DOMAIN @@ -20,7 +25,12 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the ZoneMinder switch platform.""" on_state = MonitorState(config.get(CONF_COMMAND_ON)) diff --git a/homeassistant/components/zoneminder/translations/it.json b/homeassistant/components/zoneminder/translations/it.json index cf2a3a6355369a..7078747dc97186 100644 --- a/homeassistant/components/zoneminder/translations/it.json +++ b/homeassistant/components/zoneminder/translations/it.json @@ -25,9 +25,9 @@ "path_zms": "Percorso ZMS", "ssl": "Utilizza un certificato SSL", "username": "Nome utente", - "verify_ssl": "Verificare il certificato SSL" + "verify_ssl": "Verifica il certificato SSL" }, - "title": "Aggiungi Server ZoneMinder." + "title": "Aggiungi server ZoneMinder." } } } diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index bfbd3925c5562f..cd0bda6735ff24 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -1,5 +1,7 @@ """Support for Z-Wave.""" # pylint: disable=import-outside-toplevel +from __future__ import annotations + import asyncio import copy from importlib import import_module @@ -15,8 +17,9 @@ ATTR_VIA_DEVICE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + Platform, ) -from homeassistant.core import CoreState, callback +from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall, callback from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import ( @@ -34,6 +37,7 @@ ) from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.event import async_track_time_change +from homeassistant.helpers.typing import ConfigType from homeassistant.util import convert import homeassistant.util.dt as dt_util @@ -98,14 +102,14 @@ DEFAULT_CONF_REFRESH_DELAY = 5 PLATFORMS = [ - "binary_sensor", - "climate", - "cover", - "fan", - "lock", - "light", - "sensor", - "switch", + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.SENSOR, + Platform.SWITCH, ] RENAME_NODE_SCHEMA = vol.Schema( @@ -315,7 +319,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= return True -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Z-Wave components.""" if DOMAIN not in config: return True @@ -341,7 +345,9 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, config_entry): # noqa: C901 +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: """Set up Z-Wave from a config entry. Will automatically load components to support devices found on the network. @@ -578,49 +584,49 @@ def network_complete_some_dead(): weak=False, ) - def add_node(service): + def add_node(service: ServiceCall) -> None: """Switch into inclusion mode.""" _LOGGER.info("Z-Wave add_node have been initialized") network.controller.add_node() - def add_node_secure(service): + def add_node_secure(service: ServiceCall) -> None: """Switch into secure inclusion mode.""" _LOGGER.info("Z-Wave add_node_secure have been initialized") network.controller.add_node(True) - def remove_node(service): + def remove_node(service: ServiceCall) -> None: """Switch into exclusion mode.""" _LOGGER.info("Z-Wave remove_node have been initialized") network.controller.remove_node() - def cancel_command(service): + def cancel_command(service: ServiceCall) -> None: """Cancel a running controller command.""" _LOGGER.info("Cancel running Z-Wave command") network.controller.cancel_command() - def heal_network(service): + def heal_network(service: ServiceCall) -> None: """Heal the network.""" _LOGGER.info("Z-Wave heal running") network.heal() - def soft_reset(service): + def soft_reset(service: ServiceCall) -> None: """Soft reset the controller.""" _LOGGER.info("Z-Wave soft_reset have been initialized") network.controller.soft_reset() - def test_network(service): + def test_network(service: ServiceCall) -> None: """Test the network by sending commands to all the nodes.""" _LOGGER.info("Z-Wave test_network have been initialized") network.test() - def stop_network(_service_or_event): + def stop_network(_service_or_event: Event | ServiceCall) -> None: """Stop Z-Wave network.""" _LOGGER.info("Stopping Z-Wave network") network.stop() if hass.state == CoreState.running: hass.bus.fire(const.EVENT_NETWORK_STOP) - async def rename_node(service): + async def rename_node(service: ServiceCall) -> None: """Rename a node.""" node_id = service.data.get(const.ATTR_NODE_ID) node = network.nodes[node_id] # pylint: disable=unsubscriptable-object @@ -639,7 +645,7 @@ async def rename_node(service): entity = hass.data[DATA_DEVICES][key] await entity.value_renamed(update_ids) - async def rename_value(service): + async def rename_value(service: ServiceCall) -> None: """Rename a node value.""" node_id = service.data.get(const.ATTR_NODE_ID) value_id = service.data.get(const.ATTR_VALUE_ID) @@ -655,7 +661,7 @@ async def rename_value(service): entity = hass.data[DATA_DEVICES][value_key] await entity.value_renamed(update_ids) - def set_poll_intensity(service): + def set_poll_intensity(service: ServiceCall) -> None: """Set the polling intensity of a node value.""" node_id = service.data.get(const.ATTR_NODE_ID) value_id = service.data.get(const.ATTR_VALUE_ID) @@ -682,19 +688,19 @@ def set_poll_intensity(service): "Set polling intensity failed (Node %d Value %d)", node_id, value_id ) - def remove_failed_node(service): + def remove_failed_node(service: ServiceCall) -> None: """Remove failed node.""" node_id = service.data.get(const.ATTR_NODE_ID) _LOGGER.info("Trying to remove zwave node %d", node_id) network.controller.remove_failed_node(node_id) - def replace_failed_node(service): + def replace_failed_node(service: ServiceCall) -> None: """Replace failed node.""" node_id = service.data.get(const.ATTR_NODE_ID) _LOGGER.info("Trying to replace zwave node %d", node_id) network.controller.replace_failed_node(node_id) - def set_config_parameter(service): + def set_config_parameter(service: ServiceCall) -> None: """Set a config parameter to a node.""" node_id = service.data.get(const.ATTR_NODE_ID) node = network.nodes[node_id] # pylint: disable=unsubscriptable-object @@ -751,7 +757,7 @@ def set_config_parameter(service): selection, ) - def refresh_node_value(service): + def refresh_node_value(service: ServiceCall) -> None: """Refresh the specified value from a node.""" node_id = service.data.get(const.ATTR_NODE_ID) value_id = service.data.get(const.ATTR_VALUE_ID) @@ -759,7 +765,7 @@ def refresh_node_value(service): node.values[value_id].refresh() _LOGGER.info("Node %s value %s refreshed", node_id, value_id) - def set_node_value(service): + def set_node_value(service: ServiceCall) -> None: """Set the specified value on a node.""" node_id = service.data.get(const.ATTR_NODE_ID) value_id = service.data.get(const.ATTR_VALUE_ID) @@ -768,7 +774,7 @@ def set_node_value(service): node.values[value_id].data = value _LOGGER.info("Node %s value %s set to %s", node_id, value_id, value) - def print_config_parameter(service): + def print_config_parameter(service: ServiceCall) -> None: """Print a config parameter from a node.""" node_id = service.data.get(const.ATTR_NODE_ID) node = network.nodes[node_id] # pylint: disable=unsubscriptable-object @@ -780,13 +786,13 @@ def print_config_parameter(service): get_config_value(node, param), ) - def print_node(service): + def print_node(service: ServiceCall) -> None: """Print all information about z-wave node.""" node_id = service.data.get(const.ATTR_NODE_ID) node = network.nodes[node_id] # pylint: disable=unsubscriptable-object nice_print_node(node) - def set_wakeup(service): + def set_wakeup(service: ServiceCall) -> None: """Set wake-up interval of a node.""" node_id = service.data.get(const.ATTR_NODE_ID) node = network.nodes[node_id] # pylint: disable=unsubscriptable-object @@ -798,7 +804,7 @@ def set_wakeup(service): else: _LOGGER.info("Node %s is not wakeable", node_id) - def change_association(service): + def change_association(service: ServiceCall) -> None: """Change an association in the zwave network.""" association_type = service.data.get(const.ATTR_ASSOCIATION) node_id = service.data.get(const.ATTR_NODE_ID) @@ -828,18 +834,18 @@ def change_association(service): instance, ) - async def async_refresh_entity(service): + async def async_refresh_entity(service: ServiceCall) -> None: """Refresh values that specific entity depends on.""" entity_id = service.data.get(ATTR_ENTITY_ID) async_dispatcher_send(hass, SIGNAL_REFRESH_ENTITY_FORMAT.format(entity_id)) - def refresh_node(service): + def refresh_node(service: ServiceCall) -> None: """Refresh all node info.""" node_id = service.data.get(const.ATTR_NODE_ID) node = network.nodes[node_id] # pylint: disable=unsubscriptable-object node.refresh_info() - def reset_node_meters(service): + def reset_node_meters(service: ServiceCall) -> None: """Reset meter counters of a node.""" node_id = service.data.get(const.ATTR_NODE_ID) instance = service.data.get(const.ATTR_INSTANCE) @@ -857,7 +863,7 @@ def reset_node_meters(service): "Node %s on instance %s does not have resettable meters", node_id, instance ) - def heal_node(service): + def heal_node(service: ServiceCall) -> None: """Heal a node on the network.""" node_id = service.data.get(const.ATTR_NODE_ID) update_return_routes = service.data.get(const.ATTR_RETURN_ROUTES) @@ -865,7 +871,7 @@ def heal_node(service): _LOGGER.info("Z-Wave node heal running for node %s", node_id) node.heal(update_return_routes) - def test_node(service): + def test_node(service: ServiceCall) -> None: """Send test messages to a node on the network.""" node_id = service.data.get(const.ATTR_NODE_ID) messages = service.data.get(const.ATTR_MESSAGES) @@ -873,7 +879,7 @@ def test_node(service): _LOGGER.info("Sending %s test-messages to node %s", messages, node_id) node.test(messages) - def start_zwave(_service_or_event): + def start_zwave(_service_or_event: ServiceCall | Event) -> None: """Startup Z-Wave network.""" _LOGGER.info("Starting Z-Wave network") network.start() @@ -1021,10 +1027,7 @@ def _finalize_start(): hass.services.async_register(DOMAIN, const.SERVICE_START_NETWORK, start_zwave) - for entry_component in PLATFORMS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(config_entry, entry_component) - ) + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/zwave/binary_sensor.py b/homeassistant/components/zwave/binary_sensor.py index 094279c4e7af5a..26944b6661dd42 100644 --- a/homeassistant/components/zwave/binary_sensor.py +++ b/homeassistant/components/zwave/binary_sensor.py @@ -3,8 +3,10 @@ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_point_in_time import homeassistant.util.dt as dt_util @@ -14,7 +16,11 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Z-Wave binary sensors from Config Entry.""" @callback @@ -59,7 +65,7 @@ def is_on(self): @property def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" + """Return the class of this sensor, from BinarySensorDeviceClass.""" return self._sensor_type diff --git a/homeassistant/components/zwave/climate.py b/homeassistant/components/zwave/climate.py index 0519d42a59c388..d56910e1b74d4d 100644 --- a/homeassistant/components/zwave/climate.py +++ b/homeassistant/components/zwave/climate.py @@ -31,9 +31,11 @@ SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ZWaveDeviceEntity, const @@ -129,7 +131,11 @@ ] -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Z-Wave Climate device from Config Entry.""" @callback diff --git a/homeassistant/components/zwave/cover.py b/homeassistant/components/zwave/cover.py index 688ee66667676e..2a49a34554b626 100644 --- a/homeassistant/components/zwave/cover.py +++ b/homeassistant/components/zwave/cover.py @@ -6,10 +6,13 @@ DOMAIN, SUPPORT_CLOSE, SUPPORT_OPEN, + CoverDeviceClass, CoverEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ( CONF_INVERT_OPENCLOSE_BUTTONS, @@ -29,7 +32,11 @@ SUPPORT_GARAGE = SUPPORT_OPEN | SUPPORT_CLOSE -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Z-Wave Cover from Config Entry.""" @callback @@ -156,8 +163,8 @@ def update_properties(self): @property def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return "garage" + """Return the class of this device, from CoverDeviceClass.""" + return CoverDeviceClass.GARAGE @property def supported_features(self): diff --git a/homeassistant/components/zwave/fan.py b/homeassistant/components/zwave/fan.py index 7fb0fb8e8bee5f..b368e829eb7d78 100644 --- a/homeassistant/components/zwave/fan.py +++ b/homeassistant/components/zwave/fan.py @@ -2,8 +2,10 @@ import math from homeassistant.components.fan import DOMAIN, SUPPORT_SET_SPEED, FanEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( int_states_in_range, percentage_to_ranged_value, @@ -17,7 +19,11 @@ SPEED_RANGE = (1, 99) # off is not included -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Z-Wave Fan from Config Entry.""" @callback diff --git a/homeassistant/components/zwave/light.py b/homeassistant/components/zwave/light.py index 140f601b1d9f55..a029fa35a65018 100644 --- a/homeassistant/components/zwave/light.py +++ b/homeassistant/components/zwave/light.py @@ -5,21 +5,22 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, - ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, ATTR_TRANSITION, - ATTR_WHITE_VALUE, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, DOMAIN, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION, - SUPPORT_WHITE_VALUE, LightEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -import homeassistant.util.color as color_util +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import CONF_REFRESH_DELAY, CONF_REFRESH_VALUE, ZWaveDeviceEntity, const @@ -61,7 +62,11 @@ TEMP_COLD_HASS = (TEMP_COLOR_MAX - TEMP_COLOR_MIN) / 3 + TEMP_COLOR_MIN -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Z-Wave Light from Config Entry.""" @callback @@ -108,16 +113,6 @@ def byte_to_zwave_brightness(value): return 0 -def ct_to_hs(temp): - """Convert color temperature (mireds) to hs.""" - colorlist = list( - color_util.color_temperature_to_hs( - color_util.color_temperature_mired_to_kelvin(temp) - ) - ) - return [int(val) for val in colorlist] - - class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): """Representation of a Z-Wave dimmer.""" @@ -126,6 +121,8 @@ def __init__(self, values, refresh, delay): ZWaveDeviceEntity.__init__(self, values, DOMAIN) self._brightness = None self._state = None + self._color_mode = None + self._supported_color_modes = set() self._supported_features = None self._delay = delay self._refresh_value = refresh @@ -161,9 +158,10 @@ def update_properties(self): def value_added(self): """Call when a new value is added to this entity.""" - self._supported_features = SUPPORT_BRIGHTNESS + self._supported_color_modes = {COLOR_MODE_BRIGHTNESS} + self._color_mode = COLOR_MODE_BRIGHTNESS if self.values.dimming_duration is not None: - self._supported_features |= SUPPORT_TRANSITION + self._supported_features = SUPPORT_TRANSITION def value_changed(self): """Call when a value for this entity's node has changed.""" @@ -195,6 +193,16 @@ def is_on(self): """Return true if device is on.""" return self._state == STATE_ON + @property + def color_mode(self): + """Return the current color mode.""" + return self._color_mode + + @property + def supported_color_modes(self): + """Flag supported color modes.""" + return self._supported_color_modes + @property def supported_features(self): """Flag supported features.""" @@ -260,7 +268,7 @@ class ZwaveColorLight(ZwaveDimmer): def __init__(self, values, refresh, delay): """Initialize the light.""" self._color_channels = None - self._hs = None + self._rgb = None self._ct = None self._white = None @@ -268,15 +276,18 @@ def __init__(self, values, refresh, delay): def value_added(self): """Call when a new value is added to this entity.""" - super().value_added() + if self.values.dimming_duration is not None: + self._supported_features = SUPPORT_TRANSITION - self._supported_features |= SUPPORT_COLOR + self._supported_color_modes = {COLOR_MODE_RGB} + self._color_mode = COLOR_MODE_RGB if self._zw098: - self._supported_features |= SUPPORT_COLOR_TEMP + self._supported_color_modes.add(COLOR_MODE_COLOR_TEMP) elif self._color_channels is not None and self._color_channels & ( COLOR_CHANNEL_WARM_WHITE | COLOR_CHANNEL_COLD_WHITE ): - self._supported_features |= SUPPORT_WHITE_VALUE + self._supported_color_modes = {COLOR_MODE_RGBW} + self._color_mode = COLOR_MODE_RGBW def update_properties(self): """Update internal properties based on zwave values.""" @@ -294,8 +305,7 @@ def update_properties(self): data = self.values.color.data # RGB is always present in the openzwave color data string. - rgb = [int(data[1:3], 16), int(data[3:5], 16), int(data[5:7], 16)] - self._hs = color_util.color_RGB_to_hs(*rgb) + self._rgb = (int(data[1:3], 16), int(data[3:5], 16), int(data[5:7], 16)) # Parse remaining color channels. Openzwave appends white channels # that are present. @@ -321,13 +331,12 @@ def update_properties(self): if self._zw098: if warm_white > 0: self._ct = TEMP_WARM_HASS - self._hs = ct_to_hs(self._ct) + self._color_mode = COLOR_MODE_COLOR_TEMP elif cold_white > 0: self._ct = TEMP_COLD_HASS - self._hs = ct_to_hs(self._ct) + self._color_mode = COLOR_MODE_COLOR_TEMP else: - # RGB color is being used. Just report midpoint. - self._ct = TEMP_MID_HASS + self._color_mode = COLOR_MODE_RGB elif self._color_channels & COLOR_CHANNEL_WARM_WHITE: self._white = warm_white @@ -341,17 +350,19 @@ def update_properties(self): or self._color_channels & COLOR_CHANNEL_GREEN or self._color_channels & COLOR_CHANNEL_BLUE ): - self._hs = None + self._rgb = None @property - def hs_color(self): - """Return the hs color.""" - return self._hs + def rgb_color(self): + """Return the rgb color.""" + return self._rgb @property - def white_value(self): - """Return the white value of this light between 0..255.""" - return self._white + def rgbw_color(self): + """Return the rgbw color.""" + if self._rgb is None: + return None + return (*self._rgb, self._white) @property def color_temp(self): @@ -362,31 +373,28 @@ def turn_on(self, **kwargs): """Turn the device on.""" rgbw = None - if ATTR_WHITE_VALUE in kwargs: - self._white = kwargs[ATTR_WHITE_VALUE] - if ATTR_COLOR_TEMP in kwargs: # Color temperature. With the AEOTEC ZW098 bulb, only two color # temperatures are supported. The warm and cold channel values # indicate brightness for warm/cold color temperature. if self._zw098: + self._color_mode = COLOR_MODE_COLOR_TEMP if kwargs[ATTR_COLOR_TEMP] > TEMP_MID_HASS: self._ct = TEMP_WARM_HASS rgbw = "#000000ff00" else: self._ct = TEMP_COLD_HASS rgbw = "#00000000ff" - elif ATTR_HS_COLOR in kwargs: - self._hs = kwargs[ATTR_HS_COLOR] - if ATTR_WHITE_VALUE not in kwargs: - # white LED must be off in order for color to work - self._white = 0 - - if ( - ATTR_WHITE_VALUE in kwargs or ATTR_HS_COLOR in kwargs - ) and self._hs is not None: + elif ATTR_RGB_COLOR in kwargs: + self._rgb = kwargs[ATTR_RGB_COLOR] + self._white = 0 + elif ATTR_RGBW_COLOR in kwargs: + self._rgb = kwargs[ATTR_RGBW_COLOR][0:3] + self._white = kwargs[ATTR_RGBW_COLOR][3] + + if ATTR_RGB_COLOR in kwargs or ATTR_RGBW_COLOR in kwargs: rgbw = "#" - for colorval in color_util.color_hs_to_RGB(*self._hs): + for colorval in self._rgb: rgbw += format(colorval, "02x") if self._white is not None: rgbw += format(self._white, "02x") + "00" diff --git a/homeassistant/components/zwave/lock.py b/homeassistant/components/zwave/lock.py index bc49f9c0bd222e..06ce59a1f9eb13 100644 --- a/homeassistant/components/zwave/lock.py +++ b/homeassistant/components/zwave/lock.py @@ -4,9 +4,11 @@ import voluptuous as vol from homeassistant.components.lock import DOMAIN, LockEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ZWaveDeviceEntity, const @@ -157,7 +159,11 @@ ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Z-Wave Lock from Config Entry.""" @callback @@ -169,7 +175,7 @@ def async_add_lock(lock): network = hass.data[const.DATA_NETWORK] - def set_usercode(service): + def set_usercode(service: ServiceCall) -> None: """Set the usercode to index X on the lock.""" node_id = service.data.get(const.ATTR_NODE_ID) lock_node = network.nodes[node_id] @@ -193,7 +199,7 @@ def set_usercode(service): value.data = str(usercode) break - def get_usercode(service): + def get_usercode(service: ServiceCall) -> None: """Get a usercode at index X on the lock.""" node_id = service.data.get(const.ATTR_NODE_ID) lock_node = network.nodes[node_id] @@ -207,7 +213,7 @@ def get_usercode(service): _LOGGER.info("Usercode at slot %s is: %s", value.index, value.data) break - def clear_usercode(service): + def clear_usercode(service: ServiceCall) -> None: """Set usercode to slot X on the lock.""" node_id = service.data.get(const.ATTR_NODE_ID) lock_node = network.nodes[node_id] diff --git a/homeassistant/components/zwave/sensor.py b/homeassistant/components/zwave/sensor.py index 75046c2f9d8942..1f32f8bb681982 100644 --- a/homeassistant/components/zwave/sensor.py +++ b/homeassistant/components/zwave/sensor.py @@ -1,13 +1,19 @@ """Support for Z-Wave sensors.""" -from homeassistant.components.sensor import DEVICE_CLASS_BATTERY, DOMAIN, SensorEntity -from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.core import callback +from homeassistant.components.sensor import DOMAIN, SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ZWaveDeviceEntity, const -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Z-Wave Sensor from Config Entry.""" @callback @@ -83,7 +89,7 @@ def native_value(self): def device_class(self): """Return the class of this device.""" if self._units in ["C", "F"]: - return DEVICE_CLASS_TEMPERATURE + return SensorDeviceClass.TEMPERATURE return None @property @@ -115,4 +121,4 @@ class ZWaveBatterySensor(ZWaveSensor): @property def device_class(self): """Return the class of this device.""" - return DEVICE_CLASS_BATTERY + return SensorDeviceClass.BATTERY diff --git a/homeassistant/components/zwave/switch.py b/homeassistant/components/zwave/switch.py index 06606dac938b0d..f7d3471e2ab896 100644 --- a/homeassistant/components/zwave/switch.py +++ b/homeassistant/components/zwave/switch.py @@ -2,13 +2,19 @@ import time from homeassistant.components.switch import DOMAIN, SwitchEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ZWaveDeviceEntity, workaround -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Z-Wave Switch from Config Entry.""" @callback diff --git a/homeassistant/components/zwave/translations/el.json b/homeassistant/components/zwave/translations/el.json index b047ad7158af2f..1663d4975a9f69 100644 --- a/homeassistant/components/zwave/translations/el.json +++ b/homeassistant/components/zwave/translations/el.json @@ -1,4 +1,14 @@ { + "config": { + "step": { + "user": { + "data": { + "network_key": "\u039a\u03bb\u03b5\u03b9\u03b4\u03af \u03b4\u03b9\u03ba\u03c4\u03cd\u03bf\u03c5 (\u03b1\u03c6\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf \u03ba\u03b5\u03bd\u03cc \u03b3\u03b9\u03b1 \u03b1\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03b7 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1)" + }, + "description": "\u0391\u03c5\u03c4\u03ae \u03b7 \u03b5\u03bd\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 \u03b4\u03b5\u03bd \u03b4\u03b9\u03b1\u03c4\u03b7\u03c1\u03b5\u03af\u03c4\u03b1\u03b9 \u03c0\u03bb\u03ad\u03bf\u03bd. \u0393\u03b9\u03b1 \u03bd\u03ad\u03b5\u03c2 \u03b5\u03b3\u03ba\u03b1\u03c4\u03b1\u03c3\u03c4\u03ac\u03c3\u03b5\u03b9\u03c2, \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf Z-Wave JS. \n\n \u0394\u03b5\u03af\u03c4\u03b5 https://www.home-assistant.io/docs/z-wave/installation/ \u03b3\u03b9\u03b1 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2 \u03c3\u03c7\u03b5\u03c4\u03b9\u03ba\u03ac \u03bc\u03b5 \u03c4\u03b9\u03c2 \u03bc\u03b5\u03c4\u03b1\u03b2\u03bb\u03b7\u03c4\u03ad\u03c2 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7\u03c2" + } + } + }, "state": { "_": { "dead": "\u039d\u03b5\u03ba\u03c1\u03cc", diff --git a/homeassistant/components/zwave/translations/it.json b/homeassistant/components/zwave/translations/it.json index a99cc241633046..17207c23b5003d 100644 --- a/homeassistant/components/zwave/translations/it.json +++ b/homeassistant/components/zwave/translations/it.json @@ -5,7 +5,7 @@ "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "error": { - "option_error": "Convalida Z-Wave fallita. Il percorso della chiavetta USB \u00e8 corretto?" + "option_error": "Convalida Z-Wave non riuscita. Il percorso della chiavetta USB \u00e8 corretto?" }, "step": { "user": { diff --git a/homeassistant/components/zwave/translations/zh-Hant.json b/homeassistant/components/zwave/translations/zh-Hant.json index 4be9b77a8c6b77..f7979daff9e4f0 100644 --- a/homeassistant/components/zwave/translations/zh-Hant.json +++ b/homeassistant/components/zwave/translations/zh-Hant.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "network_key": "\u7db2\u8def\u5bc6\u9470\uff08\u4fdd\u7559\u7a7a\u767d\u5c07\u6703\u81ea\u52d5\u7522\u751f\uff09", + "network_key": "\u7db2\u8def\u91d1\u9470\uff08\u4fdd\u7559\u7a7a\u767d\u5c07\u6703\u81ea\u52d5\u7522\u751f\uff09", "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" }, "description": "\u6b64\u6574\u5408\u5df2\u7d93\u4e0d\u518d\u9032\u884c\u7dad\u8b77\uff0c\u8acb\u4f7f\u7528 Z-Wave JS \u53d6\u4ee3\u70ba\u65b0\u5b89\u88dd\u65b9\u5f0f\u3002\n\n\u8acb\u53c3\u95b1 https://www.home-assistant.io/docs/z-wave/installation/ \u4ee5\n\u7372\u5f97\u8a2d\u5b9a\u8b8a\u6578\u8cc7\u8a0a" diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 38fdee9a05153f..7552ee117cc0eb 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -2,10 +2,13 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from enum import Enum from functools import partial -from typing import Any, Callable, TypeVar, cast +from typing import Any, TypeVar + +from typing_extensions import ParamSpec from homeassistant.components.hassio import ( async_create_backup, @@ -35,7 +38,8 @@ LOGGER, ) -F = TypeVar("F", bound=Callable[..., Any]) # pylint: disable=invalid-name +_R = TypeVar("_R") +_P = ParamSpec("_P") DATA_ADDON_MANAGER = f"{DOMAIN}_addon_manager" @@ -47,13 +51,17 @@ def get_addon_manager(hass: HomeAssistant) -> AddonManager: return AddonManager(hass) -def api_error(error_message: str) -> Callable[[F], F]: +def api_error( + error_message: str, +) -> Callable[[Callable[_P, Awaitable[_R]]], Callable[_P, Coroutine[Any, Any, _R]]]: """Handle HassioAPIError and raise a specific AddonError.""" - def handle_hassio_api_error(func: F) -> F: + def handle_hassio_api_error( + func: Callable[_P, Awaitable[_R]] + ) -> Callable[_P, Coroutine[Any, Any, _R]]: """Handle a HassioAPIError.""" - async def wrapper(*args, **kwargs): # type: ignore + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap an add-on manager method.""" try: return_value = await func(*args, **kwargs) @@ -62,7 +70,7 @@ async def wrapper(*args, **kwargs): # type: ignore return return_value - return cast(F, wrapper) + return wrapper return handle_hassio_api_error diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index de2662bfa27866..ee0d4eb43a344b 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -4,12 +4,10 @@ from collections.abc import Callable import dataclasses from functools import partial, wraps -import json from typing import Any -from aiohttp import hdrs, web, web_exceptions, web_request +from aiohttp import web, web_exceptions, web_request import voluptuous as vol -from zwave_js_server import dump from zwave_js_server.client import Client from zwave_js_server.const import ( CommandClass, @@ -303,7 +301,6 @@ def async_register_api(hass: HomeAssistant) -> None: """Register all of our api endpoints.""" websocket_api.async_register_command(hass, websocket_network_status) websocket_api.async_register_command(hass, websocket_node_status) - websocket_api.async_register_command(hass, websocket_node_state) websocket_api.async_register_command(hass, websocket_node_metadata) websocket_api.async_register_command(hass, websocket_ping_node) websocket_api.async_register_command(hass, websocket_add_node) @@ -337,7 +334,6 @@ def async_register_api(hass: HomeAssistant) -> None: hass, websocket_update_data_collection_preference ) websocket_api.async_register_command(hass, websocket_data_collection_status) - websocket_api.async_register_command(hass, websocket_version_info) websocket_api.async_register_command(hass, websocket_abort_firmware_update) websocket_api.async_register_command( hass, websocket_subscribe_firmware_update_status @@ -350,7 +346,6 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) websocket_api.async_register_command(hass, websocket_node_ready) websocket_api.async_register_command(hass, websocket_migrate_zwave) - hass.http.register_view(DumpView()) hass.http.register_view(FirmwareUploadView()) @@ -471,29 +466,6 @@ async def websocket_node_status( ) -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zwave_js/node_state", - vol.Required(ENTRY_ID): str, - vol.Required(NODE_ID): int, - } -) -@websocket_api.async_response -@async_get_node -async def websocket_node_state( - hass: HomeAssistant, - connection: ActiveConnection, - msg: dict, - node: Node, -) -> None: - """Get the state data of a Z-Wave JS node.""" - connection.send_result( - msg[ID], - {**node.data, "values": [value.data for value in node.values.values()]}, - ) - - @websocket_api.websocket_command( { vol.Required(TYPE): "zwave_js/node_metadata", @@ -1779,64 +1751,6 @@ async def websocket_data_collection_status( connection.send_result(msg[ID], result) -class DumpView(HomeAssistantView): - """View to dump the state of the Z-Wave JS server.""" - - url = "/api/zwave_js/dump/{config_entry_id}" - name = "api:zwave_js:dump" - - async def get(self, request: web.Request, config_entry_id: str) -> web.Response: - """Dump the state of Z-Wave.""" - # pylint: disable=no-self-use - if not request["hass_user"].is_admin: - raise Unauthorized() - hass = request.app["hass"] - - if config_entry_id not in hass.data[DOMAIN]: - raise web_exceptions.HTTPBadRequest - - entry = hass.config_entries.async_get_entry(config_entry_id) - - msgs = await dump.dump_msgs(entry.data[CONF_URL], async_get_clientsession(hass)) - - return web.Response( - body=json.dumps(msgs, indent=2) + "\n", - headers={ - hdrs.CONTENT_TYPE: "application/json", - hdrs.CONTENT_DISPOSITION: 'attachment; filename="zwave_js_dump.json"', - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zwave_js/version_info", - vol.Required(ENTRY_ID): str, - }, -) -@websocket_api.async_response -@async_get_entry -async def websocket_version_info( - hass: HomeAssistant, - connection: ActiveConnection, - msg: dict, - entry: ConfigEntry, - client: Client, -) -> None: - """Get version info from the Z-Wave JS server.""" - version_info = { - "driver_version": client.version.driver_version, - "server_version": client.version.server_version, - "min_schema_version": client.version.min_schema_version, - "max_schema_version": client.version.max_schema_version, - } - connection.send_result( - msg[ID], - version_info, - ) - - @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 5d91e9b8d93e5a..88ab722160012f 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -12,27 +12,15 @@ ) from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_DOOR, - DEVICE_CLASS_GAS, - DEVICE_CLASS_HEAT, - DEVICE_CLASS_LOCK, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_PLUG, - DEVICE_CLASS_PROBLEM, - DEVICE_CLASS_SAFETY, - DEVICE_CLASS_SMOKE, - DEVICE_CLASS_SOUND, - DEVICE_CLASS_TAMPER, DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_CLIENT, DOMAIN @@ -91,101 +79,101 @@ class PropertyZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected key=NOTIFICATION_SMOKE_ALARM, states=("1", "2"), - device_class=DEVICE_CLASS_SMOKE, + device_class=BinarySensorDeviceClass.SMOKE, ), NotificationZWaveJSEntityDescription( # NotificationType 1: Smoke Alarm - All other State Id's key=NOTIFICATION_SMOKE_ALARM, - device_class=DEVICE_CLASS_PROBLEM, + device_class=BinarySensorDeviceClass.PROBLEM, ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - State Id's 1 and 2 key=NOTIFICATION_CARBON_MONOOXIDE, states=("1", "2"), - device_class=DEVICE_CLASS_GAS, + device_class=BinarySensorDeviceClass.GAS, ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - All other State Id's key=NOTIFICATION_CARBON_MONOOXIDE, - device_class=DEVICE_CLASS_PROBLEM, + device_class=BinarySensorDeviceClass.PROBLEM, ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - State Id's 1 and 2 key=NOTIFICATION_CARBON_DIOXIDE, states=("1", "2"), - device_class=DEVICE_CLASS_GAS, + device_class=BinarySensorDeviceClass.GAS, ), NotificationZWaveJSEntityDescription( # NotificationType 3: Carbon Dioxide - All other State Id's key=NOTIFICATION_CARBON_DIOXIDE, - device_class=DEVICE_CLASS_PROBLEM, + device_class=BinarySensorDeviceClass.PROBLEM, ), NotificationZWaveJSEntityDescription( # NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat) key=NOTIFICATION_HEAT, states=("1", "2", "5", "6"), - device_class=DEVICE_CLASS_HEAT, + device_class=BinarySensorDeviceClass.HEAT, ), NotificationZWaveJSEntityDescription( # NotificationType 4: Heat - All other State Id's key=NOTIFICATION_HEAT, - device_class=DEVICE_CLASS_PROBLEM, + device_class=BinarySensorDeviceClass.PROBLEM, ), NotificationZWaveJSEntityDescription( # NotificationType 5: Water - State Id's 1, 2, 3, 4 key=NOTIFICATION_WATER, states=("1", "2", "3", "4"), - device_class=DEVICE_CLASS_MOISTURE, + device_class=BinarySensorDeviceClass.MOISTURE, ), NotificationZWaveJSEntityDescription( # NotificationType 5: Water - All other State Id's key=NOTIFICATION_WATER, - device_class=DEVICE_CLASS_PROBLEM, + device_class=BinarySensorDeviceClass.PROBLEM, ), NotificationZWaveJSEntityDescription( # NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock) key=NOTIFICATION_ACCESS_CONTROL, states=("1", "2", "3", "4"), - device_class=DEVICE_CLASS_LOCK, + device_class=BinarySensorDeviceClass.LOCK, ), NotificationZWaveJSEntityDescription( # NotificationType 6: Access Control - State Id's 11 (Lock jammed) key=NOTIFICATION_ACCESS_CONTROL, states=("11",), - device_class=DEVICE_CLASS_PROBLEM, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 6: Access Control - State Id 22 (door/window open) key=NOTIFICATION_ACCESS_CONTROL, off_state="23", states=("22", "23"), - device_class=DEVICE_CLASS_DOOR, + device_class=BinarySensorDeviceClass.DOOR, ), NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 1, 2 (intrusion) key=NOTIFICATION_HOME_SECURITY, states=("1", "2"), - device_class=DEVICE_CLASS_SAFETY, + device_class=BinarySensorDeviceClass.SAFETY, ), NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 3, 4, 9 (tampering) key=NOTIFICATION_HOME_SECURITY, states=("3", "4", "9"), - device_class=DEVICE_CLASS_TAMPER, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 5, 6 (glass breakage) key=NOTIFICATION_HOME_SECURITY, states=("5", "6"), - device_class=DEVICE_CLASS_SAFETY, + device_class=BinarySensorDeviceClass.SAFETY, ), NotificationZWaveJSEntityDescription( # NotificationType 7: Home Security - State Id's 7, 8 (motion) key=NOTIFICATION_HOME_SECURITY, states=("7", "8"), - device_class=DEVICE_CLASS_MOTION, + device_class=BinarySensorDeviceClass.MOTION, ), NotificationZWaveJSEntityDescription( # NotificationType 8: Power Management - @@ -193,55 +181,55 @@ class PropertyZWaveJSEntityDescription( key=NOTIFICATION_POWER_MANAGEMENT, off_state="2", states=("2", "3"), - device_class=DEVICE_CLASS_PLUG, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PLUG, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 8: Power Management - # State Id's 6, 7, 8, 9 (power status) key=NOTIFICATION_POWER_MANAGEMENT, states=("6", "7", "8", "9"), - device_class=DEVICE_CLASS_SAFETY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.SAFETY, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 8: Power Management - # State Id's 10, 11, 17 (Battery maintenance status) key=NOTIFICATION_POWER_MANAGEMENT, states=("10", "11", "17"), - device_class=DEVICE_CLASS_BATTERY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 9: System - State Id's 1, 2, 3, 4, 6, 7 key=NOTIFICATION_SYSTEM, states=("1", "2", "3", "4", "6", "7"), - device_class=DEVICE_CLASS_PROBLEM, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, ), NotificationZWaveJSEntityDescription( # NotificationType 10: Emergency - State Id's 1, 2, 3 key=NOTIFICATION_EMERGENCY, states=("1", "2", "3"), - device_class=DEVICE_CLASS_PROBLEM, + device_class=BinarySensorDeviceClass.PROBLEM, ), NotificationZWaveJSEntityDescription( # NotificationType 14: Siren key=NOTIFICATION_SIREN, states=("1",), - device_class=DEVICE_CLASS_SOUND, + device_class=BinarySensorDeviceClass.SOUND, ), NotificationZWaveJSEntityDescription( # NotificationType 18: Gas key=NOTIFICATION_GAS, states=("1", "2", "3", "4"), - device_class=DEVICE_CLASS_GAS, + device_class=BinarySensorDeviceClass.GAS, ), NotificationZWaveJSEntityDescription( # NotificationType 18: Gas key=NOTIFICATION_GAS, states=("6",), - device_class=DEVICE_CLASS_PROBLEM, + device_class=BinarySensorDeviceClass.PROBLEM, ), ) @@ -251,7 +239,7 @@ class PropertyZWaveJSEntityDescription( DOOR_STATUS_PROPERTY: PropertyZWaveJSEntityDescription( key=DOOR_STATUS_PROPERTY, on_states=("open",), - device_class=DEVICE_CLASS_DOOR, + device_class=BinarySensorDeviceClass.DOOR, ), } @@ -260,8 +248,8 @@ class PropertyZWaveJSEntityDescription( BOOLEAN_SENSOR_MAPPINGS: dict[str, BinarySensorEntityDescription] = { CommandClass.BATTERY: BinarySensorEntityDescription( key=str(CommandClass.BATTERY), - device_class=DEVICE_CLASS_BATTERY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, ), } diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index f9c94cc938d0f7..6df95d9bbfce0d 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -45,9 +45,6 @@ SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) -from homeassistant.components.zwave_js.discovery_data_template import ( - DynamicCurrentTempClimateDataTemplate, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, @@ -58,10 +55,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.temperature import convert_temperature +from homeassistant.util.temperature import convert as convert_temperature from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo +from .discovery_data_template import DynamicCurrentTempClimateDataTemplate from .entity import ZWaveBaseEntity from .helpers import get_value_of_zwave_value diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index c94c54e1948537..dc72b5453961b8 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -20,10 +20,6 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, - DEVICE_CLASS_BLIND, - DEVICE_CLASS_GARAGE, - DEVICE_CLASS_SHUTTER, - DEVICE_CLASS_WINDOW, DOMAIN as COVER_DOMAIN, SUPPORT_CLOSE, SUPPORT_CLOSE_TILT, @@ -32,6 +28,7 @@ SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, + CoverDeviceClass, CoverEntity, ) from homeassistant.config_entries import ConfigEntry @@ -119,11 +116,11 @@ def __init__( super().__init__(config_entry, client, info) # Entity class attributes - self._attr_device_class = DEVICE_CLASS_WINDOW + self._attr_device_class = CoverDeviceClass.WINDOW if self.info.platform_hint in ("window_shutter", "window_shutter_tilt"): - self._attr_device_class = DEVICE_CLASS_SHUTTER + self._attr_device_class = CoverDeviceClass.SHUTTER if self.info.platform_hint == "window_blind": - self._attr_device_class = DEVICE_CLASS_BLIND + self._attr_device_class = CoverDeviceClass.BLIND @property def is_closed(self) -> bool | None: @@ -180,7 +177,7 @@ async def async_stop_cover(self, **kwargs: Any) -> None: class ZWaveTiltCover(ZWaveCover): - """Representation of a Fibaro Z-Wave cover device.""" + """Representation of a Z-Wave Cover device with tilt.""" _attr_supported_features = ( SUPPORT_OPEN @@ -235,7 +232,7 @@ class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): """Representation of a Z-Wave motorized barrier device.""" _attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE - _attr_device_class = DEVICE_CLASS_GARAGE + _attr_device_class = CoverDeviceClass.GARAGE def __init__( self, diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index 4b1843782e2820..9840d89dc9d3c1 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -16,13 +16,13 @@ from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN from .const import ( ATTR_COMMAND_CLASS, ATTR_ENDPOINT, ATTR_PROPERTY, ATTR_PROPERTY_KEY, ATTR_VALUE, + DOMAIN, VALUE_SCHEMA, ) from .device_automation_helpers import ( diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py new file mode 100644 index 00000000000000..080fffe2107fd4 --- /dev/null +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -0,0 +1,49 @@ +"""Provides diagnostics for Z-Wave JS.""" +from __future__ import annotations + +from zwave_js_server.client import Client +from zwave_js_server.dump import dump_msgs +from zwave_js_server.model.node import NodeDataType + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DATA_CLIENT, DOMAIN +from .helpers import get_home_and_node_id_from_device_entry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> list[dict]: + """Return diagnostics for a config entry.""" + msgs: list[dict] = await dump_msgs( + config_entry.data[CONF_URL], async_get_clientsession(hass) + ) + return msgs + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry +) -> NodeDataType: + """Return diagnostics for a device.""" + client: Client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + identifiers = get_home_and_node_id_from_device_entry(device) + node_id = identifiers[1] if identifiers else None + if node_id is None or node_id not in client.driver.controller.nodes: + raise ValueError(f"Node for device {device.id} can't be found") + node = client.driver.controller.nodes[node_id] + return { + "versionInfo": { + "driverVersion": client.version.driver_version, + "serverVersion": client.version.server_version, + "minSchemaVersion": client.version.min_schema_version, + "maxSchemaVersion": client.version.max_schema_version, + }, + "state": { + **node.data, + "values": [value.data for value in node.values.values()], + }, + } diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 6f2d83f99c3b78..3692e50d595a12 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -442,13 +442,13 @@ def get_config_parameter_discovery_schema( dependent_value=ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), ), ), - # FortrezZ SSA1/SSA2 + # FortrezZ SSA1/SSA2/SSA3 ZWaveDiscoverySchema( platform="select", hint="multilevel_switch", manufacturer_id={0x0084}, product_id={0x0107, 0x0108, 0x010B, 0x0205}, - product_type={0x0311, 0x0313, 0x0341, 0x0343}, + product_type={0x0311, 0x0313, 0x0331, 0x0341, 0x0343}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=BaseDiscoverySchemaDataTemplate( { diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index df9b1a46683932..cafab8b84a4fb3 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -100,7 +100,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self.info.node.async_set_value(self._target_value, 0) @property - def is_on(self) -> bool | None: # type: ignore + def is_on(self) -> bool | None: """Return true if device is on (speed above 0).""" if self.info.primary_value.value is None: # guard missing value diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 363762ac72b11a..3f57f4bbe6f980 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -80,13 +80,26 @@ def get_device_id_ext(client: ZwaveClient, node: ZwaveNode) -> tuple[str, str] | @callback -def get_home_and_node_id_from_device_id(device_id: tuple[str, ...]) -> list[str]: +def get_home_and_node_id_from_device_entry( + device_entry: dr.DeviceEntry, +) -> tuple[str, int] | None: """ Get home ID and node ID for Z-Wave device registry entry. - Returns [home_id, node_id] + Returns (home_id, node_id) or None if not found. """ - return device_id[1].split("-") + device_id = next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if device_id is None: + return None + id_ = device_id.split("-") + return (id_[0], int(id_[1])) @callback @@ -128,16 +141,9 @@ def async_get_node_from_device_id( # Get node ID from device identifier, perform some validation, and then get the # node - identifier = next( - ( - get_home_and_node_id_from_device_id(identifier) - for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - ), - None, - ) + identifiers = get_home_and_node_id_from_device_entry(device_entry) - node_id = int(identifier[1]) if identifier is not None else None + node_id = identifiers[1] if identifiers else None if node_id is None or node_id not in client.driver.controller.nodes: raise ValueError(f"Node for device {device_id} can't be found") diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 4e65a9fe0935be..f56255c736dc40 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.33.0"], + "requirements": ["zwave-js-server-python==0.34.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 08f9059e12544a..c6bc11a480497e 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -1,7 +1,7 @@ """Support for Z-Wave controls using the select platform.""" from __future__ import annotations -from typing import Dict, cast +from typing import cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass @@ -9,9 +9,9 @@ from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_CONFIG from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_CLIENT, DOMAIN @@ -53,7 +53,7 @@ def async_add_select(info: ZwaveDiscoveryInfo) -> None: class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): """Representation of a Z-Wave select entity.""" - _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_entity_category = EntityCategory.CONFIG def __init__( self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo @@ -89,7 +89,7 @@ async def async_select_option(self, option: str | int) -> None: class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): """Representation of a Z-Wave default tone select entity.""" - _attr_entity_category = ENTITY_CATEGORY_CONFIG + _attr_entity_category = EntityCategory.CONFIG def __init__( self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo @@ -150,7 +150,7 @@ def __init__( self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) assert self.info.platform_data_template self._lookup_map = cast( - Dict[int, str], self.info.platform_data_template.static_data + dict[int, str], self.info.platform_data_template.static_data ) # Entity class attributes diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 70528c28427810..76cb6fd22e911b 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -17,36 +17,17 @@ from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( - DEVICE_CLASS_ENERGY, DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, SensorEntity, SensorEntityDescription, -) -from homeassistant.components.zwave_js.discovery_data_template import ( - NumericSensorDataTemplateData, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_POWER_FACTOR, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - ENTITY_CATEGORY_DIAGNOSTIC, -) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -75,6 +56,10 @@ SERVICE_RESET_METER, ) from .discovery import ZwaveDiscoveryInfo +from .discovery_data_template import ( + NumericSensorDataTemplate, + NumericSensorDataTemplateData, +) from .entity import ZWaveBaseEntity from .helpers import get_device_id @@ -92,91 +77,91 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, SensorEntityDescription] = { ENTITY_DESC_KEY_BATTERY: SensorEntityDescription( ENTITY_DESC_KEY_BATTERY, - device_class=DEVICE_CLASS_BATTERY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_CURRENT: SensorEntityDescription( ENTITY_DESC_KEY_CURRENT, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_VOLTAGE: SensorEntityDescription( ENTITY_DESC_KEY_VOLTAGE, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ENERGY_MEASUREMENT: SensorEntityDescription( ENTITY_DESC_KEY_ENERGY_MEASUREMENT, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING: SensorEntityDescription( ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), ENTITY_DESC_KEY_POWER: SensorEntityDescription( ENTITY_DESC_KEY_POWER, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_POWER_FACTOR: SensorEntityDescription( ENTITY_DESC_KEY_POWER_FACTOR, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_CO: SensorEntityDescription( ENTITY_DESC_KEY_CO, - device_class=DEVICE_CLASS_CO, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_CO2: SensorEntityDescription( ENTITY_DESC_KEY_CO2, - device_class=DEVICE_CLASS_CO2, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_HUMIDITY: SensorEntityDescription( ENTITY_DESC_KEY_HUMIDITY, - device_class=DEVICE_CLASS_HUMIDITY, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ILLUMINANCE: SensorEntityDescription( ENTITY_DESC_KEY_ILLUMINANCE, - device_class=DEVICE_CLASS_ILLUMINANCE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_PRESSURE: SensorEntityDescription( ENTITY_DESC_KEY_PRESSURE, - device_class=DEVICE_CLASS_PRESSURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_SIGNAL_STRENGTH: SensorEntityDescription( ENTITY_DESC_KEY_SIGNAL_STRENGTH, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_TEMPERATURE: SensorEntityDescription( ENTITY_DESC_KEY_TEMPERATURE, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_TARGET_TEMPERATURE: SensorEntityDescription( ENTITY_DESC_KEY_TARGET_TEMPERATURE, - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, state_class=None, ), ENTITY_DESC_KEY_MEASUREMENT: SensorEntityDescription( ENTITY_DESC_KEY_MEASUREMENT, device_class=None, - state_class=STATE_CLASS_MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_TOTAL_INCREASING: SensorEntityDescription( ENTITY_DESC_KEY_TOTAL_INCREASING, device_class=None, - state_class=STATE_CLASS_TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL_INCREASING, ), } @@ -314,6 +299,15 @@ def native_unit_of_measurement(self) -> str | None: class ZWaveNumericSensor(ZwaveSensorBase): """Representation of a Z-Wave Numeric sensor.""" + @callback + def on_value_update(self) -> None: + """Handle scale changes for this value on value updated event.""" + self._attr_native_unit_of_measurement = ( + NumericSensorDataTemplate() + .resolve_data(self.info.primary_value) + .unit_of_measurement + ) + @property def native_value(self) -> float: """Return state of the sensor.""" @@ -467,7 +461,7 @@ class ZWaveNodeStatusSensor(SensorEntity): """Representation of a node status sensor.""" _attr_should_poll = False - _attr_entity_category = ENTITY_CATEGORY_DIAGNOSTIC + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, config_entry: ConfigEntry, client: ZwaveClient, node: ZwaveNode diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 390ba6eaf0b96f..a680a8fb04a2a4 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -65,7 +65,7 @@ def __init__( self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) @property - def is_on(self) -> bool | None: # type: ignore + def is_on(self) -> bool | None: """Return a boolean for the state of the switch.""" if self.info.primary_value.value is None: # guard missing value @@ -107,7 +107,7 @@ def on_value_update(self) -> None: self._update_state() @property - def is_on(self) -> bool | None: # type: ignore + def is_on(self) -> bool | None: """Return a boolean for the state of the switch.""" return self._state diff --git a/homeassistant/components/zwave_js/translations/ca.json b/homeassistant/components/zwave_js/translations/ca.json index d0861d44f89b15..5e0f834ef1926b 100644 --- a/homeassistant/components/zwave_js/translations/ca.json +++ b/homeassistant/components/zwave_js/translations/ca.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "Clau d'S2 no autenticat", "usb_path": "Ruta del dispositiu USB" }, + "description": "El complement generar\u00e0 claus de seguretat si aquests camps es deixen buits.", "title": "Introdueix la configuraci\u00f3 del complement Z-Wave JS" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "Clau d'S2 no autenticat", "usb_path": "Ruta del dispositiu USB" }, + "description": "El complement generar\u00e0 claus de seguretat si aquests camps es deixen buits.", "title": "Introdueix la configuraci\u00f3 del complement Z-Wave JS" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/de.json b/homeassistant/components/zwave_js/translations/de.json index 8bdf7a78237bf9..4900c9055d92ed 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "S2 Nicht authentifizierter Schl\u00fcssel", "usb_path": "USB-Ger\u00e4te-Pfad" }, + "description": "Das Add-on generiert Sicherheitsschl\u00fcssel, wenn diese Felder leer gelassen werden.", "title": "Gib die Konfiguration des Z-Wave JS Add-ons ein" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "S2 Nicht authentifizierter Schl\u00fcssel", "usb_path": "USB-Ger\u00e4te-Pfad" }, + "description": "Das Add-on generiert Sicherheitsschl\u00fcssel, wenn diese Felder leer gelassen werden.", "title": "Gib die Konfiguration des Z-Wave JS-Add-ons ein" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/el.json b/homeassistant/components/zwave_js/translations/el.json index 21ba61a6af12e2..56683e692c9e3e 100644 --- a/homeassistant/components/zwave_js/translations/el.json +++ b/homeassistant/components/zwave_js/translations/el.json @@ -1,11 +1,18 @@ { "config": { "abort": { + "addon_start_failed": "\u0391\u03c0\u03ad\u03c4\u03c5\u03c7\u03b5 \u03b7 \u03b5\u03ba\u03ba\u03af\u03bd\u03b7\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03c0\u03c1\u03cc\u03c3\u03b8\u03b5\u03c4\u03bf\u03c5 Z-Wave JS.", "discovery_requires_supervisor": "\u0397 \u03b1\u03bd\u03b1\u03ba\u03ac\u03bb\u03c5\u03c8\u03b7 \u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af \u03c4\u03bf\u03bd \u03b5\u03c0\u03cc\u03c0\u03c4\u03b7.", "not_zwave_device": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c0\u03bf\u03c5 \u03b1\u03bd\u03b1\u03ba\u03b1\u03bb\u03cd\u03c6\u03b8\u03b7\u03ba\u03b5 \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae Z-Wave." }, "flow_title": "{name}", + "progress": { + "start_addon": "\u03a0\u03b5\u03c1\u03b9\u03bc\u03ad\u03bd\u03b5\u03c4\u03b5 \u03bc\u03ad\u03c7\u03c1\u03b9 \u03bd\u03b1 \u03bf\u03bb\u03bf\u03ba\u03bb\u03b7\u03c1\u03c9\u03b8\u03b5\u03af \u03b7 \u03ad\u03bd\u03b1\u03c1\u03be\u03b7 \u03c4\u03bf\u03c5 \u03c0\u03c1\u03cc\u03c3\u03b8\u03b5\u03c4\u03bf\u03c5 Z-Wave JS. \u0391\u03c5\u03c4\u03cc \u03bc\u03c0\u03bf\u03c1\u03b5\u03af \u03bd\u03b1 \u03b4\u03b9\u03b1\u03c1\u03ba\u03ad\u03c3\u03b5\u03b9 \u03bc\u03b5\u03c1\u03b9\u03ba\u03ac \u03b4\u03b5\u03c5\u03c4\u03b5\u03c1\u03cc\u03bb\u03b5\u03c0\u03c4\u03b1." + }, "step": { + "start_addon": { + "title": "\u03a4\u03bf \u03c0\u03c1\u03cc\u03c3\u03b8\u03b5\u03c4\u03bf Z-Wave JS \u03be\u03b5\u03ba\u03b9\u03bd\u03ac." + }, "usb_confirm": { "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name} \u03bc\u03b5 \u03c4\u03bf \u03c0\u03c1\u03cc\u03c3\u03b8\u03b5\u03c4\u03bf Z-Wave JS;" } diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 46650ca5439a7b..3962674ff9ca26 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "USB Device Path" }, + "description": "The add-on will generate security keys if those fields are left empty.", "title": "Enter the Z-Wave JS add-on configuration" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "USB Device Path" }, + "description": "The add-on will generate security keys if those fields are left empty.", "title": "Enter the Z-Wave JS add-on configuration" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/es.json b/homeassistant/components/zwave_js/translations/es.json index e1a9cd081ba323..1002d0ad0b3687 100644 --- a/homeassistant/components/zwave_js/translations/es.json +++ b/homeassistant/components/zwave_js/translations/es.json @@ -8,7 +8,9 @@ "addon_start_failed": "No se ha podido iniciar el complemento Z-Wave JS.", "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en proceso", - "cannot_connect": "No se pudo conectar" + "cannot_connect": "No se pudo conectar", + "discovery_requires_supervisor": "El descubrimiento requiere del supervisor.", + "not_zwave_device": "El dispositivo descubierto no es un dispositivo Z-Wave." }, "error": { "addon_start_failed": "No se pudo iniciar el complemento Z-Wave JS. Comprueba la configuraci\u00f3n.", @@ -25,8 +27,13 @@ "configure_addon": { "data": { "network_key": "Clave de red", + "s0_legacy_key": "Clave S0 (heredada)", + "s2_access_control_key": "Clave de control de acceso S2", + "s2_authenticated_key": "Clave autenticada de S2", + "s2_unauthenticated_key": "Clave no autenticada de S2", "usb_path": "Ruta del dispositivo USB" }, + "description": "El complemento generar\u00e1 claves de seguridad si esos campos se dejan vac\u00edos.", "title": "Introduzca la configuraci\u00f3n del complemento Z-Wave JS" }, "hassio_confirm": { @@ -49,10 +56,22 @@ }, "start_addon": { "title": "Se est\u00e1 iniciando el complemento Z-Wave JS." + }, + "usb_confirm": { + "description": "\u00bfQuieres configurar {name} con el complemento Z-Wave JS?" } } }, "device_automation": { + "action_type": { + "clear_lock_usercode": "Borrar c\u00f3digo de usuario en {entity_name}", + "ping": "Ping del dispositivo", + "refresh_value": "Actualizar los valores de {entity_name}", + "reset_meter": "Restablecer contadores en {subtype}", + "set_config_parameter": "Establecer el valor del par\u00e1metro de configuraci\u00f3n {subtype}", + "set_lock_usercode": "Establecer un c\u00f3digo de usuario en {entity_name}", + "set_value": "Establecer valor de un valor Z-Wave" + }, "condition_type": { "config_parameter": "Valor del par\u00e1metro de configuraci\u00f3n {subtype}", "node_status": "Estado del nodo", @@ -64,7 +83,9 @@ "event.value_notification.basic": "Evento CC b\u00e1sico en {subtype}", "event.value_notification.central_scene": "Acci\u00f3n de escena central en {subtype}", "event.value_notification.scene_activation": "Activaci\u00f3n de escena en {subtype}", - "state.node_status": "El estado del nodo ha cambiado" + "state.node_status": "El estado del nodo ha cambiado", + "zwave_js.value_updated.config_parameter": "Cambio de valor en el par\u00e1metro de configuraci\u00f3n {subtype}", + "zwave_js.value_updated.value": "Cambio de valor en un valor JS de Z-Wave" } }, "options": { @@ -93,8 +114,13 @@ "emulate_hardware": "Emular el hardware", "log_level": "Nivel de registro", "network_key": "Clave de red", + "s0_legacy_key": "Tecla S0 (heredada)", + "s2_access_control_key": "Clave de control de acceso S2", + "s2_authenticated_key": "Clave autenticada de S2", + "s2_unauthenticated_key": "Clave no autenticada de S2", "usb_path": "Ruta del dispositivo USB" }, + "description": "El complemento generar\u00e1 claves de seguridad si esos campos se dejan vac\u00edos.", "title": "Introduzca la configuraci\u00f3n del complemento Z-Wave JS" }, "install_addon": { @@ -106,7 +132,14 @@ } }, "on_supervisor": { + "data": { + "use_addon": "Usar el complemento Z-Wave JS Supervisor" + }, + "description": "\u00bfQuieres utilizar el complemento Z-Wave JS Supervisor?", "title": "Selecciona el m\u00e9todo de conexi\u00f3n" + }, + "start_addon": { + "title": "Se est\u00e1 iniciando el complemento Z-Wave JS." } } }, diff --git a/homeassistant/components/zwave_js/translations/et.json b/homeassistant/components/zwave_js/translations/et.json index 10a813aad85801..c6c5db1ebd78c1 100644 --- a/homeassistant/components/zwave_js/translations/et.json +++ b/homeassistant/components/zwave_js/translations/et.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "Autentimata S2 v\u00f5ti", "usb_path": "USB-seadme asukoha rada" }, + "description": "Lisandmoodul loob ise turvav\u00f5tmed kui need v\u00e4ljad t\u00fchjaks j\u00e4etakse.", "title": "Sisesta Z-Wave JS lisandmooduli seaded" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "Autentimata S2 v\u00f5ti", "usb_path": "USB-seadme asukoha rada" }, + "description": "Lisandmoodul loob ise turvav\u00f5tmed kui need v\u00e4ljad t\u00fchjaks j\u00e4etakse.", "title": "Sisesta Z-Wave JS lisandmooduli seaded" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/fr.json b/homeassistant/components/zwave_js/translations/fr.json index 1a8aef755b59d0..0933879e517319 100644 --- a/homeassistant/components/zwave_js/translations/fr.json +++ b/homeassistant/components/zwave_js/translations/fr.json @@ -27,8 +27,13 @@ "configure_addon": { "data": { "network_key": "Cl\u00e9 r\u00e9seau", + "s0_legacy_key": "Cl\u00e9 S0 (h\u00e9rit\u00e9e)", + "s2_access_control_key": "Cl\u00e9 de contr\u00f4le d'acc\u00e8s S2", + "s2_authenticated_key": "Cl\u00e9 d'authentification S2", + "s2_unauthenticated_key": "Cl\u00e9 non authentifi\u00e9e S2", "usb_path": "Chemin du p\u00e9riph\u00e9rique USB" }, + "description": "Le module compl\u00e9mentaire g\u00e9n\u00e9rera des cl\u00e9s de s\u00e9curit\u00e9 si ces champs sont laiss\u00e9s vides.", "title": "Entrez la configuration du module compl\u00e9mentaire Z-Wave JS" }, "hassio_confirm": { @@ -60,7 +65,12 @@ "device_automation": { "action_type": { "clear_lock_usercode": "Effacer le code utilisateur sur {entity_name}", - "ping": "Pinger l'appareil" + "ping": "Pinger l'appareil", + "refresh_value": "Actualisez la ou les valeurs de {entity_name}", + "reset_meter": "R\u00e9initialiser les compteurs sur {subtype}", + "set_config_parameter": "D\u00e9finir la valeur du param\u00e8tre de configuration {subtype}", + "set_lock_usercode": "D\u00e9finir un code utilisateur sur {entity_name}", + "set_value": "D\u00e9finir la valeur d'une valeur Z-Wave" }, "condition_type": { "config_parameter": "Valeur du param\u00e8tre de configuration {subtype}", @@ -104,9 +114,13 @@ "emulate_hardware": "\u00c9muler le mat\u00e9riel", "log_level": "Niveau du journal", "network_key": "Cl\u00e9 r\u00e9seau", + "s0_legacy_key": "Cl\u00e9 S0 (h\u00e9rit\u00e9e)", + "s2_access_control_key": "Cl\u00e9 de contr\u00f4le d'acc\u00e8s S2", "s2_authenticated_key": "Cl\u00e9 d'authentification S2", + "s2_unauthenticated_key": "Cl\u00e9 non authentifi\u00e9e S2", "usb_path": "Chemin du p\u00e9riph\u00e9rique USB" }, + "description": "Le module compl\u00e9mentaire g\u00e9n\u00e9rera des cl\u00e9s de s\u00e9curit\u00e9 si ces champs sont laiss\u00e9s vides.", "title": "Entrer dans la configuration du module compl\u00e9mentaire Z-Wave JS" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/hu.json b/homeassistant/components/zwave_js/translations/hu.json index 62afae9472587d..1803cfa4a26cf3 100644 --- a/homeassistant/components/zwave_js/translations/hu.json +++ b/homeassistant/components/zwave_js/translations/hu.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "addon_get_discovery_info_failed": "Nem siker\u00fclt megszerezni a Z-Wave JS kieg\u00e9sz\u00edt\u0151 felfedez\u00e9si inform\u00e1ci\u00f3kat.", - "addon_info_failed": "Nem siker\u00fclt megszerezni a Z-Wave JS kieg\u00e9sz\u00edt\u0151 inform\u00e1ci\u00f3it.", + "addon_get_discovery_info_failed": "Nem siker\u00fclt leh\u00edvni a Z-Wave JS b\u0151v\u00edtm\u00e9ny felfedez\u00e9si inform\u00e1ci\u00f3kat.", + "addon_info_failed": "Nem siker\u00fclt leh\u00edvni a Z-Wave JS b\u0151v\u00edtm\u00e9ny inform\u00e1ci\u00f3it.", "addon_install_failed": "Nem siker\u00fclt telep\u00edteni a Z-Wave JS b\u0151v\u00edtm\u00e9nyt.", "addon_set_config_failed": "Nem siker\u00fclt be\u00e1ll\u00edtani a Z-Wave JS konfigur\u00e1ci\u00f3t.", "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt.", @@ -20,7 +20,7 @@ }, "flow_title": "{name}", "progress": { - "install_addon": "V\u00e1rjon, am\u00edg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 telep\u00edt\u00e9se befejez\u0151dik. Ez t\u00f6bb percig is eltarthat.", + "install_addon": "V\u00e1rjon, am\u00edg a Z-Wave JS b\u0151v\u00edtm\u00e9ny telep\u00edt\u00e9se befejez\u0151dik. Ez t\u00f6bb percig is eltarthat.", "start_addon": "V\u00e1rj am\u00edg a Z-Wave JS b\u0151v\u00edtm\u00e9ny elindul. Ez eltarthat n\u00e9h\u00e1ny m\u00e1sodpercig." }, "step": { @@ -33,10 +33,11 @@ "s2_unauthenticated_key": "S2 nem hiteles\u00edtett kulcs", "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" }, - "title": "Adja meg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 konfigur\u00e1ci\u00f3j\u00e1t" + "description": "A b\u0151v\u00edtm\u00e9ny gener\u00e1lni fogja a biztons\u00e1gi kulcsokat, ha ezek a mez\u0151k \u00fcresen maradnak.", + "title": "Adja meg a Z-Wave JS b\u0151v\u00edtm\u00e9ny konfigur\u00e1ci\u00f3j\u00e1t" }, "hassio_confirm": { - "title": "\u00c1ll\u00edtsa be a Z-Wave JS integr\u00e1ci\u00f3t a Z-Wave JS kieg\u00e9sz\u00edt\u0151vel" + "title": "\u00c1ll\u00edtsa be a Z-Wave JS integr\u00e1ci\u00f3t a Z-Wave JS b\u0151v\u00edtm\u00e9nnyel" }, "install_addon": { "title": "Elkezd\u0151d\u00f6tt a Z-Wave JS b\u0151v\u00edtm\u00e9ny telep\u00edt\u00e9se" @@ -89,8 +90,8 @@ }, "options": { "abort": { - "addon_get_discovery_info_failed": "Nem siker\u00fclt megszerezni a Z-Wave JS kieg\u00e9sz\u00edt\u0151 felfedez\u00e9si inform\u00e1ci\u00f3kat.", - "addon_info_failed": "Nem siker\u00fclt megszerezni a Z-Wave JS kieg\u00e9sz\u00edt\u0151 inform\u00e1ci\u00f3it.", + "addon_get_discovery_info_failed": "Nem siker\u00fclt leh\u00edvni a Z-Wave JS b\u0151v\u00edtm\u00e9ny felfedez\u00e9si inform\u00e1ci\u00f3kat.", + "addon_info_failed": "Nem siker\u00fclt leh\u00edvni a Z-Wave JS b\u0151v\u00edtm\u00e9ny inform\u00e1ci\u00f3it.", "addon_install_failed": "Nem siker\u00fclt telep\u00edteni a Z-Wave JS b\u0151v\u00edtm\u00e9nyt.", "addon_set_config_failed": "Nem siker\u00fclt be\u00e1ll\u00edtani a Z-Wave JS konfigur\u00e1ci\u00f3t.", "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt.", @@ -104,7 +105,7 @@ "unknown": "V\u00e1ratlan hiba" }, "progress": { - "install_addon": "V\u00e1rjon, am\u00edg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 telep\u00edt\u00e9se befejez\u0151dik. Ez t\u00f6bb percig is eltarthat.", + "install_addon": "V\u00e1rjon, am\u00edg a Z-Wave JS b\u0151v\u00edtm\u00e9ny telep\u00edt\u00e9se befejez\u0151dik. Ez t\u00f6bb percig is eltarthat.", "start_addon": "V\u00e1rjon, am\u00edg a Z-Wave JS b\u0151v\u00edtm\u00e9ny elindul. Ez eltarthat n\u00e9h\u00e1ny m\u00e1sodpercig." }, "step": { @@ -119,10 +120,11 @@ "s2_unauthenticated_key": "S2 nem hiteles\u00edtett kulcs", "usb_path": "USB eszk\u00f6z \u00fatvonala" }, - "title": "Adja meg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 konfigur\u00e1ci\u00f3j\u00e1t" + "description": "A b\u0151v\u00edtm\u00e9ny gener\u00e1lni fogja a biztons\u00e1gi kulcsokat, ha ezek a mez\u0151k \u00fcresen maradnak.", + "title": "Adja meg a Z-Wave JS b\u0151v\u00edtm\u00e9ny konfigur\u00e1ci\u00f3j\u00e1t" }, "install_addon": { - "title": "Elkezd\u0151d\u00f6tt a Z-Wave JS kieg\u00e9sz\u00edt\u0151 telep\u00edt\u00e9se" + "title": "Elkezd\u0151d\u00f6tt a Z-Wave JS b\u0151v\u00edtm\u00e9ny telep\u00edt\u00e9se" }, "manual": { "data": { diff --git a/homeassistant/components/zwave_js/translations/id.json b/homeassistant/components/zwave_js/translations/id.json index 19f94f0ab1fa04..7ffb9d7b7138f7 100644 --- a/homeassistant/components/zwave_js/translations/id.json +++ b/homeassistant/components/zwave_js/translations/id.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "Kunci S2 Tidak Diautentikasi", "usb_path": "Jalur Perangkat USB" }, + "description": "Add-on akan menghasilkan kunci keamanan jika bidang tersebut dibiarkan kosong.", "title": "Masukkan konfigurasi add-on Z-Wave JS" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "Kunci S2 Tidak Diautentikasi", "usb_path": "Jalur Perangkat USB" }, + "description": "Add-on akan menghasilkan kunci keamanan jika bidang tersebut dibiarkan kosong.", "title": "Masukkan konfigurasi add-on Z-Wave JS" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/it.json b/homeassistant/components/zwave_js/translations/it.json index af3416ed9a92d2..bab70cd4b948eb 100644 --- a/homeassistant/components/zwave_js/translations/it.json +++ b/homeassistant/components/zwave_js/translations/it.json @@ -9,7 +9,7 @@ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", "cannot_connect": "Impossibile connettersi", - "discovery_requires_supervisor": "Il rilevamento richiede il Supervisor.", + "discovery_requires_supervisor": "Il rilevamento richiede il supervisore.", "not_zwave_device": "Il dispositivo rilevato non \u00e8 un dispositivo Z-Wave." }, "error": { @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "Chiave S2 non autenticata", "usb_path": "Percorso del dispositivo USB" }, + "description": "Il componente aggiuntivo generer\u00e0 chiavi di sicurezza se questi campi vengono lasciati vuoti.", "title": "Accedi alla configurazione del componente aggiuntivo Z-Wave JS" }, "hassio_confirm": { @@ -48,9 +49,9 @@ }, "on_supervisor": { "data": { - "use_addon": "Usa il componente aggiuntivo Z-Wave JS Supervisor" + "use_addon": "Usa il componente aggiuntivo Z-Wave JS del supervisore" }, - "description": "Desideri utilizzare il componente aggiuntivo Z-Wave JS Supervisor?", + "description": "Desideri utilizzare il componente aggiuntivo Z-Wave JS del supervisore?", "title": "Seleziona il metodo di connessione" }, "start_addon": { @@ -66,7 +67,7 @@ "clear_lock_usercode": "Cancella codice utente su {entity_name}", "ping": "Dispositivo ping", "refresh_value": "Aggiorna il/i valore/i per {entity_name}", - "reset_meter": "Azzerare i contatori su {subtype}", + "reset_meter": "Azzera i contatori su {subtype}", "set_config_parameter": "Imposta il valore del parametro di configurazione {subtype}", "set_lock_usercode": "Imposta un codice utente su {entity_name}", "set_value": "Imposta un valore Z-Wave" @@ -110,7 +111,7 @@ "step": { "configure_addon": { "data": { - "emulate_hardware": "Emulare l'hardware", + "emulate_hardware": "Emula l'hardware", "log_level": "Livello di registro", "network_key": "Chiave di rete", "s0_legacy_key": "Chiave S0 (Obsoleta)", @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "Chiave S2 non autenticata", "usb_path": "Percorso del dispositivo USB" }, + "description": "Il componente aggiuntivo generer\u00e0 chiavi di sicurezza se questi campi vengono lasciati vuoti.", "title": "Entra nella configurazione del componente aggiuntivo Z-Wave JS" }, "install_addon": { @@ -131,9 +133,9 @@ }, "on_supervisor": { "data": { - "use_addon": "Usa il componente aggiuntivo Z-Wave JS di Supervisor" + "use_addon": "Usa il componente aggiuntivo Z-Wave JS del supervisore" }, - "description": "Desideri utilizzare il componente aggiuntivo Z-Wave JS di Supervisor?", + "description": "Desideri utilizzare il componente aggiuntivo Z-Wave JS del supervisore?", "title": "Seleziona il metodo di connessione" }, "start_addon": { diff --git a/homeassistant/components/zwave_js/translations/ja.json b/homeassistant/components/zwave_js/translations/ja.json index 248c8d001e6c93..c85025c1394d20 100644 --- a/homeassistant/components/zwave_js/translations/ja.json +++ b/homeassistant/components/zwave_js/translations/ja.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "S2\u8a8d\u8a3c\u3055\u308c\u3066\u3044\u306a\u3044\u30ad\u30fc", "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" }, + "description": "\u3053\u308c\u3089\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u304c\u7a7a\u306e\u307e\u307e\u306e\u5834\u5408\u3001\u30a2\u30c9\u30aa\u30f3\u306f\u30bb\u30ad\u30e5\u30ea\u30c6\u30a3\u30ad\u30fc\u3092\u751f\u6210\u3057\u307e\u3059\u3002", "title": "Z-Wave JS\u30a2\u30c9\u30aa\u30f3\u306e\u8a2d\u5b9a\u3092\u5165\u529b" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "S2\u8a8d\u8a3c\u3055\u308c\u3066\u3044\u306a\u3044\u30ad\u30fc", "usb_path": "USB\u30c7\u30d0\u30a4\u30b9\u306e\u30d1\u30b9" }, + "description": "\u3053\u308c\u3089\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u304c\u7a7a\u306e\u307e\u307e\u306e\u5834\u5408\u3001\u30a2\u30c9\u30aa\u30f3\u306f\u30bb\u30ad\u30e5\u30ea\u30c6\u30a3\u30ad\u30fc\u3092\u751f\u6210\u3057\u307e\u3059\u3002", "title": "Z-Wave JS\u306e\u30a2\u30c9\u30aa\u30f3\u304c\u59cb\u307e\u308a\u307e\u3059\u3002" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/nl.json b/homeassistant/components/zwave_js/translations/nl.json index 76718aa534656a..1f99373c18f84d 100644 --- a/homeassistant/components/zwave_js/translations/nl.json +++ b/homeassistant/components/zwave_js/translations/nl.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "S2 niet-geverifieerde sleutel", "usb_path": "USB-apparaatpad" }, + "description": "De add-on genereert beveiligingssleutels als deze velden leeg worden gelaten.", "title": "Voer de Z-Wave JS add-on configuratie in" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "S2 niet-geverifieerde sleutel", "usb_path": "USB-apparaatpad" }, + "description": "De add-on genereert beveiligingssleutels als deze velden leeg worden gelaten.", "title": "Voer de configuratie van de Z-Wave JS-add-on in" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/no.json b/homeassistant/components/zwave_js/translations/no.json index f08f5bd07cbbd7..24efdf1c573879 100644 --- a/homeassistant/components/zwave_js/translations/no.json +++ b/homeassistant/components/zwave_js/translations/no.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "S2 Uautentisert n\u00f8kkel", "usb_path": "USB enhetsbane" }, + "description": "Tillegget vil generere sikkerhetsn\u00f8kler hvis disse feltene er tomme.", "title": "Angi konfigurasjon for Z-Wave JS-tillegg" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "S2 Uautentisert n\u00f8kkel", "usb_path": "USB enhetsbane" }, + "description": "Tillegget vil generere sikkerhetsn\u00f8kler hvis disse feltene er tomme.", "title": "Angi konfigurasjon for Z-Wave JS-tillegg" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/pl.json b/homeassistant/components/zwave_js/translations/pl.json index 812d00b8e20bf9..68dd6555552b25 100644 --- a/homeassistant/components/zwave_js/translations/pl.json +++ b/homeassistant/components/zwave_js/translations/pl.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "Klucz nieuwierzytelniony S2", "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" }, + "description": "Dodatek wygeneruje klucze bezpiecze\u0144stwa, je\u015bli te pola pozostan\u0105 puste.", "title": "Wprowad\u017a konfiguracj\u0119 dodatku Z-Wave JS" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "Klucz nieuwierzytelniony S2", "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" }, + "description": "Dodatek wygeneruje klucze bezpiecze\u0144stwa, je\u015bli te pola pozostan\u0105 puste.", "title": "Wprowad\u017a konfiguracj\u0119 dodatku Z-Wave JS" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/ru.json b/homeassistant/components/zwave_js/translations/ru.json index 9ae79edb32d9ad..bc1d3e2cbd4e71 100644 --- a/homeassistant/components/zwave_js/translations/ru.json +++ b/homeassistant/components/zwave_js/translations/ru.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "\u041a\u043b\u044e\u0447 \u0431\u0435\u0437 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 S2", "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, + "description": "\u0415\u0441\u043b\u0438 \u044d\u0442\u0438 \u043f\u043e\u043b\u044f \u043e\u0441\u0442\u0430\u0432\u0438\u0442\u044c \u043f\u0443\u0441\u0442\u044b\u043c\u0438, \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 \u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0443\u0435\u0442 \u043a\u043b\u044e\u0447\u0438 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438.", "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "\u041a\u043b\u044e\u0447 \u0431\u0435\u0437 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 S2", "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, + "description": "\u0415\u0441\u043b\u0438 \u044d\u0442\u0438 \u043f\u043e\u043b\u044f \u043e\u0441\u0442\u0430\u0432\u0438\u0442\u044c \u043f\u0443\u0441\u0442\u044b\u043c\u0438, \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435 \u0441\u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0443\u0435\u0442 \u043a\u043b\u044e\u0447\u0438 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438.", "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/tr.json b/homeassistant/components/zwave_js/translations/tr.json index f77b7f1f0f436f..efaa87cc48d5b8 100644 --- a/homeassistant/components/zwave_js/translations/tr.json +++ b/homeassistant/components/zwave_js/translations/tr.json @@ -33,6 +33,7 @@ "s2_unauthenticated_key": "S2 Kimli\u011fi Do\u011frulanmam\u0131\u015f Anahtar", "usb_path": "USB Ayg\u0131t Yolu" }, + "description": "Bu alanlar bo\u015f b\u0131rak\u0131l\u0131rsa eklenti g\u00fcvenlik anahtarlar\u0131 \u00fcretecektir.", "title": "Z-Wave JS eklenti yap\u0131land\u0131rmas\u0131na girin" }, "hassio_confirm": { @@ -119,6 +120,7 @@ "s2_unauthenticated_key": "S2 Kimli\u011fi Do\u011frulanmam\u0131\u015f Anahtar", "usb_path": "USB Cihaz Yolu" }, + "description": "Bu alanlar bo\u015f b\u0131rak\u0131l\u0131rsa eklenti g\u00fcvenlik anahtarlar\u0131 \u00fcretecektir.", "title": "Z-Wave JS eklenti yap\u0131land\u0131rmas\u0131na girin" }, "install_addon": { diff --git a/homeassistant/components/zwave_js/translations/zh-Hant.json b/homeassistant/components/zwave_js/translations/zh-Hant.json index 7b495ed0ca0821..9dd44c4457fe6f 100644 --- a/homeassistant/components/zwave_js/translations/zh-Hant.json +++ b/homeassistant/components/zwave_js/translations/zh-Hant.json @@ -26,13 +26,14 @@ "step": { "configure_addon": { "data": { - "network_key": "\u7db2\u8def\u5bc6\u9470", - "s0_legacy_key": "S0 \u5bc6\u9470\uff08\u820a\u7248\uff09", - "s2_access_control_key": "S2 \u5b58\u53d6\u63a7\u5236\u5bc6\u9470", - "s2_authenticated_key": "S2 \u9a57\u8b49\u5bc6\u9470", - "s2_unauthenticated_key": "S2 \u672a\u9a57\u8b49\u5bc6\u9470", + "network_key": "\u7db2\u8def\u91d1\u9470", + "s0_legacy_key": "S0 \u91d1\u9470\uff08\u820a\u7248\uff09", + "s2_access_control_key": "S2 \u5b58\u53d6\u63a7\u5236\u91d1\u9470", + "s2_authenticated_key": "S2 \u9a57\u8b49\u91d1\u9470", + "s2_unauthenticated_key": "S2 \u672a\u9a57\u8b49\u91d1\u9470", "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" }, + "description": "\u5047\u5982\u6b04\u4f4d\u4fdd\u6301\u7a7a\u767d\u3001\u9644\u52a0\u5143\u4ef6\u5c07\u6703\u7522\u751f\u4e00\u7d44\u5b89\u5168\u91d1\u9470\u3002", "title": "\u8f38\u5165 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u8a2d\u5b9a" }, "hassio_confirm": { @@ -112,13 +113,14 @@ "data": { "emulate_hardware": "\u6a21\u64ec\u786c\u9ad4", "log_level": "\u65e5\u8a8c\u8a18\u9304\u7b49\u7d1a", - "network_key": "\u7db2\u8def\u5bc6\u9470", - "s0_legacy_key": "S0 \u5bc6\u9470\uff08\u820a\u7248\uff09", - "s2_access_control_key": "S2 \u5b58\u53d6\u63a7\u5236\u5bc6\u9470", - "s2_authenticated_key": "S2 \u9a57\u8b49\u5bc6\u9470", - "s2_unauthenticated_key": "S2 \u672a\u9a57\u8b49\u5bc6\u9470", + "network_key": "\u7db2\u8def\u91d1\u9470", + "s0_legacy_key": "S0 \u91d1\u9470\uff08\u820a\u7248\uff09", + "s2_access_control_key": "S2 \u5b58\u53d6\u63a7\u5236\u91d1\u9470", + "s2_authenticated_key": "S2 \u9a57\u8b49\u91d1\u9470", + "s2_unauthenticated_key": "S2 \u672a\u9a57\u8b49\u91d1\u9470", "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" }, + "description": "\u5047\u5982\u6b04\u4f4d\u4fdd\u6301\u7a7a\u767d\u3001\u9644\u52a0\u5143\u4ef6\u5c07\u6703\u7522\u751f\u4e00\u7d44\u5b89\u5168\u91d1\u9470\u3002", "title": "\u8f38\u5165 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u8a2d\u5b9a" }, "install_addon": { diff --git a/homeassistant/config.py b/homeassistant/config.py index 540336eeca3c57..74a8055e97188b 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -16,12 +16,9 @@ import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant import auth -from homeassistant.auth import ( - mfa_modules as auth_mfa_modules, - providers as auth_providers, -) -from homeassistant.const import ( +from . import auth +from .auth import mfa_modules as auth_mfa_modules, providers as auth_providers +from .const import ( ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, @@ -52,20 +49,20 @@ TEMP_CELSIUS, __version__, ) -from homeassistant.core import DOMAIN as CONF_CORE, SOURCE_YAML, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, extract_domain_configs -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_values import EntityValues -from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import Integration, IntegrationNotFound -from homeassistant.requirements import ( - RequirementsNotFound, - async_get_integration_with_requirements, +from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback +from .exceptions import HomeAssistantError +from .helpers import ( + config_per_platform, + config_validation as cv, + extract_domain_configs, ) -from homeassistant.util.package import is_docker_env -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM -from homeassistant.util.yaml import SECRET_YAML, Secrets, load_yaml +from .helpers.entity_values import EntityValues +from .helpers.typing import ConfigType +from .loader import Integration, IntegrationNotFound +from .requirements import RequirementsNotFound, async_get_integration_with_requirements +from .util.package import is_docker_env +from .util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from .util.yaml import SECRET_YAML, Secrets, load_yaml _LOGGER = logging.getLogger(__name__) @@ -77,7 +74,6 @@ CONFIG_DIR_NAME = ".homeassistant" DATA_CUSTOMIZE = "hass_customize" -GROUP_CONFIG_PATH = "groups.yaml" AUTOMATION_CONFIG_PATH = "automations.yaml" SCRIPT_CONFIG_PATH = "scripts.yaml" SCENE_CONFIG_PATH = "scenes.yaml" @@ -97,7 +93,6 @@ tts: - platform: google_translate -group: !include {GROUP_CONFIG_PATH} automation: !include {AUTOMATION_CONFIG_PATH} script: !include {SCRIPT_CONFIG_PATH} scene: !include {SCENE_CONFIG_PATH} @@ -265,8 +260,8 @@ def _filter_bad_internal_external_urls(conf: dict) -> dict: def get_default_config_dir() -> str: """Put together the default configuration directory based on the OS.""" - data_dir = os.getenv("APPDATA") if os.name == "nt" else os.path.expanduser("~") - return os.path.join(data_dir, CONFIG_DIR_NAME) # type: ignore + data_dir = os.path.expanduser("~") + return os.path.join(data_dir, CONFIG_DIR_NAME) async def async_ensure_config_exists(hass: HomeAssistant) -> bool: @@ -301,7 +296,6 @@ def _write_default_config(config_dir: str) -> bool: config_path = os.path.join(config_dir, YAML_CONFIG_FILE) secret_path = os.path.join(config_dir, SECRET_YAML) version_path = os.path.join(config_dir, VERSION_FILE) - group_yaml_path = os.path.join(config_dir, GROUP_CONFIG_PATH) automation_yaml_path = os.path.join(config_dir, AUTOMATION_CONFIG_PATH) script_yaml_path = os.path.join(config_dir, SCRIPT_CONFIG_PATH) scene_yaml_path = os.path.join(config_dir, SCENE_CONFIG_PATH) @@ -319,10 +313,6 @@ def _write_default_config(config_dir: str) -> bool: with open(version_path, "wt", encoding="utf8") as version_file: version_file.write(__version__) - if not os.path.isfile(group_yaml_path): - with open(group_yaml_path, "wt", encoding="utf8"): - pass - if not os.path.isfile(automation_yaml_path): with open(automation_yaml_path, "wt", encoding="utf8") as automation_file: automation_file.write("[]") @@ -542,7 +532,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non CONF_CURRENCY, ) ): - hac.config_source = SOURCE_YAML + hac.config_source = ConfigSource.YAML for key, attr in ( (CONF_LATITUDE, "latitude"), @@ -645,10 +635,10 @@ def _log_pkg_error(package: str, component: str, config: dict, message: str) -> def _identify_config_schema(module: ModuleType) -> str | None: """Extract the schema and identify list or dict based.""" - if not isinstance(module.CONFIG_SCHEMA, vol.Schema): # type: ignore + if not isinstance(module.CONFIG_SCHEMA, vol.Schema): return None - schema = module.CONFIG_SCHEMA.schema # type: ignore + schema = module.CONFIG_SCHEMA.schema if isinstance(schema, vol.All): for subschema in schema.validators: @@ -659,7 +649,7 @@ def _identify_config_schema(module: ModuleType) -> str | None: return None try: - key = next(k for k in schema if k == module.DOMAIN) # type: ignore + key = next(k for k in schema if k == module.DOMAIN) except (TypeError, AttributeError, StopIteration): return None except Exception: # pylint: disable=broad-except @@ -669,8 +659,8 @@ def _identify_config_schema(module: ModuleType) -> str | None: if hasattr(key, "default") and not isinstance( key.default, vol.schema_builder.Undefined ): - default_value = module.CONFIG_SCHEMA({module.DOMAIN: key.default()})[ # type: ignore - module.DOMAIN # type: ignore + default_value = module.CONFIG_SCHEMA({module.DOMAIN: key.default()})[ + module.DOMAIN ] if isinstance(default_value, dict): @@ -750,7 +740,7 @@ async def merge_packages_config( # If integration has a custom config validator, it needs to provide a hint. if config_platform is not None: - merge_list = config_platform.PACKAGE_MERGE_HINT == "list" # type: ignore[attr-defined] + merge_list = config_platform.PACKAGE_MERGE_HINT == "list" if not merge_list: merge_list = hasattr(component, "PLATFORM_SCHEMA") @@ -892,7 +882,7 @@ async def async_process_component_config( # noqa: C901 # Validate platform specific schema if hasattr(platform, "PLATFORM_SCHEMA"): try: - p_validated = platform.PLATFORM_SCHEMA(p_config) # type: ignore + p_validated = platform.PLATFORM_SCHEMA(p_config) except vol.Invalid as ex: async_log_exception( ex, @@ -933,7 +923,7 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> str | None: This method is a coroutine. """ # pylint: disable=import-outside-toplevel - from homeassistant.helpers import check_config + from .helpers import check_config res = await check_config.async_check_ha_config_file(hass) @@ -951,7 +941,7 @@ def async_notify_setup_error( This method must be run in the event loop. """ # pylint: disable=import-outside-toplevel - from homeassistant.components import persistent_notification + from .components import persistent_notification if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: errors = hass.data[DATA_PERSISTENT_ERRORS] = {} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index cdea9da2540564..32014be77748c9 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2,43 +2,37 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from contextvars import ContextVar import dataclasses from enum import Enum import functools import logging from types import MappingProxyType, MethodType -from typing import TYPE_CHECKING, Any, Callable, Optional, cast +from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast import weakref -from homeassistant import data_entry_flow, loader -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) -from homeassistant.helpers import device_registry, entity_registry -from homeassistant.helpers.event import Event -from homeassistant.helpers.typing import ( - UNDEFINED, - ConfigType, - DiscoveryInfoType, - UndefinedType, -) -from homeassistant.setup import async_process_deps_reqs, async_setup_component -from homeassistant.util.decorator import Registry -import homeassistant.util.uuid as uuid_util +from . import data_entry_flow, loader +from .backports.enum import StrEnum +from .components import persistent_notification +from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform +from .core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback +from .exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError +from .helpers import device_registry, entity_registry +from .helpers.event import async_call_later +from .helpers.frame import report +from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType +from .setup import async_process_deps_reqs, async_setup_component +from .util import uuid as uuid_util +from .util.decorator import Registry if TYPE_CHECKING: - from homeassistant.components.dhcp import DhcpServiceInfo - from homeassistant.components.hassio import HassioServiceInfo - from homeassistant.components.mqtt import MqttServiceInfo - from homeassistant.components.ssdp import SsdpServiceInfo - from homeassistant.components.usb import UsbServiceInfo - from homeassistant.components.zeroconf import ZeroconfServiceInfo + from .components.dhcp import DhcpServiceInfo + from .components.hassio import HassioServiceInfo + from .components.mqtt import MqttServiceInfo + from .components.ssdp import SsdpServiceInfo + from .components.usb import UsbServiceInfo + from .components.zeroconf import ZeroconfServiceInfo _LOGGER = logging.getLogger(__name__) @@ -77,6 +71,8 @@ SAVE_DELAY = 1 +_T = TypeVar("_T", bound="ConfigEntryState") + class ConfigEntryState(Enum): """Config entry state.""" @@ -96,12 +92,12 @@ class ConfigEntryState(Enum): _recoverable: bool - def __new__(cls: type[object], value: str, recoverable: bool) -> ConfigEntryState: + def __new__(cls: type[_T], value: str, recoverable: bool) -> _T: """Create new ConfigEntryState.""" obj = object.__new__(cls) obj._value_ = value obj._recoverable = recoverable - return cast("ConfigEntryState", obj) + return obj @property def recoverable(self) -> bool: @@ -128,7 +124,15 @@ def recoverable(self) -> bool: EVENT_FLOW_DISCOVERED = "config_entry_discovered" -DISABLED_USER = "user" + +class ConfigEntryDisabler(StrEnum): + """What disabled a config entry.""" + + USER = "user" + + +# DISABLED_* is deprecated, to be removed in 2022.3 +DISABLED_USER = ConfigEntryDisabler.USER.value RELOAD_AFTER_UPDATE_DELAY = 30 @@ -195,7 +199,7 @@ def __init__( unique_id: str | None = None, entry_id: str | None = None, state: ConfigEntryState = ConfigEntryState.NOT_LOADED, - disabled_by: str | None = None, + disabled_by: ConfigEntryDisabler | None = None, ) -> None: """Initialize a config entry.""" # Unique id of the config entry @@ -237,6 +241,16 @@ def __init__( self.unique_id = unique_id # Config entry is disabled + if isinstance(disabled_by, str) and not isinstance( + disabled_by, ConfigEntryDisabler + ): + report( # type: ignore[unreachable] + "uses str for config entry disabled_by. This is deprecated and will " + "stop working in Home Assistant 2022.3, it should be updated to use " + "ConfigEntryDisabler instead", + error_if_core=False, + ) + disabled_by = ConfigEntryDisabler(disabled_by) self.disabled_by = disabled_by # Supports unload @@ -310,7 +324,7 @@ async def async_setup( error_reason = None try: - result = await component.async_setup_entry(hass, self) # type: ignore + result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): _LOGGER.error( @@ -362,8 +376,8 @@ async def setup_again(*_: Any) -> None: await self.async_setup(hass, integration=integration, tries=tries) if hass.state == CoreState.running: - self._async_cancel_retry_setup = hass.helpers.event.async_call_later( - wait_time, setup_again + self._async_cancel_retry_setup = async_call_later( + hass, wait_time, setup_again ) else: self._async_cancel_retry_setup = hass.bus.async_listen_once( @@ -449,7 +463,7 @@ async def async_unload( return False try: - result = await component.async_unload_entry(hass, self) # type: ignore + result = await component.async_unload_entry(hass, self) assert isinstance(result, bool) @@ -460,7 +474,8 @@ async def async_unload( self._async_process_on_unload() - return result + # https://github.com/python/mypy/issues/11839 + return result # type: ignore[no-any-return] except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain @@ -488,7 +503,7 @@ async def async_remove(self, hass: HomeAssistant) -> None: if not hasattr(component, "async_remove_entry"): return try: - await component.async_remove_entry(hass, self) # type: ignore + await component.async_remove_entry(hass, self) except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error calling entry remove callback %s for %s", @@ -525,7 +540,7 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: return False try: - result = await component.async_migrate_entry(hass, self) # type: ignore + result = await component.async_migrate_entry(hass, self) if not isinstance(result, bool): _LOGGER.error( "%s.async_migrate_entry did not return boolean", self.domain @@ -534,7 +549,8 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: if result: # pylint: disable=protected-access hass.config_entries._async_schedule_save() - return result + # https://github.com/python/mypy/issues/11839 + return result # type: ignore[no-any-return] except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error migrating entry %s for %s", self.title, self.domain @@ -592,6 +608,7 @@ def async_start_reauth(self, hass: HomeAssistant) -> None: flow_context = { "source": SOURCE_REAUTH, "entry_id": self.entry_id, + "title_placeholders": {"name": self.title}, "unique_id": self.unique_id, } @@ -643,9 +660,7 @@ async def async_finish_flow( # Remove notification if no other discovery config entries in progress if not self._async_has_other_discovery_flows(flow.flow_id): - self.hass.components.persistent_notification.async_dismiss( - DISCOVERY_NOTIFICATION_ID - ) + persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID) if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: return result @@ -743,7 +758,8 @@ async def async_post_init( # Create notification. if source in DISCOVERY_SOURCES: self.hass.bus.async_fire(EVENT_FLOW_DISCOVERED) - self.hass.components.persistent_notification.async_create( + persistent_notification.async_create( + self.hass, title="New devices discovered", message=( "We have discovered new devices on your network. " @@ -752,7 +768,8 @@ async def async_post_init( notification_id=DISCOVERY_NOTIFICATION_ID, ) elif source == SOURCE_REAUTH: - self.hass.components.persistent_notification.async_create( + persistent_notification.async_create( + self.hass, title="Integration requires reconfiguration", message=( "At least one of your integrations requires reconfiguration to " @@ -924,7 +941,9 @@ async def async_initialize(self) -> None: # New in 0.104 unique_id=entry.get("unique_id"), # New in 2021.3 - disabled_by=entry.get("disabled_by"), + disabled_by=ConfigEntryDisabler(entry["disabled_by"]) + if entry.get("disabled_by") + else None, # New in 2021.6 pref_disable_new_entities=pref_disable_new_entities, pref_disable_polling=entry.get("pref_disable_polling"), @@ -985,7 +1004,7 @@ async def async_reload(self, entry_id: str) -> bool: return await self.async_setup(entry_id) async def async_set_disabled_by( - self, entry_id: str, disabled_by: str | None + self, entry_id: str, disabled_by: ConfigEntryDisabler | None ) -> bool: """Disable an entry. @@ -994,7 +1013,18 @@ async def async_set_disabled_by( if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry - if entry.disabled_by == disabled_by: + if isinstance(disabled_by, str) and not isinstance( + disabled_by, ConfigEntryDisabler + ): + report( # type: ignore[unreachable] + "uses str for config entry disabled_by. This is deprecated and will " + "stop working in Home Assistant 2022.3, it should be updated to use " + "ConfigEntryDisabler instead", + error_if_core=False, + ) + disabled_by = ConfigEntryDisabler(disabled_by) + + if entry.disabled_by is disabled_by: return True entry.disabled_by = disabled_by @@ -1025,7 +1055,7 @@ def async_update_entry( *, unique_id: str | None | UndefinedType = UNDEFINED, title: str | UndefinedType = UNDEFINED, - data: dict | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, options: Mapping[str, Any] | UndefinedType = UNDEFINED, pref_disable_new_entities: bool | UndefinedType = UNDEFINED, pref_disable_polling: bool | UndefinedType = UNDEFINED, @@ -1052,7 +1082,7 @@ def async_update_entry( setattr(entry, attr, value) changed = True - if data is not UNDEFINED and entry.data != data: # type: ignore + if data is not UNDEFINED and entry.data != data: changed = True entry.data = MappingProxyType(data) @@ -1073,13 +1103,15 @@ def async_update_entry( @callback def async_setup_platforms( - self, entry: ConfigEntry, platforms: Iterable[str] + self, entry: ConfigEntry, platforms: Iterable[Platform | str] ) -> None: """Forward the setup of an entry to platforms.""" for platform in platforms: self.hass.async_create_task(self.async_forward_entry_setup(entry, platform)) - async def async_forward_entry_setup(self, entry: ConfigEntry, domain: str) -> bool: + async def async_forward_entry_setup( + self, entry: ConfigEntry, domain: Platform | str + ) -> bool: """Forward the setup of an entry to a different component. By default an entry is setup with the component it belongs to. If that @@ -1102,7 +1134,7 @@ async def async_forward_entry_setup(self, entry: ConfigEntry, domain: str) -> bo return True async def async_unload_platforms( - self, entry: ConfigEntry, platforms: Iterable[str] + self, entry: ConfigEntry, platforms: Iterable[Platform | str] ) -> bool: """Forward the unloading of an entry to platforms.""" return all( @@ -1114,7 +1146,9 @@ async def async_unload_platforms( ) ) - async def async_forward_entry_unload(self, entry: ConfigEntry, domain: str) -> bool: + async def async_forward_entry_unload( + self, entry: ConfigEntry, domain: Platform | str + ) -> bool: """Forward the unloading of an entry to a different component.""" # It was never loaded. if domain not in self.hass.config.components: @@ -1145,7 +1179,7 @@ class ConfigFlow(data_entry_flow.FlowHandler): def __init_subclass__(cls, domain: str | None = None, **kwargs: Any) -> None: """Initialize a subclass, register if possible.""" - super().__init_subclass__(**kwargs) # type: ignore + super().__init_subclass__(**kwargs) if domain is not None: HANDLERS.register(domain)(cls) @@ -1351,8 +1385,8 @@ def async_abort( ) if ent["flow_id"] != self.flow_id ): - self.hass.components.persistent_notification.async_dismiss( - RECONFIGURE_NOTIFICATION_ID + persistent_notification.async_dismiss( + self.hass, RECONFIGURE_NOTIFICATION_ID ) return super().async_abort( @@ -1529,8 +1563,8 @@ async def _handle_entry_updated(self, event: Event) -> None: if self._remove_call_later: self._remove_call_later() - self._remove_call_later = self.hass.helpers.event.async_call_later( - RELOAD_AFTER_UPDATE_DELAY, self._handle_reload + self._remove_call_later = async_call_later( + self.hass, RELOAD_AFTER_UPDATE_DELAY, self._handle_reload ) async def _handle_reload(self, _now: Any) -> None: @@ -1554,12 +1588,13 @@ def _handle_entry_updated_filter(event: Event) -> bool: """Handle entity registry entry update filter. Only handle changes to "disabled_by". - If "disabled_by" was DISABLED_CONFIG_ENTRY, reload is not needed. + If "disabled_by" was CONFIG_ENTRY, reload is not needed. """ if ( event.data["action"] != "update" or "disabled_by" not in event.data["changes"] - or event.data["changes"]["disabled_by"] == entity_registry.DISABLED_CONFIG_ENTRY + or event.data["changes"]["disabled_by"] + is entity_registry.RegistryEntryDisabler.CONFIG_ENTRY ): return False return True diff --git a/homeassistant/const.py b/homeassistant/const.py index 3f2f57c489eaa4..c6403514ac9964 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -3,17 +3,17 @@ from typing import Final -from homeassistant.backports.enum import StrEnum +from .backports.enum import StrEnum -MAJOR_VERSION: Final = 2021 -MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "10" +MAJOR_VERSION: Final = 2022 +MINOR_VERSION: Final = 2 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" -REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) # Truthy date string triggers showing related deprecation warning messages. REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2022.1" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" @@ -400,6 +400,7 @@ class Platform(StrEnum): ATTR_MODEL: Final = "model" ATTR_SUGGESTED_AREA: Final = "suggested_area" ATTR_SW_VERSION: Final = "sw_version" +ATTR_HW_VERSION: Final = "hw_version" ATTR_VIA_DEVICE: Final = "via_device" ATTR_BATTERY_CHARGING: Final = "battery_charging" @@ -457,12 +458,17 @@ class Platform(StrEnum): # #### UNITS OF MEASUREMENT #### +# Apparent power units +POWER_VOLT_AMPERE: Final = "VA" + # Power units POWER_WATT: Final = "W" POWER_KILO_WATT: Final = "kW" -POWER_VOLT_AMPERE: Final = "VA" POWER_BTU_PER_HOUR: Final = "BTU/h" +# Reactive power units +POWER_VOLT_AMPERE_REACTIVE: Final = "var" + # Energy units ENERGY_WATT_HOUR: Final = "Wh" ENERGY_KILO_WATT_HOUR: Final = "kWh" @@ -693,7 +699,6 @@ class Platform(StrEnum): URL_API: Final = "/api/" URL_API_STREAM: Final = "/api/stream" URL_API_CONFIG: Final = "/api/config" -URL_API_DISCOVERY_INFO: Final = "/api/discovery_info" URL_API_STATES: Final = "/api/states" URL_API_STATES_ENTITY: Final = "/api/states/{}" URL_API_EVENTS: Final = "/api/events" @@ -759,3 +764,5 @@ class Platform(StrEnum): # User used by Supervisor HASSIO_USER_NAME = "Supervisor" + +SIGNAL_BOOTSTRAP_INTEGRATONS = "bootstrap_integrations" diff --git a/homeassistant/core.py b/homeassistant/core.py index 2f5783de443de3..30e98da763731c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -7,7 +7,14 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Collection, Coroutine, Iterable, Mapping +from collections.abc import ( + Awaitable, + Callable, + Collection, + Coroutine, + Iterable, + Mapping, +) import datetime import enum import functools @@ -18,15 +25,25 @@ import threading from time import monotonic from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, cast +from typing import ( + TYPE_CHECKING, + Any, + Generic, + NamedTuple, + Optional, + TypeVar, + cast, + overload, +) from urllib.parse import urlparse import attr import voluptuous as vol import yarl -from homeassistant import async_timeout_backcompat, block_async_io, loader, util -from homeassistant.const import ( +from . import async_timeout_backcompat, block_async_io, loader, util +from .backports.enum import StrEnum +from .const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, @@ -52,7 +69,7 @@ MAX_LENGTH_STATE_STATE, __version__, ) -from homeassistant.exceptions import ( +from .exceptions import ( HomeAssistantError, InvalidEntityFormatError, InvalidStateError, @@ -60,22 +77,20 @@ ServiceNotFound, Unauthorized, ) -from homeassistant.util import location -from homeassistant.util.async_ import ( +from .util import dt as dt_util, location, uuid as uuid_util +from .util.async_ import ( fire_coroutine_threadsafe, run_callback_threadsafe, shutdown_run_callback_threadsafe, ) -import homeassistant.util.dt as dt_util -from homeassistant.util.timeout import TimeoutManager -from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem -import homeassistant.util.uuid as uuid_util +from .util.timeout import TimeoutManager +from .util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem # Typing imports that create a circular dependency if TYPE_CHECKING: - from homeassistant.auth import AuthManager - from homeassistant.components.http import HomeAssistantHTTP - from homeassistant.config_entries import ConfigEntries + from .auth import AuthManager + from .components.http import HomeAssistantHTTP + from .config_entries import ConfigEntries STAGE_1_SHUTDOWN_TIMEOUT = 100 @@ -86,9 +101,12 @@ block_async_io.enable() T = TypeVar("T") -_UNDEF: dict = {} # Internal; not helpers.typing.UNDEFINED due to circular dependency +_R = TypeVar("_R") +_R_co = TypeVar("_R_co", covariant=True) # pylint: disable=invalid-name +# Internal; not helpers.typing.UNDEFINED due to circular dependency +_UNDEF: dict[Any, Any] = {} # pylint: disable=invalid-name -CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) +CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable[..., Any]) CALLBACK_TYPE = Callable[[], None] # pylint: enable=invalid-name @@ -103,10 +121,20 @@ # How long we wait for the result of a service call SERVICE_CALL_LIMIT = 10 # seconds -# Source of core configuration -SOURCE_DISCOVERED = "discovered" -SOURCE_STORAGE = "storage" -SOURCE_YAML = "yaml" + +class ConfigSource(StrEnum): + """Source of core configuration.""" + + DEFAULT = "default" + DISCOVERED = "discovered" + STORAGE = "storage" + YAML = "yaml" + + +# SOURCE_* are deprecated as of Home Assistant 2022.2, use ConfigSource instead +SOURCE_DISCOVERED = ConfigSource.DISCOVERED.value +SOURCE_STORAGE = ConfigSource.STORAGE.value +SOURCE_YAML = ConfigSource.YAML.value # How long to wait until things that run on startup have to finish. TIMEOUT_EVENT_START = 15 @@ -155,7 +183,7 @@ class HassJobType(enum.Enum): Executor = 3 -class HassJob: +class HassJob(Generic[_R_co]): """Represent a job to be run later. We check the callable type in advance @@ -165,7 +193,7 @@ class HassJob: __slots__ = ("job_type", "target") - def __init__(self, target: Callable) -> None: + def __init__(self, target: Callable[..., _R_co]) -> None: """Create a job object.""" if asyncio.iscoroutine(target): raise ValueError("Coroutine not allowed to be passed to HassJob") @@ -178,7 +206,7 @@ def __repr__(self) -> str: return f"" -def _get_callable_job_type(target: Callable) -> HassJobType: +def _get_callable_job_type(target: Callable[..., Any]) -> HassJobType: """Determine the job type from the callable.""" # Check for partials to properly determine if coroutine function check_target = target @@ -217,7 +245,7 @@ class HomeAssistant: def __init__(self) -> None: """Initialize new Home Assistant object.""" self.loop = asyncio.get_running_loop() - self._pending_tasks: list = [] + self._pending_tasks: list[asyncio.Future[Any]] = [] self._track_task = True self.bus = EventBus(self) self.services = ServiceRegistry(self) @@ -226,7 +254,7 @@ def __init__(self) -> None: self.components = loader.Components(self) self.helpers = loader.Helpers(self) # This is a dictionary that any component can store any data on. - self.data: dict = {} + self.data: dict[str, Any] = {} self.state: CoreState = CoreState.not_running self.exit_code: int = 0 # If not None, use to signal end-of-loop @@ -275,7 +303,7 @@ async def async_run(self, *, attach_signals: bool = True) -> int: await self.async_start() if attach_signals: # pylint: disable=import-outside-toplevel - from homeassistant.helpers.signal import async_register_signal_handling + from .helpers.signal import async_register_signal_handling async_register_signal_handling(self) @@ -323,7 +351,10 @@ async def async_start(self) -> None: _async_create_timer(self) def add_job(self, target: Callable[..., Any], *args: Any) -> None: - """Add job to the executor pool. + """Add a job to be executed by the event loop or by an executor. + + If the job is either a coroutine or decorated with @callback, it will be + run by the event loop, if not it will be run by an executor. target: target to call. args: parameters for method to call. @@ -332,11 +363,37 @@ def add_job(self, target: Callable[..., Any], *args: Any) -> None: raise ValueError("Don't call add_job with None") self.loop.call_soon_threadsafe(self.async_add_job, target, *args) + @overload + @callback + def async_add_job( + self, target: Callable[..., Awaitable[_R]], *args: Any + ) -> asyncio.Future[_R] | None: + ... + + @overload + @callback + def async_add_job( + self, target: Callable[..., Awaitable[_R] | _R], *args: Any + ) -> asyncio.Future[_R] | None: + ... + + @overload @callback def async_add_job( - self, target: Callable[..., Any], *args: Any - ) -> asyncio.Future | None: - """Add a job from within the event loop. + self, target: Coroutine[Any, Any, _R], *args: Any + ) -> asyncio.Future[_R] | None: + ... + + @callback + def async_add_job( + self, + target: Callable[..., Awaitable[_R] | _R] | Coroutine[Any, Any, _R], + *args: Any, + ) -> asyncio.Future[_R] | None: + """Add a job to be executed by the event loop or by an executor. + + If the job is either a coroutine or decorated with @callback, it will be + run by the event loop, if not it will be run by an executor. This method must be run in the event loop. @@ -347,26 +404,46 @@ def async_add_job( raise ValueError("Don't call async_add_job with None") if asyncio.iscoroutine(target): - return self.async_create_task(cast(Coroutine, target)) + return self.async_create_task(target) + target = cast(Callable[..., _R], target) return self.async_add_hass_job(HassJob(target), *args) + @overload + @callback + def async_add_hass_job( + self, hassjob: HassJob[Awaitable[_R]], *args: Any + ) -> asyncio.Future[_R] | None: + ... + + @overload @callback - def async_add_hass_job(self, hassjob: HassJob, *args: Any) -> asyncio.Future | None: + def async_add_hass_job( + self, hassjob: HassJob[Awaitable[_R] | _R], *args: Any + ) -> asyncio.Future[_R] | None: + ... + + @callback + def async_add_hass_job( + self, hassjob: HassJob[Awaitable[_R] | _R], *args: Any + ) -> asyncio.Future[_R] | None: """Add a HassJob from within the event loop. This method must be run in the event loop. hassjob: HassJob to call. args: parameters for method to call. """ + task: asyncio.Future[_R] if hassjob.job_type == HassJobType.Coroutinefunction: - task = self.loop.create_task(hassjob.target(*args)) + task = self.loop.create_task( + cast(Callable[..., Awaitable[_R]], hassjob.target)(*args) + ) elif hassjob.job_type == HassJobType.Callback: self.loop.call_soon(hassjob.target, *args) return None else: - task = self.loop.run_in_executor( # type: ignore - None, hassjob.target, *args + task = self.loop.run_in_executor( + None, cast(Callable[..., _R], hassjob.target), *args ) # If a task is scheduled @@ -375,7 +452,7 @@ def async_add_hass_job(self, hassjob: HassJob, *args: Any) -> asyncio.Future | N return task - def create_task(self, target: Awaitable) -> None: + def create_task(self, target: Awaitable[Any]) -> None: """Add task to the executor pool. target: target to call. @@ -383,14 +460,14 @@ def create_task(self, target: Awaitable) -> None: self.loop.call_soon_threadsafe(self.async_create_task, target) @callback - def async_create_task(self, target: Awaitable) -> asyncio.Task: + def async_create_task(self, target: Awaitable[_R]) -> asyncio.Task[_R]: """Create a task from within the eventloop. This method must be run in the event loop. target: target to call. """ - task: asyncio.Task = self.loop.create_task(target) + task = self.loop.create_task(target) if self._track_task: self._pending_tasks.append(task) @@ -400,7 +477,7 @@ def async_create_task(self, target: Awaitable) -> asyncio.Task: @callback def async_add_executor_job( self, target: Callable[..., T], *args: Any - ) -> Awaitable[T]: + ) -> asyncio.Future[T]: """Add an executor job from within the event loop.""" task = self.loop.run_in_executor(None, target, *args) @@ -420,8 +497,24 @@ def async_stop_track_tasks(self) -> None: """Stop track tasks so you can't wait for all tasks to be done.""" self._track_task = False + @overload + @callback + def async_run_hass_job( + self, hassjob: HassJob[Awaitable[_R]], *args: Any + ) -> asyncio.Future[_R] | None: + ... + + @overload + @callback + def async_run_hass_job( + self, hassjob: HassJob[Awaitable[_R] | _R], *args: Any + ) -> asyncio.Future[_R] | None: + ... + @callback - def async_run_hass_job(self, hassjob: HassJob, *args: Any) -> asyncio.Future | None: + def async_run_hass_job( + self, hassjob: HassJob[Awaitable[_R] | _R], *args: Any + ) -> asyncio.Future[_R] | None: """Run a HassJob from within the event loop. This method must be run in the event loop. @@ -430,15 +523,38 @@ def async_run_hass_job(self, hassjob: HassJob, *args: Any) -> asyncio.Future | N args: parameters for method to call. """ if hassjob.job_type == HassJobType.Callback: - hassjob.target(*args) + cast(Callable[..., _R], hassjob.target)(*args) return None return self.async_add_hass_job(hassjob, *args) + @overload + @callback + def async_run_job( + self, target: Callable[..., Awaitable[_R]], *args: Any + ) -> asyncio.Future[_R] | None: + ... + + @overload + @callback + def async_run_job( + self, target: Callable[..., Awaitable[_R] | _R], *args: Any + ) -> asyncio.Future[_R] | None: + ... + + @overload @callback def async_run_job( - self, target: Callable[..., None | Awaitable], *args: Any - ) -> asyncio.Future | None: + self, target: Coroutine[Any, Any, _R], *args: Any + ) -> asyncio.Future[_R] | None: + ... + + @callback + def async_run_job( + self, + target: Callable[..., Awaitable[_R] | _R] | Coroutine[Any, Any, _R], + *args: Any, + ) -> asyncio.Future[_R] | None: """Run a job from within the event loop. This method must be run in the event loop. @@ -447,8 +563,9 @@ def async_run_job( args: parameters for method to call. """ if asyncio.iscoroutine(target): - return self.async_create_task(cast(Coroutine, target)) + return self.async_create_task(target) + target = cast(Callable[..., _R], target) return self.async_run_hass_job(HassJob(target), *args) def block_till_done(self) -> None: @@ -657,12 +774,19 @@ def __eq__(self, other: Any) -> bool: ) +class _FilterableJob(NamedTuple): + """Event listener job to be executed with optional filter.""" + + job: HassJob[None | Awaitable[None]] + event_filter: Callable[[Event], bool] | None + + class EventBus: """Allow the firing of and listening for events.""" def __init__(self, hass: HomeAssistant) -> None: """Initialize a new event bus.""" - self._listeners: dict[str, list[tuple[HassJob, Callable | None]]] = {} + self._listeners: dict[str, list[_FilterableJob]] = {} self._hass = hass @callback @@ -681,7 +805,7 @@ def listeners(self) -> dict[str, int]: def fire( self, event_type: str, - event_data: dict | None = None, + event_data: dict[str, Any] | None = None, origin: EventOrigin = EventOrigin.local, context: Context | None = None, ) -> None: @@ -710,7 +834,7 @@ def async_fire( listeners = self._listeners.get(event_type, []) - # EVENT_HOMEASSISTANT_CLOSE should go only to his listeners + # EVENT_HOMEASSISTANT_CLOSE should go only to this listeners match_all_listeners = self._listeners.get(MATCH_ALL) if match_all_listeners is not None and event_type != EVENT_HOMEASSISTANT_CLOSE: listeners = match_all_listeners + listeners @@ -733,7 +857,11 @@ def async_fire( continue self._hass.async_add_hass_job(job, event) - def listen(self, event_type: str, listener: Callable) -> CALLBACK_TYPE: + def listen( + self, + event_type: str, + listener: Callable[[Event], None | Awaitable[None]], + ) -> CALLBACK_TYPE: """Listen for all events or events of a specific type. To listen to all events specify the constant ``MATCH_ALL`` @@ -753,8 +881,8 @@ def remove_listener() -> None: def async_listen( self, event_type: str, - listener: Callable, - event_filter: Callable | None = None, + listener: Callable[[Event], None | Awaitable[None]], + event_filter: Callable[[Event], bool] | None = None, ) -> CALLBACK_TYPE: """Listen for all events or events of a specific type. @@ -770,12 +898,12 @@ def async_listen( if event_filter is not None and not is_callback(event_filter): raise HomeAssistantError(f"Event filter {event_filter} is not a callback") return self._async_listen_filterable_job( - event_type, (HassJob(listener), event_filter) + event_type, _FilterableJob(HassJob(listener), event_filter) ) @callback def _async_listen_filterable_job( - self, event_type: str, filterable_job: tuple[HassJob, Callable | None] + self, event_type: str, filterable_job: _FilterableJob ) -> CALLBACK_TYPE: self._listeners.setdefault(event_type, []).append(filterable_job) @@ -786,7 +914,7 @@ def remove_listener() -> None: return remove_listener def listen_once( - self, event_type: str, listener: Callable[[Event], None] + self, event_type: str, listener: Callable[[Event], None | Awaitable[None]] ) -> CALLBACK_TYPE: """Listen once for event of a specific type. @@ -806,7 +934,9 @@ def remove_listener() -> None: return remove_listener @callback - def async_listen_once(self, event_type: str, listener: Callable) -> CALLBACK_TYPE: + def async_listen_once( + self, event_type: str, listener: Callable[[Event], None | Awaitable[None]] + ) -> CALLBACK_TYPE: """Listen once for event of a specific type. To listen to all events specify the constant ``MATCH_ALL`` @@ -816,7 +946,7 @@ def async_listen_once(self, event_type: str, listener: Callable) -> CALLBACK_TYP This method must be run in the event loop. """ - filterable_job: tuple[HassJob, Callable | None] | None = None + filterable_job: _FilterableJob | None = None @callback def _onetime_listener(event: Event) -> None: @@ -838,13 +968,13 @@ def _onetime_listener(event: Event) -> None: _onetime_listener, listener, ("__name__", "__qualname__", "__module__"), [] ) - filterable_job = (HassJob(_onetime_listener), None) + filterable_job = _FilterableJob(HassJob(_onetime_listener), None) return self._async_listen_filterable_job(event_type, filterable_job) @callback def _async_remove_listener( - self, event_type: str, filterable_job: tuple[HassJob, Callable | None] + self, event_type: str, filterable_job: _FilterableJob ) -> None: """Remove a listener of a specific event_type. @@ -864,6 +994,9 @@ def _async_remove_listener( ) +_StateT = TypeVar("_StateT", bound="State") + + class State: """Object to represent a state within the state machine. @@ -930,7 +1063,7 @@ def name(self) -> str: "_", " " ) - def as_dict(self) -> dict: + def as_dict(self) -> dict[str, Collection[Any]]: """Return a dict representation of the State. Async friendly. @@ -955,7 +1088,7 @@ def as_dict(self) -> dict: return self._as_dict @classmethod - def from_dict(cls, json_dict: dict) -> Any: + def from_dict(cls: type[_StateT], json_dict: dict[str, Any]) -> _StateT | None: """Initialize a state from a dict. Async friendly. @@ -1026,7 +1159,7 @@ def entity_ids(self, domain_filter: str | None = None) -> list[str]: @callback def async_entity_ids( - self, domain_filter: str | Iterable | None = None + self, domain_filter: str | Iterable[str] | None = None ) -> list[str]: """List of entity ids that are being tracked. @@ -1046,7 +1179,7 @@ def async_entity_ids( @callback def async_entity_ids_count( - self, domain_filter: str | Iterable | None = None + self, domain_filter: str | Iterable[str] | None = None ) -> int: """Count the entity ids that are being tracked. @@ -1062,14 +1195,16 @@ def async_entity_ids_count( [None for state in self._states.values() if state.domain in domain_filter] ) - def all(self, domain_filter: str | Iterable | None = None) -> list[State]: + def all(self, domain_filter: str | Iterable[str] | None = None) -> list[State]: """Create a list of all states.""" return run_callback_threadsafe( self._loop, self.async_all, domain_filter ).result() @callback - def async_all(self, domain_filter: str | Iterable | None = None) -> list[State]: + def async_all( + self, domain_filter: str | Iterable[str] | None = None + ) -> list[State]: """Create a list of all states matching the filter. This method must be run in the event loop. @@ -1245,7 +1380,7 @@ class Service: def __init__( self, - func: Callable, + func: Callable[[ServiceCall], None | Awaitable[None]], schema: vol.Schema | None, context: Context | None = None, ) -> None: @@ -1263,7 +1398,7 @@ def __init__( self, domain: str, service: str, - data: dict | None = None, + data: dict[str, Any] | None = None, context: Context | None = None, ) -> None: """Initialize a service call.""" @@ -1315,7 +1450,7 @@ def register( self, domain: str, service: str, - service_func: Callable, + service_func: Callable[[ServiceCall], Awaitable[None] | None], schema: vol.Schema | None = None, ) -> None: """ @@ -1332,7 +1467,7 @@ def async_register( self, domain: str, service: str, - service_func: Callable, + service_func: Callable[[ServiceCall], Awaitable[None] | None], schema: vol.Schema | None = None, ) -> None: """ @@ -1387,11 +1522,11 @@ def call( self, domain: str, service: str, - service_data: dict | None = None, + service_data: dict[str, Any] | None = None, blocking: bool = False, context: Context | None = None, limit: float | None = SERVICE_CALL_LIMIT, - target: dict | None = None, + target: dict[str, Any] | None = None, ) -> bool | None: """ Call a service. @@ -1409,11 +1544,11 @@ async def async_call( self, domain: str, service: str, - service_data: dict | None = None, + service_data: dict[str, Any] | None = None, blocking: bool = False, context: Context | None = None, limit: float | None = SERVICE_CALL_LIMIT, - target: dict | None = None, + target: dict[str, Any] | None = None, ) -> bool | None: """ Call a service. @@ -1446,7 +1581,7 @@ async def async_call( if handler.schema: try: - processed_data = handler.schema(service_data) + processed_data: dict[str, Any] = handler.schema(service_data) except vol.Invalid: _LOGGER.debug( "Invalid data for service call %s.%s: %s", @@ -1502,7 +1637,9 @@ async def async_call( return False def _run_service_in_background( - self, coro_or_task: Coroutine | asyncio.Task, service_call: ServiceCall + self, + coro_or_task: Coroutine[Any, Any, None] | asyncio.Task[None], + service_call: ServiceCall, ) -> None: """Run service call in background, catching and logging any exceptions.""" @@ -1527,11 +1664,15 @@ async def _execute_service( ) -> None: """Execute a service.""" if handler.job.job_type == HassJobType.Coroutinefunction: - await handler.job.target(service_call) + await cast(Callable[[ServiceCall], Awaitable[None]], handler.job.target)( + service_call + ) elif handler.job.job_type == HassJobType.Callback: - handler.job.target(service_call) + cast(Callable[[ServiceCall], None], handler.job.target)(service_call) else: - await self._hass.async_add_executor_job(handler.job.target, service_call) + await self._hass.async_add_executor_job( + cast(Callable[[ServiceCall], None], handler.job.target), service_call + ) class Config: @@ -1551,7 +1692,7 @@ def __init__(self, hass: HomeAssistant) -> None: self.external_url: str | None = None self.currency: str = "EUR" - self.config_source: str = "default" + self.config_source: ConfigSource = ConfigSource.DEFAULT # If True, pip install is skipped for requirements on startup self.skip_pip: bool = False @@ -1631,7 +1772,7 @@ def is_allowed_path(self, path: str) -> bool: return False - def as_dict(self) -> dict: + def as_dict(self) -> dict[str, Any]: """Create a dictionary representation of the configuration. Async friendly. @@ -1670,7 +1811,7 @@ def set_time_zone(self, time_zone_str: str) -> None: def _update( self, *, - source: str, + source: ConfigSource, latitude: float | None = None, longitude: float | None = None, elevation: int | None = None, @@ -1678,8 +1819,8 @@ def _update( location_name: str | None = None, time_zone: str | None = None, # pylint: disable=dangerous-default-value # _UNDEFs not modified - external_url: str | dict | None = _UNDEF, - internal_url: str | dict | None = _UNDEF, + external_url: str | dict[Any, Any] | None = _UNDEF, + internal_url: str | dict[Any, Any] | None = _UNDEF, currency: str | None = None, ) -> None: """Update the configuration from a dictionary.""" @@ -1708,7 +1849,7 @@ def _update( async def async_update(self, **kwargs: Any) -> None: """Update the configuration from a dictionary.""" - self._update(source=SOURCE_STORAGE, **kwargs) + self._update(source=ConfigSource.STORAGE, **kwargs) await self.async_store() self.hass.bus.async_fire(EVENT_CORE_CONFIG_UPDATE, kwargs) @@ -1736,7 +1877,7 @@ async def async_load(self) -> None: _LOGGER.warning("Invalid internal_url set. It's not allowed to have a path") self._update( - source=SOURCE_STORAGE, + source=ConfigSource.STORAGE, latitude=data.get("latitude"), longitude=data.get("longitude"), elevation=data.get("elevation"), diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e84689ee269bd7..734a568ce4e1f1 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -7,12 +7,12 @@ from dataclasses import dataclass from types import MappingProxyType from typing import Any, TypedDict -import uuid import voluptuous as vol from .core import HomeAssistant, callback from .exceptions import HomeAssistantError +from .util import uuid as uuid_util RESULT_TYPE_FORM = "form" RESULT_TYPE_CREATE_ENTRY = "create_entry" @@ -223,7 +223,7 @@ async def _async_init( raise UnknownFlow("Flow was not created") flow.hass = self.hass flow.handler = handler - flow.flow_id = uuid.uuid4().hex + flow.flow_id = uuid_util.random_uuid_hex() flow.context = context flow.init_data = data self._async_add_flow_progress(flow) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 2a82c2652edb5d..052d3de4768aff 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -9,8 +9,6 @@ if TYPE_CHECKING: from .core import Context -# mypy: disallow-any-generics - class HomeAssistantError(Exception): """General Home Assistant exception occurred.""" @@ -201,3 +199,15 @@ def __init__(self, parameter_names: list[str]) -> None: ), ) self.parameter_names = parameter_names + + +class DependencyError(HomeAssistantError): + """Raised when dependencies can not be setup.""" + + def __init__(self, failed_dependencies: list[str]) -> None: + """Initialize error.""" + super().__init__( + self, + f"Could not setup dependencies: {', '.join(failed_dependencies)}", + ) + self.failed_dependencies = failed_dependencies diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c2648ec04cd30a..bf64bc4a51c2db 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -25,16 +25,20 @@ "amberelectric", "ambiclimate", "ambient_station", + "androidtv", "apple_tv", "arcam_fmj", + "aseko_pool_live", "asuswrt", "atag", "august", "aurora", "aurora_abb_powerone", + "aussie_broadband", "awair", "axis", "azure_devops", + "azure_event_hub", "balboa", "blebox", "blink", @@ -57,6 +61,7 @@ "control4", "coolmaster", "coronavirus", + "cpuspeed", "crownstone", "daikin", "deconz", @@ -67,6 +72,7 @@ "dialogflow", "directv", "dlna_dmr", + "dnsip", "doorbird", "dsmr", "dunehd", @@ -77,6 +83,7 @@ "efergy", "elgato", "elkm1", + "elmax", "emonitor", "emulated_roku", "enocean", @@ -110,9 +117,11 @@ "geonetnz_quakes", "geonetnz_volcano", "gios", + "github", "glances", "goalzero", "gogogate2", + "goodwe", "google_travel_time", "gpslogger", "gree", @@ -130,6 +139,7 @@ "homekit", "homekit_controller", "homematicip_cloud", + "homewizard", "honeywell", "huawei_lte", "hue", @@ -142,6 +152,7 @@ "icloud", "ifttt", "insteon", + "intellifire", "ios", "iotawatt", "ipma", @@ -160,6 +171,7 @@ "kostal_plenticore", "kraken", "kulersky", + "launch_library", "life360", "lifx", "litejet", @@ -202,6 +214,7 @@ "nexia", "nfandroidtv", "nightscout", + "nina", "nmap_tracker", "notion", "nuheat", @@ -211,13 +224,16 @@ "nzbget", "octoprint", "omnilogic", + "oncue", "ondilo_ico", "onewire", "onvif", + "open_meteo", "opengarage", "opentherm_gw", "openuv", "openweathermap", + "overkiz", "ovo_energy", "owntracks", "ozw", @@ -237,6 +253,7 @@ "progettihwsw", "prosegur", "ps4", + "pvoutput", "pvpc_hourly_pricing", "rachio", "rainforest_eagle", @@ -253,10 +270,13 @@ "roomba", "roon", "rpi_power", + "rtsp_to_webrtc", "ruckus_unleashed", "samsungtv", "screenlogic", "sense", + "senseme", + "sensibo", "sentry", "sharkiq", "shelly", @@ -273,6 +293,7 @@ "sms", "solaredge", "solarlog", + "solax", "soma", "somfy", "somfy_mylink", @@ -285,6 +306,7 @@ "squeezebox", "srp_energy", "starline", + "steamist", "stookalert", "subaru", "surepetcare", @@ -315,14 +337,17 @@ "twilio", "twinkly", "unifi", + "unifiprotect", "upb", "upcloud", "upnp", "uptimerobot", + "vallox", "velbus", "venstar", "vera", "verisure", + "version", "vesync", "vicare", "vilfo", @@ -332,8 +357,10 @@ "wallbox", "watttime", "waze_travel_time", + "webostv", "wemo", "whirlpool", + "whois", "wiffi", "wilight", "withings", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 4313aa3f4862ee..f05a7f73e50af2 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -46,6 +46,11 @@ "hostname": "blink*", "macaddress": "B85F98*" }, + { + "domain": "blink", + "hostname": "blink*", + "macaddress": "00037F*" + }, { "domain": "broadlink", "macaddress": "34EA34*" @@ -86,6 +91,11 @@ "macaddress": "7CB94C*", "hostname": "[ba][lk]*" }, + { + "domain": "flux_led", + "macaddress": "ACCF23*", + "hostname": "[hba][flk]*" + }, { "domain": "flux_led", "macaddress": "B4E842*", @@ -94,7 +104,7 @@ { "domain": "flux_led", "macaddress": "F0FE6B*", - "hostname": "[ba][lk]*" + "hostname": "[hba][flk]*" }, { "domain": "flux_led", @@ -170,6 +180,18 @@ "domain": "nest", "macaddress": "18B430*" }, + { + "domain": "nest", + "macaddress": "641666*" + }, + { + "domain": "nest", + "macaddress": "D8EB46*" + }, + { + "domain": "nest", + "macaddress": "1C53F9*" + }, { "domain": "nexia", "hostname": "xl857-*", @@ -184,6 +206,16 @@ "domain": "nuki", "hostname": "nuki_bridge_*" }, + { + "domain": "oncue", + "hostname": "kohlergen*", + "macaddress": "00146F*" + }, + { + "domain": "overkiz", + "hostname": "gateway*", + "macaddress": "F8811A*" + }, { "domain": "powerwall", "hostname": "1118431-*", @@ -228,6 +260,11 @@ "hostname": "roomba-*", "macaddress": "80A589*" }, + { + "domain": "roomba", + "hostname": "roomba-*", + "macaddress": "DCF505*" + }, { "domain": "samsungtv", "hostname": "tizen*" @@ -268,6 +305,10 @@ "hostname": "sense-*", "macaddress": "A4D578*" }, + { + "domain": "senseme", + "macaddress": "20F85E*" + }, { "domain": "simplisafe", "hostname": "simplisafe*", @@ -313,6 +354,11 @@ "hostname": "squeezebox*", "macaddress": "000420*" }, + { + "domain": "steamist", + "macaddress": "001E0C*", + "hostname": "my[45]50*" + }, { "domain": "tado", "hostname": "tado*" @@ -361,6 +407,11 @@ "hostname": "k[lp]*", "macaddress": "403F8C*" }, + { + "domain": "tplink", + "hostname": "k[lp]*", + "macaddress": "C0C9E3*" + }, { "domain": "tplink", "hostname": "ep*", @@ -515,6 +566,46 @@ "domain": "tuya", "macaddress": "D81F12*" }, + { + "domain": "twinkly", + "hostname": "twinkly_*" + }, + { + "domain": "unifiprotect", + "macaddress": "B4FBE4*" + }, + { + "domain": "unifiprotect", + "macaddress": "802AA8*" + }, + { + "domain": "unifiprotect", + "macaddress": "F09FC2*" + }, + { + "domain": "unifiprotect", + "macaddress": "68D79A*" + }, + { + "domain": "unifiprotect", + "macaddress": "18E829*" + }, + { + "domain": "unifiprotect", + "macaddress": "245A4C*" + }, + { + "domain": "unifiprotect", + "macaddress": "784558*" + }, + { + "domain": "unifiprotect", + "macaddress": "E063DA*" + }, + { + "domain": "unifiprotect", + "macaddress": "265A4C*" + }, { "domain": "verisure", "macaddress": "0023C1*" diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 9434bc11f618b1..1a243d954b9f0e 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -228,14 +228,30 @@ ], "unifi": [ { - "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", "manufacturer": "Ubiquiti Networks", "modelDescription": "UniFi Dream Machine" }, { - "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", "manufacturer": "Ubiquiti Networks", "modelDescription": "UniFi Dream Machine Pro" + }, + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine SE" + } + ], + "unifiprotect": [ + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine" + }, + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine Pro" + }, + { + "manufacturer": "Ubiquiti Networks", + "modelDescription": "UniFi Dream Machine SE" } ], "upnp": [ @@ -246,6 +262,11 @@ "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:2" } ], + "webostv": [ + { + "st": "urn:lge-com:service:webos-second-screen:1" + } + ], "wemo": [ { "manufacturer": "Belkin International Inc." diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 57f2090fee242b..1ba9b235f85b7e 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -11,12 +11,38 @@ "vid": "0572", "pid": "1340" }, + { + "domain": "velbus", + "vid": "10CF", + "pid": "0B1B" + }, + { + "domain": "velbus", + "vid": "10CF", + "pid": "0516" + }, + { + "domain": "velbus", + "vid": "10CF", + "pid": "0517" + }, + { + "domain": "velbus", + "vid": "10CF", + "pid": "0518" + }, { "domain": "zha", "vid": "10C4", "pid": "EA60", "description": "*2652*" }, + { + "domain": "zha", + "vid": "10C4", + "pid": "EA60", + "description": "*sonoff*plus*" + }, { "domain": "zha", "vid": "10C4", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index aec93dd36c93fb..da48577a1460c5 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -12,9 +12,34 @@ } ], "_airplay._tcp.local.": [ + { + "domain": "apple_tv", + "properties": { + "model": "appletv*" + } + }, + { + "domain": "apple_tv", + "properties": { + "model": "audioaccessory*" + } + }, + { + "domain": "apple_tv", + "properties": { + "am": "airport*" + } + }, { "domain": "samsungtv", - "manufacturer": "samsung*" + "properties": { + "manufacturer": "samsung*" + } + } + ], + "_airport._tcp.local.": [ + { + "domain": "apple_tv" } ], "_api._udp.local.": [ @@ -22,22 +47,35 @@ "domain": "guardian" } ], + "_appletv-v2._tcp.local.": [ + { + "domain": "apple_tv" + } + ], "_axis-video._tcp.local.": [ { "domain": "axis", - "macaddress": "00408C*" + "properties": { + "macaddress": "00408c*" + } }, { "domain": "axis", - "macaddress": "ACCC8E*" + "properties": { + "macaddress": "accc8e*" + } }, { "domain": "axis", - "macaddress": "B8A44F*" + "properties": { + "macaddress": "b8a44f*" + } }, { "domain": "doorbird", - "macaddress": "1CCAE3*" + "properties": { + "macaddress": "1ccae3*" + } } ], "_bond._tcp.local.": [ @@ -45,6 +83,11 @@ "domain": "bond" } ], + "_companion-link._tcp.local.": [ + { + "domain": "apple_tv" + } + ], "_daap._tcp.local.": [ { "domain": "forked_daapd" @@ -108,6 +151,11 @@ "domain": "homekit" } ], + "_hscp._tcp.local.": [ + { + "domain": "apple_tv" + } + ], "_http._tcp.local.": [ { "domain": "bosch_shc", @@ -119,7 +167,9 @@ }, { "domain": "nam", - "manufacturer": "nettigo" + "properties": { + "manufacturer": "nettigo" + } }, { "domain": "rachio", @@ -139,6 +189,11 @@ "domain": "hue" } ], + "_hwenergy._tcp.local.": [ + { + "domain": "homewizard" + } + ], "_ipp._tcp.local.": [ { "domain": "ipp" @@ -150,6 +205,10 @@ } ], "_kizbox._tcp.local.": [ + { + "domain": "overkiz", + "name": "gateway*" + }, { "domain": "somfy", "name": "gateway*" @@ -223,6 +282,45 @@ "name": "brother*" } ], + "_raop._tcp.local.": [ + { + "domain": "apple_tv", + "properties": { + "am": "appletv*" + } + }, + { + "domain": "apple_tv", + "properties": { + "am": "audioaccessory*" + } + }, + { + "domain": "apple_tv", + "properties": { + "am": "airport*" + } + } + ], + "_sideplay._tcp.local.": [ + { + "domain": "ecobee", + "properties": { + "mdl": "eb-*" + } + }, + { + "domain": "ecobee", + "properties": { + "mdl": "ecobee*" + } + } + ], + "_sleep-proxy._udp.local.": [ + { + "domain": "apple_tv" + } + ], "_sonos._tcp.local.": [ { "domain": "sonos" @@ -284,6 +382,7 @@ "BSB002": "hue", "C105X": "roku", "C135X": "roku", + "EB-*": "ecobee", "Healty Home Coach": "netatmo", "Iota": "abode", "LIFX": "lifx", @@ -294,6 +393,7 @@ "Presence": "netatmo", "Rachio": "rachio", "SPK5": "rainmachine", + "Sensibo": "sensibo", "Smart Bridge": "lutron_caseta", "Socket": "wemo", "TRADFRI": "tradfri", @@ -301,6 +401,7 @@ "Welcome": "netatmo", "Wemo": "wemo", "YL*": "yeelight", + "ecobee*": "ecobee", "iSmartGate": "gogogate2", "iZone": "izone", "tado": "tado" diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index 93383f49b1e13f..f74aec0efe87e5 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -3,7 +3,7 @@ from collections.abc import Iterable, Sequence import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from homeassistant.const import CONF_PLATFORM @@ -11,7 +11,9 @@ from .typing import ConfigType -def config_per_platform(config: ConfigType, domain: str) -> Iterable[tuple[Any, Any]]: +def config_per_platform( + config: ConfigType, domain: str +) -> Iterable[tuple[str | None, ConfigType]]: """Break a component config into different platforms. For example, will find 'switch', 'switch 2', 'switch 3', .. etc @@ -24,6 +26,8 @@ def config_per_platform(config: ConfigType, domain: str) -> Iterable[tuple[Any, if not isinstance(platform_config, list): platform_config = [platform_config] + item: ConfigType + platform: str | None for item in platform_config: try: platform = item.get(CONF_PLATFORM) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 908a9d68ddf259..65b1b657ef451f 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -18,10 +18,11 @@ from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers.frame import warn_use from homeassistant.loader import bind_hass from homeassistant.util import ssl as ssl_util +from .frame import warn_use + DATA_CONNECTOR = "aiohttp_connector" DATA_CONNECTOR_NOTVERIFY = "aiohttp_connector_notverify" DATA_CLIENTSESSION = "aiohttp_clientsession" diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 11b7e5a78bd477..3f3e526b0c2bfb 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -8,14 +8,12 @@ import attr from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.loader import bind_hass from homeassistant.util import slugify +from . import device_registry as dr, entity_registry as er from .typing import UNDEFINED, UndefinedType -# mypy: disallow-any-generics - DATA_REGISTRY = "area_registry" EVENT_AREA_REGISTRY_UPDATED = "area_registry_updated" STORAGE_KEY = "core.area_registry" diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 83505fc8356821..3bda89c9a736df 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant import loader -from homeassistant.config import ( +from homeassistant.config import ( # type: ignore[attr-defined] CONF_CORE, CONF_PACKAGES, CORE_CONFIG_SCHEMA, @@ -23,7 +23,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType from homeassistant.requirements import ( RequirementsNotFound, async_clear_install_history, @@ -31,6 +30,8 @@ ) import homeassistant.util.yaml.loader as yaml_loader +from .typing import ConfigType + class CheckConfigError(NamedTuple): """Configuration check error.""" @@ -158,9 +159,7 @@ def _comp_error(ex: Exception, domain: str, config: ConfigType) -> None: ): try: result[domain] = ( - await config_validator.async_validate_config( # type: ignore - hass, config - ) + await config_validator.async_validate_config(hass, config) )[domain] continue except (vol.Invalid, HomeAssistantError) as ex: diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index f1ea4800c16bc0..f6f9c968f104b2 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -3,11 +3,11 @@ from abc import ABC, abstractmethod import asyncio -from collections.abc import Coroutine +from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from itertools import groupby import logging -from typing import Any, Awaitable, Callable, Iterable, Optional, cast +from typing import Any, Optional, cast import voluptuous as vol from voluptuous.humanize import humanize_error @@ -16,12 +16,13 @@ from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.storage import Store from homeassistant.util import slugify +from . import entity_registry +from .entity import Entity +from .entity_component import EntityComponent +from .storage import Store + STORAGE_VERSION = 1 SAVE_DELAY = 10 diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 030e5dacfd5659..80bed9137d0a0b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -3,20 +3,21 @@ import asyncio from collections import deque -from collections.abc import Container, Generator +from collections.abc import Callable, Container, Generator from contextlib import contextmanager -from datetime import datetime, timedelta +from datetime import datetime, time as dt_time, timedelta import functools as ft import logging import re import sys -from typing import Any, Callable, cast +from typing import Any, cast from homeassistant.components import zone as zone_cmp from homeassistant.components.device_automation import ( + DeviceAutomationType, async_get_device_automation_platform, ) -from homeassistant.components.sensor import DEVICE_CLASS_TIMESTAMP +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_GPS_ACCURACY, @@ -51,13 +52,12 @@ HomeAssistantError, TemplateError, ) -from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.sun import get_astral_event_date -from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as dt_util +from . import config_validation as cv, entity_registry as er +from .sun import get_astral_event_date +from .template import Template from .trace import ( TraceElement, trace_append_element, @@ -68,8 +68,7 @@ trace_stack_push, trace_stack_top, ) - -# mypy: disallow-any-generics +from .typing import ConfigType, TemplateVarsType ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" FROM_CONFIG_FORMAT = "{}_from_config" @@ -152,19 +151,12 @@ def wrapper(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: async def async_from_config( hass: HomeAssistant, - config: ConfigType | Template, + config: ConfigType, ) -> ConditionCheckerType: """Turn a condition configuration into a method. Should be run on the event loop. """ - if isinstance(config, Template): - # We got a condition template, wrap it in a configuration to pass along. - config = { - CONF_CONDITION: "template", - CONF_VALUE_TEMPLATE: config, - } - condition = config.get(CONF_CONDITION) for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): factory = getattr(sys.modules[__name__], fmt.format(condition), None) @@ -693,8 +685,8 @@ def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool def time( hass: HomeAssistant, - before: dt_util.dt.time | str | None = None, - after: dt_util.dt.time | str | None = None, + before: dt_time | str | None = None, + after: dt_time | str | None = None, weekday: None | str | Container[str] = None, ) -> bool: """Test if local time condition matches. @@ -708,19 +700,19 @@ def time( now_time = now.time() if after is None: - after = dt_util.dt.time(0) + after = dt_time(0) elif isinstance(after, str): if not (after_entity := hass.states.get(after)): raise ConditionErrorMessage("time", f"unknown 'after' entity {after}") if after_entity.domain == "input_datetime": - after = dt_util.dt.time( + after = dt_time( after_entity.attributes.get("hour", 23), after_entity.attributes.get("minute", 59), after_entity.attributes.get("second", 59), ) elif after_entity.attributes.get( ATTR_DEVICE_CLASS - ) == DEVICE_CLASS_TIMESTAMP and after_entity.state not in ( + ) == SensorDeviceClass.TIMESTAMP and after_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, ): @@ -732,19 +724,19 @@ def time( return False if before is None: - before = dt_util.dt.time(23, 59, 59, 999999) + before = dt_time(23, 59, 59, 999999) elif isinstance(before, str): if not (before_entity := hass.states.get(before)): raise ConditionErrorMessage("time", f"unknown 'before' entity {before}") if before_entity.domain == "input_datetime": - before = dt_util.dt.time( + before = dt_time( before_entity.attributes.get("hour", 23), before_entity.attributes.get("minute", 59), before_entity.attributes.get("second", 59), ) elif before_entity.attributes.get( ATTR_DEVICE_CLASS - ) == DEVICE_CLASS_TIMESTAMP and before_entity.state not in ( + ) == SensorDeviceClass.TIMESTAMP and before_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, ): @@ -881,12 +873,12 @@ async def async_device_from_config( ) -> ConditionCheckerType: """Test a device condition.""" platform = await async_get_device_automation_platform( - hass, config[CONF_DOMAIN], "condition" + hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION ) return trace_condition_function( cast( ConditionCheckerType, - platform.async_condition_from_config(hass, config), # type: ignore + platform.async_condition_from_config(hass, config), ) ) @@ -934,12 +926,9 @@ def state_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType async def async_validate_condition_config( - hass: HomeAssistant, config: ConfigType | Template -) -> ConfigType | Template: + hass: HomeAssistant, config: ConfigType +) -> ConfigType: """Validate config.""" - if isinstance(config, Template): - return config - condition = config[CONF_CONDITION] if condition in ("and", "not", "or"): conditions = [] @@ -950,13 +939,12 @@ async def async_validate_condition_config( if condition == "device": config = cv.DEVICE_CONDITION_SCHEMA(config) - assert not isinstance(config, Template) platform = await async_get_device_automation_platform( - hass, config[CONF_DOMAIN], "condition" + hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION ) if hasattr(platform, "async_validate_condition_config"): return await platform.async_validate_condition_config(hass, config) # type: ignore - return cast(ConfigType, platform.CONDITION_SCHEMA(config)) # type: ignore + return cast(ConfigType, platform.CONDITION_SCHEMA(config)) if condition in ("numeric_state", "state"): validator = getattr( @@ -968,7 +956,7 @@ async def async_validate_condition_config( async def async_validate_conditions_config( - hass: HomeAssistant, conditions: list[ConfigType | Template] + hass: HomeAssistant, conditions: list[ConfigType] ) -> list[ConfigType | Template]: """Validate config.""" return await asyncio.gather( diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 0a565b3b9ebb71..d7920f809410ea 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -1,14 +1,19 @@ """Helpers for data entry flows for config entries.""" from __future__ import annotations +from collections.abc import Awaitable, Callable import logging -from typing import Any, Awaitable, Callable, Union +from typing import TYPE_CHECKING, Any, Union, cast from homeassistant import config_entries from homeassistant.components import dhcp, mqtt, ssdp, zeroconf from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import UNDEFINED, DiscoveryInfoType, UndefinedType + +from .typing import UNDEFINED, DiscoveryInfoType, UndefinedType + +if TYPE_CHECKING: + import asyncio DiscoveryFunctionType = Callable[[HomeAssistant], Union[Awaitable[bool], bool]] @@ -54,9 +59,10 @@ async def async_step_confirm( # Get current discovered entries. in_progress = self._async_in_progress() - if not (has_devices := in_progress): - has_devices = await self.hass.async_add_job( # type: ignore - self._discovery_function, self.hass + if not (has_devices := bool(in_progress)): + has_devices = await cast( + "asyncio.Future[bool]", + self.hass.async_add_job(self._discovery_function, self.hass), ) if not has_devices: @@ -209,6 +215,9 @@ async def async_step_user( "cloud" in self.hass.config.components and self.hass.components.cloud.async_active_subscription() ): + if not self.hass.components.cloud.async_is_connected(): + return self.async_abort(reason="cloud_not_connected") + webhook_url = await self.hass.components.cloud.async_create_cloudhook( webhook_id ) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 3c987b1ea9e563..04e11ab99be55d 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -13,7 +13,7 @@ import logging import secrets import time -from typing import Any, Dict, cast +from typing import Any, cast from aiohttp import client, web import async_timeout @@ -25,9 +25,9 @@ from homeassistant.components import http from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.network import NoURLAvailableError from .aiohttp_client import async_get_clientsession +from .network import NoURLAvailableError _LOGGER = logging.getLogger(__name__) @@ -346,7 +346,7 @@ async def async_get_implementations( ) -> dict[str, AbstractOAuth2Implementation]: """Return OAuth2 implementations for specified domain.""" registered = cast( - Dict[str, AbstractOAuth2Implementation], + dict[str, AbstractOAuth2Implementation], hass.data.setdefault(DATA_IMPLEMENTATIONS, {}).get(domain, {}), ) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index cbcfb551dadb2e..ed3c50cdb0020a 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -16,7 +16,7 @@ import os import re from socket import _GLOBAL_DEFAULT_TIMEOUT # type: ignore # private, not in typeshed -from typing import Any, Dict, TypeVar, cast +from typing import Any, TypeVar, cast, overload from urllib.parse import urlparse from uuid import UUID @@ -75,13 +75,11 @@ ) from homeassistant.core import split_entity_id, valid_entity_id from homeassistant.exceptions import TemplateError -from homeassistant.helpers import ( - script_variables as script_variables_helper, - template as template_helper, -) from homeassistant.util import raise_if_invalid_path, slugify as util_slugify import homeassistant.util.dt as dt_util +from . import script_variables as script_variables_helper, template as template_helper + # pylint: disable=invalid-name TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'" @@ -247,11 +245,26 @@ def isdir(value: Any) -> str: return dir_in -def ensure_list(value: T | list[T] | None) -> list[T]: +@overload +def ensure_list(value: None) -> list[Any]: + ... + + +@overload +def ensure_list(value: list[T]) -> list[T]: + ... + + +@overload +def ensure_list(value: list[T] | T) -> list[T]: + ... + + +def ensure_list(value: T | None) -> list[T] | list[Any]: """Wrap value in list if it is not one.""" if value is None: return [] - return value if isinstance(value, list) else [value] + return cast("list[T]", value) if isinstance(value, list) else [value] def entity_id(value: Any) -> str: @@ -298,6 +311,12 @@ def entity_ids_or_uuids(value: str | list) -> list[str]: ) +comp_entity_ids_or_uuids = vol.Any( + vol.All(vol.Lower, vol.Any(ENTITY_MATCH_ALL, ENTITY_MATCH_NONE)), + entity_ids_or_uuids, +) + + def entity_domain(domain: str | list[str]) -> Callable[[Any], str]: """Validate that entity belong to domain.""" ent_domain = entities_domain(domain) @@ -860,7 +879,10 @@ def removed( def key_value_schemas( - key: str, value_schemas: dict[Hashable, vol.Schema] + key: str, + value_schemas: dict[Hashable, vol.Schema], + default_schema: vol.Schema | None = None, + default_description: str | None = None, ) -> Callable[[Any], dict[Hashable, Any]]: """Create a validator that validates based on a value for specific key. @@ -874,10 +896,17 @@ def key_value_validator(value: Any) -> dict[Hashable, Any]: key_value = value.get(key) if isinstance(key_value, Hashable) and key_value in value_schemas: - return cast(Dict[Hashable, Any], value_schemas[key_value](value)) + return cast(dict[Hashable, Any], value_schemas[key_value](value)) + + if default_schema: + with contextlib.suppress(vol.Invalid): + return cast(dict[Hashable, Any], default_schema(value)) + alternatives = ", ".join(str(key) for key in value_schemas) + if default_description: + alternatives += ", " + default_description raise vol.Invalid( - f"Unexpected value for {key}: '{key_value}'. Expected {', '.join(str(key) for key in value_schemas)}" + f"Unexpected value for {key}: '{key_value}'. Expected {alternatives}" ) return key_value_validator @@ -949,6 +978,23 @@ def custom_serializer(schema: Any) -> Any: ), } +TARGET_SERVICE_FIELDS = { + # Same as ENTITY_SERVICE_FIELDS but supports specifying entity by entity registry + # ID. + # Either accept static entity IDs, a single dynamic template or a mixed list + # of static and dynamic templates. While this could be solved with a single + # complex template, handling it like this, keeps config validation useful. + vol.Optional(ATTR_ENTITY_ID): vol.Any( + comp_entity_ids_or_uuids, dynamic_template, vol.All(list, template_complex) + ), + vol.Optional(ATTR_DEVICE_ID): vol.Any( + ENTITY_MATCH_NONE, vol.All(ensure_list, [vol.Any(dynamic_template, str)]) + ), + vol.Optional(ATTR_AREA_ID): vol.Any( + ENTITY_MATCH_NONE, vol.All(ensure_list, [vol.Any(dynamic_template, str)]) + ), +} + def make_entity_service_schema( schema: dict, *, extra: int = vol.PREVENT_EXTRA @@ -1011,7 +1057,7 @@ def script_action(value: Any) -> dict: template, vol.All(dict, template_complex) ), vol.Optional(CONF_ENTITY_ID): comp_entity_ids, - vol.Optional(CONF_TARGET): vol.Any(ENTITY_SERVICE_FIELDS, dynamic_template), + vol.Optional(CONF_TARGET): vol.Any(TARGET_SERVICE_FIELDS, dynamic_template), } ), has_at_least_one_key(CONF_SERVICE, CONF_SERVICE_TEMPLATE), @@ -1185,6 +1231,16 @@ def STATE_CONDITION_SCHEMA(value: Any) -> dict: # pylint: disable=invalid-name DEVICE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) +dynamic_template_condition_action = vol.All( + # Wrap a shorthand template condition in a template condition + dynamic_template, + lambda config: { + CONF_VALUE_TEMPLATE: config, + CONF_CONDITION: "template", + }, +) + + CONDITION_SCHEMA: vol.Schema = vol.Schema( vol.Any( key_value_schemas( @@ -1203,7 +1259,42 @@ def STATE_CONDITION_SCHEMA(value: Any) -> dict: # pylint: disable=invalid-name "zone": ZONE_CONDITION_SCHEMA, }, ), - dynamic_template, + dynamic_template_condition_action, + ) +) + + +dynamic_template_condition_action = vol.All( + # Wrap a shorthand template condition action in a template condition + vol.Schema( + {**CONDITION_BASE_SCHEMA, vol.Required(CONF_CONDITION): dynamic_template} + ), + lambda config: { + **config, + CONF_VALUE_TEMPLATE: config[CONF_CONDITION], + CONF_CONDITION: "template", + }, +) + + +CONDITION_ACTION_SCHEMA: vol.Schema = vol.Schema( + key_value_schemas( + CONF_CONDITION, + { + "and": AND_CONDITION_SCHEMA, + "device": DEVICE_CONDITION_SCHEMA, + "not": NOT_CONDITION_SCHEMA, + "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA, + "or": OR_CONDITION_SCHEMA, + "state": STATE_CONDITION_SCHEMA, + "sun": SUN_CONDITION_SCHEMA, + "template": TEMPLATE_CONDITION_SCHEMA, + "time": TIME_CONDITION_SCHEMA, + "trigger": TRIGGER_CONDITION_SCHEMA, + "zone": ZONE_CONDITION_SCHEMA, + }, + dynamic_template_condition_action, + "a valid template", ) ) @@ -1352,7 +1443,7 @@ def determine_script_action(action: dict[str, Any]) -> str: 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_CHECK_CONDITION: CONDITION_ACTION_SCHEMA, SCRIPT_ACTION_DEVICE_AUTOMATION: DEVICE_ACTION_SCHEMA, SCRIPT_ACTION_ACTIVATE_SCENE: _SCRIPT_SCENE_SCHEMA, SCRIPT_ACTION_REPEAT: _SCRIPT_REPEAT_SCHEMA, diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 061233c0e1aedb..09345bf51bfd11 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -10,7 +10,8 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -import homeassistant.helpers.config_validation as cv + +from . import config_validation as cv class _BaseFlowManagerView(HomeAssistantView): diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index eb812f15b48ac6..96425b2ea93bc7 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -12,12 +12,12 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import RequiredParameterMissing -from homeassistant.helpers import storage -from homeassistant.helpers.frame import report from homeassistant.loader import bind_hass import homeassistant.util.uuid as uuid_util +from . import storage from .debounce import Debouncer +from .frame import report from .typing import UNDEFINED, UndefinedType # mypy: disallow_any_generics @@ -33,7 +33,7 @@ EVENT_DEVICE_REGISTRY_UPDATED = "device_registry_updated" STORAGE_KEY = "core.device_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 SAVE_DELAY = 10 CLEANUP_DELAY = 10 @@ -87,6 +87,7 @@ class DeviceEntry: name: str | None = attr.ib(default=None) suggested_area: str | None = attr.ib(default=None) sw_version: str | None = attr.ib(default=None) + hw_version: str | None = attr.ib(default=None) via_device_id: str | None = attr.ib(default=None) # This value is not stored, just used to keep track of events to fire. is_new: bool = attr.ib(default=False) @@ -168,32 +169,37 @@ async def _async_migrate_func( self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] ) -> dict[str, Any]: """Migrate to the new version.""" - if old_major_version < 2 and old_minor_version < 2: - # From version 1.1 - for device in old_data["devices"]: - # Introduced in 0.110 - try: - device["entry_type"] = DeviceEntryType(device.get("entry_type")) - except ValueError: - device["entry_type"] = None - - # Introduced in 0.79 - # renamed in 0.95 - device["via_device_id"] = device.get("via_device_id") or device.get( - "hub_device_id" - ) - # Introduced in 0.87 - device["area_id"] = device.get("area_id") - device["name_by_user"] = device.get("name_by_user") - # Introduced in 0.119 - device["disabled_by"] = device.get("disabled_by") - # Introduced in 2021.11 - device["configuration_url"] = device.get("configuration_url") - # Introduced in 0.111 - old_data["deleted_devices"] = old_data.get("deleted_devices", []) - for device in old_data["deleted_devices"]: - # Introduced in 2021.2 - device["orphaned_timestamp"] = device.get("orphaned_timestamp") + if old_major_version < 2: + if old_minor_version < 2: + # From version 1.1 + for device in old_data["devices"]: + # Introduced in 0.110 + try: + device["entry_type"] = DeviceEntryType(device.get("entry_type")) + except ValueError: + device["entry_type"] = None + + # Introduced in 0.79 + # renamed in 0.95 + device["via_device_id"] = device.get("via_device_id") or device.get( + "hub_device_id" + ) + # Introduced in 0.87 + device["area_id"] = device.get("area_id") + device["name_by_user"] = device.get("name_by_user") + # Introduced in 0.119 + device["disabled_by"] = device.get("disabled_by") + # Introduced in 2021.11 + device["configuration_url"] = device.get("configuration_url") + # Introduced in 0.111 + old_data["deleted_devices"] = old_data.get("deleted_devices", []) + for device in old_data["deleted_devices"]: + # Introduced in 2021.2 + device["orphaned_timestamp"] = device.get("orphaned_timestamp") + if old_minor_version < 3: + # Introduced in 2022.2 + for device in old_data["devices"]: + device["hw_version"] = device.get("hw_version") if old_major_version > 1: raise NotImplementedError @@ -314,6 +320,7 @@ def async_get_or_create( name: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, + hw_version: str | None | UndefinedType = UNDEFINED, via_device: tuple[str, str] | None = None, ) -> DeviceEntry: """Get device. Create if it doesn't exist.""" @@ -365,7 +372,7 @@ def async_get_or_create( ) entry_type = DeviceEntryType(entry_type) - device = self._async_update_device( + device = self.async_update_device( device.id, add_config_entry_id=config_entry_id, configuration_url=configuration_url, @@ -378,6 +385,7 @@ def async_get_or_create( name=name, suggested_area=suggested_area, sw_version=sw_version, + hw_version=hw_version, via_device_id=via_device_id, ) @@ -388,43 +396,6 @@ def async_get_or_create( @callback def async_update_device( - self, - device_id: str, - *, - add_config_entry_id: str | UndefinedType = UNDEFINED, - area_id: str | None | UndefinedType = UNDEFINED, - configuration_url: str | None | UndefinedType = UNDEFINED, - disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, - manufacturer: str | None | UndefinedType = UNDEFINED, - model: str | None | UndefinedType = UNDEFINED, - name_by_user: str | None | UndefinedType = UNDEFINED, - name: str | None | UndefinedType = UNDEFINED, - new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, - remove_config_entry_id: str | UndefinedType = UNDEFINED, - suggested_area: str | None | UndefinedType = UNDEFINED, - sw_version: str | None | UndefinedType = UNDEFINED, - via_device_id: str | None | UndefinedType = UNDEFINED, - ) -> DeviceEntry | None: - """Update properties of a device.""" - return self._async_update_device( - device_id, - add_config_entry_id=add_config_entry_id, - area_id=area_id, - configuration_url=configuration_url, - disabled_by=disabled_by, - manufacturer=manufacturer, - model=model, - name_by_user=name_by_user, - name=name, - new_identifiers=new_identifiers, - remove_config_entry_id=remove_config_entry_id, - suggested_area=suggested_area, - sw_version=sw_version, - via_device_id=via_device_id, - ) - - @callback - def _async_update_device( self, device_id: str, *, @@ -443,6 +414,7 @@ def _async_update_device( remove_config_entry_id: str | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, + hw_version: str | None | UndefinedType = UNDEFINED, via_device_id: str | None | UndefinedType = UNDEFINED, ) -> DeviceEntry | None: """Update device attributes.""" @@ -511,19 +483,16 @@ def _async_update_device( ("manufacturer", manufacturer), ("model", model), ("name", name), + ("name_by_user", name_by_user), + ("area_id", area_id), ("suggested_area", suggested_area), ("sw_version", sw_version), + ("hw_version", hw_version), ("via_device_id", via_device_id), ): if value is not UNDEFINED and value != getattr(old, attr_name): changes[attr_name] = value - if area_id is not UNDEFINED and area_id != old.area_id: - changes["area_id"] = area_id - - if name_by_user is not UNDEFINED and name_by_user != old.name_by_user: - changes["name_by_user"] = name_by_user - if old.is_new: changes["is_new"] = False @@ -560,7 +529,7 @@ def async_remove_device(self, device_id: str) -> None: ) for other_device in list(self.devices.values()): if other_device.via_device_id == device_id: - self._async_update_device(other_device.id, via_device_id=None) + self.async_update_device(other_device.id, via_device_id=None) self.hass.bus.async_fire( EVENT_DEVICE_REGISTRY_UPDATED, {"action": "remove", "device_id": device_id} ) @@ -597,6 +566,7 @@ async def async_load(self) -> None: name_by_user=device["name_by_user"], name=device["name"], sw_version=device["sw_version"], + hw_version=device["hw_version"], via_device_id=device["via_device_id"], ) # Introduced in 0.111 @@ -633,6 +603,7 @@ def _data_to_save(self) -> dict[str, list[dict[str, Any]]]: "model": entry.model, "name": entry.name, "sw_version": entry.sw_version, + "hw_version": entry.hw_version, "entry_type": entry.entry_type, "id": entry.id, "via_device_id": entry.via_device_id, @@ -661,7 +632,7 @@ def async_clear_config_entry(self, config_entry_id: str) -> None: """Clear config entry from registry entries.""" now_time = time.time() for device in list(self.devices.values()): - self._async_update_device(device.id, remove_config_entry_id=config_entry_id) + self.async_update_device(device.id, remove_config_entry_id=config_entry_id) for deleted_device in list(self.deleted_devices.values()): config_entries = deleted_device.config_entries if config_entry_id not in config_entries: @@ -703,7 +674,7 @@ def async_clear_area_id(self, area_id: str) -> None: """Clear area id from registry entries.""" for dev_id, device in self.devices.items(): if area_id == device.area_id: - self._async_update_device(dev_id, area_id=None) + self.async_update_device(dev_id, area_id=None) @callback diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 1923ba9556c434..ed90b5b893b584 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -7,11 +7,11 @@ """ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from typing import Any, TypedDict from homeassistant import core, setup -from homeassistant.core import CALLBACK_TYPE +from homeassistant.const import Platform from homeassistant.loader import bind_hass from .dispatcher import async_dispatcher_connect, async_dispatcher_send @@ -22,8 +22,6 @@ ATTR_PLATFORM = "platform" ATTR_DISCOVERED = "discovered" -# mypy: disallow-any-generics - class DiscoveryDict(TypedDict): """Discovery data.""" @@ -38,7 +36,7 @@ class DiscoveryDict(TypedDict): def async_listen( hass: core.HomeAssistant, service: str, - callback: CALLBACK_TYPE, + callback: Callable[[str, DiscoveryInfoType | None], Awaitable[None] | None], ) -> None: """Set up listener for discovery of specific service. @@ -126,9 +124,9 @@ async def discovery_platform_listener(discovered: DiscoveryDict) -> None: @bind_hass def load_platform( hass: core.HomeAssistant, - component: str, + component: Platform | str, platform: str, - discovered: DiscoveryInfoType, + discovered: DiscoveryInfoType | None, hass_config: ConfigType, ) -> None: """Load a component and platform dynamically.""" @@ -142,9 +140,9 @@ def load_platform( @bind_hass async def async_load_platform( hass: core.HomeAssistant, - component: str, + component: Platform | str, platform: str, - discovered: DiscoveryInfoType, + discovered: DiscoveryInfoType | None, hass_config: ConfigType, ) -> None: """Load a component and platform dynamically. diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index cd04f9db1842ca..a716e465450c59 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -35,16 +35,18 @@ TEMP_CELSIUS, TEMP_FAHRENHEIT, ) -from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Context, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity_platform import EntityPlatform -from homeassistant.helpers.event import Event, async_track_entity_registry_updated_event -from homeassistant.helpers.typing import StateType from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, ensure_unique_string, slugify +from . import entity_registry as er +from .device_registry import DeviceEntryType +from .entity_platform import EntityPlatform +from .event import async_track_entity_registry_updated_event +from .frame import report +from .typing import StateType + _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 DATA_ENTITY_SOURCE = "entity_info" @@ -177,6 +179,7 @@ class DeviceInfo(TypedDict, total=False): name: str | None suggested_area: str | None sw_version: str | None + hw_version: str | None via_device: tuple[str, str] @@ -198,6 +201,26 @@ class EntityCategory(StrEnum): SYSTEM = "system" +def convert_to_entity_category( + value: EntityCategory | str | None, raise_report: bool = True +) -> EntityCategory | None: + """Force incoming entity_category to be an enum.""" + + if value is None: + return value + + if not isinstance(value, EntityCategory): + if raise_report: + report( + "uses %s (%s) for entity category. This is deprecated and will " + "stop working in Home Assistant 2022.4, it should be updated to use " + "EntityCategory instead" % (type(value).__name__, value), + error_if_core=False, + ) + return EntityCategory(value) + return value + + @dataclass class EntityDescription: """A class that describes Home Assistant entities.""" @@ -206,6 +229,7 @@ class EntityDescription: key: str device_class: str | None = None + # Type string is deprecated as of 2021.12, use EntityCategory entity_category: EntityCategory | Literal[ "config", "diagnostic", "system" ] | None = None @@ -270,7 +294,7 @@ class Entity(ABC): _attr_context_recent_time: timedelta = timedelta(seconds=5) _attr_device_class: str | None _attr_device_info: DeviceInfo | None = None - _attr_entity_category: EntityCategory | str | None + _attr_entity_category: EntityCategory | None _attr_entity_picture: str | None = None _attr_entity_registry_enabled_default: bool _attr_extra_state_attributes: MutableMapping[str, Any] @@ -437,6 +461,7 @@ def attribution(self) -> str | None: """Return the attribution.""" return self._attr_attribution + # Type str is deprecated as of 2021.12, use EntityCategory @property def entity_category(self) -> EntityCategory | str | None: """Return the category of the entity, if any.""" @@ -925,17 +950,19 @@ class ToggleEntity(Entity): """An abstract class for entities that can be turned on and off.""" entity_description: ToggleEntityDescription - _attr_is_on: bool + _attr_is_on: bool | None = None _attr_state: None = None @property @final - def state(self) -> str | None: + def state(self) -> Literal["on", "off"] | None: """Return the state.""" - return STATE_ON if self.is_on else STATE_OFF + if (is_on := self.is_on) is None: + return None + return STATE_ON if is_on else STATE_OFF @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return True if entity is on.""" return self._attr_is_on diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index c190fd5fc35890..da6732d05e76fe 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -20,18 +20,12 @@ ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - discovery, - entity, - service, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform +from . import config_per_platform, config_validation as cv, discovery, entity, service from .entity_platform import EntityPlatform +from .typing import ConfigType, DiscoveryInfoType DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) DATA_INSTANCES = "entity_components" @@ -127,7 +121,8 @@ async def async_setup(self, config: ConfigType) -> None: # Look in config for Domain, Domain 2, Domain 3 etc and load them for p_type, p_config in config_per_platform(config, self.domain): - self.hass.async_create_task(self.async_setup_platform(p_type, p_config)) + if p_type is not None: + self.hass.async_create_task(self.async_setup_platform(p_type, p_config)) # Generic discovery listener for loading platform dynamically # Refer to: homeassistant.helpers.discovery.async_load_platform() @@ -204,7 +199,7 @@ def async_register_entity_service( if isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - async def handle_service(call: Callable) -> None: + async def handle_service(call: ServiceCall) -> None: """Handle the service.""" await self.hass.helpers.service.entity_service_call( self._platforms.values(), func, call, required_features diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index d8cb8477f11267..799b209f16e3c5 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -474,6 +474,7 @@ async def _async_add_entity( # noqa: C901 "name", "suggested_area", "sw_version", + "hw_version", "via_device", ): if key in device_info: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b79e9af209eb43..36ac5cc3dde575 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -38,18 +38,20 @@ valid_entity_id, ) from homeassistant.exceptions import MaxLengthExceeded -from homeassistant.helpers import device_registry as dr, storage -from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED -from homeassistant.helpers.frame import report from homeassistant.loader import bind_hass from homeassistant.util import slugify, uuid as uuid_util from homeassistant.util.yaml import load_yaml +from . import device_registry as dr, storage +from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED +from .frame import report from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry + from .entity import EntityCategory + PATH_REGISTRY = "entity_registry.yaml" DATA_REGISTRY = "entity_registry" EVENT_ENTITY_REGISTRY_UPDATED = "entity_registry_updated" @@ -57,7 +59,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 4 +STORAGE_VERSION_MINOR = 5 STORAGE_KEY = "core.entity_registry" # Attributes relevant to describing entity @@ -91,6 +93,16 @@ class RegistryEntryDisabler(StrEnum): DISABLED_USER = RegistryEntryDisabler.USER.value +def _convert_to_entity_category( + value: EntityCategory | str | None, raise_report: bool = True +) -> EntityCategory | None: + """Force incoming entity_category to be an enum.""" + # pylint: disable=import-outside-toplevel + from .entity import convert_to_entity_category + + return convert_to_entity_category(value, raise_report=raise_report) + + @attr.s(slots=True, frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -105,10 +117,15 @@ class RegistryEntry: device_id: str | None = attr.ib(default=None) domain: str = attr.ib(init=False, repr=False) disabled_by: RegistryEntryDisabler | None = attr.ib(default=None) - entity_category: str | None = attr.ib(default=None) + entity_category: EntityCategory | None = attr.ib( + default=None, converter=_convert_to_entity_category + ) icon: str | None = attr.ib(default=None) id: str = attr.ib(factory=uuid_util.random_uuid_hex) name: str | None = attr.ib(default=None) + options: Mapping[str, Mapping[str, Any]] = attr.ib( + default=None, converter=attr.converters.default_if_none(factory=dict) # type: ignore[misc] + ) # As set by integration original_device_class: str | None = attr.ib(default=None) original_icon: str | None = attr.ib(default=None) @@ -165,7 +182,7 @@ async def _async_migrate_func( return await _async_migrate(old_major_version, old_minor_version, old_data) -class EntityRegistryItems(UserDict): +class EntityRegistryItems(UserDict[str, "RegistryEntry"]): """Container for entity registry items, maps entity_id -> entry. Maintains two additional indexes: @@ -196,10 +213,6 @@ def __delitem__(self, key: str) -> None: self._index.__delitem__((entry.domain, entry.platform, entry.unique_id)) super().__delitem__(key) - def __getitem__(self, key: str) -> RegistryEntry: - """Get an item.""" - return cast(RegistryEntry, super().__getitem__(key)) - def get_entity_id(self, key: tuple[str, str, str]) -> str | None: """Get entity_id from (domain, platform, unique_id).""" return self._index.get(key) @@ -212,10 +225,11 @@ def get_entry(self, key: str) -> RegistryEntry | None: class EntityRegistry: """Class to hold a registry of entities.""" + entities: EntityRegistryItems + def __init__(self, hass: HomeAssistant) -> None: """Initialize the registry.""" self.hass = hass - self.entities: EntityRegistryItems self._store = EntityRegistryStore( hass, STORAGE_VERSION_MAJOR, @@ -230,13 +244,13 @@ def __init__(self, hass: HomeAssistant) -> None: @callback def async_get_device_class_lookup( self, domain_device_classes: set[tuple[str, str | None]] - ) -> dict: + ) -> dict[str, dict[tuple[str, str | None], str]]: """Return a lookup of entity ids for devices which have matching entities. Entities must match a set of (domain, device_class) tuples. The result is indexed by device_id, then by the matching (domain, device_class) """ - lookup: dict[str, dict[tuple[Any, Any], str]] = {} + lookup: dict[str, dict[tuple[str, str | None], str]] = {} for entity in self.entities.values(): if not entity.device_id: continue @@ -320,7 +334,8 @@ def async_get_or_create( capabilities: Mapping[str, Any] | None = None, config_entry: ConfigEntry | None = None, device_id: str | None = None, - entity_category: str | None = None, + # Type str (ENTITY_CATEG*) is deprecated as of 2021.12, use EntityCategory + entity_category: EntityCategory | str | None = None, original_device_class: str | None = None, original_icon: str | None = None, original_name: str | None = None, @@ -335,7 +350,7 @@ def async_get_or_create( entity_id = self.async_get_entity_id(domain, platform, unique_id) if entity_id: - return self._async_update_entity( + return self.async_update_entity( entity_id, area_id=area_id or UNDEFINED, capabilities=capabilities or UNDEFINED, @@ -383,7 +398,7 @@ def async_get_or_create( config_entry_id=config_entry_id, device_id=device_id, disabled_by=disabled_by, - entity_category=entity_category, + entity_category=_convert_to_entity_category(entity_category), entity_id=entity_id, original_device_class=original_device_class, original_icon=original_icon, @@ -460,43 +475,6 @@ def async_device_modified(self, event: Event) -> None: @callback def async_update_entity( - self, - entity_id: str, - *, - area_id: str | None | UndefinedType = UNDEFINED, - config_entry_id: str | None | UndefinedType = UNDEFINED, - device_class: str | None | UndefinedType = UNDEFINED, - disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, - entity_category: str | None | UndefinedType = UNDEFINED, - icon: str | None | UndefinedType = UNDEFINED, - name: str | None | UndefinedType = UNDEFINED, - new_entity_id: str | UndefinedType = UNDEFINED, - new_unique_id: str | UndefinedType = UNDEFINED, - original_device_class: str | None | UndefinedType = UNDEFINED, - original_icon: str | None | UndefinedType = UNDEFINED, - original_name: str | None | UndefinedType = UNDEFINED, - unit_of_measurement: str | None | UndefinedType = UNDEFINED, - ) -> RegistryEntry: - """Update properties of an entity.""" - return self._async_update_entity( - entity_id, - area_id=area_id, - config_entry_id=config_entry_id, - device_class=device_class, - disabled_by=disabled_by, - entity_category=entity_category, - icon=icon, - name=name, - new_entity_id=new_entity_id, - new_unique_id=new_unique_id, - original_device_class=original_device_class, - original_icon=original_icon, - original_name=original_name, - unit_of_measurement=unit_of_measurement, - ) - - @callback - def _async_update_entity( self, entity_id: str, *, @@ -506,7 +484,8 @@ def _async_update_entity( device_class: str | None | UndefinedType = UNDEFINED, device_id: str | None | UndefinedType = UNDEFINED, disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, - entity_category: str | None | UndefinedType = UNDEFINED, + # Type str (ENTITY_CATEG*) is deprecated as of 2021.12, use EntityCategory + entity_category: EntityCategory | str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, @@ -520,8 +499,8 @@ def _async_update_entity( """Private facing update properties method.""" old = self.entities[entity_id] - new_values = {} # Dict with new key/value pairs - old_values = {} # Dict with old key/value pairs + new_values: dict[str, Any] = {} # Dict with new key/value pairs + old_values: dict[str, Any] = {} # Dict with old key/value pairs if isinstance(disabled_by, str) and not isinstance( disabled_by, RegistryEntryDisabler @@ -587,7 +566,11 @@ def _async_update_entity( self.async_schedule_save() - data = {"action": "update", "entity_id": entity_id, "changes": old_values} + data: dict[str, str | dict[str, Any]] = { + "action": "update", + "entity_id": entity_id, + "changes": old_values, + } if old.entity_id != entity_id: data["old_entity_id"] = old.entity_id @@ -596,6 +579,25 @@ def _async_update_entity( return new + @callback + def async_update_entity_options( + self, entity_id: str, domain: str, options: dict[str, Any] + ) -> None: + """Update entity options.""" + old = self.entities[entity_id] + new_options: Mapping[str, Mapping[str, Any]] = {**old.options, domain: options} + self.entities[entity_id] = attr.evolve(old, options=new_options) + + self.async_schedule_save() + + data: dict[str, str | dict[str, Any]] = { + "action": "update", + "entity_id": entity_id, + "changes": {"options": old.options}, + } + + self.hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, data) + async def async_load(self) -> None: """Load the entity registry.""" async_setup_entity_restore(self.hass, self) @@ -626,11 +628,14 @@ async def async_load(self) -> None: disabled_by=RegistryEntryDisabler(entity["disabled_by"]) if entity["disabled_by"] else None, - entity_category=entity["entity_category"], + entity_category=_convert_to_entity_category( + entity["entity_category"], raise_report=False + ), entity_id=entity["entity_id"], icon=entity["icon"], id=entity["id"], name=entity["name"], + options=entity["options"], original_device_class=entity["original_device_class"], original_icon=entity["original_icon"], original_name=entity["original_name"], @@ -650,7 +655,7 @@ def async_schedule_save(self) -> None: @callback def _data_to_save(self) -> dict[str, Any]: """Return data of entity registry to store in a file.""" - data = {} + data: dict[str, Any] = {} data["entities"] = [ { @@ -665,6 +670,7 @@ def _data_to_save(self) -> dict[str, Any]: "icon": entry.icon, "id": entry.id, "name": entry.name, + "options": entry.options, "original_device_class": entry.original_device_class, "original_icon": entry.original_icon, "original_name": entry.original_name, @@ -693,7 +699,7 @@ def async_clear_area_id(self, area_id: str) -> None: """Clear area id from registry entries.""" for entity_id, entry in self.entities.items(): if area_id == entry.area_id: - self._async_update_entity(entity_id, area_id=None) + self.async_update_entity(entity_id, area_id=None) @callback @@ -785,7 +791,7 @@ async def _async_migrate( old_major_version: int, old_minor_version: int, data: dict ) -> dict: """Migrate to the new version.""" - if old_major_version < 2 and old_minor_version < 2: + if old_major_version == 1 and old_minor_version < 2: # From version 1.1 for entity in data["entities"]: # Populate all keys @@ -804,18 +810,23 @@ async def _async_migrate( entity["supported_features"] = entity.get("supported_features", 0) entity["unit_of_measurement"] = entity.get("unit_of_measurement") - if old_major_version < 2 and old_minor_version < 3: + if old_major_version == 1 and old_minor_version < 3: # Version 1.3 adds original_device_class for entity in data["entities"]: # Move device_class to original_device_class entity["original_device_class"] = entity["device_class"] entity["device_class"] = None - if old_major_version < 2 and old_minor_version < 4: + if old_major_version == 1 and old_minor_version < 4: # Version 1.4 adds id for entity in data["entities"]: entity["id"] = uuid_util.random_uuid_hex() + if old_major_version == 1 and old_minor_version < 5: + # Version 1.5 adds entity options + for entity in data["entities"]: + entity["options"] = {} + if old_major_version > 1: raise NotImplementedError return data @@ -878,7 +889,7 @@ def _write_unavailable_states(_: Event) -> None: async def async_migrate_entries( hass: HomeAssistant, config_entry_id: str, - entry_callback: Callable[[RegistryEntry], dict | None], + entry_callback: Callable[[RegistryEntry], dict[str, Any] | None], ) -> None: """Migrator of unique IDs.""" ent_reg = await async_get_registry(hass) diff --git a/homeassistant/helpers/entity_values.py b/homeassistant/helpers/entity_values.py index 8f11fbf31161bf..d489a4b1d379fa 100644 --- a/homeassistant/helpers/entity_values.py +++ b/homeassistant/helpers/entity_values.py @@ -8,8 +8,6 @@ from homeassistant.core import split_entity_id -# mypy: disallow-any-generics - class EntityValues: """Class to store entity id based values.""" diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 522f789b163e63..d4722eeca44f70 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -9,7 +9,8 @@ from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import split_entity_id -from homeassistant.helpers import config_validation as cv + +from . import config_validation as cv CONF_INCLUDE_DOMAINS = "include_domains" CONF_INCLUDE_ENTITY_GLOBS = "include_entity_globs" @@ -21,19 +22,54 @@ CONF_ENTITY_GLOBS = "entity_globs" -def convert_filter(config: dict[str, list[str]]) -> Callable[[str], bool]: +class EntityFilter: + """A entity filter.""" + + def __init__(self, config: dict[str, list[str]]) -> None: + """Init the filter.""" + self.empty_filter: bool = sum(len(val) for val in config.values()) == 0 + self.config = config + self._include_e = set(config[CONF_INCLUDE_ENTITIES]) + self._exclude_e = set(config[CONF_EXCLUDE_ENTITIES]) + self._include_d = set(config[CONF_INCLUDE_DOMAINS]) + self._exclude_d = set(config[CONF_EXCLUDE_DOMAINS]) + self._include_eg = _convert_globs_to_pattern_list( + config[CONF_INCLUDE_ENTITY_GLOBS] + ) + self._exclude_eg = _convert_globs_to_pattern_list( + config[CONF_EXCLUDE_ENTITY_GLOBS] + ) + self._filter: Callable[[str], bool] | None = None + + def explicitly_included(self, entity_id: str) -> bool: + """Check if an entity is explicitly included.""" + return entity_id in self._include_e or _test_against_patterns( + self._include_eg, entity_id + ) + + def explicitly_excluded(self, entity_id: str) -> bool: + """Check if an entity is explicitly excluded.""" + return entity_id in self._exclude_e or _test_against_patterns( + self._exclude_eg, entity_id + ) + + def __call__(self, entity_id: str) -> bool: + """Run the filter.""" + if self._filter is None: + self._filter = _generate_filter_from_sets_and_pattern_lists( + self._include_d, + self._include_e, + self._exclude_d, + self._exclude_e, + self._include_eg, + self._exclude_eg, + ) + return self._filter(entity_id) + + +def convert_filter(config: dict[str, list[str]]) -> EntityFilter: """Convert the filter schema into a filter.""" - filt = generate_filter( - config[CONF_INCLUDE_DOMAINS], - config[CONF_INCLUDE_ENTITIES], - config[CONF_EXCLUDE_DOMAINS], - config[CONF_EXCLUDE_ENTITIES], - config[CONF_INCLUDE_ENTITY_GLOBS], - config[CONF_EXCLUDE_ENTITY_GLOBS], - ) - setattr(filt, "config", config) - setattr(filt, "empty_filter", sum(len(val) for val in config.values()) == 0) - return filt + return EntityFilter(config) BASE_FILTER_SCHEMA = vol.Schema( @@ -60,11 +96,11 @@ def convert_filter(config: dict[str, list[str]]) -> Callable[[str], bool]: def convert_include_exclude_filter( config: dict[str, dict[str, list[str]]] -) -> Callable[[str], bool]: +) -> EntityFilter: """Convert the include exclude filter schema into a filter.""" include = config[CONF_INCLUDE] exclude = config[CONF_EXCLUDE] - filt = convert_filter( + return convert_filter( { CONF_INCLUDE_DOMAINS: include[CONF_DOMAINS], CONF_INCLUDE_ENTITY_GLOBS: include[CONF_ENTITY_GLOBS], @@ -74,8 +110,6 @@ def convert_include_exclude_filter( CONF_EXCLUDE_ENTITIES: exclude[CONF_ENTITIES], } ) - setattr(filt, "config", config) - return filt INCLUDE_EXCLUDE_FILTER_SCHEMA_INNER = vol.Schema( @@ -118,6 +152,11 @@ def _test_against_patterns(patterns: list[re.Pattern[str]], entity_id: str) -> b return False +def _convert_globs_to_pattern_list(globs: list[str] | None) -> list[re.Pattern[str]]: + """Convert a list of globs to a re pattern list.""" + return list(map(_glob_to_re, set(globs or []))) + + def generate_filter( include_domains: list[str], include_entities: list[str], @@ -127,19 +166,25 @@ def generate_filter( exclude_entity_globs: list[str] | None = None, ) -> Callable[[str], bool]: """Return a function that will filter entities based on the args.""" - include_d = set(include_domains) - include_e = set(include_entities) - exclude_d = set(exclude_domains) - exclude_e = set(exclude_entities) - include_eg_set = ( - set(include_entity_globs) if include_entity_globs is not None else set() - ) - exclude_eg_set = ( - set(exclude_entity_globs) if exclude_entity_globs is not None else set() + return _generate_filter_from_sets_and_pattern_lists( + set(include_domains), + set(include_entities), + set(exclude_domains), + set(exclude_entities), + _convert_globs_to_pattern_list(include_entity_globs), + _convert_globs_to_pattern_list(exclude_entity_globs), ) - include_eg = list(map(_glob_to_re, include_eg_set)) - exclude_eg = list(map(_glob_to_re, exclude_eg_set)) + +def _generate_filter_from_sets_and_pattern_lists( + include_d: set[str], + include_e: set[str], + exclude_d: set[str], + exclude_e: set[str], + include_eg: list[re.Pattern[str]], + exclude_eg: list[re.Pattern[str]], +) -> Callable[[str], bool]: + """Generate a filter from pre-comuted sets and pattern lists.""" have_exclude = bool(exclude_e or exclude_d or exclude_eg) have_include = bool(include_e or include_d or include_eg) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 1404b3730f1f51..cbafd2e7e959cf 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -2,16 +2,17 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Iterable, Sequence +from collections.abc import Awaitable, Callable, Iterable, Sequence import copy from dataclasses import dataclass from datetime import datetime, timedelta import functools as ft import logging import time -from typing import Any, Callable, List, cast +from typing import Any, Union, cast import attr +from typing_extensions import Concatenate, ParamSpec from homeassistant.const import ( ATTR_ENTITY_ID, @@ -33,15 +34,16 @@ split_entity_id, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED -from homeassistant.helpers.ratelimit import KeyedRateLimit -from homeassistant.helpers.sun import get_astral_event_next -from homeassistant.helpers.template import RenderInfo, Template, result_as_boolean -from homeassistant.helpers.typing import TemplateVarsType from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe +from .entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from .ratelimit import KeyedRateLimit +from .sun import get_astral_event_next +from .template import RenderInfo, Template, result_as_boolean +from .typing import TemplateVarsType + TRACK_STATE_CHANGE_CALLBACKS = "track_state_change_callbacks" TRACK_STATE_CHANGE_LISTENER = "track_state_change_listener" @@ -60,6 +62,8 @@ _LOGGER = logging.getLogger(__name__) +_P = ParamSpec("_P") + @dataclass class TrackStates: @@ -109,20 +113,20 @@ class TrackTemplateResult: def threaded_listener_factory( - async_factory: Callable[..., Any] -) -> Callable[..., CALLBACK_TYPE]: + async_factory: Callable[Concatenate[HomeAssistant, _P], Any] # type: ignore[misc] +) -> Callable[Concatenate[HomeAssistant, _P], CALLBACK_TYPE]: # type: ignore[misc] """Convert an async event helper to a threaded one.""" @ft.wraps(async_factory) - def factory(*args: Any, **kwargs: Any) -> CALLBACK_TYPE: + def factory( + hass: HomeAssistant, *args: _P.args, **kwargs: _P.kwargs + ) -> CALLBACK_TYPE: """Call async event helper safely.""" - hass = args[0] - if not isinstance(hass, HomeAssistant): raise TypeError("First parameter needs to be a hass instance") async_remove = run_callback_threadsafe( - hass.loop, ft.partial(async_factory, *args, **kwargs) + hass.loop, ft.partial(async_factory, hass, *args, **kwargs) ).result() def remove() -> None: @@ -232,7 +236,7 @@ def async_track_state_change_event( hass: HomeAssistant, entity_ids: str | Iterable[str], action: Callable[[Event], Any], -) -> Callable[[], None]: +) -> CALLBACK_TYPE: """Track specific state change events indexed by entity_id. Unlike async_track_state_change, async_track_state_change_event @@ -308,7 +312,7 @@ def _async_remove_indexed_listeners( data_key: str, listener_key: str, storage_keys: Iterable[str], - job: HassJob, + job: HassJob[Any], ) -> None: """Remove a listener.""" callbacks = hass.data[data_key] @@ -328,7 +332,7 @@ def async_track_entity_registry_updated_event( hass: HomeAssistant, entity_ids: str | Iterable[str], action: Callable[[Event], Any], -) -> Callable[[], None]: +) -> CALLBACK_TYPE: """Track specific entity registry updated events indexed by entity_id. Similar to async_track_state_change_event. @@ -390,7 +394,7 @@ def remove_listener() -> None: @callback def _async_dispatch_domain_event( - hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob]] + hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[Any]]] ) -> None: domain = split_entity_id(event.data["entity_id"])[0] @@ -413,7 +417,7 @@ def async_track_state_added_domain( hass: HomeAssistant, domains: str | Iterable[str], action: Callable[[Event], Any], -) -> Callable[[], None]: +) -> CALLBACK_TYPE: """Track state change events when an entity is added to domains.""" if not (domains := _async_string_to_lower_list(domains)): return _remove_empty_listener @@ -465,7 +469,7 @@ def async_track_state_removed_domain( hass: HomeAssistant, domains: str | Iterable[str], action: Callable[[Event], Any], -) -> Callable[[], None]: +) -> CALLBACK_TYPE: """Track state change events when an entity is removed from domains.""" if not (domains := _async_string_to_lower_list(domains)): return _remove_empty_listener @@ -532,7 +536,8 @@ def __init__( """Handle removal / refresh of tracker init.""" self.hass = hass self._action = action - self._listeners: dict[str, Callable] = {} + self._action_as_hassjob = HassJob(action) + self._listeners: dict[str, Callable[[], None]] = {} self._last_track_states: TrackStates = track_states @callback @@ -627,13 +632,21 @@ def _setup_entities_listener(self, domains: set[str], entities: set[str]) -> Non self.hass, entities, self._action ) + @callback + def _state_added(self, event: Event) -> None: + self._cancel_listener(_ENTITIES_LISTENER) + self._setup_entities_listener( + self._last_track_states.domains, self._last_track_states.entities + ) + self.hass.async_run_hass_job(self._action_as_hassjob, event) + @callback def _setup_domains_listener(self, domains: set[str]) -> None: if not domains: return self._listeners[_DOMAINS_LISTENER] = async_track_state_added_domain( - self.hass, domains, self._action + self.hass, domains, self._state_added ) @callback @@ -679,7 +692,7 @@ def async_track_template( template: Template, action: Callable[[str, State | None, State | None], Awaitable[None] | None], variables: TemplateVarsType | None = None, -) -> Callable[[], None]: +) -> CALLBACK_TYPE: """Add a listener that fires when a a template evaluates to 'true'. Listen for the result of the template becoming true, or a true-like @@ -720,7 +733,7 @@ def async_track_template( @callback def _template_changed_listener( - event: Event, updates: list[TrackTemplateResult] + event: Event | None, updates: list[TrackTemplateResult] ) -> None: """Check if condition is correct and run action.""" track_result = updates.pop() @@ -768,7 +781,7 @@ def __init__( self, hass: HomeAssistant, track_templates: Sequence[TrackTemplate], - action: Callable, + action: Callable[[Event | None, list[TrackTemplateResult]], None], has_super_template: bool = False, ) -> None: """Handle removal / refresh of tracker init.""" @@ -785,7 +798,7 @@ def __init__( self._rate_limit = KeyedRateLimit(hass) self._info: dict[Template, RenderInfo] = {} self._track_state_changes: _TrackStateChangeFiltered | None = None - self._time_listeners: dict[Template, Callable] = {} + self._time_listeners: dict[Template, Callable[[], None]] = {} def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> None: """Activation of template tracking.""" @@ -985,7 +998,7 @@ def _refresh( replayed is True if the event is being replayed because the rate limit was hit. """ - updates = [] + updates: list[TrackTemplateResult] = [] info_changed = False now = event.time_fired if not replayed and event else dt_util.utcnow() @@ -1074,8 +1087,8 @@ def _apply_update( TrackTemplateResultListener = Callable[ [ - Event, - List[TrackTemplateResult], + Union[Event, None], + list[TrackTemplateResult], ], None, ] @@ -1151,7 +1164,7 @@ def async_track_template_result( def async_track_same_state( hass: HomeAssistant, period: timedelta, - action: Callable[..., Awaitable[None] | None], + action: Callable[[], Awaitable[None] | None], async_check_same_func: Callable[[str, State | None, State | None], bool], entity_ids: str | Iterable[str] = MATCH_ALL, ) -> CALLBACK_TYPE: @@ -1220,7 +1233,8 @@ def state_for_cancel_listener(event: Event) -> None: @bind_hass def async_track_point_in_time( hass: HomeAssistant, - action: HassJob | Callable[..., Awaitable[None] | None], + action: HassJob[Awaitable[None] | None] + | Callable[[datetime], Awaitable[None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in time.""" @@ -1241,7 +1255,8 @@ def utc_converter(utc_now: datetime) -> None: @bind_hass def async_track_point_in_utc_time( hass: HomeAssistant, - action: HassJob | Callable[..., Awaitable[None] | None], + action: HassJob[Awaitable[None] | None] + | Callable[[datetime], Awaitable[None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in UTC time.""" @@ -1253,7 +1268,7 @@ def async_track_point_in_utc_time( cancel_callback: asyncio.TimerHandle | None = None @callback - def run_action(job: HassJob) -> None: + def run_action(job: HassJob[Awaitable[None] | None]) -> None: """Call the action.""" nonlocal cancel_callback @@ -1293,7 +1308,8 @@ def unsub_point_in_time_listener() -> None: def async_call_later( hass: HomeAssistant, delay: float | timedelta, - action: HassJob | Callable[..., Awaitable[None] | None], + action: HassJob[Awaitable[None] | None] + | Callable[[datetime], Awaitable[None] | None], ) -> CALLBACK_TYPE: """Add a listener that is called in .""" if not isinstance(delay, timedelta): @@ -1308,7 +1324,7 @@ def async_call_later( @bind_hass def async_track_time_interval( hass: HomeAssistant, - action: Callable[..., Awaitable[None] | None], + action: Callable[[datetime], Awaitable[None] | None], interval: timedelta, ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" @@ -1350,7 +1366,7 @@ class SunListener: """Helper class to help listen to sun events.""" hass: HomeAssistant = attr.ib() - job: HassJob = attr.ib() + job: HassJob[Awaitable[None] | None] = attr.ib() event: str = attr.ib() offset: timedelta | None = attr.ib() _unsub_sun: CALLBACK_TYPE | None = attr.ib(default=None) @@ -1408,7 +1424,7 @@ def _handle_config_event(self, _event: Any) -> None: @callback @bind_hass def async_track_sunrise( - hass: HomeAssistant, action: Callable[..., None], offset: timedelta | None = None + hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: """Add a listener that will fire a specified offset from sunrise daily.""" listener = SunListener(hass, HassJob(action), SUN_EVENT_SUNRISE, offset) @@ -1422,7 +1438,7 @@ def async_track_sunrise( @callback @bind_hass def async_track_sunset( - hass: HomeAssistant, action: Callable[..., None], offset: timedelta | None = None + hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: """Add a listener that will fire a specified offset from sunset daily.""" listener = SunListener(hass, HassJob(action), SUN_EVENT_SUNSET, offset) @@ -1440,7 +1456,7 @@ def async_track_sunset( @bind_hass def async_track_utc_time_change( hass: HomeAssistant, - action: Callable[..., Awaitable[None] | None], + action: Callable[[datetime], Awaitable[None] | None], hour: Any | None = None, minute: Any | None = None, second: Any | None = None, @@ -1455,7 +1471,7 @@ def async_track_utc_time_change( @callback def time_change_listener(event: Event) -> None: """Fire every time event that comes in.""" - hass.async_run_hass_job(job, event.data[ATTR_NOW]) + hass.async_run_hass_job(job, cast(datetime, event.data[ATTR_NOW])) return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener) @@ -1506,7 +1522,7 @@ def unsub_pattern_time_change_listener() -> None: @bind_hass def async_track_time_change( hass: HomeAssistant, - action: Callable[..., None], + action: Callable[[datetime], Awaitable[None] | None], hour: Any | None = None, minute: Any | None = None, second: Any | None = None, diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index ec5b0a5e7cad0a..d1dc11aae4d2cf 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -9,9 +9,10 @@ from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers.frame import warn_use from homeassistant.loader import bind_hass +from .frame import warn_use + DATA_ASYNC_CLIENT = "httpx_async_client" DATA_ASYNC_CLIENT_NOVERIFY = "httpx_async_client_noverify" SERVER_SOFTWARE = "HomeAssistant/{0} httpx/{1} Python/{2[0]}.{2[1]}".format( diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 0e619fe551b821..c1d7487abb68c2 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -6,9 +6,10 @@ import logging from typing import Any +from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.core import Event, HomeAssistant from homeassistant.loader import async_get_integration, bind_hass -from homeassistant.setup import ATTR_COMPONENT, EVENT_COMPONENT_LOADED +from homeassistant.setup import ATTR_COMPONENT _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index d3494c3f41b2a5..ca154d20b75cec 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -4,18 +4,19 @@ from collections.abc import Callable, Iterable import logging import re -from typing import Any, Dict +from typing import Any import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES from homeassistant.core import Context, HomeAssistant, State, T, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv from homeassistant.loader import bind_hass +from . import config_validation as cv + _LOGGER = logging.getLogger(__name__) -_SlotsType = Dict[str, Any] +_SlotsType = dict[str, Any] INTENT_TURN_OFF = "HassTurnOff" INTENT_TURN_ON = "HassTurnOn" diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py index e9d3f34b970523..a3f2dfb4c6fbdc 100644 --- a/homeassistant/helpers/location.py +++ b/homeassistant/helpers/location.py @@ -86,7 +86,7 @@ def find_coordinates( # Check if state is valid coordinate set try: # Import here, not at top-level to avoid circular import - import homeassistant.helpers.config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # pylint: disable=import-outside-toplevel cv.gps(entity_state.state.split(",")) except vol.Invalid: diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 8f9ce48a59b9ce..5a826d4129e147 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -10,13 +10,12 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform -from homeassistant.helpers.entity_platform import EntityPlatform, async_get_platforms -from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component -# mypy: disallow-any-generics +from . import config_per_platform +from .entity_platform import EntityPlatform, async_get_platforms +from .typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -78,8 +77,8 @@ async def _resetup_platform( if hasattr(component, "async_reset_platform"): # If the integration has its own way to reset # use this method. - await component.async_reset_platform(hass, integration_name) # type: ignore - await component.async_setup(hass, root_config) # type: ignore + await component.async_reset_platform(hass, integration_name) + await component.async_setup(hass, root_config) return # If it's an entity platform, we use the entity_platform diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index f1e74e26908bd7..4857210f125ed8 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -4,19 +4,20 @@ import asyncio from datetime import datetime, timedelta import logging -from typing import Any, cast +from typing import Any, TypeVar, cast -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry, start -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.singleton import singleton -from homeassistant.helpers.storage import Store import homeassistant.util.dt as dt_util +from . import start +from .entity import Entity +from .event import async_track_time_interval +from .json import JSONEncoder +from .singleton import singleton +from .storage import Store + DATA_RESTORE_STATE_TASK = "restore_state_task" _LOGGER = logging.getLogger(__name__) @@ -30,6 +31,8 @@ # How long should a saved state be preserved if the entity no longer exists STATE_EXPIRATION = timedelta(days=7) +_StoredStateT = TypeVar("_StoredStateT", bound="StoredState") + class StoredState: """Object to represent a stored state.""" @@ -44,14 +47,14 @@ def as_dict(self) -> dict[str, Any]: return {"state": self.state.as_dict(), "last_seen": self.last_seen} @classmethod - def from_dict(cls, json_dict: dict) -> StoredState: + def from_dict(cls: type[_StoredStateT], json_dict: dict) -> _StoredStateT: """Initialize a stored state from a dict.""" last_seen = json_dict["last_seen"] if isinstance(last_seen, str): last_seen = dt_util.parse_datetime(last_seen) - return cls(State.from_dict(json_dict["state"]), last_seen) + return cls(cast(State, State.from_dict(json_dict["state"])), last_seen) class RestoreStateData: @@ -117,7 +120,7 @@ def async_get_stored_states(self) -> list[StoredState]: current_entity_ids = { state.entity_id for state in all_states - if not state.attributes.get(entity_registry.ATTR_RESTORED) + if not state.attributes.get(ATTR_RESTORED) } # Start with the currently registered states @@ -126,7 +129,7 @@ def async_get_stored_states(self) -> list[StoredState]: for state in all_states if state.entity_id in self.entity_ids and # Ignore all states that are entity registry placeholders - not state.attributes.get(entity_registry.ATTR_RESTORED) + not state.attributes.get(ATTR_RESTORED) ] expiration_time = now - STATE_EXPIRATION diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 20a1dbb8aec7ea..8e54e294f4bfa0 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -9,7 +9,7 @@ import itertools import logging from types import MappingProxyType -from typing import Any, Dict, TypedDict, Union, cast +from typing import Any, TypedDict, Union, cast import async_timeout import voluptuous as vol @@ -56,29 +56,18 @@ HomeAssistant, callback, ) -from homeassistant.helpers import condition, config_validation as cv, service, template -from homeassistant.helpers.condition import ( - ConditionCheckerType, - trace_condition_function, -) -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.event import async_call_later, async_track_template -from homeassistant.helpers.script_variables import ScriptVariables -from homeassistant.helpers.trace import script_execution_set -from homeassistant.helpers.trigger import ( - async_initialize_triggers, - async_validate_trigger_config, -) -from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify from homeassistant.util.dt import utcnow +from . import condition, config_validation as cv, service, template +from .condition import ConditionCheckerType, trace_condition_function +from .dispatcher import async_dispatcher_connect, async_dispatcher_send +from .event import async_call_later, async_track_template +from .script_variables import ScriptVariables from .trace import ( TraceElement, async_trace_path, + script_execution_set, trace_append_element, trace_id_get, trace_path, @@ -90,6 +79,8 @@ trace_stack_top, trace_update_result, ) +from .trigger import async_initialize_triggers, async_validate_trigger_config +from .typing import ConfigType # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs @@ -254,15 +245,15 @@ async def async_validate_action_config( elif action_type == cv.SCRIPT_ACTION_DEVICE_AUTOMATION: platform = await device_automation.async_get_device_automation_platform( - hass, config[CONF_DOMAIN], "action" + hass, config[CONF_DOMAIN], device_automation.DeviceAutomationType.ACTION ) if hasattr(platform, "async_validate_action_config"): - config = await platform.async_validate_action_config(hass, config) # type: ignore + config = await platform.async_validate_action_config(hass, config) else: - config = platform.ACTION_SCHEMA(config) # type: ignore + config = platform.ACTION_SCHEMA(config) elif action_type == cv.SCRIPT_ACTION_CHECK_CONDITION: - config = await condition.async_validate_condition_config(hass, config) # type: ignore + config = await condition.async_validate_condition_config(hass, config) elif action_type == cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER: config[CONF_WAIT_FOR_TRIGGER] = await async_validate_trigger_config( @@ -590,7 +581,9 @@ async def _async_device_step(self): """Perform the device automation specified in the action.""" self._step_log("device automation") platform = await device_automation.async_get_device_automation_platform( - self._hass, self._action[CONF_DOMAIN], "action" + self._hass, + self._action[CONF_DOMAIN], + device_automation.DeviceAutomationType.ACTION, ) await platform.async_call_action_from_config( self._hass, self._action, self._variables, self._context @@ -922,7 +915,7 @@ async def _async_stop_scripts_at_shutdown(hass, event): ) -_VarsType = Union[Dict[str, Any], MappingProxyType] +_VarsType = Union[dict[str, Any], MappingProxyType] def _referenced_extract_ids(data: dict[str, Any], key: str, found: set[str]) -> None: @@ -1223,7 +1216,7 @@ async def async_run( self._hass, run_variables, ) - except template.TemplateError as err: + except exceptions.TemplateError as err: self._log("Error rendering variables: %s", err, level=logging.ERROR) raise elif run_variables: diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py index 3dae84166f6b23..6c5edfd0ac36c6 100644 --- a/homeassistant/helpers/script_variables.py +++ b/homeassistant/helpers/script_variables.py @@ -8,8 +8,6 @@ from . import template -# mypy: disallow-any-generics - class ScriptVariables: """Class to hold and render script variables.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 00369d43536f47..3cf11453e20f1e 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -8,6 +8,7 @@ import logging from typing import TYPE_CHECKING, Any, TypedDict +from typing_extensions import TypeGuard import voluptuous as vol from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL @@ -20,7 +21,6 @@ CONF_SERVICE_DATA, CONF_SERVICE_TEMPLATE, CONF_TARGET, - ENTITY_CATEGORIES, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, ) @@ -31,14 +31,6 @@ Unauthorized, UnknownUser, ) -from homeassistant.helpers import ( - area_registry, - config_validation as cv, - device_registry, - entity_registry, - template, -) -from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.loader import ( MAX_LOAD_CONCURRENTLY, Integration, @@ -49,9 +41,18 @@ from homeassistant.util.yaml import load_yaml from homeassistant.util.yaml.loader import JSON_TYPE +from . import ( + area_registry, + config_validation as cv, + device_registry, + entity_registry, + template, +) +from .typing import ConfigType, TemplateVarsType + if TYPE_CHECKING: - from homeassistant.helpers.entity import Entity - from homeassistant.helpers.entity_platform import EntityPlatform + from .entity import Entity + from .entity_platform import EntityPlatform CONF_SERVICE_ENTITY_ID = "entity_id" @@ -216,7 +217,10 @@ def async_prepare_call_from_config( target.update(template.render_complex(conf, variables)) if CONF_ENTITY_ID in target: - target[CONF_ENTITY_ID] = cv.comp_entity_ids(target[CONF_ENTITY_ID]) + registry = entity_registry.async_get(hass) + target[CONF_ENTITY_ID] = entity_registry.async_resolve_entity_ids( + registry, cv.comp_entity_ids_or_uuids(target[CONF_ENTITY_ID]) + ) except TemplateError as ex: raise HomeAssistantError( f"Error rendering service target template: {ex}" @@ -318,7 +322,7 @@ async def async_extract_entity_ids( return referenced.referenced | referenced.indirectly_referenced -def _has_match(ids: str | list | None) -> bool: +def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: """Check if ids can match anything.""" return ids not in (None, ENTITY_MATCH_NONE) @@ -366,7 +370,8 @@ def async_extract_referenced_entity_ids( for ent_entry in ent_reg.entities.values(): # Do not add config or diagnostic entities referenced by areas or devices - if ent_entry.entity_category in ENTITY_CATEGORIES: + + if ent_entry.entity_category is not None: continue if ( @@ -705,7 +710,7 @@ async def _handle_entity_call( func, entity.entity_id, ) - await result # type: ignore + await result @bind_hass @@ -714,7 +719,7 @@ def async_register_admin_service( hass: HomeAssistant, domain: str, service: str, - service_func: Callable[[ServiceCall], Awaitable | None], + service_func: Callable[[ServiceCall], Awaitable[None] | None], schema: vol.Schema = vol.Schema({}, extra=vol.PREVENT_EXTRA), ) -> None: """Register a service that requires admin access.""" diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index 53802a2a1196a3..9fd643a775747a 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -1,8 +1,6 @@ """Signal handling related helpers.""" import logging import signal -import sys -from types import FrameType from homeassistant.const import RESTART_EXIT_CODE from homeassistant.core import HomeAssistant, callback @@ -15,57 +13,31 @@ @bind_hass def async_register_signal_handling(hass: HomeAssistant) -> None: """Register system signal handler for core.""" - if sys.platform != "win32": - @callback - def async_signal_handle(exit_code: int) -> None: - """Wrap signal handling. - - * queue call to shutdown task - * re-instate default handler - """ - hass.loop.remove_signal_handler(signal.SIGTERM) - hass.loop.remove_signal_handler(signal.SIGINT) - hass.async_create_task(hass.async_stop(exit_code)) - - try: - hass.loop.add_signal_handler(signal.SIGTERM, async_signal_handle, 0) - except ValueError: - _LOGGER.warning("Could not bind to SIGTERM") - - try: - hass.loop.add_signal_handler(signal.SIGINT, async_signal_handle, 0) - except ValueError: - _LOGGER.warning("Could not bind to SIGINT") - - try: - hass.loop.add_signal_handler( - signal.SIGHUP, async_signal_handle, RESTART_EXIT_CODE - ) - except ValueError: - _LOGGER.warning("Could not bind to SIGHUP") - - else: - old_sigterm = None - old_sigint = None - - @callback - def async_signal_handle(exit_code: int, frame: FrameType) -> None: - """Wrap signal handling. - - * queue call to shutdown task - * re-instate default handler - """ - signal.signal(signal.SIGTERM, old_sigterm) - signal.signal(signal.SIGINT, old_sigint) - hass.async_create_task(hass.async_stop(exit_code)) - - try: - old_sigterm = signal.signal(signal.SIGTERM, async_signal_handle) - except ValueError: - _LOGGER.warning("Could not bind to SIGTERM") - - try: - old_sigint = signal.signal(signal.SIGINT, async_signal_handle) - except ValueError: - _LOGGER.warning("Could not bind to SIGINT") + @callback + def async_signal_handle(exit_code: int) -> None: + """Wrap signal handling. + + * queue call to shutdown task + * re-instate default handler + """ + hass.loop.remove_signal_handler(signal.SIGTERM) + hass.loop.remove_signal_handler(signal.SIGINT) + hass.async_create_task(hass.async_stop(exit_code)) + + try: + hass.loop.add_signal_handler(signal.SIGTERM, async_signal_handle, 0) + except ValueError: + _LOGGER.warning("Could not bind to SIGTERM") + + try: + hass.loop.add_signal_handler(signal.SIGINT, async_signal_handle, 0) + except ValueError: + _LOGGER.warning("Could not bind to SIGINT") + + try: + hass.loop.add_signal_handler( + signal.SIGHUP, async_signal_handle, RESTART_EXIT_CODE + ) + except ValueError: + _LOGGER.warning("Could not bind to SIGHUP") diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index d2791def987e79..c1dbaf8c6e46ee 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -28,8 +28,9 @@ async def async_check_significant_change( """ from __future__ import annotations +from collections.abc import Callable from types import MappingProxyType -from typing import Any, Callable, Optional, Union +from typing import Any, Optional, Union from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State, callback diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index a3cde0b2f27c42..7012241fe4ecab 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import functools -from typing import Callable, TypeVar, cast +from typing import TypeVar, cast from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass diff --git a/homeassistant/helpers/start.py b/homeassistant/helpers/start.py index 805ac193834678..4560119a685acb 100644 --- a/homeassistant/helpers/start.py +++ b/homeassistant/helpers/start.py @@ -4,23 +4,24 @@ from collections.abc import Awaitable, Callable from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HassJob, HomeAssistant, callback @callback def async_at_start( - hass: HomeAssistant, at_start_cb: Callable[[HomeAssistant], Awaitable] + hass: HomeAssistant, at_start_cb: Callable[[HomeAssistant], Awaitable[None] | None] ) -> None: """Execute something when Home Assistant is started. Will execute it now if Home Assistant is already started. """ + at_start_job = HassJob(at_start_cb) if hass.is_running: - hass.async_create_task(at_start_cb(hass)) + hass.async_run_hass_job(at_start_job, hass) return async def _matched_event(event: Event) -> None: """Call the callback when Home Assistant started.""" - await at_start_cb(hass) + hass.async_run_hass_job(at_start_job, hass) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _matched_event) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 323737d5b98740..af0c50ec5fa2a7 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -197,7 +197,7 @@ async def async_save(self, data: dict | list) -> None: def async_delay_save(self, data_func: Callable[[], dict], delay: float = 0) -> None: """Save data with an optional delay.""" # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers.event import async_call_later + from .event import async_call_later self._data = { "version": self.version, @@ -277,8 +277,7 @@ async def _async_handle_write_data(self, *_args): def _write_data(self, path: str, data: dict) -> None: """Write the data.""" - if not os.path.isdir(os.path.dirname(path)): - os.makedirs(os.path.dirname(path)) + os.makedirs(os.path.dirname(path), exist_ok=True) _LOGGER.debug("Writing data for %s to %s", self.key, path) json_util.save_json( diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 7e65ab858ad53b..e137d0f673ef67 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -34,9 +34,7 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: except KeyError: info_object["user"] = None - if platform.system() == "Windows": - info_object["os_version"] = platform.win32_ver()[0] - elif platform.system() == "Darwin": + if platform.system() == "Darwin": info_object["os_version"] = platform.mac_ver()[0] elif platform.system() == "Linux": info_object["docker"] = os.path.isfile("/.dockerenv") diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 0ba1d6bfa14c55..916d203782ab3e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -24,7 +24,7 @@ import weakref import jinja2 -from jinja2 import pass_context +from jinja2 import pass_context, pass_environment from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace import voluptuous as vol @@ -45,18 +45,19 @@ valid_entity_id, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import ( - area_registry, - device_registry, - entity_registry, - location as loc_helper, -) -from homeassistant.helpers.typing import TemplateVarsType from homeassistant.loader import bind_hass -from homeassistant.util import convert, dt as dt_util, location as loc_util +from homeassistant.util import ( + convert, + dt as dt_util, + location as loc_util, + slugify as slugify_util, +) from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.thread import ThreadWithException +from . import area_registry, device_registry, entity_registry, location as loc_helper +from .typing import TemplateVarsType + # mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) @@ -877,9 +878,7 @@ def result_as_boolean(template_result: Any | None) -> bool: try: # Import here, not at top-level to avoid circular import - from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel - config_validation as cv, - ) + from . import config_validation as cv # pylint: disable=import-outside-toplevel return cv.boolean(template_result) except vol.Invalid: @@ -947,7 +946,7 @@ def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]: # fallback to just returning all entities for a domain # pylint: disable=import-outside-toplevel - from homeassistant.helpers.entity import entity_sources + from .entity import entity_sources return [ entity_id @@ -1009,9 +1008,7 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: ent_reg = entity_registry.async_get(hass) dev_reg = device_registry.async_get(hass) # Import here, not at top-level to avoid circular import - from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel - config_validation as cv, - ) + from . import config_validation as cv # pylint: disable=import-outside-toplevel try: cv.entity_id(lookup_value) @@ -1049,9 +1046,7 @@ def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: dev_reg = device_registry.async_get(hass) ent_reg = entity_registry.async_get(hass) # Import here, not at top-level to avoid circular import - from homeassistant.helpers import ( # pylint: disable=import-outside-toplevel - config_validation as cv, - ) + from . import config_validation as cv # pylint: disable=import-outside-toplevel try: cv.entity_id(lookup_value) @@ -1530,6 +1525,30 @@ def fail_when_undefined(value): return value +def min_max_from_filter(builtin_filter: Any, name: str) -> Any: + """ + Convert a built-in min/max Jinja filter to a global function. + + The parameters may be passed as an iterable or as separate arguments. + """ + + @pass_environment + @wraps(builtin_filter) + def wrapper(environment: jinja2.Environment, *args: Any, **kwargs: Any) -> Any: + if len(args) == 0: + raise TypeError(f"{name} expected at least 1 argument, got 0") + + if len(args) == 1: + if isinstance(args[0], Iterable): + return builtin_filter(environment, args[0], **kwargs) + + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + + return builtin_filter(environment, args, **kwargs) + + return pass_environment(wrapper) + + def average(*args: Any) -> float: """ Filter and function to calculate the arithmetic mean of an iterable or of two or more arguments. @@ -1753,6 +1772,30 @@ def urlencode(value): return urllib_urlencode(value).encode("utf-8") +def slugify(value, separator="_"): + """Convert a string into a slug, such as what is used for entity ids.""" + return slugify_util(value, separator=separator) + + +def iif( + value: Any, if_true: Any = True, if_false: Any = False, if_none: Any = _SENTINEL +) -> Any: + """Immediate if function/filter that allow for common if/else constructs. + + https://en.wikipedia.org/wiki/IIf + + Examples: + {{ is_state("device_tracker.frenck", "home") | iif("yes", "no") }} + {{ iif(1==2, "yes", "no") }} + {{ (1 == 1) | iif("yes", "no") }} + """ + if value is None and if_none is not _SENTINEL: + return if_none + if bool(value): + return if_true + return if_false + + @contextmanager def set_template(template_str: str, action: str) -> Generator: """Store template being parsed or rendered in a Contextvar to aid error handling.""" @@ -1846,8 +1889,6 @@ def __init__(self, hass, limited=False, strict=False): self.filters["from_json"] = from_json self.filters["is_defined"] = fail_when_undefined self.filters["average"] = average - self.filters["max"] = max - self.filters["min"] = min self.filters["random"] = random_every_time self.filters["base64_encode"] = base64_encode self.filters["base64_decode"] = base64_decode @@ -1866,6 +1907,8 @@ def __init__(self, hass, limited=False, strict=False): self.filters["float"] = forgiving_float_filter self.filters["int"] = forgiving_int_filter self.filters["relative_time"] = relative_time + self.filters["slugify"] = slugify + self.filters["iif"] = iif self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -1888,12 +1931,15 @@ def __init__(self, hass, limited=False, strict=False): self.globals["strptime"] = strptime self.globals["urlencode"] = urlencode self.globals["average"] = average - self.globals["max"] = max - self.globals["min"] = min + self.globals["max"] = min_max_from_filter(self.filters["max"], "max") + self.globals["min"] = min_max_from_filter(self.filters["min"], "min") self.globals["is_number"] = is_number self.globals["int"] = forgiving_int self.globals["pack"] = struct_pack self.globals["unpack"] = struct_unpack + self.globals["slugify"] = slugify + self.globals["iif"] = iif + self.tests["is_number"] = is_number self.tests["match"] = regex_match self.tests["search"] = regex_search diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 8848be00e79403..bc3e7ff3565682 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -8,9 +8,10 @@ from functools import wraps from typing import Any, cast -from homeassistant.helpers.typing import TemplateVarsType import homeassistant.util.dt as dt_util +from .typing import TemplateVarsType + class TraceElement: """Container for trace data.""" @@ -216,7 +217,7 @@ def script_execution_set(reason: str) -> None: def script_execution_get() -> str | None: - """Return the current trace.""" + """Return the stop reason.""" if (data := script_execution_cv.get()) is None: return None return data.script_execution diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index e10c814389bec2..2f20e1404d81a3 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -17,8 +17,6 @@ from homeassistant.util.async_ import gather_with_concurrency from homeassistant.util.json import load_json -# mypy: disallow-any-generics - _LOGGER = logging.getLogger(__name__) TRANSLATION_LOAD_LOCK = "translation_load_lock" diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index c7ef6d31be45c5..175a29fcc5da1f 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -11,9 +11,10 @@ from homeassistant.const import CONF_ID, CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.loader import IntegrationNotFound, async_get_integration +from .typing import ConfigType, TemplateVarsType + _PLATFORM_ALIASES = { "device_automation": ("device",), "homeassistant": ("event", "numeric_state", "state", "time_pattern", "time"), diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index 7d01b0b6a77fbf..a7430d1fe696c1 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -1,15 +1,16 @@ """Typing Helpers for Home Assistant.""" +from collections.abc import Mapping from enum import Enum -from typing import Any, Dict, Mapping, Optional, Tuple, Union +from typing import Any, Optional, Union import homeassistant.core -GPSType = Tuple[float, float] -ConfigType = Dict[str, Any] +GPSType = tuple[float, float] +ConfigType = dict[str, Any] ContextType = homeassistant.core.Context -DiscoveryInfoType = Dict[str, Any] +DiscoveryInfoType = dict[str, Any] EventType = homeassistant.core.Event -ServiceDataType = Dict[str, Any] +ServiceDataType = dict[str, Any] StateType = Union[None, str, int, float] TemplateVarsType = Optional[Mapping[str, Any]] diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index a48fca8a01f542..d9ab337e84e2fe 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -15,9 +15,9 @@ from homeassistant import config_entries from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity, event from homeassistant.util.dt import utcnow +from . import entity, event from .debounce import Debouncer REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 84d9cd2a72f45f..04ddd8df571fe2 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from contextlib import suppress import functools as ft import importlib @@ -15,7 +16,7 @@ import pathlib import sys from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable, Dict, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast from awesomeversion import ( AwesomeVersion, @@ -23,18 +24,16 @@ AwesomeVersionStrategy, ) -from homeassistant.generated.dhcp import DHCP -from homeassistant.generated.mqtt import MQTT -from homeassistant.generated.ssdp import SSDP -from homeassistant.generated.usb import USB -from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF -from homeassistant.util.async_ import gather_with_concurrency +from .generated.dhcp import DHCP +from .generated.mqtt import MQTT +from .generated.ssdp import SSDP +from .generated.usb import USB +from .generated.zeroconf import HOMEKIT, ZEROCONF +from .util.async_ import gather_with_concurrency # Typing imports that create a circular dependency if TYPE_CHECKING: - from homeassistant.core import HomeAssistant - -# mypy: disallow-any-generics + from .core import HomeAssistant CALLABLE_T = TypeVar( # pylint: disable=invalid-name "CALLABLE_T", bound=Callable[..., Any] @@ -58,6 +57,8 @@ MAX_LOAD_CONCURRENTLY = 4 +MOVED_ZEROCONF_PROPS = ("macaddress", "model", "manufacturer") + class Manifest(TypedDict, total=False): """ @@ -157,15 +158,15 @@ async def async_get_custom_components( if isinstance(reg_or_evt, asyncio.Event): await reg_or_evt.wait() - return cast(Dict[str, "Integration"], hass.data.get(DATA_CUSTOM_COMPONENTS)) + return cast(dict[str, "Integration"], hass.data.get(DATA_CUSTOM_COMPONENTS)) - return cast(Dict[str, "Integration"], reg_or_evt) + return cast(dict[str, "Integration"], reg_or_evt) async def async_get_config_flows(hass: HomeAssistant) -> set[str]: """Return cached list of config flows.""" # pylint: disable=import-outside-toplevel - from homeassistant.generated.config_flows import FLOWS + from .generated.config_flows import FLOWS flows: set[str] = set() flows.update(FLOWS) @@ -182,21 +183,42 @@ async def async_get_config_flows(hass: HomeAssistant) -> set[str]: return flows -async def async_get_zeroconf(hass: HomeAssistant) -> dict[str, list[dict[str, str]]]: +def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> dict[str, Any]: + """Handle backwards compat with zeroconf matchers.""" + entry_without_type: dict[str, Any] = entry.copy() + del entry_without_type["type"] + # These properties keys used to be at the top level, we relocate + # them for backwards compat + for moved_prop in MOVED_ZEROCONF_PROPS: + if value := entry_without_type.pop(moved_prop, None): + _LOGGER.warning( + 'Matching the zeroconf property "%s" at top-level is deprecated and should be moved into a properties dict; Check the developer documentation', + moved_prop, + ) + if "properties" not in entry_without_type: + prop_dict: dict[str, str] = {} + entry_without_type["properties"] = prop_dict + else: + prop_dict = entry_without_type["properties"] + prop_dict[moved_prop] = value.lower() + return entry_without_type + + +async def async_get_zeroconf( + hass: HomeAssistant, +) -> dict[str, list[dict[str, str | dict[str, str]]]]: """Return cached list of zeroconf types.""" - zeroconf: dict[str, list[dict[str, str]]] = ZEROCONF.copy() + zeroconf: dict[str, list[dict[str, str | dict[str, str]]]] = ZEROCONF.copy() # type: ignore[assignment] integrations = await async_get_custom_components(hass) for integration in integrations.values(): if not integration.zeroconf: continue for entry in integration.zeroconf: - data = {"domain": integration.domain} + data: dict[str, str | dict[str, str]] = {"domain": integration.domain} if isinstance(entry, dict): typ = entry["type"] - entry_without_type = entry.copy() - del entry_without_type["type"] - data.update(entry_without_type) + data.update(async_process_zeroconf_match_dict(entry)) else: typ = entry @@ -295,7 +317,7 @@ def resolve_from_root( cls, hass: HomeAssistant, root_module: ModuleType, domain: str ) -> Integration | None: """Resolve an integration from a root module.""" - for base in root_module.__path__: # type: ignore + for base in root_module.__path__: manifest_path = pathlib.Path(base) / domain / "manifest.json" if not manifest_path.is_file(): @@ -517,18 +539,44 @@ async def resolve_dependencies(self) -> bool: def get_component(self) -> ModuleType: """Return the component.""" - cache = self.hass.data.setdefault(DATA_COMPONENTS, {}) - if self.domain not in cache: + cache: dict[str, ModuleType] = self.hass.data.setdefault(DATA_COMPONENTS, {}) + if self.domain in cache: + return cache[self.domain] + + try: cache[self.domain] = importlib.import_module(self.pkg_path) - return cache[self.domain] # type: ignore + except ImportError: + raise + except Exception as err: + _LOGGER.exception( + "Unexpected exception importing component %s", self.pkg_path + ) + raise ImportError(f"Exception importing {self.pkg_path}") from err + + return cache[self.domain] def get_platform(self, platform_name: str) -> ModuleType: """Return a platform for an integration.""" - cache = self.hass.data.setdefault(DATA_COMPONENTS, {}) + cache: dict[str, ModuleType] = self.hass.data.setdefault(DATA_COMPONENTS, {}) full_name = f"{self.domain}.{platform_name}" - if full_name not in cache: + if full_name in cache: + return cache[full_name] + + try: cache[full_name] = self._import_platform(platform_name) - return cache[full_name] # type: ignore + except ImportError: + raise + except Exception as err: + _LOGGER.exception( + "Unexpected exception importing platform %s.%s", + self.pkg_path, + platform_name, + ) + raise ImportError( + f"Exception importing {self.pkg_path}.{platform_name}" + ) from err + + return cache[full_name] def _import_platform(self, platform_name: str) -> ModuleType: """Import the platform.""" @@ -584,7 +632,7 @@ async def _async_get_integration(hass: HomeAssistant, domain: str) -> Integratio if integration := (await async_get_custom_components(hass)).get(domain): return integration - from homeassistant import components # pylint: disable=import-outside-toplevel + from . import components # pylint: disable=import-outside-toplevel if integration := await hass.async_add_executor_job( Integration.resolve_from_root, hass, components, domain diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1152007c417b5e..438fa0eba8d587 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,61 +1,60 @@ PyJWT==2.1.0 PyNaCl==1.4.0 -aiodiscover==1.4.5 +aiodiscover==1.4.7 aiohttp==3.8.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.23.1 -async_timeout==4.0.0 +async-upnp-client==0.23.4 +async_timeout==4.0.2 atomicwrites==1.4.0 attrs==21.2.0 -awesomeversion==21.11.0 -backports.zoneinfo;python_version<"3.9" +awesomeversion==22.1.0 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 cryptography==35.0.0 -emoji==1.5.0 -hass-nabucasa==0.51.0 -home-assistant-frontend==20211229.1 -httpx==0.21.0 +emoji==1.6.3 +hass-nabucasa==0.52.0 +home-assistant-frontend==20220202.0 +httpx==0.21.3 ifaddr==0.1.7 jinja2==3.0.3 paho-mqtt==1.6.1 -pillow==8.2.0 +pillow==9.0.0 pip>=8.0.3,<20.3 pyserial==3.5 python-slugify==4.0.1 pyudev==0.22.0 pyyaml==6.0 -requests==2.26.0 +requests==2.27.1 scapy==2.4.5 sqlalchemy==1.4.27 +typing-extensions>=3.10.0.2,<5.0 voluptuous-serialize==2.5.0 voluptuous==0.12.2 -yarl==1.6.3 -zeroconf==0.38.1 +yarl==1.7.2 +zeroconf==0.38.3 +# Constrain pycryptodome to avoid vulnerability +# see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 urllib3>=1.26.5 -# Constrain H11 to ensure we get a new enough version to support non-rfc line endings -h11>=0.12.0 - # Constrain httplib2 to protect against GHSA-93xj-8mrv-444m # https://github.com/advisories/GHSA-93xj-8mrv-444m httplib2>=0.19.0 -# gRPC 1.32+ currently causes issues on ARMv7, see: -# https://github.com/home-assistant/core/issues/40148 -# Newer versions of some other libraries pin a higher version of grpcio, -# so those also need to be kept at an old version until the grpcio pin -# is reverted, see: -# https://github.com/home-assistant/core/issues/53427 -grpcio==1.31.0 -google-cloud-pubsub==2.1.0 -google-api-core<=1.31.2 +# gRPC is an implicit dependency that we want to make explicit so we manage +# upgrades intentionally. It is a large package to build from source and we +# want to ensure we have wheels built. +grpcio==1.43.0 + +# libcst >=0.4.0 requires a newer Rust than we currently have available, +# thus our wheels builds fail. This pins it to the last working version, +# which at this point satisfies our needs. +libcst==0.3.23 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -77,10 +76,23 @@ pandas==1.3.0 # This is fixed in 2021.8.28 regex==2021.8.28 -# anyio has a bug that was fixed in 3.3.1 -# can remove after httpx/httpcore updates its anyio version pin -anyio>=3.3.1 +# httpx requires httpcore, and httpcore requires anyio and h11, but the version constraints on +# these requirements are quite loose. As the entire stack has some outstanding issues, and +# even newer versions seem to introduce new issues, it's useful for us to pin all these +# requirements so we can directly link HA versions to these library versions. +anyio==3.5.0 +h11==0.12.0 +httpcore==0.14.5 + +# pytest_asyncio breaks our test suite. We rely on pytest-aiohttp instead +pytest_asyncio==1000000000.0.0 + +# Prevent dependency conflicts between sisyphus-control and aioambient +# until upper bounds for sisyphus-control have been updated +# https://github.com/jkeljo/sisyphus-control/issues/6 +python-engineio>=3.13.1,<4.0 +python-socketio>=4.6.0,<5.0 -# websockets 10.0 is broken with AWS -# https://github.com/aaugustin/websockets/issues/1065 -websockets==9.1 +# Constrain multidict to avoid typing issues +# https://github.com/home-assistant/core/pull/64792 +multidict<6.0.0 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index e98ba2afe68b0c..7631586d626012 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -7,13 +7,11 @@ import os from typing import Any, cast -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from homeassistant.loader import Integration, IntegrationNotFound, async_get_integration -import homeassistant.util.package as pkg_util - -# mypy: disallow-any-generics +from .core import HomeAssistant, callback +from .exceptions import HomeAssistantError +from .helpers.typing import UNDEFINED, UndefinedType +from .loader import Integration, IntegrationNotFound, async_get_integration +from .util import package as pkg_util PIP_TIMEOUT = 60 # The default is too low when the internet connection is satellite or high latency MAX_INSTALL_FAILURES = 3 diff --git a/homeassistant/runner.py b/homeassistant/runner.py index dcf39485531827..571111d1077282 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -8,13 +8,11 @@ import traceback from typing import Any -from homeassistant import bootstrap -from homeassistant.core import callback -from homeassistant.helpers.frame import warn_use -from homeassistant.util.executor import InterruptibleThreadPoolExecutor -from homeassistant.util.thread import deadlock_safe_shutdown - -# mypy: disallow-any-generics +from . import bootstrap +from .core import callback +from .helpers.frame import warn_use +from .util.executor import InterruptibleThreadPoolExecutor +from .util.thread import deadlock_safe_shutdown # # Python 3.8 has significantly less workers by default diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index 69ca1d6083bf37..5b781d4eb37e28 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -64,7 +64,7 @@ def run(args: list[str]) -> int: asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(False)) - return script.run(args[1:]) # type: ignore + return script.run(args[1:]) def extract_config_dir(args: Sequence[str] | None = None) -> str: diff --git a/homeassistant/scripts/ensure_config.py b/homeassistant/scripts/ensure_config.py index b78b38735e1d37..f25639c7986978 100644 --- a/homeassistant/scripts/ensure_config.py +++ b/homeassistant/scripts/ensure_config.py @@ -30,7 +30,7 @@ def run(args): # Test if configuration directory exists if not os.path.isdir(config_dir): print("Creating directory", config_dir) - os.makedirs(config_dir) + os.makedirs(config_dir, exist_ok=True) config_path = asyncio.run(async_run(config_dir)) print("Configuration file:", config_path) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 3e6db28a194c97..5c56cb55b19900 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -7,21 +7,20 @@ import logging.handlers from timeit import default_timer as timer from types import ModuleType +from typing import Any -from homeassistant import config as conf_util, core, loader, requirements -from homeassistant.config import async_notify_setup_error -from homeassistant.const import ( +from . import config as conf_util, core, loader, requirements +from .config import async_notify_setup_error +from .const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, PLATFORM_FORMAT, Platform, ) -from homeassistant.core import CALLBACK_TYPE -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util, ensure_unique_string - -# mypy: disallow-any-generics +from .core import CALLBACK_TYPE +from .exceptions import DependencyError, HomeAssistantError +from .helpers.typing import ConfigType +from .util import dt as dt_util, ensure_unique_string _LOGGER = logging.getLogger(__name__) @@ -76,7 +75,7 @@ async def async_setup_component( ) try: - return await task # type: ignore + return await task finally: if domain in hass.data.get(DATA_SETUP_DONE, {}): hass.data[DATA_SETUP_DONE].pop(domain).set() @@ -84,8 +83,11 @@ async def async_setup_component( async def _async_process_dependencies( hass: core.HomeAssistant, config: ConfigType, integration: loader.Integration -) -> bool: - """Ensure all dependencies are set up.""" +) -> list[str]: + """Ensure all dependencies are set up. + + Returns a list of dependencies which failed to set up. + """ dependencies_tasks = { dep: hass.loop.create_task(async_setup_component(hass, dep, config)) for dep in integration.dependencies @@ -105,7 +107,7 @@ async def _async_process_dependencies( ) if not dependencies_tasks and not after_dependencies_tasks: - return True + return [] if dependencies_tasks: _LOGGER.debug( @@ -136,8 +138,7 @@ async def _async_process_dependencies( ", ".join(failed), ) - return False - return True + return failed async def _async_setup_component( @@ -182,9 +183,6 @@ def log_error(msg: str, link: str | None = None) -> None: except ImportError as err: log_error(f"Unable to import component: {err}", integration.documentation) return False - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Setup failed for %s: unknown error", domain) - return False processed_config = await conf_util.async_process_component_config( hass, config, integration @@ -210,15 +208,15 @@ def log_error(msg: str, link: str | None = None) -> None: ) task = None - result = True + result: Any | bool = True try: if hasattr(component, "async_setup"): - task = component.async_setup(hass, processed_config) # type: ignore + task = component.async_setup(hass, processed_config) elif hasattr(component, "setup"): # This should not be replaced with hass.async_add_executor_job because # we don't want to track this task in case it blocks startup. task = hass.loop.run_in_executor( - None, component.setup, hass, processed_config # type: ignore + None, component.setup, hass, processed_config ) elif not hasattr(component, "async_setup_entry"): log_error("No setup or config entry setup function defined.") @@ -345,8 +343,8 @@ async def async_process_deps_reqs( elif integration.domain in processed: return - if not await _async_process_dependencies(hass, config, integration): - raise HomeAssistantError("Could not set up all dependencies.") + if failed_deps := await _async_process_dependencies(hass, config, integration): + raise DependencyError(failed_deps) if not hass.config.skip_pip and integration.requirements: async with hass.timeout.async_freeze(integration.domain): diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 31693c5bba183a..74ba965f6c5aeb 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -56,7 +56,8 @@ "invalid_api_key": "Invalid API key", "invalid_auth": "Invalid authentication", "invalid_host": "Invalid hostname or IP address", - "unknown": "Unexpected error" + "unknown": "Unexpected error", + "timeout_connect": "Timeout establishing connection" }, "abort": { "single_instance_allowed": "Already configured. Only a single configuration possible.", @@ -72,7 +73,8 @@ "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", "reauth_successful": "Re-authentication was successful", - "unknown_authorize_url_generation": "Unknown error generating an authorize URL." + "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", + "cloud_not_connected": "Not connected to Home Assistant Cloud." } } } diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index b7758df0cb0af8..3c82639251a8bf 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Callable, Coroutine, Iterable, KeysView from datetime import datetime, timedelta -import enum from functools import wraps import random import re @@ -19,7 +18,6 @@ T = TypeVar("T") U = TypeVar("U") # pylint: disable=invalid-name -ENUM_T = TypeVar("ENUM_T", bound=enum.Enum) # pylint: disable=invalid-name RE_SANITIZE_FILENAME = re.compile(r"(~|\.\.|/|\\)") RE_SANITIZE_PATH = re.compile(r"(~|\.(\.)+)") diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index b23e5cf29e8f7c..aa1aea1abc3707 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -7,6 +7,7 @@ from typing import Any from urllib.parse import parse_qsl +from aiohttp import payload, web from multidict import CIMultiDict, MultiDict @@ -74,3 +75,22 @@ async def post(self) -> MultiDict[str]: async def text(self) -> str: """Return the body as text.""" return self._text + + +def serialize_response(response: web.Response) -> dict[str, Any]: + """Serialize an aiohttp response to a dictionary.""" + if (body := response.body) is None: + body_decoded = None + elif isinstance(body, payload.StringPayload): + # pylint: disable=protected-access + body_decoded = body._value.decode(body.encoding) + elif isinstance(body, bytes): + body_decoded = body.decode(response.charset or "utf-8") + else: + raise ValueError("Unknown payload encoding") + + return { + "status": response.status, + "body": body_decoded, + "headers": dict(response.headers), + } diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index bf7250b68e6d83..8f9526b6800696 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -41,7 +41,7 @@ def callback() -> None: def run_callback_threadsafe( loop: AbstractEventLoop, callback: Callable[..., T], *args: Any -) -> concurrent.futures.Future[T]: # pylint: disable=unsubscriptable-object +) -> concurrent.futures.Future[T]: """Submit a callback object to a given event loop. Return a concurrent.futures.Future to access the result. @@ -88,8 +88,8 @@ def run_callback() -> None: return future -def check_loop() -> None: - """Warn if called inside the event loop.""" +def check_loop(func: Callable, strict: bool = True) -> None: + """Warn if called inside the event loop. Raise if `strict` is True.""" try: get_running_loop() in_loop = True @@ -101,7 +101,18 @@ def check_loop() -> None: found_frame = None - for frame in reversed(extract_stack()): + stack = extract_stack() + + if ( + func.__name__ == "sleep" + and len(stack) >= 3 + and stack[-3].filename.endswith("pydevd.py") + ): + # Don't report `time.sleep` injected by the debugger (pydevd.py) + # stack[-1] is us, stack[-2] is protected_loop_func, stack[-3] is the offender + return + + for frame in reversed(stack): for path in ("custom_components/", "homeassistant/components/"): try: index = frame.filename.index(path) @@ -116,7 +127,8 @@ def check_loop() -> None: # Did not source from integration? Hard error. if found_frame is None: raise RuntimeError( - "Detected I/O inside the event loop. This is causing stability issues. Please report issue" + "Detected blocking call inside the event loop. " + "This is causing stability issues. Please report issue" ) start = index + len(path) @@ -130,25 +142,28 @@ def check_loop() -> None: extra = "" _LOGGER.warning( - "Detected I/O inside the event loop. This is causing stability issues. Please report issue%s for %s doing I/O at %s, line %s: %s", + "Detected blocking call inside the event loop. This is causing stability issues. " + "Please report issue%s for %s doing blocking calls at %s, line %s: %s", extra, integration, found_frame.filename[index:], found_frame.lineno, found_frame.line.strip(), ) - raise RuntimeError( - f"I/O must be done in the executor; Use `await hass.async_add_executor_job()` " - f"at {found_frame.filename[index:]}, line {found_frame.lineno}: {found_frame.line.strip()}" - ) + if strict: + raise RuntimeError( + "Blocking calls must be done in the executor or a separate thread; " + "Use `await hass.async_add_executor_job()` " + f"at {found_frame.filename[index:]}, line {found_frame.lineno}: {found_frame.line.strip()}" + ) -def protect_loop(func: Callable) -> Callable: +def protect_loop(func: Callable, strict: bool = True) -> Callable: """Protect function from running in event loop.""" @functools.wraps(func) def protected_loop_func(*args, **kwargs): # type: ignore - check_loop() + check_loop(func, strict=strict) return func(*args, **kwargs) return protected_loop_func diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 3d4f7122ad0e70..f308595adbdcd8 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -3,12 +3,10 @@ import colorsys import math -from typing import NamedTuple +from typing import NamedTuple, cast import attr -# mypy: disallow-any-generics - class RGBColor(NamedTuple): """RGB hex values.""" @@ -299,7 +297,7 @@ def color_xy_brightness_to_RGB( r, g, b = map( lambda x: (12.92 * x) if (x <= 0.0031308) - else ((1.0 + 0.055) * pow(x, (1.0 / 2.4)) - 0.055), + else ((1.0 + 0.055) * cast(float, pow(x, (1.0 / 2.4))) - 0.055), [r, g, b], ) @@ -450,7 +448,9 @@ def color_rgb_to_rgbww( w_r, w_g, w_b = color_temperature_to_rgb(color_temp_kelvin) # Find the ratio of the midpoint white in the input rgb channels - white_level = min(r / w_r, g / w_g, b / w_b if w_b else 0) + white_level = min( + r / w_r if w_r else 0, g / w_g if w_g else 0, b / w_b if w_b else 0 + ) # Subtract the white portion from the rgb channels. rgb = (r - w_r * white_level, g - w_g * white_level, b - w_b * white_level) @@ -528,6 +528,16 @@ def color_temperature_to_rgb( return red, green, blue +def color_temperature_to_rgbww( + temperature: int, brightness: int, min_mireds: int, max_mireds: int +) -> tuple[int, int, int, int, int]: + """Convert color temperature to rgbcw.""" + mired_range = max_mireds - min_mireds + warm = ((max_mireds - temperature) / mired_range) * brightness + cold = brightness - warm + return (0, 0, 0, round(cold), round(warm)) + + def _clamp(color_component: float, minimum: float = 0, maximum: float = 255) -> float: """ Clamp the given color component value between the given min and max values. diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 0c8a1cd9aadced..4b4b798a2d8113 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -5,16 +5,11 @@ from contextlib import suppress import datetime as dt import re -import sys -from typing import Any, cast +from typing import Any +import zoneinfo import ciso8601 -if sys.version_info[:2] >= (3, 9): - import zoneinfo -else: - from backports import zoneinfo - DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.timezone.utc DEFAULT_TIME_ZONE: dt.tzinfo = dt.timezone.utc @@ -48,8 +43,7 @@ def get_time_zone(time_zone_str: str) -> dt.tzinfo | None: Async friendly. """ try: - # Cast can be removed when mypy is switched to Python 3.9. - return cast(dt.tzinfo, zoneinfo.ZoneInfo(time_zone_str)) + return zoneinfo.ZoneInfo(time_zone_str) except zoneinfo.ZoneInfoNotFoundError: return None diff --git a/homeassistant/util/executor.py b/homeassistant/util/executor.py index 9277e396bc44bc..5a8f15f434f6e0 100644 --- a/homeassistant/util/executor.py +++ b/homeassistant/util/executor.py @@ -10,7 +10,7 @@ import time import traceback -from homeassistant.util.thread import async_raise +from .thread import async_raise _LOGGER = logging.getLogger(__name__) @@ -64,7 +64,7 @@ class InterruptibleThreadPoolExecutor(ThreadPoolExecutor): def shutdown(self, *args, **kwargs) -> None: # type: ignore """Shutdown backport from cpython 3.9 with interrupt support added.""" - with self._shutdown_lock: # type: ignore[attr-defined] + with self._shutdown_lock: self._shutdown = True # Drain all work items from the queue, and then cancel their # associated futures. @@ -77,7 +77,7 @@ def shutdown(self, *args, **kwargs) -> None: # type: ignore work_item.future.cancel() # Send a wake-up to prevent threads calling # _work_queue.get(block=True) from permanently blocking. - self._work_queue.put(None) + self._work_queue.put(None) # type: ignore[arg-type] # The above code is backported from python 3.9 # @@ -89,7 +89,7 @@ def shutdown(self, *args, **kwargs) -> None: # type: ignore def join_threads_or_timeout(self) -> None: """Join threads or timeout.""" - remaining_threads = set(self._threads) # type: ignore[attr-defined] + remaining_threads = set(self._threads) start_time = time.monotonic() timeout_remaining: float = EXECUTOR_SHUTDOWN_TIMEOUT attempt = 0 diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index dd3cb119c6bbea..9216993eb5359b 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -2,14 +2,14 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Awaitable, Callable, Coroutine from functools import partial, wraps import inspect import logging import logging.handlers import queue import traceback -from typing import Any, Awaitable, Callable, cast, overload +from typing import Any, cast, overload from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.core import HomeAssistant, callback, is_callback @@ -106,7 +106,7 @@ def log_exception(format_err: Callable[..., Any], *args: Any) -> None: @overload -def catch_log_exception( # type: ignore +def catch_log_exception( # type: ignore[misc] func: Callable[..., Awaitable[Any]], format_err: Callable[..., Any], *args: Any ) -> Callable[..., Awaitable[None]]: """Overload for Callables that return an Awaitable.""" diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index a0b5c2832ad2c2..a1ee2b9f584534 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -89,10 +89,9 @@ def install_package( # This only works if not running in venv args += ["--user"] env["PYTHONUSERBASE"] = os.path.abspath(target) - if sys.platform != "win32": - # Workaround for incompatible prefix setting - # See http://stackoverflow.com/a/4495175 - args += ["--prefix="] + # Workaround for incompatible prefix setting + # See http://stackoverflow.com/a/4495175 + args += ["--prefix="] _LOGGER.debug("Running pip command: args=%s", args) with Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) as process: _, stderr = process.communicate() diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py index 8ff3f9f5f93d26..dca94764de3ce5 100644 --- a/homeassistant/util/pressure.py +++ b/homeassistant/util/pressure.py @@ -11,6 +11,7 @@ PRESSURE_INHG, PRESSURE_KPA, PRESSURE_MBAR, + PRESSURE_MMHG, PRESSURE_PA, PRESSURE_PSI, UNIT_NOT_RECOGNIZED_TEMPLATE, @@ -25,6 +26,7 @@ PRESSURE_MBAR, PRESSURE_INHG, PRESSURE_PSI, + PRESSURE_MMHG, ) UNIT_CONVERSION: dict[str, float] = { @@ -36,6 +38,7 @@ PRESSURE_MBAR: 1 / 100, PRESSURE_INHG: 1 / 3386.389, PRESSURE_PSI: 1 / 6894.757, + PRESSURE_MMHG: 1 / 133.322, } diff --git a/homeassistant/util/process.py b/homeassistant/util/process.py index f89b2eb96eea3d..3affa28e909635 100644 --- a/homeassistant/util/process.py +++ b/homeassistant/util/process.py @@ -5,13 +5,8 @@ import subprocess from typing import Any -# mypy: disallow-any-generics - -def kill_subprocess( - # pylint: disable=unsubscriptable-object # https://github.com/PyCQA/pylint/issues/4369 - process: subprocess.Popen[Any], -) -> None: +def kill_subprocess(process: subprocess.Popen[Any]) -> None: """Force kill a subprocess and wait for it to exit.""" process.kill() process.communicate() diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index bdb637a9149734..dfe73b0e937ae9 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -31,7 +31,8 @@ VOLUME_LITERS, WIND_SPEED, ) -from homeassistant.util import ( + +from . import ( distance as distance_util, pressure as pressure_util, speed as speed_util, @@ -39,8 +40,6 @@ volume as volume_util, ) -# mypy: disallow-any-generics - LENGTH_UNITS = distance_util.VALID_UNITS MASS_UNITS: tuple[str, ...] = (MASS_POUNDS, MASS_OUNCES, MASS_KILOGRAMS, MASS_GRAMS) diff --git a/mypy.ini b/mypy.ini index 7346cc83ba9019..886b0fce2ceda4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,7 +3,7 @@ # To update, run python3 -m script.hassfest [mypy] -python_version = 3.8 +python_version = 3.9 show_error_codes = true follow_imports = silent ignore_missing_imports = true @@ -22,6 +22,63 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.*] +no_implicit_reexport = true + +[mypy-homeassistant.exceptions] +disallow_any_generics = true + +[mypy-homeassistant.core] +disallow_any_generics = true + +[mypy-homeassistant.loader] +disallow_any_generics = true + +[mypy-homeassistant.requirements] +disallow_any_generics = true + +[mypy-homeassistant.runner] +disallow_any_generics = true + +[mypy-homeassistant.setup] +disallow_any_generics = true + +[mypy-homeassistant.auth.auth_store] +disallow_any_generics = true + +[mypy-homeassistant.auth.providers.*] +disallow_any_generics = true + +[mypy-homeassistant.helpers.area_registry] +disallow_any_generics = true + +[mypy-homeassistant.helpers.condition] +disallow_any_generics = true + +[mypy-homeassistant.helpers.discovery] +disallow_any_generics = true + +[mypy-homeassistant.helpers.entity_values] +disallow_any_generics = true + +[mypy-homeassistant.helpers.reload] +disallow_any_generics = true + +[mypy-homeassistant.helpers.script_variables] +disallow_any_generics = true + +[mypy-homeassistant.helpers.translation] +disallow_any_generics = true + +[mypy-homeassistant.util.color] +disallow_any_generics = true + +[mypy-homeassistant.util.process] +disallow_any_generics = true + +[mypy-homeassistant.util.unit_system] +disallow_any_generics = true + [mypy-homeassistant.components.*] check_untyped_defs = false disallow_incomplete_defs = false @@ -32,6 +89,7 @@ disallow_untyped_defs = false no_implicit_optional = false warn_return_any = false warn_unreachable = false +no_implicit_reexport = false [mypy-homeassistant.components] check_untyped_defs = true @@ -43,6 +101,18 @@ disallow_untyped_defs = true no_implicit_optional = true warn_return_any = true warn_unreachable = true +no_implicit_reexport = true + +[mypy-homeassistant.components.abode.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true [mypy-homeassistant.components.acer_projector.*] check_untyped_defs = true @@ -198,6 +268,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aseko_pool_live.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.automation.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -275,6 +356,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.browser.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.button.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -341,6 +433,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.cpuspeed.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.device_automation.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -671,6 +774,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homewizard.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.http.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -715,6 +829,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.input_button.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.input_select.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -792,6 +917,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lametric.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lcn.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -847,6 +983,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.luftdaten.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.mailbox.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -990,6 +1137,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.nissan_leaf.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.no_ip.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1034,6 +1192,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.oncue.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.onewire.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1045,6 +1214,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.open_meteo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.openuv.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1056,6 +1236,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.overkiz.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.persistent_notification.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1089,6 +1280,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pvoutput.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.rainmachine.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1210,6 +1412,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rtsp_to_webrtc.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.samsungtv.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1254,6 +1467,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.senseme.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.shelly.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1287,6 +1511,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.smhi.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.sonos.media_player] check_untyped_defs = true disallow_incomplete_defs = true @@ -1320,6 +1555,28 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.statistics.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.steamist.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.stream.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1496,6 +1753,28 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trafikverket_train.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.trafikverket_weatherstation.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tts.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1518,6 +1797,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.unifiprotect.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.upcloud.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1639,6 +1929,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.webostv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.websocket_api.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1650,6 +1951,28 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.wemo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.whois.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.zodiac.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1694,6 +2017,9 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.diagnostics.*] +no_implicit_reexport = true + [mypy-tests.*] check_untyped_defs = false disallow_incomplete_defs = false @@ -1708,9 +2034,6 @@ warn_unreachable = false [mypy-homeassistant.components.blueprint.*] ignore_errors = true -[mypy-homeassistant.components.climacell.*] -ignore_errors = true - [mypy-homeassistant.components.cloud.*] ignore_errors = true @@ -1729,15 +2052,6 @@ ignore_errors = true [mypy-homeassistant.components.denonavr.*] ignore_errors = true -[mypy-homeassistant.components.dhcp.*] -ignore_errors = true - -[mypy-homeassistant.components.doorbird.*] -ignore_errors = true - -[mypy-homeassistant.components.enphase_envoy.*] -ignore_errors = true - [mypy-homeassistant.components.evohome.*] ignore_errors = true @@ -1747,36 +2061,18 @@ ignore_errors = true [mypy-homeassistant.components.firmata.*] ignore_errors = true -[mypy-homeassistant.components.flo.*] -ignore_errors = true - -[mypy-homeassistant.components.fortios.*] -ignore_errors = true - -[mypy-homeassistant.components.foscam.*] -ignore_errors = true - [mypy-homeassistant.components.freebox.*] ignore_errors = true [mypy-homeassistant.components.geniushub.*] ignore_errors = true -[mypy-homeassistant.components.glances.*] -ignore_errors = true - [mypy-homeassistant.components.google_assistant.*] ignore_errors = true [mypy-homeassistant.components.gree.*] ignore_errors = true -[mypy-homeassistant.components.growatt_server.*] -ignore_errors = true - -[mypy-homeassistant.components.habitica.*] -ignore_errors = true - [mypy-homeassistant.components.harmony.*] ignore_errors = true @@ -1786,12 +2082,6 @@ ignore_errors = true [mypy-homeassistant.components.here_travel_time.*] ignore_errors = true -[mypy-homeassistant.components.hisense_aehw4a1.*] -ignore_errors = true - -[mypy-homeassistant.components.home_connect.*] -ignore_errors = true - [mypy-homeassistant.components.home_plus_control.*] ignore_errors = true @@ -1804,69 +2094,33 @@ ignore_errors = true [mypy-homeassistant.components.honeywell.*] ignore_errors = true -[mypy-homeassistant.components.humidifier.*] -ignore_errors = true - -[mypy-homeassistant.components.iaqualink.*] -ignore_errors = true - [mypy-homeassistant.components.icloud.*] ignore_errors = true -[mypy-homeassistant.components.image.*] -ignore_errors = true - -[mypy-homeassistant.components.incomfort.*] -ignore_errors = true - [mypy-homeassistant.components.influxdb.*] ignore_errors = true [mypy-homeassistant.components.input_datetime.*] ignore_errors = true -[mypy-homeassistant.components.input_number.*] -ignore_errors = true - -[mypy-homeassistant.components.ipp.*] -ignore_errors = true - [mypy-homeassistant.components.isy994.*] ignore_errors = true [mypy-homeassistant.components.izone.*] ignore_errors = true -[mypy-homeassistant.components.kaiterra.*] -ignore_errors = true - -[mypy-homeassistant.components.keenetic_ndms2.*] -ignore_errors = true - -[mypy-homeassistant.components.kodi.*] -ignore_errors = true - [mypy-homeassistant.components.konnected.*] ignore_errors = true [mypy-homeassistant.components.kostal_plenticore.*] ignore_errors = true -[mypy-homeassistant.components.kulersky.*] -ignore_errors = true - -[mypy-homeassistant.components.litejet.*] -ignore_errors = true - [mypy-homeassistant.components.litterrobot.*] ignore_errors = true [mypy-homeassistant.components.lovelace.*] ignore_errors = true -[mypy-homeassistant.components.luftdaten.*] -ignore_errors = true - [mypy-homeassistant.components.lutron_caseta.*] ignore_errors = true @@ -1879,147 +2133,54 @@ ignore_errors = true [mypy-homeassistant.components.meteo_france.*] ignore_errors = true -[mypy-homeassistant.components.metoffice.*] -ignore_errors = true - [mypy-homeassistant.components.minecraft_server.*] ignore_errors = true [mypy-homeassistant.components.mobile_app.*] ignore_errors = true -[mypy-homeassistant.components.motion_blinds.*] -ignore_errors = true - -[mypy-homeassistant.components.mullvad.*] -ignore_errors = true - -[mypy-homeassistant.components.ness_alarm.*] -ignore_errors = true - [mypy-homeassistant.components.nest.legacy.*] ignore_errors = true [mypy-homeassistant.components.netgear.*] ignore_errors = true -[mypy-homeassistant.components.nightscout.*] -ignore_errors = true - [mypy-homeassistant.components.nilu.*] ignore_errors = true -[mypy-homeassistant.components.nsw_fuel_station.*] -ignore_errors = true - -[mypy-homeassistant.components.nuki.*] -ignore_errors = true - -[mypy-homeassistant.components.nws.*] -ignore_errors = true - [mypy-homeassistant.components.nzbget.*] ignore_errors = true [mypy-homeassistant.components.omnilogic.*] ignore_errors = true -[mypy-homeassistant.components.onboarding.*] -ignore_errors = true - -[mypy-homeassistant.components.ondilo_ico.*] -ignore_errors = true - [mypy-homeassistant.components.onvif.*] ignore_errors = true -[mypy-homeassistant.components.ovo_energy.*] -ignore_errors = true - [mypy-homeassistant.components.ozw.*] ignore_errors = true [mypy-homeassistant.components.philips_js.*] ignore_errors = true -[mypy-homeassistant.components.ping.*] -ignore_errors = true - -[mypy-homeassistant.components.pioneer.*] -ignore_errors = true - -[mypy-homeassistant.components.plaato.*] -ignore_errors = true - [mypy-homeassistant.components.plex.*] ignore_errors = true -[mypy-homeassistant.components.plugwise.*] -ignore_errors = true - -[mypy-homeassistant.components.plum_lightpad.*] -ignore_errors = true - -[mypy-homeassistant.components.point.*] -ignore_errors = true - [mypy-homeassistant.components.profiler.*] ignore_errors = true -[mypy-homeassistant.components.rachio.*] -ignore_errors = true - -[mypy-homeassistant.components.ring.*] -ignore_errors = true - -[mypy-homeassistant.components.ruckus_unleashed.*] -ignore_errors = true - -[mypy-homeassistant.components.screenlogic.*] -ignore_errors = true - -[mypy-homeassistant.components.search.*] -ignore_errors = true - -[mypy-homeassistant.components.sense.*] -ignore_errors = true - -[mypy-homeassistant.components.sharkiq.*] -ignore_errors = true - -[mypy-homeassistant.components.sma.*] -ignore_errors = true - -[mypy-homeassistant.components.smartthings.*] -ignore_errors = true - [mypy-homeassistant.components.solaredge.*] ignore_errors = true -[mypy-homeassistant.components.somfy.*] -ignore_errors = true - -[mypy-homeassistant.components.somfy_mylink.*] -ignore_errors = true - [mypy-homeassistant.components.sonos.*] ignore_errors = true [mypy-homeassistant.components.spotify.*] ignore_errors = true -[mypy-homeassistant.components.stt.*] -ignore_errors = true - [mypy-homeassistant.components.system_health.*] ignore_errors = true -[mypy-homeassistant.components.system_log.*] -ignore_errors = true - -[mypy-homeassistant.components.tado.*] -ignore_errors = true - [mypy-homeassistant.components.telegram_bot.*] ignore_errors = true @@ -2035,18 +2196,9 @@ ignore_errors = true [mypy-homeassistant.components.upnp.*] ignore_errors = true -[mypy-homeassistant.components.vera.*] -ignore_errors = true - -[mypy-homeassistant.components.verisure.*] -ignore_errors = true - [mypy-homeassistant.components.vizio.*] ignore_errors = true -[mypy-homeassistant.components.wemo.*] -ignore_errors = true - [mypy-homeassistant.components.withings.*] ignore_errors = true diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py new file mode 100644 index 00000000000000..288fcc560c3855 --- /dev/null +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -0,0 +1,324 @@ +"""Plugin to enforce type hints on specific functions.""" +from __future__ import annotations + +from dataclasses import dataclass +import re + +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import IAstroidChecker +from pylint.lint import PyLinter + +from homeassistant.const import Platform + + +@dataclass +class TypeHintMatch: + """Class for pattern matching.""" + + module_filter: re.Pattern + function_name: str + arg_types: dict[int, str] + return_type: list[str] | str | None + + +_TYPE_HINT_MATCHERS: dict[str, re.Pattern] = { + # a_or_b matches items such as "DiscoveryInfoType | None" + "a_or_b": re.compile(r"^(\w+) \| (\w+)$"), + # x_of_y matches items such as "Awaitable[None]" + "x_of_y": re.compile(r"^(\w+)\[(.*?]*)\]$"), + # x_of_y_comma_z matches items such as "Callable[..., Awaitable[None]]" + "x_of_y_comma_z": re.compile(r"^(\w+)\[(.*?]*), (.*?]*)\]$"), +} + +_MODULE_FILTERS: dict[str, re.Pattern] = { + # init matches only in the package root (__init__.py) + "init": re.compile(r"^homeassistant\.components\.\w+$"), + # any_platform matches any platform in the package root ({platform}.py) + "any_platform": re.compile( + f"^homeassistant\\.components\\.\\w+\\.({'|'.join([platform.value for platform in Platform])})$" + ), + # device_tracker matches only in the package root (device_tracker.py) + "device_tracker": re.compile( + f"^homeassistant\\.components\\.\\w+\\.({Platform.DEVICE_TRACKER.value})$" + ), +} + +_METHOD_MATCH: list[TypeHintMatch] = [ + TypeHintMatch( + module_filter=_MODULE_FILTERS["init"], + function_name="setup", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="bool", + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["init"], + function_name="async_setup", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="bool", + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["init"], + function_name="async_setup_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type="bool", + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["init"], + function_name="async_remove_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type=None, + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["init"], + function_name="async_unload_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type="bool", + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["init"], + function_name="async_migrate_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type="bool", + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["any_platform"], + function_name="setup_platform", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "AddEntitiesCallback", + 3: "DiscoveryInfoType | None", + }, + return_type=None, + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["any_platform"], + function_name="async_setup_platform", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "AddEntitiesCallback", + 3: "DiscoveryInfoType | None", + }, + return_type=None, + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["any_platform"], + function_name="async_setup_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + 2: "AddEntitiesCallback", + }, + return_type=None, + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["device_tracker"], + function_name="setup_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "Callable[..., None]", + 3: "DiscoveryInfoType | None", + }, + return_type="bool", + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["device_tracker"], + function_name="async_setup_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "Callable[..., Awaitable[None]]", + 3: "DiscoveryInfoType | None", + }, + return_type="bool", + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["device_tracker"], + function_name="get_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type=["DeviceScanner", "DeviceScanner | None"], + ), + TypeHintMatch( + module_filter=_MODULE_FILTERS["device_tracker"], + function_name="async_get_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type=["DeviceScanner", "DeviceScanner | None"], + ), +] + + +def _is_valid_type(expected_type: list[str] | str | None, node: astroid.NodeNG) -> bool: + """Check the argument node against the expected type.""" + if isinstance(expected_type, list): + for expected_type_item in expected_type: + if _is_valid_type(expected_type_item, node): + return True + return False + + # Const occurs when the type is None + if expected_type is None or expected_type == "None": + return isinstance(node, astroid.Const) and node.value is None + + # Const occurs when the type is an Ellipsis + if expected_type == "...": + return isinstance(node, astroid.Const) and node.value == Ellipsis + + # Special case for `xxx | yyy` + if match := _TYPE_HINT_MATCHERS["a_or_b"].match(expected_type): + return ( + isinstance(node, astroid.BinOp) + and _is_valid_type(match.group(1), node.left) + and _is_valid_type(match.group(2), node.right) + ) + + # Special case for xxx[yyy, zzz]` + if match := _TYPE_HINT_MATCHERS["x_of_y_comma_z"].match(expected_type): + return ( + isinstance(node, astroid.Subscript) + and _is_valid_type(match.group(1), node.value) + and isinstance(node.slice, astroid.Tuple) + and _is_valid_type(match.group(2), node.slice.elts[0]) + and _is_valid_type(match.group(3), node.slice.elts[1]) + ) + + # Special case for xxx[yyy]` + if match := _TYPE_HINT_MATCHERS["x_of_y"].match(expected_type): + return ( + isinstance(node, astroid.Subscript) + and _is_valid_type(match.group(1), node.value) + and _is_valid_type(match.group(2), node.slice) + ) + + # Name occurs when a namespace is not used, eg. "HomeAssistant" + if isinstance(node, astroid.Name) and node.name == expected_type: + return True + + # Attribute occurs when a namespace is used, eg. "core.HomeAssistant" + return isinstance(node, astroid.Attribute) and node.attrname == expected_type + + +def _get_all_annotations(node: astroid.FunctionDef) -> list[astroid.NodeNG | None]: + args = node.args + annotations: list[astroid.NodeNG | None] = ( + args.posonlyargs_annotations + args.annotations + args.kwonlyargs_annotations + ) + if args.vararg is not None: + annotations.append(args.varargannotation) + if args.kwarg is not None: + annotations.append(args.kwargannotation) + return annotations + + +def _has_valid_annotations( + annotations: list[astroid.NodeNG | None], +) -> bool: + for annotation in annotations: + if annotation is not None: + return True + return False + + +class HassTypeHintChecker(BaseChecker): # type: ignore[misc] + """Checker for setup type hints.""" + + __implements__ = IAstroidChecker + + name = "hass_enforce_type_hints" + priority = -1 + msgs = { + "W0020": ( + "Argument %d should be of type %s", + "hass-argument-type", + "Used when method argument type is incorrect", + ), + "W0021": ( + "Return type should be %s", + "hass-return-type", + "Used when method return type is incorrect", + ), + } + options = () + + def __init__(self, linter: PyLinter | None = None) -> None: + super().__init__(linter) + self.current_package: str | None = None + self.module: str | None = None + + def visit_module(self, node: astroid.Module) -> None: + """Called when a Module node is visited.""" + self.module = node.name + if node.package: + self.current_package = node.name + else: + # Strip name of the current module + self.current_package = node.name[: node.name.rfind(".")] + + def visit_functiondef(self, node: astroid.FunctionDef) -> None: + """Called when a FunctionDef node is visited.""" + for match in _METHOD_MATCH: + self._visit_functiondef(node, match) + + def visit_asyncfunctiondef(self, node: astroid.AsyncFunctionDef) -> None: + """Called when an AsyncFunctionDef node is visited.""" + for match in _METHOD_MATCH: + self._visit_functiondef(node, match) + + def _visit_functiondef( + self, node: astroid.FunctionDef, match: TypeHintMatch + ) -> None: + if node.name != match.function_name: + return + if node.is_method(): + return + if not match.module_filter.match(self.module): + return + + # Check that at least one argument is annotated. + annotations = _get_all_annotations(node) + if node.returns is None and not _has_valid_annotations(annotations): + return + + # Check that all arguments are correctly annotated. + for key, expected_type in match.arg_types.items(): + if not _is_valid_type(expected_type, annotations[key]): + self.add_message( + "hass-argument-type", + node=node.args.args[key], + args=(key + 1, expected_type), + ) + + # Check the return type. + if not _is_valid_type(return_type := match.return_type, node.returns): + self.add_message("hass-return-type", node=node, args=return_type or "None") + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassTypeHintChecker(linter)) diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 341abff202a0fc..beb7f87616f836 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -25,24 +25,28 @@ class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] def __init__(self, linter: PyLinter | None = None) -> None: super().__init__(linter) - self.current_module: str | None = None + self.current_package: str | None = None def visit_module(self, node: Module) -> None: - """Called when a Import node is visited.""" - self.current_module = node.name + """Called when a Module node is visited.""" + if node.package: + self.current_package = node.name + else: + # Strip name of the current module + self.current_package = node.name[: node.name.rfind(".")] def visit_import(self, node: Import) -> None: """Called when a Import node is visited.""" for module, _alias in node.names: - if module.startswith(f"{self.current_module}."): + if module.startswith(f"{self.current_package}."): self.add_message("hass-relative-import", node=node) def visit_importfrom(self, node: ImportFrom) -> None: """Called when a ImportFrom node is visited.""" if node.level is not None: return - if node.modname == self.current_module or node.modname.startswith( - f"{self.current_module}." + if node.modname == self.current_package or node.modname.startswith( + f"{self.current_package}." ): self.add_message("hass-relative-import", node=node) diff --git a/pyproject.toml b/pyproject.toml index d5be195d2b20b0..69398645d18303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["setuptools~=60.5", "wheel~=0.37.1"] +build-backend = "setuptools.build_meta" + [tool.black] target-version = ["py38"] exclude = 'generated' @@ -17,7 +21,7 @@ forced_separate = [ combine_as_imports = true [tool.pylint.MASTER] -py-version = "3.8" +py-version = "3.9" ignore = [ "tests", ] @@ -30,6 +34,7 @@ load-plugins = [ "pylint.extensions.typing", "pylint_strict_informational", "hass_constructor", + "hass_enforce_type_hints", "hass_imports", "hass_logger", ] diff --git a/requirements.txt b/requirements.txt index 4c6af849ce88f2..c8ee1d91368ac7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,15 +3,14 @@ # Home Assistant Core aiohttp==3.8.1 astral==2.2 -async_timeout==4.0.0 +async_timeout==4.0.2 attrs==21.2.0 atomicwrites==1.4.0 -awesomeversion==21.11.0 -backports.zoneinfo;python_version<"3.9" +awesomeversion==22.1.0 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 -httpx==0.21.0 +httpx==0.21.3 ifaddr==0.1.7 jinja2==3.0.3 PyJWT==2.1.0 @@ -19,7 +18,8 @@ cryptography==35.0.0 pip>=8.0.3,<20.3 python-slugify==4.0.1 pyyaml==6.0 -requests==2.26.0 +requests==2.27.1 +typing-extensions>=3.10.0.2,<5.0 voluptuous==0.12.2 voluptuous-serialize==2.5.0 -yarl==1.6.3 +yarl==1.7.2 diff --git a/requirements_all.txt b/requirements_all.txt index c97ab17efc5f2a..213388b0f3c484 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -13,6 +13,9 @@ Adafruit-SHT31==1.0.2 # homeassistant.components.bbb_gpio # Adafruit_BBIO==1.1.1 +# homeassistant.components.adax +Adax-local==0.1.3 + # homeassistant.components.homekit HAP-python==4.4.0 @@ -28,9 +31,6 @@ PyFlick==0.0.2 # homeassistant.components.mvglive PyMVGLive==1.1.4 -# homeassistant.components.arduino -PyMata==2.20 - # homeassistant.components.mobile_app # homeassistant.components.owntracks PyNaCl==1.4.0 @@ -52,10 +52,11 @@ PySocks==1.7.1 PyTransportNSW==0.1.1 # homeassistant.components.camera -PyTurboJPEG==1.6.3 +# homeassistant.components.stream +PyTurboJPEG==1.6.5 # homeassistant.components.vicare -PyViCare==2.13.1 +PyViCare==2.16.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 @@ -110,7 +111,7 @@ adb-shell[async]==0.4.0 adext==0.4.2 # homeassistant.components.adguard -adguardhome==0.5.0 +adguardhome==0.5.1 # homeassistant.components.advantage_air advantage_air==0.2.5 @@ -136,6 +137,9 @@ aio_georss_gdacs==0.5 # homeassistant.components.ambient_station aioambient==2021.11.0 +# homeassistant.components.aseko_pool_live +aioaseko==0.0.1 + # homeassistant.components.asuswrt aioasuswrt==1.4.0 @@ -143,10 +147,10 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.aws -aiobotocore==1.2.2 +aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.5 +aiodiscover==1.4.7 # homeassistant.components.dnsip # homeassistant.components.minecraft_server @@ -162,7 +166,7 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==10.6.0 +aioesphomeapi==10.8.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -171,7 +175,7 @@ aioflo==2021.11.0 aioftp==0.12.0 # homeassistant.components.github -aiogithubapi==21.11.0 +aiogithubapi==22.1.0 # homeassistant.components.guardian aioguardian==2021.11.0 @@ -180,14 +184,17 @@ aioguardian==2021.11.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.6.4 +aiohomekit==0.6.11 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==3.0.11 +aiohue==4.0.1 + +# homeassistant.components.homewizard +aiohwenergy==0.8.0 # homeassistant.components.imap aioimaplib==0.9.0 @@ -204,11 +211,8 @@ aiolifx==0.7.0 # homeassistant.components.lifx aiolifx_effects==0.2.2 -# homeassistant.components.lutron_caseta -aiolip==1.1.6 - # homeassistant.components.lookin -aiolookin==0.0.4 +aiolookin==0.1.0 # homeassistant.components.lyric aiolyric==1.0.8 @@ -217,7 +221,7 @@ aiolyric==1.0.8 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.2 +aiomusiccast==0.14.3 # homeassistant.components.nanoleaf aionanoleaf==0.1.1 @@ -228,6 +232,9 @@ aionotify==0.2.0 # homeassistant.components.notion aionotion==3.0.2 +# homeassistant.components.oncue +aiooncue==0.3.2 + # homeassistant.components.acmeda aiopulse==0.4.3 @@ -237,17 +244,20 @@ aiopvapi==1.6.19 # homeassistant.components.pvpc_hourly_pricing aiopvpc==3.0.0 -# homeassistant.components.webostv -aiopylgtv==0.4.0 - # homeassistant.components.recollect_waste aiorecollect==1.0.8 # homeassistant.components.ridwell -aioridwell==0.2.0 +aioridwell==2021.12.2 + +# homeassistant.components.senseme +aiosenseme==0.6.1 # homeassistant.components.shelly -aioshelly==1.0.5 +aioshelly==1.0.8 + +# homeassistant.components.steamist +aiosteamist==0.3.1 # homeassistant.components.switcher_kis aioswitcher==2.0.6 @@ -259,7 +269,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.2 # homeassistant.components.unifi -aiounifi==28 +aiounifi==30 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -267,6 +277,9 @@ aiovlc==0.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webostv +aiowebostv==0.1.2 + # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -280,7 +293,7 @@ airthings_cloud==0.1.0 airtouch4pyapi==1.0.5 # homeassistant.components.aladdin_connect -aladdin_connect==0.3 +aladdin_connect==0.4 # homeassistant.components.alpha_vantage alpha_vantage==2.3.1 @@ -298,7 +311,7 @@ ambiclimate==0.2.1 amcrest==1.9.3 # homeassistant.components.androidtv -androidtv[async]==0.0.60 +androidtv[async]==0.0.61 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 @@ -316,7 +329,7 @@ apns2==0.3.0 apprise==0.9.6 # homeassistant.components.aprs -aprslib==0.6.46 +aprslib==0.7.0 # homeassistant.components.aqualogic aqualogic==2.6 @@ -325,7 +338,7 @@ aqualogic==2.6 arcam-fmj==0.12.0 # homeassistant.components.arris_tg2492lg -arris-tg2492lg==1.1.0 +arris-tg2492lg==1.2.1 # homeassistant.components.ampio asmog==0.0.6 @@ -337,13 +350,13 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.23.1 +async-upnp-client==0.23.4 # homeassistant.components.supla asyncpysupla==0.0.5 # homeassistant.components.aten_pe -atenpdu==0.3.0 +atenpdu==0.3.2 # homeassistant.components.aurora auroranoaa==0.0.2 @@ -351,6 +364,9 @@ auroranoaa==0.0.2 # homeassistant.components.aurora_abb_powerone aurorapy==0.2.6 +# homeassistant.components.stream +av==8.1.0 + # homeassistant.components.avea # avea==1.5.1 @@ -397,7 +413,7 @@ bizkaibus==0.1.1 blebox_uniapi==1.3.3 # homeassistant.components.blink -blinkpy==0.17.0 +blinkpy==0.18.0 # homeassistant.components.blinksticklight blinkstick==1.2.0 @@ -419,14 +435,14 @@ blockchain==1.4.4 # bme680==1.0.5 # homeassistant.components.bond -bond-api==0.1.15 +bond-api==0.1.16 # homeassistant.components.bosch_shc -boschshcpy==0.2.19 +boschshcpy==0.2.28 # homeassistant.components.amazon_polly # homeassistant.components.route53 -boto3==1.16.52 +boto3==1.20.24 # homeassistant.components.braviatv bravia-tv==1.0.11 @@ -441,10 +457,10 @@ brother==1.1.0 brottsplatskartan==0.0.1 # homeassistant.components.brunt -brunt==1.1.0 +brunt==1.1.1 # homeassistant.components.bsblan -bsblan==0.4.0 +bsblan==0.5.0 # homeassistant.components.bluetooth_tracker bt_proximity==0.2.1 @@ -459,7 +475,7 @@ btsmarthub_devicelist==0.2.0 buienradar==1.0.5 # homeassistant.components.caldav -caldav==0.7.1 +caldav==0.8.2 # homeassistant.components.circuit circuit-webhook==1.0.1 @@ -535,13 +551,13 @@ defusedxml==0.7.1 deluge-client==1.7.1 # homeassistant.components.denonavr -denonavr==0.10.9 +denonavr==0.10.10 # homeassistant.components.devolo_home_control devolo-home-control-api==0.17.4 # homeassistant.components.devolo_home_network -devolo-plc-api==0.6.3 +devolo-plc-api==0.7.1 # homeassistant.components.directv directv==0.4.0 @@ -552,6 +568,9 @@ discogs_client==2.3.0 # homeassistant.components.discord discord.py==1.7.3 +# homeassistant.components.steamist +discovery30303==0.2.1 + # homeassistant.components.digitalloggers dlipower==0.7.165 @@ -562,10 +581,10 @@ doorbirdpy==2.1.0 dovado==0.4.1 # homeassistant.components.dsmr -dsmr_parser==0.30 +dsmr_parser==0.32 # homeassistant.components.dwd_weather_warnings -dwdwfsapi==1.0.4 +dwdwfsapi==1.0.5 # homeassistant.components.dweet dweepy==0.3.0 @@ -580,7 +599,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.elgato -elgato==2.2.0 +elgato==3.0.0 # homeassistant.components.eliqonline eliqonline==1.2.2 @@ -588,8 +607,11 @@ eliqonline==1.2.2 # homeassistant.components.elkm1 elkm1-lib==1.0.0 +# homeassistant.components.elmax +elmax_api==0.0.2 + # homeassistant.components.mobile_app -emoji==1.5.0 +emoji==1.6.3 # homeassistant.components.emulated_roku emulated_roku==0.2.1 @@ -598,7 +620,7 @@ emulated_roku==0.2.1 enocean==0.50 # homeassistant.components.entur_public_transport -enturclient==0.2.2 +enturclient==0.2.3 # homeassistant.components.environment_canada env_canada==0.5.20 @@ -641,7 +663,7 @@ fastdotcom==0.0.3 feedparser==6.0.2 # homeassistant.components.fibaro -fiblary3==0.1.7 +fiblary3==0.1.8 # homeassistant.components.fints fints==1.0.1 @@ -659,7 +681,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.28.4 +flux_led==0.28.17 # homeassistant.components.homekit fnvhash==0.1.0 @@ -681,13 +703,13 @@ freesms==0.2.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection==1.7.2 +fritzconnection==1.8.0 # homeassistant.components.google_translate gTTS==2.2.3 # homeassistant.components.garages_amsterdam -garages-amsterdam==2.1.1 +garages-amsterdam==3.0.0 # homeassistant.components.geniushub geniushub-client==0.6.30 @@ -721,7 +743,7 @@ gios==2.1.0 gitterpy==0.1.7 # homeassistant.components.glances -glances_api==0.2.0 +glances_api==0.3.4 # homeassistant.components.gntp gntp==1.0.3 @@ -729,17 +751,20 @@ gntp==1.0.3 # homeassistant.components.goalzero goalzero==0.2.1 +# homeassistant.components.goodwe +goodwe==0.2.15 + # homeassistant.components.google google-api-python-client==1.6.4 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.1.0 +google-cloud-pubsub==2.9.0 # homeassistant.components.google_cloud google-cloud-texttospeech==0.4.0 # homeassistant.components.nest -google-nest-sdm==0.4.9 +google-nest-sdm==1.6.0 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -754,10 +779,10 @@ gpiozero==1.5.1 gps3==0.33.3 # homeassistant.components.gree -greeclimate==1.0.1 +greeclimate==1.0.2 # homeassistant.components.greeneye_monitor -greeneye_monitor==2.1 +greeneye_monitor==3.0.1 # homeassistant.components.greenwave greenwavereality==0.5.1 @@ -771,9 +796,6 @@ gstreamer-player==1.1.2 # homeassistant.components.profiler guppy3==3.1.2 -# homeassistant.components.stream -ha-av==8.0.4-rc.1 - # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 @@ -784,10 +806,10 @@ ha-philipsjs==2.7.6 habitipy==0.2.0 # homeassistant.components.hangouts -hangups==0.4.14 +hangups==0.4.17 # homeassistant.components.cloud -hass-nabucasa==0.51.0 +hass-nabucasa==0.52.0 # homeassistant.components.splunk hass_splunk==0.1.1 @@ -817,10 +839,10 @@ hlk-sw16==0.0.9 hole==0.7.0 # homeassistant.components.workday -holidays==0.11.3.1 +holidays==0.12 # homeassistant.components.frontend -home-assistant-frontend==20211229.1 +home-assistant-frontend==20220202.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -862,7 +884,7 @@ hyperion-py==0.7.4 iammeter==0.1.7 # homeassistant.components.iaqualink -iaqualink==0.3.90 +iaqualink==0.4.1 # homeassistant.components.watson_tts ibm-watson==5.2.2 @@ -880,16 +902,19 @@ ifaddr==0.1.7 iglo==1.2.7 # homeassistant.components.ihc -ihcsdk==2.7.0 +ihcsdk==2.7.6 # homeassistant.components.incomfort incomfort-client==0.4.4 # homeassistant.components.influxdb -influxdb-client==1.14.0 +influxdb-client==1.24.0 # homeassistant.components.influxdb -influxdb==5.2.3 +influxdb==5.3.1 + +# homeassistant.components.intellifire +intellifire4py==0.5 # homeassistant.components.iotawatt iotawattpy==0.1.0 @@ -946,7 +971,7 @@ life360==4.1.1 lightify==1.0.7.3 # homeassistant.components.lightwave -lightwave==0.19 +lightwave==0.20 # homeassistant.components.limitlessled limitlessled==1.1.3 @@ -967,7 +992,7 @@ logi_circle==0.2.2 london-tube-status==0.2 # homeassistant.components.luftdaten -luftdaten==0.7.1 +luftdaten==0.7.2 # homeassistant.components.lupusec lupupy==0.0.24 @@ -1009,10 +1034,10 @@ mficlient==0.3.0 micloud==0.5 # homeassistant.components.miflora -miflora==0.7.0 +miflora==0.7.2 # homeassistant.components.mill -mill-local==0.1.0 +mill-local==0.1.1 # homeassistant.components.mill millheater==0.9.0 @@ -1024,7 +1049,7 @@ minio==5.0.10 mitemp_bt==0.0.5 # homeassistant.components.motion_blinds -motionblinds==0.5.8 +motionblinds==0.5.10 # homeassistant.components.motioneye motioneye-client==0.3.12 @@ -1109,7 +1134,7 @@ numpy==1.21.4 oasatelematics==0.3 # homeassistant.components.google -oauth2client==4.0.0 +oauth2client==4.1.3 # homeassistant.components.profiler objgraph==3.4.1 @@ -1132,6 +1157,9 @@ onvif-zeep-async==1.2.0 # homeassistant.components.opengarage open-garage==0.2.0 +# homeassistant.components.open_meteo +open-meteo==0.2.1 + # homeassistant.components.opencv # opencv-python-headless==4.5.2.54 @@ -1166,7 +1194,7 @@ orvibo==1.1.1 ovoenergy==1.1.12 # homeassistant.components.p1_monitor -p1monitor==1.0.0 +p1monitor==1.0.1 # homeassistant.components.mqtt # homeassistant.components.shiftr @@ -1221,13 +1249,13 @@ pilight==0.1.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -pillow==8.2.0 +pillow==9.0.0 # homeassistant.components.dominos pizzapi==0.0.3 # homeassistant.components.plex -plexapi==4.7.1 +plexapi==4.9.1 # homeassistant.components.plex plexauth==0.0.6 @@ -1281,6 +1309,9 @@ pushbullet.py==0.11.0 # homeassistant.components.pushover pushover_complete==1.1.1 +# homeassistant.components.pvoutput +pvo==0.2.0 + # homeassistant.components.rpi_gpio_pwm pwmled==1.6.7 @@ -1300,7 +1331,7 @@ py-nightscout==1.2.2 py-schluter==0.1.7 # homeassistant.components.synology_dsm -py-synologydsm-api==1.0.4 +py-synologydsm-api==1.0.5 # homeassistant.components.zabbix py-zabbix==1.1.7 @@ -1322,13 +1353,13 @@ pyMetEireann==2021.8.0 pyMetno==0.9.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.27.0 +pyRFXtrx==0.27.1 # homeassistant.components.switchmate # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.21.1 +pyTibber==0.21.7 # homeassistant.components.dlink pyW215==0.7.0 @@ -1346,7 +1377,7 @@ pyads==3.2.2 pyaehw4a1==0.3.9 # homeassistant.components.aftership -pyaftership==0.1.2 +pyaftership==21.11.0 # homeassistant.components.airnow pyairnow==1.1.0 @@ -1364,13 +1395,16 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==6.2.2 +pyatmo==6.2.4 # homeassistant.components.atome pyatome==0.1.1 # homeassistant.components.apple_tv -pyatv==0.8.2 +pyatv==0.10.0 + +# homeassistant.components.aussie_broadband +pyaussiebb==0.0.9 # homeassistant.components.balboa pybalboa==0.13 @@ -1385,10 +1419,10 @@ pyblackbird==0.5 # pybluez==0.22 # homeassistant.components.neato -pybotvac==0.0.22 +pybotvac==0.0.23 # homeassistant.components.nissan_leaf -pycarwings2==2.12 +pycarwings2==2.13 # homeassistant.components.cloudflare pycfdns==1.2.2 @@ -1424,13 +1458,13 @@ pycsspeechtts==1.0.4 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.6.0 +pydaikin==2.7.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==85 +pydeconz==86 # homeassistant.components.delijn pydelijn==0.6.1 @@ -1460,13 +1494,13 @@ pyedimax==0.2.1 pyefergy==0.1.5 # homeassistant.components.eight_sleep -pyeight==0.1.9 +pyeight==0.2.0 # homeassistant.components.emby pyemby==1.8 # homeassistant.components.envisalink -pyenvisalink==4.0 +pyenvisalink==4.3 # homeassistant.components.ephember pyephember==0.3.1 @@ -1478,7 +1512,7 @@ pyeverlights==0.1.0 pyevilgenius==1.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.5 +pyezviz==0.2.0.6 # homeassistant.components.fido pyfido==2.1.1 @@ -1536,7 +1570,7 @@ pyhik==0.3.0 pyhiveapi==0.4.2 # homeassistant.components.homematic -pyhomematic==0.1.76 +pyhomematic==0.1.77 # homeassistant.components.homeworks pyhomeworks==0.0.6 @@ -1548,7 +1582,7 @@ pyialarm==1.9.0 pyicloud==0.10.2 # homeassistant.components.insteon -pyinsteon==1.0.13 +pyinsteon==1.0.14 # homeassistant.components.intesishome pyintesishome==1.7.6 @@ -1569,7 +1603,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.0 +pyisy==3.0.1 # homeassistant.components.itach pyitachip2ir==0.0.7 @@ -1599,10 +1633,10 @@ pylacrosse==0.4 pylast==4.2.1 # homeassistant.components.launch_library -pylaunches==1.2.0 +pylaunches==1.3.0 # homeassistant.components.lg_netcast -pylgnetcast==0.3.5 +pylgnetcast==0.3.7 # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 @@ -1611,10 +1645,10 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.11.0 +pylitterbot==2021.12.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.11.0 +pylutron-caseta==0.13.1 # homeassistant.components.lutron pylutron==0.2.8 @@ -1626,7 +1660,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.2.2 +pymazda==0.3.2 # homeassistant.components.mediaroom pymediaroom==0.6.4.1 @@ -1662,13 +1696,16 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.8.0 +pynetgear==0.9.1 # homeassistant.components.netio pynetio==0.1.9.1 +# homeassistant.components.nina +pynina==0.1.4 + # homeassistant.components.nuki -pynuki==1.4.1 +pynuki==1.5.2 # homeassistant.components.nut pynut2==2.1.2 @@ -1686,7 +1723,7 @@ pynzbgetapi==0.2.0 pyobihai==1.3.1 # homeassistant.components.octoprint -pyoctoprintapi==0.1.6 +pyoctoprintapi==0.1.7 # homeassistant.components.ombi pyombi==0.1.10 @@ -1708,6 +1745,9 @@ pyotgw==1.1b1 # homeassistant.components.otp pyotp==2.6.0 +# homeassistant.components.overkiz +pyoverkiz==1.3.2 + # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1718,7 +1758,7 @@ pyownet==0.10.0.post1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.7.10 +pypck==0.7.13 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1727,7 +1767,7 @@ pypjlink2==1.2.1 pyplaato==0.0.15 # homeassistant.components.point -pypoint==2.2.1 +pypoint==2.3.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 @@ -1736,7 +1776,7 @@ pyprof2calltree==1.4.5 pyprosegur==0.0.5 # homeassistant.components.ps4 -pyps4-2ndscreen==1.2.0 +pyps4-2ndscreen==1.3.1 # homeassistant.components.qvr_pro pyqvrpro==0.52 @@ -1779,7 +1819,7 @@ pysensibo==1.0.3 # homeassistant.components.serial # homeassistant.components.zha -pyserial-asyncio==0.5 +pyserial-asyncio==0.6 # homeassistant.components.acer_projector # homeassistant.components.crownstone @@ -1797,13 +1837,13 @@ pysher==1.0.1 pysiaalarm==3.0.2 # homeassistant.components.signal_messenger -pysignalclirestapi==0.3.4 +pysignalclirestapi==0.3.18 # homeassistant.components.sky_hub -pyskyqhub==0.1.3 +pyskyqhub==0.1.4 # homeassistant.components.sma -pysma==0.6.9 +pysma==0.6.10 # homeassistant.components.smappee pysmappee==0.2.29 @@ -1818,7 +1858,7 @@ pysmartthings==0.7.6 pysmarty==0.8 # homeassistant.components.edl21 -pysml==0.0.5 +pysml==0.0.7 # homeassistant.components.snmp pysnmp==4.4.12 @@ -1899,7 +1939,7 @@ python-join-api==0.0.6 python-juicenet==1.0.2 # homeassistant.components.tplink -python-kasa==0.4.0 +python-kasa==0.4.1 # homeassistant.components.lirc # python-lirc==1.2.3 @@ -1949,20 +1989,17 @@ python-twitch-client==0.6.0 # homeassistant.components.vlc python-vlc==1.1.2 -# homeassistant.components.whois -python-whois==0.7.3 - # homeassistant.components.awair python_awair==0.2.1 # homeassistant.components.swiss_public_transport -python_opendata_transport==0.2.1 +python_opendata_transport==0.3.0 # homeassistant.components.egardia pythonegardia==1.0.40 # homeassistant.components.tile -pytile==2021.12.0 +pytile==2022.01.0 # homeassistant.components.touchline pytouchline==0.7 @@ -1971,7 +2008,7 @@ pytouchline==0.7 pytraccar==0.10.0 # homeassistant.components.tradfri -pytradfri[async]==7.2.1 +pytradfri[async]==8.0.1 # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation @@ -1980,6 +2017,9 @@ pytrafikverket==0.1.6.2 # homeassistant.components.usb pyudev==0.22.0 +# homeassistant.components.unifiprotect +pyunifiprotect==3.2.0 + # homeassistant.components.uptimerobot pyuptimerobot==21.11.0 @@ -1993,7 +2033,7 @@ pyvera==0.3.13 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==1.4.1 +pyvesync==1.4.2 # homeassistant.components.vizio pyvizio==0.1.57 @@ -2002,7 +2042,7 @@ pyvizio==0.1.57 pyvlx==0.2.19 # homeassistant.components.volumio -pyvolumio==0.1.3 +pyvolumio==0.1.5 # homeassistant.components.html5 pywebpush==1.9.2 @@ -2044,10 +2084,10 @@ raincloudy==0.0.7 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2021.10.0 +regenmaschine==2022.01.0 # homeassistant.components.renault -renault-api==0.1.4 +renault-api==0.1.7 # homeassistant.components.python_script restrictedpython==5.2 @@ -2056,7 +2096,7 @@ restrictedpython==5.2 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.58 +rflink==0.0.62 # homeassistant.components.ring ring_doorbell==0.7.2 @@ -2071,7 +2111,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.8.4 +rokuecp==0.12.0 # homeassistant.components.roomba roombapy==1.6.5 @@ -2088,6 +2128,9 @@ rpi-bad-power==0.1.0 # homeassistant.components.rpi_rf # rpi-rf==0.9.7 +# homeassistant.components.rtsp_to_webrtc +rtsp-to-webrtc==0.5.0 + # homeassistant.components.russound_rnet russound==0.1.9 @@ -2126,10 +2169,10 @@ sense-hat==2.2.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.9.3 +sense_energy==0.9.6 # homeassistant.components.sentry -sentry-sdk==1.5.0 +sentry-sdk==1.5.3 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -2138,7 +2181,7 @@ sharkiqpy==0.1.8 sharp_aquos_rc==0.3.2 # homeassistant.components.shodan -shodan==1.25.0 +shodan==1.26.1 # homeassistant.components.sighthound simplehound==0.3 @@ -2147,10 +2190,10 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2021.12.2 +simplisafe-python==2022.01.0 # homeassistant.components.sisyphus -sisyphus-control==3.0 +sisyphus-control==3.1.2 # homeassistant.components.skybell skybellpy==0.6.3 @@ -2185,7 +2228,7 @@ smhi-pkg==1.0.15 snapcast==2.1.3 # homeassistant.components.sonos -soco==0.25.3 +soco==0.26.0 # homeassistant.components.solaredge_local solaredge-local==0.2.0 @@ -2194,7 +2237,7 @@ solaredge-local==0.2.0 solaredge==0.0.2 # homeassistant.components.solax -solax==0.2.8 +solax==0.2.9 # homeassistant.components.honeywell somecomfort==0.8.0 @@ -2219,6 +2262,7 @@ spotipy==2.19.0 # homeassistant.components.recorder # homeassistant.components.sql +# homeassistant.components.webostv sqlalchemy==1.4.27 # homeassistant.components.srp_energy @@ -2269,11 +2313,8 @@ synology-srm==0.2.0 # homeassistant.components.system_bridge systembridge==2.2.3 -# homeassistant.components.tahoma -tahoma-api==0.0.16 - # homeassistant.components.tailscale -tailscale==0.1.6 +tailscale==0.2.0 # homeassistant.components.tank_utility tank_utility==1.4.0 @@ -2297,7 +2338,7 @@ temescal==0.3 temperusb==1.5.3 # homeassistant.components.tensorflow -# tensorflow==2.3.0 +# tensorflow==2.5.0 # homeassistant.components.powerwall tesla-powerwall==0.3.12 @@ -2306,7 +2347,7 @@ tesla-powerwall==0.3.12 tesla-wall-connector==1.0.1 # homeassistant.components.tensorflow -# tf-models-official==2.3.0 +# tf-models-official==2.5.0 # homeassistant.components.thermoworks_smoke thermoworks_smoke==0.1.8 @@ -2330,7 +2371,7 @@ tololib==0.1.0b3 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2021.12 +total_connect_client==2022.1 # homeassistant.components.tplink_lte tp-connected==0.0.4 @@ -2338,6 +2379,9 @@ tp-connected==0.0.4 # homeassistant.components.transmission transmissionrpc==0.11 +# homeassistant.components.twinkly +ttls==1.4.2 + # homeassistant.components.tuya tuya-iot-py-sdk==0.6.6 @@ -2347,12 +2391,12 @@ twentemilieu==0.5.0 # homeassistant.components.twilio twilio==6.32.0 -# homeassistant.components.twinkly -twinkly-client==0.0.2 - # homeassistant.components.rainforest_eagle uEagle==0.0.2 +# homeassistant.components.unifiprotect +unifi-discovery==1.1.2 + # homeassistant.components.unifiled unifiled==0.11 @@ -2373,13 +2417,13 @@ uscisstatus==0.1.1 uvcclient==0.11.0 # homeassistant.components.vallox -vallox-websocket-api==2.8.1 +vallox-websocket-api==2.9.0 # homeassistant.components.rdw -vehicle==0.2.2 +vehicle==0.3.1 # homeassistant.components.velbus -velbus-aio==2021.11.7 +velbus-aio==2022.2.1 # homeassistant.components.venstar venstarcolortouch==0.15 @@ -2424,6 +2468,9 @@ webexteamssdk==1.1.1 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.15.1 +# homeassistant.components.whois +whois==0.9.13 + # homeassistant.components.wiffi wiffi==1.1.0 @@ -2434,7 +2481,7 @@ wirelesstagpy==0.8.1 withings-api==2.3.2 # homeassistant.components.wled -wled==0.10.1 +wled==0.13.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 @@ -2449,7 +2496,7 @@ xbox-webapi==2.0.11 xboxapi==2.0.1 # homeassistant.components.knx -xknx==0.18.15 +xknx==0.19.1 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2463,10 +2510,10 @@ xmltodict==0.12.0 xs1-api-client==3.0.0 # homeassistant.components.yale_smart_alarm -yalesmartalarmclient==0.3.4 +yalesmartalarmclient==0.3.7 # homeassistant.components.august -yalexs==1.1.19 +yalexs==1.1.20 # homeassistant.components.yeelight yeelight==0.7.8 @@ -2478,16 +2525,16 @@ yeelightsunflower==0.0.10 youless-api==0.16 # homeassistant.components.media_extractor -youtube_dl==2021.06.06 +youtube_dl==2021.12.17 # homeassistant.components.zengge zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.38.1 +zeroconf==0.38.3 # homeassistant.components.zha -zha-quirks==0.0.65 +zha-quirks==0.0.66 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2505,13 +2552,13 @@ zigpy-xbee==0.14.0 zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.6.4 +zigpy-znp==0.7.0 # homeassistant.components.zha -zigpy==0.42.0 +zigpy==0.43.0 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.33.0 +zwave-js-server-python==0.34.0 diff --git a/requirements_test.txt b/requirements_test.txt index 6f580cb5159db6..6c516c09d8356c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,10 +12,10 @@ coverage==6.2.0 freezegun==1.1.0 jsonpickle==1.4.1 mock-open==1.4.0 -mypy==0.910 -pre-commit==2.16.0 -pylint==2.12.1 -pipdeptree==2.2.0 +mypy==0.931 +pre-commit==2.17.0 +pylint==2.12.2 +pipdeptree==2.2.1 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 pytest-cov==2.12.1 @@ -23,7 +23,7 @@ pytest-freezegun==0.4.2 pytest-socket==0.4.1 pytest-test-groups==1.0.3 pytest-sugar==0.9.4 -pytest-timeout==2.0.1 +pytest-timeout==2.1.0 pytest-xdist==2.4.0 pytest==6.2.5 requests_mock==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36f119e77d4c8d..7aba9ba5fa796f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -6,6 +6,9 @@ # homeassistant.components.aemet AEMET-OpenData==0.2.1 +# homeassistant.components.adax +Adax-local==0.1.3 + # homeassistant.components.homekit HAP-python==4.4.0 @@ -30,10 +33,11 @@ PyRMVtransport==0.3.3 PyTransportNSW==0.1.1 # homeassistant.components.camera -PyTurboJPEG==1.6.3 +# homeassistant.components.stream +PyTurboJPEG==1.6.5 # homeassistant.components.vicare -PyViCare==2.13.1 +PyViCare==2.16.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 @@ -63,7 +67,7 @@ adb-shell[async]==0.4.0 adext==0.4.2 # homeassistant.components.adguard -adguardhome==0.5.0 +adguardhome==0.5.1 # homeassistant.components.advantage_air advantage_air==0.2.5 @@ -86,6 +90,9 @@ aio_georss_gdacs==0.5 # homeassistant.components.ambient_station aioambient==2021.11.0 +# homeassistant.components.aseko_pool_live +aioaseko==0.0.1 + # homeassistant.components.asuswrt aioasuswrt==1.4.0 @@ -93,10 +100,10 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.aws -aiobotocore==1.2.2 +aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.5 +aiodiscover==1.4.7 # homeassistant.components.dnsip # homeassistant.components.minecraft_server @@ -112,11 +119,14 @@ aioeagle==1.1.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==10.6.0 +aioesphomeapi==10.8.1 # homeassistant.components.flo aioflo==2021.11.0 +# homeassistant.components.github +aiogithubapi==22.1.0 + # homeassistant.components.guardian aioguardian==2021.11.0 @@ -124,23 +134,23 @@ aioguardian==2021.11.0 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.6.4 +aiohomekit==0.6.11 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==3.0.11 +aiohue==4.0.1 + +# homeassistant.components.homewizard +aiohwenergy==0.8.0 # homeassistant.components.apache_kafka aiokafka==0.6.0 -# homeassistant.components.lutron_caseta -aiolip==1.1.6 - # homeassistant.components.lookin -aiolookin==0.0.4 +aiolookin==0.1.0 # homeassistant.components.lyric aiolyric==1.0.8 @@ -149,7 +159,7 @@ aiolyric==1.0.8 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.2 +aiomusiccast==0.14.3 # homeassistant.components.nanoleaf aionanoleaf==0.1.1 @@ -157,6 +167,9 @@ aionanoleaf==0.1.1 # homeassistant.components.notion aionotion==3.0.2 +# homeassistant.components.oncue +aiooncue==0.3.2 + # homeassistant.components.acmeda aiopulse==0.4.3 @@ -166,17 +179,20 @@ aiopvapi==1.6.19 # homeassistant.components.pvpc_hourly_pricing aiopvpc==3.0.0 -# homeassistant.components.webostv -aiopylgtv==0.4.0 - # homeassistant.components.recollect_waste aiorecollect==1.0.8 # homeassistant.components.ridwell -aioridwell==0.2.0 +aioridwell==2021.12.2 + +# homeassistant.components.senseme +aiosenseme==0.6.1 # homeassistant.components.shelly -aioshelly==1.0.5 +aioshelly==1.0.8 + +# homeassistant.components.steamist +aiosteamist==0.3.1 # homeassistant.components.switcher_kis aioswitcher==2.0.6 @@ -188,7 +204,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.2 # homeassistant.components.unifi -aiounifi==28 +aiounifi==30 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -196,6 +212,9 @@ aiovlc==0.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webostv +aiowebostv==0.1.2 + # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -218,7 +237,7 @@ amberelectric==1.0.3 ambiclimate==0.2.1 # homeassistant.components.androidtv -androidtv[async]==0.0.60 +androidtv[async]==0.0.61 # homeassistant.components.apns apns2==0.3.0 @@ -227,7 +246,7 @@ apns2==0.3.0 apprise==0.9.6 # homeassistant.components.aprs -aprslib==0.6.46 +aprslib==0.7.0 # homeassistant.components.arcam_fmj arcam-fmj==0.12.0 @@ -236,7 +255,7 @@ arcam-fmj==0.12.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.23.1 +async-upnp-client==0.23.4 # homeassistant.components.aurora auroranoaa==0.0.2 @@ -244,6 +263,9 @@ auroranoaa==0.0.2 # homeassistant.components.aurora_abb_powerone aurorapy==0.2.6 +# homeassistant.components.stream +av==8.1.0 + # homeassistant.components.axis axis==44 @@ -263,13 +285,13 @@ bimmer_connected==0.8.10 blebox_uniapi==1.3.3 # homeassistant.components.blink -blinkpy==0.17.0 +blinkpy==0.18.0 # homeassistant.components.bond -bond-api==0.1.15 +bond-api==0.1.16 # homeassistant.components.bosch_shc -boschshcpy==0.2.19 +boschshcpy==0.2.28 # homeassistant.components.braviatv bravia-tv==1.0.11 @@ -281,16 +303,16 @@ broadlink==0.18.0 brother==1.1.0 # homeassistant.components.brunt -brunt==1.1.0 +brunt==1.1.1 # homeassistant.components.bsblan -bsblan==0.4.0 +bsblan==0.5.0 # homeassistant.components.buienradar buienradar==1.0.5 # homeassistant.components.caldav -caldav==0.7.1 +caldav==0.8.2 # homeassistant.components.co2signal co2signal==0.4.2 @@ -339,34 +361,40 @@ debugpy==1.5.1 defusedxml==0.7.1 # homeassistant.components.denonavr -denonavr==0.10.9 +denonavr==0.10.10 # homeassistant.components.devolo_home_control devolo-home-control-api==0.17.4 # homeassistant.components.devolo_home_network -devolo-plc-api==0.6.3 +devolo-plc-api==0.7.1 # homeassistant.components.directv directv==0.4.0 +# homeassistant.components.steamist +discovery30303==0.2.1 + # homeassistant.components.doorbird doorbirdpy==2.1.0 # homeassistant.components.dsmr -dsmr_parser==0.30 +dsmr_parser==0.32 # homeassistant.components.dynalite dynalite_devices==0.1.46 # homeassistant.components.elgato -elgato==2.2.0 +elgato==3.0.0 # homeassistant.components.elkm1 elkm1-lib==1.0.0 +# homeassistant.components.elmax +elmax_api==0.0.2 + # homeassistant.components.mobile_app -emoji==1.5.0 +emoji==1.6.3 # homeassistant.components.emulated_roku emulated_roku==0.2.1 @@ -399,7 +427,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.28.4 +flux_led==0.28.17 # homeassistant.components.homekit fnvhash==0.1.0 @@ -415,13 +443,13 @@ freebox-api==0.0.10 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection==1.7.2 +fritzconnection==1.8.0 # homeassistant.components.google_translate gTTS==2.2.3 # homeassistant.components.garages_amsterdam -garages-amsterdam==2.1.1 +garages-amsterdam==3.0.0 # homeassistant.components.geo_json_events # homeassistant.components.usgs_earthquakes_feed @@ -449,28 +477,31 @@ getmac==0.8.2 gios==2.1.0 # homeassistant.components.glances -glances_api==0.2.0 +glances_api==0.3.4 # homeassistant.components.goalzero goalzero==0.2.1 +# homeassistant.components.goodwe +goodwe==0.2.15 + # homeassistant.components.google google-api-python-client==1.6.4 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.1.0 +google-cloud-pubsub==2.9.0 # homeassistant.components.nest -google-nest-sdm==0.4.9 +google-nest-sdm==1.6.0 # homeassistant.components.google_travel_time googlemaps==2.5.1 # homeassistant.components.gree -greeclimate==1.0.1 +greeclimate==1.0.2 # homeassistant.components.greeneye_monitor -greeneye_monitor==2.1 +greeneye_monitor==3.0.1 # homeassistant.components.growatt_server growattServer==1.1.0 @@ -478,9 +509,6 @@ growattServer==1.1.0 # homeassistant.components.profiler guppy3==3.1.2 -# homeassistant.components.stream -ha-av==8.0.4-rc.1 - # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 @@ -491,10 +519,10 @@ ha-philipsjs==2.7.6 habitipy==0.2.0 # homeassistant.components.hangouts -hangups==0.4.14 +hangups==0.4.17 # homeassistant.components.cloud -hass-nabucasa==0.51.0 +hass-nabucasa==0.52.0 # homeassistant.components.tasmota hatasmota==0.3.1 @@ -512,10 +540,10 @@ hlk-sw16==0.0.9 hole==0.7.0 # homeassistant.components.workday -holidays==0.11.3.1 +holidays==0.12 # homeassistant.components.frontend -home-assistant-frontend==20211229.1 +home-assistant-frontend==20220202.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -543,7 +571,7 @@ huisbaasje-client==0.1.0 hyperion-py==0.7.4 # homeassistant.components.iaqualink -iaqualink==0.3.90 +iaqualink==0.4.1 # homeassistant.components.ping icmplib==3.0 @@ -552,10 +580,13 @@ icmplib==3.0 ifaddr==0.1.7 # homeassistant.components.influxdb -influxdb-client==1.14.0 +influxdb-client==1.24.0 # homeassistant.components.influxdb -influxdb==5.2.3 +influxdb==5.3.1 + +# homeassistant.components.intellifire +intellifire4py==0.5 # homeassistant.components.iotawatt iotawattpy==0.1.0 @@ -591,7 +622,7 @@ libsoundtouch==0.8 logi_circle==0.2.2 # homeassistant.components.luftdaten -luftdaten==0.7.1 +luftdaten==0.7.2 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.11 @@ -615,7 +646,7 @@ mficlient==0.3.0 micloud==0.5 # homeassistant.components.mill -mill-local==0.1.0 +mill-local==0.1.1 # homeassistant.components.mill millheater==0.9.0 @@ -624,7 +655,7 @@ millheater==0.9.0 minio==5.0.10 # homeassistant.components.motion_blinds -motionblinds==0.5.8 +motionblinds==0.5.10 # homeassistant.components.motioneye motioneye-client==0.3.12 @@ -679,7 +710,7 @@ numato-gpio==0.10.0 numpy==1.21.4 # homeassistant.components.google -oauth2client==4.0.0 +oauth2client==4.1.3 # homeassistant.components.profiler objgraph==3.4.1 @@ -696,6 +727,9 @@ onvif-zeep-async==1.2.0 # homeassistant.components.opengarage open-garage==0.2.0 +# homeassistant.components.open_meteo +open-meteo==0.2.1 + # homeassistant.components.openerz openerz-api==0.1.0 @@ -703,7 +737,7 @@ openerz-api==0.1.0 ovoenergy==1.1.12 # homeassistant.components.p1_monitor -p1monitor==1.0.0 +p1monitor==1.0.1 # homeassistant.components.mqtt # homeassistant.components.shiftr @@ -737,10 +771,10 @@ pilight==0.1.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -pillow==8.2.0 +pillow==9.0.0 # homeassistant.components.plex -plexapi==4.7.1 +plexapi==4.9.1 # homeassistant.components.plex plexauth==0.0.6 @@ -779,9 +813,15 @@ pure-python-adb[async]==0.3.0.dev0 # homeassistant.components.pushbullet pushbullet.py==0.11.0 +# homeassistant.components.pvoutput +pvo==0.2.0 + # homeassistant.components.canary py-canary==0.5.1 +# homeassistant.components.cpuspeed +py-cpuinfo==8.0.0 + # homeassistant.components.melissa py-melissa-climate==2.1.4 @@ -789,7 +829,7 @@ py-melissa-climate==2.1.4 py-nightscout==1.2.2 # homeassistant.components.synology_dsm -py-synologydsm-api==1.0.4 +py-synologydsm-api==1.0.5 # homeassistant.components.seventeentrack py17track==2021.12.2 @@ -805,10 +845,10 @@ pyMetEireann==2021.8.0 pyMetno==0.9.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.27.0 +pyRFXtrx==0.27.1 # homeassistant.components.tibber -pyTibber==0.21.1 +pyTibber==0.21.7 # homeassistant.components.nextbus py_nextbusnext==0.1.5 @@ -832,10 +872,13 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==6.2.2 +pyatmo==6.2.4 # homeassistant.components.apple_tv -pyatv==0.8.2 +pyatv==0.10.0 + +# homeassistant.components.aussie_broadband +pyaussiebb==0.0.9 # homeassistant.components.balboa pybalboa==0.13 @@ -844,7 +887,7 @@ pybalboa==0.13 pyblackbird==0.5 # homeassistant.components.neato -pybotvac==0.0.22 +pybotvac==0.0.23 # homeassistant.components.cloudflare pycfdns==1.2.2 @@ -862,10 +905,10 @@ pycomfoconnect==0.4 pycoolmasternet-async==0.1.2 # homeassistant.components.daikin -pydaikin==2.6.0 +pydaikin==2.7.0 # homeassistant.components.deconz -pydeconz==85 +pydeconz==86 # homeassistant.components.dexcom pydexcom==0.2.2 @@ -886,7 +929,7 @@ pyeverlights==0.1.0 pyevilgenius==1.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.5 +pyezviz==0.2.0.6 # homeassistant.components.fido pyfido==2.1.1 @@ -894,6 +937,9 @@ pyfido==2.1.1 # homeassistant.components.fireservicerota pyfireservicerota==0.0.43 +# homeassistant.components.flic +pyflic==2.0.3 + # homeassistant.components.flume pyflume==0.6.5 @@ -932,7 +978,7 @@ pyheos==0.7.2 pyhiveapi==0.4.2 # homeassistant.components.homematic -pyhomematic==0.1.76 +pyhomematic==0.1.77 # homeassistant.components.ialarm pyialarm==1.9.0 @@ -941,7 +987,7 @@ pyialarm==1.9.0 pyicloud==0.10.2 # homeassistant.components.insteon -pyinsteon==1.0.13 +pyinsteon==1.0.14 # homeassistant.components.ipma pyipma==2.0.5 @@ -953,7 +999,7 @@ pyipp==0.11.0 pyiqvia==2021.11.0 # homeassistant.components.isy994 -pyisy==3.0.0 +pyisy==3.0.1 # homeassistant.components.kira pykira==0.1.1 @@ -973,6 +1019,9 @@ pykulersky==0.5.2 # homeassistant.components.lastfm pylast==4.2.1 +# homeassistant.components.launch_library +pylaunches==1.3.0 + # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 @@ -980,10 +1029,10 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.11.0 +pylitterbot==2021.12.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.11.0 +pylutron-caseta==0.13.1 # homeassistant.components.mailgun pymailgunner==1.4 @@ -992,7 +1041,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.2.2 +pymazda==0.3.2 # homeassistant.components.melcloud pymelcloud==2.5.6 @@ -1019,10 +1068,13 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.8.0 +pynetgear==0.9.1 + +# homeassistant.components.nina +pynina==0.1.4 # homeassistant.components.nuki -pynuki==1.4.1 +pynuki==1.5.2 # homeassistant.components.nut pynut2==2.1.2 @@ -1037,7 +1089,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.octoprint -pyoctoprintapi==0.1.6 +pyoctoprintapi==0.1.7 # homeassistant.components.openuv pyopenuv==2021.11.0 @@ -1053,6 +1105,9 @@ pyotgw==1.1b1 # homeassistant.components.otp pyotp==2.6.0 +# homeassistant.components.overkiz +pyoverkiz==1.3.2 + # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1060,13 +1115,13 @@ pyowm==3.2.0 pyownet==0.10.0.post1 # homeassistant.components.lcn -pypck==0.7.10 +pypck==0.7.13 # homeassistant.components.plaato pyplaato==0.0.15 # homeassistant.components.point -pypoint==2.2.1 +pypoint==2.3.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 @@ -1075,7 +1130,7 @@ pyprof2calltree==1.4.5 pyprosegur==0.0.5 # homeassistant.components.ps4 -pyps4-2ndscreen==1.2.0 +pyps4-2ndscreen==1.3.1 # homeassistant.components.qwikswitch pyqwikswitch==0.93 @@ -1089,9 +1144,12 @@ pyrituals==0.0.6 # homeassistant.components.ruckus_unleashed pyruckus==0.12 +# homeassistant.components.sensibo +pysensibo==1.0.3 + # homeassistant.components.serial # homeassistant.components.zha -pyserial-asyncio==0.5 +pyserial-asyncio==0.6 # homeassistant.components.acer_projector # homeassistant.components.crownstone @@ -1103,10 +1161,10 @@ pyserial==3.5 pysiaalarm==3.0.2 # homeassistant.components.signal_messenger -pysignalclirestapi==0.3.4 +pysignalclirestapi==0.3.18 # homeassistant.components.sma -pysma==0.6.9 +pysma==0.6.10 # homeassistant.components.smappee pysmappee==0.2.29 @@ -1142,7 +1200,7 @@ python-izone==1.2.3 python-juicenet==1.0.2 # homeassistant.components.tplink -python-kasa==0.4.0 +python-kasa==0.4.1 # homeassistant.components.xiaomi_miio python-miio==0.5.9.2 @@ -1172,13 +1230,13 @@ python-twitch-client==0.6.0 python_awair==0.2.1 # homeassistant.components.tile -pytile==2021.12.0 +pytile==2022.01.0 # homeassistant.components.traccar pytraccar==0.10.0 # homeassistant.components.tradfri -pytradfri[async]==7.2.1 +pytradfri[async]==8.0.1 # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation @@ -1187,6 +1245,9 @@ pytrafikverket==0.1.6.2 # homeassistant.components.usb pyudev==0.22.0 +# homeassistant.components.unifiprotect +pyunifiprotect==3.2.0 + # homeassistant.components.uptimerobot pyuptimerobot==21.11.0 @@ -1194,13 +1255,13 @@ pyuptimerobot==21.11.0 pyvera==0.3.13 # homeassistant.components.vesync -pyvesync==1.4.1 +pyvesync==1.4.2 # homeassistant.components.vizio pyvizio==0.1.57 # homeassistant.components.volumio -pyvolumio==0.1.3 +pyvolumio==0.1.5 # homeassistant.components.html5 pywebpush==1.9.2 @@ -1218,22 +1279,22 @@ pyzerproc==0.4.8 rachiopy==1.0.3 # homeassistant.components.rainmachine -regenmaschine==2021.10.0 +regenmaschine==2022.01.0 # homeassistant.components.renault -renault-api==0.1.4 +renault-api==0.1.7 # homeassistant.components.python_script restrictedpython==5.2 # homeassistant.components.rflink -rflink==0.0.58 +rflink==0.0.62 # homeassistant.components.ring ring_doorbell==0.7.2 # homeassistant.components.roku -rokuecp==0.8.4 +rokuecp==0.12.0 # homeassistant.components.roomba roombapy==1.6.5 @@ -1244,6 +1305,9 @@ roonapi==0.0.38 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 +# homeassistant.components.rtsp_to_webrtc +rtsp-to-webrtc==0.5.0 + # homeassistant.components.yamaha rxv==0.7.0 @@ -1261,10 +1325,10 @@ screenlogicpy==0.5.4 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense_energy==0.9.3 +sense_energy==0.9.6 # homeassistant.components.sentry -sentry-sdk==1.5.0 +sentry-sdk==1.5.3 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -1273,7 +1337,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==2021.12.2 +simplisafe-python==2022.01.0 # homeassistant.components.slack slackclient==2.5.0 @@ -1291,11 +1355,14 @@ smarthab==0.21 smhi-pkg==1.0.15 # homeassistant.components.sonos -soco==0.25.3 +soco==0.26.0 # homeassistant.components.solaredge solaredge==0.0.2 +# homeassistant.components.solax +solax==0.2.9 + # homeassistant.components.honeywell somecomfort==0.8.0 @@ -1319,6 +1386,7 @@ spotipy==2.19.0 # homeassistant.components.recorder # homeassistant.components.sql +# homeassistant.components.webostv sqlalchemy==1.4.27 # homeassistant.components.srp_energy @@ -1352,7 +1420,7 @@ surepy==0.7.2 systembridge==2.2.3 # homeassistant.components.tailscale -tailscale==0.1.6 +tailscale==0.2.0 # homeassistant.components.tellduslive tellduslive==0.10.11 @@ -1370,11 +1438,14 @@ tololib==0.1.0b3 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2021.12 +total_connect_client==2022.1 # homeassistant.components.transmission transmissionrpc==0.11 +# homeassistant.components.twinkly +ttls==1.4.2 + # homeassistant.components.tuya tuya-iot-py-sdk==0.6.6 @@ -1384,12 +1455,12 @@ twentemilieu==0.5.0 # homeassistant.components.twilio twilio==6.32.0 -# homeassistant.components.twinkly -twinkly-client==0.0.2 - # homeassistant.components.rainforest_eagle uEagle==0.0.2 +# homeassistant.components.unifiprotect +unifi-discovery==1.1.2 + # homeassistant.components.upb upb_lib==0.4.12 @@ -1403,11 +1474,14 @@ url-normalize==1.4.1 # homeassistant.components.uvc uvcclient==0.11.0 +# homeassistant.components.vallox +vallox-websocket-api==2.9.0 + # homeassistant.components.rdw -vehicle==0.2.2 +vehicle==0.3.1 # homeassistant.components.velbus -velbus-aio==2021.11.7 +velbus-aio==2022.2.1 # homeassistant.components.venstar venstarcolortouch==0.15 @@ -1434,6 +1508,9 @@ watchdog==2.1.6 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.15.1 +# homeassistant.components.whois +whois==0.9.13 + # homeassistant.components.wiffi wiffi==1.1.0 @@ -1441,7 +1518,7 @@ wiffi==1.1.0 withings-api==2.3.2 # homeassistant.components.wled -wled==0.10.1 +wled==0.13.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 @@ -1450,7 +1527,7 @@ wolf_smartset==0.1.11 xbox-webapi==2.0.11 # homeassistant.components.knx -xknx==0.18.15 +xknx==0.19.1 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -1461,10 +1538,10 @@ xknx==0.18.15 xmltodict==0.12.0 # homeassistant.components.yale_smart_alarm -yalesmartalarmclient==0.3.4 +yalesmartalarmclient==0.3.7 # homeassistant.components.august -yalexs==1.1.19 +yalexs==1.1.20 # homeassistant.components.yeelight yeelight==0.7.8 @@ -1473,10 +1550,10 @@ yeelight==0.7.8 youless-api==0.16 # homeassistant.components.zeroconf -zeroconf==0.38.1 +zeroconf==0.38.3 # homeassistant.components.zha -zha-quirks==0.0.65 +zha-quirks==0.0.66 # homeassistant.components.zha zigpy-deconz==0.14.0 @@ -1488,10 +1565,10 @@ zigpy-xbee==0.14.0 zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.6.4 +zigpy-znp==0.7.0 # homeassistant.components.zha -zigpy==0.42.0 +zigpy==0.43.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.33.0 +zwave-js-server-python==0.34.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 66786035e98728..2e37b2175ba947 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,16 +1,16 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit bandit==1.7.0 -black==21.11b1 -codespell==2.0.0 +black==21.12b0 +codespell==2.1.0 flake8-comprehensions==3.7.0 flake8-docstrings==1.6.0 -flake8-noqa==1.2.0 +flake8-noqa==1.2.1 flake8==4.0.1 isort==5.10.0 mccabe==0.6.1 pycodestyle==2.8.0 pydocstyle==6.1.1 pyflakes==2.4.0 -pyupgrade==2.29.0 +pyupgrade==2.31.0 yamllint==1.26.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 3deec512b4fe80..872f2d0c7a8ec3 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Generate an updated requirements_all.txt.""" +import configparser import difflib import importlib import os @@ -61,27 +62,26 @@ os.path.dirname(__file__), "../homeassistant/package_constraints.txt" ) CONSTRAINT_BASE = """ +# Constrain pycryptodome to avoid vulnerability +# see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 urllib3>=1.26.5 -# Constrain H11 to ensure we get a new enough version to support non-rfc line endings -h11>=0.12.0 - # Constrain httplib2 to protect against GHSA-93xj-8mrv-444m # https://github.com/advisories/GHSA-93xj-8mrv-444m httplib2>=0.19.0 -# gRPC 1.32+ currently causes issues on ARMv7, see: -# https://github.com/home-assistant/core/issues/40148 -# Newer versions of some other libraries pin a higher version of grpcio, -# so those also need to be kept at an old version until the grpcio pin -# is reverted, see: -# https://github.com/home-assistant/core/issues/53427 -grpcio==1.31.0 -google-cloud-pubsub==2.1.0 -google-api-core<=1.31.2 +# gRPC is an implicit dependency that we want to make explicit so we manage +# upgrades intentionally. It is a large package to build from source and we +# want to ensure we have wheels built. +grpcio==1.43.0 + +# libcst >=0.4.0 requires a newer Rust than we currently have available, +# thus our wheels builds fail. This pins it to the last working version, +# which at this point satisfies our needs. +libcst==0.3.23 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 @@ -103,13 +103,26 @@ # This is fixed in 2021.8.28 regex==2021.8.28 -# anyio has a bug that was fixed in 3.3.1 -# can remove after httpx/httpcore updates its anyio version pin -anyio>=3.3.1 - -# websockets 10.0 is broken with AWS -# https://github.com/aaugustin/websockets/issues/1065 -websockets==9.1 +# httpx requires httpcore, and httpcore requires anyio and h11, but the version constraints on +# these requirements are quite loose. As the entire stack has some outstanding issues, and +# even newer versions seem to introduce new issues, it's useful for us to pin all these +# requirements so we can directly link HA versions to these library versions. +anyio==3.5.0 +h11==0.12.0 +httpcore==0.14.5 + +# pytest_asyncio breaks our test suite. We rely on pytest-aiohttp instead +pytest_asyncio==1000000000.0.0 + +# Prevent dependency conflicts between sisyphus-control and aioambient +# until upper bounds for sisyphus-control have been updated +# https://github.com/jkeljo/sisyphus-control/issues/6 +python-engineio>=3.13.1,<4.0 +python-socketio>=4.6.0,<5.0 + +# Constrain multidict to avoid typing issues +# https://github.com/home-assistant/core/pull/64792 +multidict<6.0.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( @@ -127,22 +140,12 @@ def has_tests(module: str): """Test if a module has tests. Module format: homeassistant.components.hue - Test if exists: tests/components/hue + Test if exists: tests/components/hue/__init__.py """ - path = Path(module.replace(".", "/").replace("homeassistant", "tests")) - if not path.exists(): - return False - - if not path.is_dir(): - return True - - # Dev environments might have stale directories around - # from removed tests. Check for that. - content = [f.name for f in path.glob("*")] - - # Directories need to contain more than `__pycache__` - # to exist in Git and so be seen by CI. - return content != ["__pycache__"] + path = ( + Path(module.replace(".", "/").replace("homeassistant", "tests")) / "__init__.py" + ) + return path.exists() def explore_module(package, explore_children): @@ -165,10 +168,9 @@ def explore_module(package, explore_children): def core_requirements(): """Gather core requirements out of setup.py.""" - reqs_raw = re.search( - r"REQUIRES = \[(.*?)\]", Path("setup.py").read_text(), re.S - ).group(1) - return [x[1] for x in re.findall(r"(['\"])(.*?)\1", reqs_raw)] + parser = configparser.ConfigParser() + parser.read("setup.cfg") + return parser["options"]["install_requires"].strip().split("\n") def gather_recursive_requirements(domain, seen=None): @@ -179,7 +181,7 @@ def gather_recursive_requirements(domain, seen=None): seen.add(domain) integration = Integration(Path(f"homeassistant/components/{domain}")) integration.load_manifest() - reqs = set(integration.requirements) + reqs = {x for x in integration.requirements if x not in CONSTRAINT_BASE} for dep_domain in integration.dependencies: reqs.update(gather_recursive_requirements(dep_domain, seen)) return reqs diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index d4935196cc738d..ac3d3ce8a85791 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -12,6 +12,7 @@ dhcp, json, manifest, + metadata, mqtt, mypy_config, requirements, @@ -41,6 +42,7 @@ HASS_PLUGINS = [ coverage, mypy_config, + metadata, ] diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py index 81c3c883965480..91bd81efef5c87 100644 --- a/script/hassfest/codeowners.py +++ b/script/hassfest/codeowners.py @@ -33,7 +33,7 @@ """ -def generate_and_validate(integrations: dict[str, Integration]): +def generate_and_validate(integrations: dict[str, Integration], config: Config): """Generate CODEOWNERS.""" parts = [BASE] @@ -56,6 +56,9 @@ def generate_and_validate(integrations: dict[str, Integration]): parts.append(f"homeassistant/components/{domain}/* {' '.join(codeowners)}") + if (config.root / "tests/components" / domain).exists(): + parts.append(f"tests/components/{domain}/* {' '.join(codeowners)}") + parts.append(f"\n{INDIVIDUAL_FILES.strip()}") return "\n".join(parts) @@ -64,7 +67,7 @@ def generate_and_validate(integrations: dict[str, Integration]): def validate(integrations: dict[str, Integration], config: Config): """Validate CODEOWNERS.""" codeowners_path = config.root / "CODEOWNERS" - config.cache["codeowners"] = content = generate_and_validate(integrations) + config.cache["codeowners"] = content = generate_and_validate(integrations, config) if config.specific_integrations: return diff --git a/script/hassfest/coverage.py b/script/hassfest/coverage.py index e4e8058c69b9cf..ec0b437186e738 100644 --- a/script/hassfest/coverage.py +++ b/script/hassfest/coverage.py @@ -52,7 +52,6 @@ ("spider", "config_flow.py"), ("starline", "config_flow.py"), ("tado", "config_flow.py"), - ("tahoma", "scene.py"), ("totalconnect", "config_flow.py"), ("tradfri", "config_flow.py"), ("tuya", "config_flow.py"), diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index c52a926e4e1321..b5d82f7b3481e7 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -41,6 +41,14 @@ def visit_ImportFrom(self, node): if node.module is None: return + # Exception: we will allow importing the sign path code. + if ( + node.module == "homeassistant.components.http.auth" + and len(node.names) == 1 + and node.names[0].name == "async_sign_path" + ): + return + if node.module.startswith("homeassistant.components."): # from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME # from homeassistant.components.logbook import bla @@ -102,10 +110,12 @@ def visit_Attribute(self, node): "hassio", "homeassistant", "input_boolean", + "input_button", "input_datetime", "input_number", "input_select", "input_text", + "media_source", "onboarding", "persistent_notification", "person", diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index ecc00142e3016a..54d4944cf70340 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -12,6 +12,8 @@ import voluptuous as vol from voluptuous.humanize import humanize_error +from homeassistant.helpers import config_validation as cv + from .model import Config, Integration DOCUMENTATION_URL_SCHEMA = "https" @@ -50,6 +52,7 @@ "default_config", "device_automation", "device_tracker", + "diagnostics", "discovery", "downloader", "fan", @@ -62,6 +65,7 @@ "image_processing", "image", "input_boolean", + "input_button", "input_datetime", "input_number", "input_select", @@ -180,16 +184,26 @@ def verify_wildcard(value: str): vol.Optional("zeroconf"): [ vol.Any( str, - vol.Schema( - { - vol.Required("type"): str, - vol.Optional("macaddress"): vol.All( - str, verify_uppercase, verify_wildcard - ), - vol.Optional("manufacturer"): vol.All(str, verify_lowercase), - vol.Optional("model"): vol.All(str, verify_lowercase), - vol.Optional("name"): vol.All(str, verify_lowercase), - } + vol.All( + cv.deprecated("macaddress"), + cv.deprecated("model"), + cv.deprecated("manufacturer"), + vol.Schema( + { + vol.Required("type"): str, + vol.Optional("macaddress"): vol.All( + str, verify_uppercase, verify_wildcard + ), + vol.Optional("manufacturer"): vol.All( + str, verify_lowercase + ), + vol.Optional("model"): vol.All(str, verify_lowercase), + vol.Optional("name"): vol.All(str, verify_lowercase), + vol.Optional("properties"): vol.Schema( + {str: verify_lowercase} + ), + } + ), ), ) ], diff --git a/script/hassfest/metadata.py b/script/hassfest/metadata.py new file mode 100644 index 00000000000000..ab5ba3f036dd14 --- /dev/null +++ b/script/hassfest/metadata.py @@ -0,0 +1,31 @@ +"""Package metadata validation.""" +import configparser + +from homeassistant.const import REQUIRED_PYTHON_VER, __version__ + +from .model import Config, Integration + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate project metadata keys.""" + metadata_path = config.root / "setup.cfg" + parser = configparser.ConfigParser() + parser.read(metadata_path) + + try: + if parser["metadata"]["version"] != __version__: + config.add_error( + "metadata", f"'metadata.version' value does not match '{__version__}'" + ) + except KeyError: + config.add_error("metadata", "No 'metadata.version' key found!") + + required_py_version = f">={'.'.join(map(str, REQUIRED_PYTHON_VER))}" + try: + if parser["options"]["python_requires"] != required_py_version: + config.add_error( + "metadata", + f"'options.python_requires' value doesn't match '{required_py_version}", + ) + except KeyError: + config.add_error("metadata", "No 'options.python_requires' key found!") diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 6fc6c0e399301c..d2bd437c2d9d5f 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import Final +from homeassistant.const import REQUIRED_PYTHON_VER + from .model import Config, Integration # Modules which have type hints which known to be broken. @@ -15,119 +17,61 @@ # Do your best to not add anything new here. IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.blueprint.*", - "homeassistant.components.climacell.*", "homeassistant.components.cloud.*", "homeassistant.components.config.*", "homeassistant.components.conversation.*", "homeassistant.components.deconz.*", "homeassistant.components.demo.*", "homeassistant.components.denonavr.*", - "homeassistant.components.dhcp.*", - "homeassistant.components.doorbird.*", - "homeassistant.components.enphase_envoy.*", "homeassistant.components.evohome.*", "homeassistant.components.fireservicerota.*", "homeassistant.components.firmata.*", - "homeassistant.components.flo.*", - "homeassistant.components.fortios.*", - "homeassistant.components.foscam.*", "homeassistant.components.freebox.*", "homeassistant.components.geniushub.*", - "homeassistant.components.glances.*", "homeassistant.components.google_assistant.*", "homeassistant.components.gree.*", - "homeassistant.components.growatt_server.*", - "homeassistant.components.habitica.*", "homeassistant.components.harmony.*", "homeassistant.components.hassio.*", "homeassistant.components.here_travel_time.*", - "homeassistant.components.hisense_aehw4a1.*", - "homeassistant.components.home_connect.*", "homeassistant.components.home_plus_control.*", "homeassistant.components.homekit.*", "homeassistant.components.homekit_controller.*", "homeassistant.components.honeywell.*", - "homeassistant.components.humidifier.*", - "homeassistant.components.iaqualink.*", "homeassistant.components.icloud.*", - "homeassistant.components.image.*", - "homeassistant.components.incomfort.*", "homeassistant.components.influxdb.*", "homeassistant.components.input_datetime.*", - "homeassistant.components.input_number.*", - "homeassistant.components.ipp.*", "homeassistant.components.isy994.*", "homeassistant.components.izone.*", - "homeassistant.components.kaiterra.*", - "homeassistant.components.keenetic_ndms2.*", - "homeassistant.components.kodi.*", "homeassistant.components.konnected.*", "homeassistant.components.kostal_plenticore.*", - "homeassistant.components.kulersky.*", - "homeassistant.components.litejet.*", "homeassistant.components.litterrobot.*", "homeassistant.components.lovelace.*", - "homeassistant.components.luftdaten.*", "homeassistant.components.lutron_caseta.*", "homeassistant.components.lyric.*", "homeassistant.components.melcloud.*", "homeassistant.components.meteo_france.*", - "homeassistant.components.metoffice.*", "homeassistant.components.minecraft_server.*", "homeassistant.components.mobile_app.*", - "homeassistant.components.motion_blinds.*", - "homeassistant.components.mullvad.*", - "homeassistant.components.ness_alarm.*", "homeassistant.components.nest.legacy.*", "homeassistant.components.netgear.*", - "homeassistant.components.nightscout.*", "homeassistant.components.nilu.*", - "homeassistant.components.nsw_fuel_station.*", - "homeassistant.components.nuki.*", - "homeassistant.components.nws.*", "homeassistant.components.nzbget.*", "homeassistant.components.omnilogic.*", - "homeassistant.components.onboarding.*", - "homeassistant.components.ondilo_ico.*", "homeassistant.components.onvif.*", - "homeassistant.components.ovo_energy.*", "homeassistant.components.ozw.*", "homeassistant.components.philips_js.*", - "homeassistant.components.ping.*", - "homeassistant.components.pioneer.*", - "homeassistant.components.plaato.*", "homeassistant.components.plex.*", - "homeassistant.components.plugwise.*", - "homeassistant.components.plum_lightpad.*", - "homeassistant.components.point.*", "homeassistant.components.profiler.*", - "homeassistant.components.rachio.*", - "homeassistant.components.ring.*", - "homeassistant.components.ruckus_unleashed.*", - "homeassistant.components.screenlogic.*", - "homeassistant.components.search.*", - "homeassistant.components.sense.*", - "homeassistant.components.sharkiq.*", - "homeassistant.components.sma.*", - "homeassistant.components.smartthings.*", "homeassistant.components.solaredge.*", - "homeassistant.components.somfy.*", - "homeassistant.components.somfy_mylink.*", "homeassistant.components.sonos.*", "homeassistant.components.spotify.*", - "homeassistant.components.stt.*", "homeassistant.components.system_health.*", - "homeassistant.components.system_log.*", - "homeassistant.components.tado.*", "homeassistant.components.telegram_bot.*", "homeassistant.components.template.*", "homeassistant.components.toon.*", "homeassistant.components.unifi.*", "homeassistant.components.upnp.*", - "homeassistant.components.vera.*", - "homeassistant.components.verisure.*", "homeassistant.components.vizio.*", - "homeassistant.components.wemo.*", "homeassistant.components.withings.*", "homeassistant.components.xbox.*", "homeassistant.components.xiaomi_aqara.*", @@ -137,6 +81,12 @@ "homeassistant.components.zwave.*", ] +# Component modules which should set no_implicit_reexport = true. +NO_IMPLICIT_REEXPORT_MODULES: set[str] = { + "homeassistant.components", + "homeassistant.components.diagnostics.*", +} + HEADER: Final = """ # Automatically generated by hassfest. # @@ -145,7 +95,7 @@ """.lstrip() GENERAL_SETTINGS: Final[dict[str, str]] = { - "python_version": "3.8", + "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), "show_error_codes": "true", "follow_imports": "silent", # Enable some checks globally. @@ -176,6 +126,12 @@ # "no_implicit_reexport", ] +# Strict settings are already applied for core files. +# To enable granular typing, add additional settings if core files are given. +STRICT_SETTINGS_CORE: Final[list[str]] = [ + "disallow_any_generics", +] + def generate_and_validate(config: Config) -> str: """Validate and generate mypy config.""" @@ -186,12 +142,20 @@ def generate_and_validate(config: Config) -> str: lines = fp.readlines() # Filter empty and commented lines. - strict_modules: list[str] = [ + parsed_modules: list[str] = [ line.strip() for line in lines if line.strip() != "" and not line.startswith("#") ] + strict_modules: list[str] = [] + strict_core_modules: list[str] = [] + for module in parsed_modules: + if module.startswith("homeassistant.components"): + strict_modules.append(module) + else: + strict_core_modules.append(module) + ignored_modules_set: set[str] = set(IGNORED_MODULES) for module in strict_modules: if ( @@ -207,7 +171,12 @@ def generate_and_validate(config: Config) -> str: ) # Validate that all modules exist. - all_modules = strict_modules + IGNORED_MODULES + all_modules = ( + strict_modules + + strict_core_modules + + IGNORED_MODULES + + list(NO_IMPLICIT_REEXPORT_MODULES) + ) for module in all_modules: if module.endswith(".*"): module_path = Path(module[:-2].replace(".", os.path.sep)) @@ -235,17 +204,37 @@ def generate_and_validate(config: Config) -> str: for key in STRICT_SETTINGS: mypy_config.set(general_section, key, "true") + # By default enable no_implicit_reexport only for homeassistant.* + # Disable it afterwards for all components + components_section = "mypy-homeassistant.*" + mypy_config.add_section(components_section) + mypy_config.set(components_section, "no_implicit_reexport", "true") + + for core_module in strict_core_modules: + core_section = f"mypy-{core_module}" + mypy_config.add_section(core_section) + for key in STRICT_SETTINGS_CORE: + mypy_config.set(core_section, key, "true") + # By default strict checks are disabled for components. components_section = "mypy-homeassistant.components.*" mypy_config.add_section(components_section) for key in STRICT_SETTINGS: mypy_config.set(components_section, key, "false") + mypy_config.set(components_section, "no_implicit_reexport", "false") for strict_module in strict_modules: strict_section = f"mypy-{strict_module}" mypy_config.add_section(strict_section) for key in STRICT_SETTINGS: mypy_config.set(strict_section, key, "true") + if strict_module in NO_IMPLICIT_REEXPORT_MODULES: + mypy_config.set(strict_section, "no_implicit_reexport", "true") + + for reexport_module in NO_IMPLICIT_REEXPORT_MODULES.difference(strict_modules): + reexport_section = f"mypy-{reexport_module}" + mypy_config.add_section(reexport_section) + mypy_config.set(reexport_section, "no_implicit_reexport", "true") # Disable strict checks for tests tests_section = "mypy-tests.*" diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 2da82762240d60..09afd11b147f75 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -3,7 +3,6 @@ from collections import deque import json -import operator import os import re import subprocess @@ -13,7 +12,7 @@ from stdlib_list import stdlib_list from tqdm import tqdm -from homeassistant.const import REQUIRED_PYTHON_VER +from homeassistant.const import REQUIRED_NEXT_PYTHON_VER, REQUIRED_PYTHON_VER import homeassistant.util.package as pkg_util from script.gen_requirements_all import COMMENT_REQUIREMENTS, normalize_package_name @@ -29,8 +28,10 @@ PIP_VERSION_RANGE_SEPARATOR = re.compile(r"^(==|>=|<=|~=|!=|<|>|===)?(.*)$") SUPPORTED_PYTHON_TUPLES = [ REQUIRED_PYTHON_VER[:2], - tuple(map(operator.add, REQUIRED_PYTHON_VER, (0, 1, 0)))[:2], ] +if REQUIRED_PYTHON_VER[0] == REQUIRED_NEXT_PYTHON_VER[0]: + for minor in range(REQUIRED_PYTHON_VER[1] + 1, REQUIRED_NEXT_PYTHON_VER[1] + 1): + SUPPORTED_PYTHON_TUPLES.append((REQUIRED_PYTHON_VER[0], minor)) SUPPORTED_PYTHON_VERSIONS = [ ".".join(map(str, version_tuple)) for version_tuple in SUPPORTED_PYTHON_TUPLES ] diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 841638a971808c..d174f238217d9f 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -24,6 +24,7 @@ # Only allow translatino of integration names if they contain non-brand names ALLOW_NAME_TRANSLATION = { "cert_expiry", + "cpuspeed", "emulated_roku", "garages_amsterdam", "google_travel_time", diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 4ce4896952e2e6..446a6f32aeb22d 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -4,6 +4,8 @@ from collections import OrderedDict, defaultdict import json +from homeassistant.loader import async_process_zeroconf_match_dict + from .model import Config, Integration BASE = """ @@ -42,9 +44,7 @@ def generate_and_validate(integrations: dict[str, Integration]): data = {"domain": domain} if isinstance(entry, dict): typ = entry["type"] - entry_without_type = entry.copy() - del entry_without_type["type"] - data.update(entry_without_type) + data.update(async_process_zeroconf_match_dict(entry)) else: typ = entry diff --git a/script/pip_check b/script/pip_check new file mode 100755 index 00000000000000..1b2be96132120c --- /dev/null +++ b/script/pip_check @@ -0,0 +1,27 @@ +#!/bin/bash +PIP_CACHE=$1 + +# Number of existing dependency conflicts +# Update if a PR resolve one! +DEPENDENCY_CONFLICTS=13 + +PIP_CHECK=$(pip check --cache-dir=$PIP_CACHE) +LINE_COUNT=$(echo "$PIP_CHECK" | wc -l) +echo "$PIP_CHECK" + +if [[ $((LINE_COUNT)) -gt $DEPENDENCY_CONFLICTS ]] +then + echo "------" + echo "Requirements change added another dependency conflict." + echo "Make sure to check the 'pip check' output above!" + exit 1 +elif [[ $((LINE_COUNT)) -lt $DEPENDENCY_CONFLICTS ]] +then + echo "------" + echo "It seems like this PR resolves $(( + DEPENDENCY_CONFLICTS - LINE_COUNT)) dependency conflicts." + echo "Please update the 'DEPENDENCY_CONFLICTS' constant " + echo "in 'script/pip_check' to help prevent regressions." + echo "Update it to: $((LINE_COUNT))" + exit 1 +fi diff --git a/script/release b/script/release deleted file mode 100755 index 4dc94eb7f1578b..00000000000000 --- a/script/release +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh -# Pushes a new version to PyPi. - -cd "$(dirname "$0")/.." - -head -n 5 homeassistant/const.py | tail -n 1 | grep PATCH_VERSION > /dev/null - -if [ $? -eq 1 ] -then - echo "Patch version not found on const.py line 5" - exit 1 -fi - -head -n 5 homeassistant/const.py | tail -n 1 | grep dev > /dev/null - -if [ $? -eq 0 ] -then - echo "Release version should not contain dev tag" - exit 1 -fi - -CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD` - -if [ "$CURRENT_BRANCH" != "master" ] && [ "$CURRENT_BRANCH" != "rc" ] -then - echo "You have to be on the master or rc branch to release." - exit 1 -fi - -rm -rf dist build -python3 setup.py sdist bdist_wheel -python3 -m twine upload dist/* --skip-existing diff --git a/script/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py index ab5c93364e1a7a..dc92ecc1d15119 100644 --- a/script/scaffold/templates/config_flow/integration/__init__.py +++ b/script/scaffold/templates/config_flow/integration/__init__.py @@ -2,13 +2,14 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS: list[str] = ["light"] +PLATFORMS: list[Platform] = [Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -23,8 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/script/scaffold/templates/config_flow_discovery/integration/__init__.py b/script/scaffold/templates/config_flow_discovery/integration/__init__.py index c9f56b3919b367..d7fb1e56eefa27 100644 --- a/script/scaffold/templates/config_flow_discovery/integration/__init__.py +++ b/script/scaffold/templates/config_flow_discovery/integration/__init__.py @@ -2,13 +2,14 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS = ["binary_sensor"] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -23,8 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index 8b1bdc93749048..b580e609bba117 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import ( aiohttp_client, @@ -30,7 +30,7 @@ # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. -PLATFORMS = ["light"] +PLATFORMS: list[Platform] = [Platform.LIGHT] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -80,8 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/script/scaffold/templates/device_action/tests/test_device_action.py b/script/scaffold/templates/device_action/tests/test_device_action.py index 424fa0a9afd08c..f300ae55cf70c8 100644 --- a/script/scaffold/templates/device_action/tests/test_device_action.py +++ b/script/scaffold/templates/device_action/tests/test_device_action.py @@ -3,6 +3,7 @@ from homeassistant.components import automation from homeassistant.components.NEW_DOMAIN import DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_registry from homeassistant.setup import async_setup_component @@ -56,7 +57,9 @@ async def test_get_actions( "entity_id": "NEW_DOMAIN.test_5678", }, ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) diff --git a/script/scaffold/templates/device_condition/tests/test_device_condition.py b/script/scaffold/templates/device_condition/tests/test_device_condition.py index 9a283fa1f5bb83..539a60ded97e5a 100644 --- a/script/scaffold/templates/device_condition/tests/test_device_condition.py +++ b/script/scaffold/templates/device_condition/tests/test_device_condition.py @@ -5,6 +5,7 @@ from homeassistant.components import automation from homeassistant.components.NEW_DOMAIN import DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry, entity_registry @@ -67,7 +68,9 @@ async def test_get_conditions( "entity_id": f"{DOMAIN}.test_5678", }, ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert_lists_same(conditions, expected_conditions) diff --git a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py index 55343abadb16c3..59ba9654566830 100644 --- a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py +++ b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py @@ -3,6 +3,7 @@ from homeassistant.components import automation from homeassistant.components.NEW_DOMAIN import DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -60,7 +61,9 @@ async def test_get_triggers(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) diff --git a/script/scaffold/templates/reproduce_state/integration/reproduce_state.py b/script/scaffold/templates/reproduce_state/integration/reproduce_state.py index 19e046f4c92edd..4247a1dc8d2d66 100644 --- a/script/scaffold/templates/reproduce_state/integration/reproduce_state.py +++ b/script/scaffold/templates/reproduce_state/integration/reproduce_state.py @@ -2,8 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable import logging -from typing import Any, Iterable +from typing import Any from homeassistant.const import ( ATTR_ENTITY_ID, diff --git a/script/version_bump.py b/script/version_bump.py index 5f1988f3c26b57..6044cdb277c408 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -117,7 +117,18 @@ def write_version(version): ) with open("homeassistant/const.py", "wt") as fil: - content = fil.write(content) + fil.write(content) + + +def write_version_metadata(version: Version) -> None: + """Update setup.cfg file with new version.""" + with open("setup.cfg") as fp: + content = fp.read() + + content = re.sub(r"(version\W+=\W).+\n", f"\\g<1>{version}\n", content, count=1) + + with open("setup.cfg", "w") as fp: + fp.write(content) def main(): @@ -142,6 +153,7 @@ def main(): assert bumped > current, "BUG! New version is not newer than old version" write_version(bumped) + write_version_metadata(bumped) if not arguments.commit: return diff --git a/setup.cfg b/setup.cfg index ad1e6650a59b3c..970a736b68f1e3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,20 +1,70 @@ [metadata] +name = homeassistant +version = 2022.2.0 +author = The Home Assistant Authors +author_email = hello@home-assistant.io license = Apache-2.0 -license_file = LICENSE.md platforms = any description = Open-source home automation platform running on Python 3. long_description = file: README.rst +long_description_content_type = text/x-rst keywords = home, automation +url = https://www.home-assistant.io/ +project_urls = + Source Code = https://github.com/home-assistant/core + Bug Reports = https://github.com/home-assistant/core/issues + Docs: Dev = https://developers.home-assistant.io/ + Discord = https://discordapp.com/invite/c5DvZ4e + Forum = https://community.home-assistant.io/ classifier = Development Status :: 4 - Beta Intended Audience :: End Users/Desktop Intended Audience :: Developers License :: OSI Approved :: Apache Software License Operating System :: OS Independent - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Topic :: Home Automation +[options] +packages = find: +zip_safe = False +include_package_data = True +python_requires = >=3.9.0 +install_requires = + aiohttp==3.8.1 + astral==2.2 + async_timeout==4.0.2 + attrs==21.2.0 + atomicwrites==1.4.0 + awesomeversion==22.1.0 + bcrypt==3.1.7 + certifi>=2021.5.30 + ciso8601==2.2.0 + # When bumping httpx, please check the version pins of + # httpcore, anyio, and h11 in gen_requirements_all + httpx==0.21.3 + ifaddr==0.1.7 + jinja2==3.0.3 + PyJWT==2.1.0 + # PyJWT has loose dependency. We want the latest one. + cryptography==35.0.0 + pip>=8.0.3,<20.3 + python-slugify==4.0.1 + pyyaml==6.0 + requests==2.27.1 + typing-extensions>=3.10.0.2,<5.0 + voluptuous==0.12.2 + voluptuous-serialize==2.5.0 + yarl==1.7.2 + +[options.packages.find] +include = + homeassistant* + +[options.entry_points] +console_scripts = + hass = homeassistant.__main__:main + [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build max-complexity = 25 diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index 270f5c58f5844a..69bf65dd8a4bde --- a/setup.py +++ b/setup.py @@ -1,77 +1,7 @@ -#!/usr/bin/env python3 -"""Home Assistant setup script.""" -from datetime import datetime as dt +""" +Entry point for setuptools. Required for editable installs. +TODO: Remove file after updating to pip 21.3 +""" +from setuptools import setup -from setuptools import find_packages, setup - -import homeassistant.const as hass_const - -PROJECT_NAME = "Home Assistant" -PROJECT_PACKAGE_NAME = "homeassistant" -PROJECT_LICENSE = "Apache License 2.0" -PROJECT_AUTHOR = "The Home Assistant Authors" -PROJECT_COPYRIGHT = f" 2013-{dt.now().year}, {PROJECT_AUTHOR}" -PROJECT_URL = "https://www.home-assistant.io/" -PROJECT_EMAIL = "hello@home-assistant.io" - -PROJECT_GITHUB_USERNAME = "home-assistant" -PROJECT_GITHUB_REPOSITORY = "core" - -PYPI_URL = f"https://pypi.python.org/pypi/{PROJECT_PACKAGE_NAME}" -GITHUB_PATH = f"{PROJECT_GITHUB_USERNAME}/{PROJECT_GITHUB_REPOSITORY}" -GITHUB_URL = f"https://github.com/{GITHUB_PATH}" - -DOWNLOAD_URL = f"{GITHUB_URL}/archive/{hass_const.__version__}.zip" -PROJECT_URLS = { - "Bug Reports": f"{GITHUB_URL}/issues", - "Dev Docs": "https://developers.home-assistant.io/", - "Discord": "https://discordapp.com/invite/c5DvZ4e", - "Forum": "https://community.home-assistant.io/", -} - -PACKAGES = find_packages(exclude=["tests", "tests.*"]) - -REQUIRES = [ - "aiohttp==3.8.1", - "astral==2.2", - "async_timeout==4.0.0", - "attrs==21.2.0", - "atomicwrites==1.4.0", - "awesomeversion==21.11.0", - 'backports.zoneinfo;python_version<"3.9"', - "bcrypt==3.1.7", - "certifi>=2021.5.30", - "ciso8601==2.2.0", - "httpx==0.21.0", - "ifaddr==0.1.7", - "jinja2==3.0.3", - "PyJWT==2.1.0", - # PyJWT has loose dependency. We want the latest one. - "cryptography==35.0.0", - "pip>=8.0.3,<20.3", - "python-slugify==4.0.1", - "pyyaml==6.0", - "requests==2.26.0", - "voluptuous==0.12.2", - "voluptuous-serialize==2.5.0", - "yarl==1.6.3", -] - -MIN_PY_VERSION = ".".join(map(str, hass_const.REQUIRED_PYTHON_VER)) - -setup( - name=PROJECT_PACKAGE_NAME, - version=hass_const.__version__, - url=PROJECT_URL, - download_url=DOWNLOAD_URL, - project_urls=PROJECT_URLS, - author=PROJECT_AUTHOR, - author_email=PROJECT_EMAIL, - packages=PACKAGES, - include_package_data=True, - zip_safe=False, - install_requires=REQUIRES, - python_requires=f">={MIN_PY_VERSION}", - test_suite="tests", - entry_points={"console_scripts": ["hass = homeassistant.__main__:main"]}, -) +setup() diff --git a/tests/common.py b/tests/common.py index 9d4a9cfe36675e..3ea4cde2cecb0e 100644 --- a/tests/common.py +++ b/tests/common.py @@ -4,6 +4,7 @@ import asyncio import collections from collections import OrderedDict +from collections.abc import Awaitable, Collection from contextlib import contextmanager from datetime import datetime, timedelta import functools as ft @@ -16,7 +17,7 @@ import time from time import monotonic import types -from typing import Any, Awaitable, Collection +from typing import Any from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 @@ -69,7 +70,9 @@ async def async_get_device_automations( - hass: HomeAssistant, automation_type: str, device_id: str + hass: HomeAssistant, + automation_type: device_automation.DeviceAutomationType, + device_id: str, ) -> Any: """Get a device automation for a single device id.""" automations = await device_automation.async_get_device_automations( @@ -284,7 +287,12 @@ async def _await_count_and_log_pending( hass.config.media_dirs = {"local": get_test_config_dir("media")} hass.config.skip_pip = True - hass.config_entries = config_entries.ConfigEntries(hass, {}) + hass.config_entries = config_entries.ConfigEntries( + hass, + { + "_": "Not empty or else some bad checks for hass config in discovery.py breaks" + }, + ) hass.config_entries._entries = {} hass.config_entries._store._async_ensure_stop_listener = lambda: None @@ -1053,8 +1061,9 @@ async def mock_async_load(store): def mock_write_data(store, path, data_to_write): """Mock version of write data.""" - _LOGGER.info("Writing data to %s: %s", store.key, data_to_write) # To ensure that the data can be serialized + _LOGGER.info("Writing data to %s: %s", store.key, data_to_write) + raise_contains_mocks(data_to_write) data[store.key] = json.loads(json.dumps(data_to_write, cls=store._encoder)) async def mock_remove(store): @@ -1238,3 +1247,17 @@ def assert_lists_same(a, b): assert collections.Counter([hashdict(i) for i in a]) == collections.Counter( [hashdict(i) for i in b] ) + + +def raise_contains_mocks(val): + """Raise for mocks.""" + if isinstance(val, Mock): + raise ValueError + + if isinstance(val, dict): + for dict_value in val.values(): + raise_contains_mocks(dict_value) + + if isinstance(val, list): + for dict_value in val: + raise_contains_mocks(dict_value) diff --git a/tests/components/abode/common.py b/tests/components/abode/common.py index c134552ccd41a6..dd9b889fe2702a 100644 --- a/tests/components/abode/common.py +++ b/tests/components/abode/common.py @@ -2,17 +2,23 @@ from unittest.mock import patch from homeassistant.components.abode import DOMAIN as ABODE_DOMAIN +from homeassistant.components.abode.const import CONF_POLLING from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def setup_platform(hass, platform): +async def setup_platform(hass: HomeAssistant, platform: str) -> MockConfigEntry: """Set up the Abode platform.""" mock_entry = MockConfigEntry( domain=ABODE_DOMAIN, - data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + data={ + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_POLLING: False, + }, ) mock_entry.add_to_hass(hass) diff --git a/tests/components/abode/conftest.py b/tests/components/abode/conftest.py index 472587781cacae..e41cf3ec5874e8 100644 --- a/tests/components/abode/conftest.py +++ b/tests/components/abode/conftest.py @@ -7,7 +7,7 @@ @pytest.fixture(autouse=True) -def requests_mock_fixture(requests_mock): +def requests_mock_fixture(requests_mock) -> None: """Fixture to provide a requests mocker.""" # Mocks the login response for abodepy. requests_mock.post(CONST.LOGIN_URL, text=load_fixture("login.json", "abode")) diff --git a/tests/components/abode/fixtures/devices.json b/tests/components/abode/fixtures/devices.json index 370b264427ae18..002947f408594b 100644 --- a/tests/components/abode/fixtures/devices.json +++ b/tests/components/abode/fixtures/devices.json @@ -622,7 +622,7 @@ } ], "status_icons": [], - "icon": "assets/icons/streaming-camaera-new.svg", + "icon": "assets/icons/streaming-camera-new.svg", "control_url_snapshot": "api/v1/cams/XF:b0c5ba27592a/capture", "ptt_supported": true, "is_new_camera": 1, diff --git a/tests/components/abode/test_alarm_control_panel.py b/tests/components/abode/test_alarm_control_panel.py index 55ff22b9ee3922..74d647311288d9 100644 --- a/tests/components/abode/test_alarm_control_panel.py +++ b/tests/components/abode/test_alarm_control_panel.py @@ -16,6 +16,7 @@ STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import setup_platform @@ -23,7 +24,7 @@ DEVICE_ID = "alarm_control_panel.abode_alarm" -async def test_entity_registry(hass): +async def test_entity_registry(hass: HomeAssistant) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, ALARM_DOMAIN) entity_registry = er.async_get(hass) @@ -33,7 +34,7 @@ async def test_entity_registry(hass): assert entry.unique_id == "001122334455" -async def test_attributes(hass): +async def test_attributes(hass: HomeAssistant) -> None: """Test the alarm control panel attributes are correct.""" await setup_platform(hass, ALARM_DOMAIN) @@ -46,7 +47,7 @@ async def test_attributes(hass): assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 -async def test_set_alarm_away(hass): +async def test_set_alarm_away(hass: HomeAssistant) -> None: """Test the alarm control panel can be set to away.""" with patch("abodepy.AbodeEventController.add_device_callback") as mock_callback: with patch("abodepy.ALARM.AbodeAlarm.set_away") as mock_set_away: @@ -75,7 +76,7 @@ async def test_set_alarm_away(hass): assert state.state == STATE_ALARM_ARMED_AWAY -async def test_set_alarm_home(hass): +async def test_set_alarm_home(hass: HomeAssistant) -> None: """Test the alarm control panel can be set to home.""" with patch("abodepy.AbodeEventController.add_device_callback") as mock_callback: with patch("abodepy.ALARM.AbodeAlarm.set_home") as mock_set_home: @@ -103,7 +104,7 @@ async def test_set_alarm_home(hass): assert state.state == STATE_ALARM_ARMED_HOME -async def test_set_alarm_standby(hass): +async def test_set_alarm_standby(hass: HomeAssistant) -> None: """Test the alarm control panel can be set to standby.""" with patch("abodepy.AbodeEventController.add_device_callback") as mock_callback: with patch("abodepy.ALARM.AbodeAlarm.set_standby") as mock_set_standby: @@ -130,7 +131,7 @@ async def test_set_alarm_standby(hass): assert state.state == STATE_ALARM_DISARMED -async def test_state_unknown(hass): +async def test_state_unknown(hass: HomeAssistant) -> None: """Test an unknown alarm control panel state.""" with patch("abodepy.ALARM.AbodeAlarm.mode", new_callable=PropertyMock) as mock_mode: await setup_platform(hass, ALARM_DOMAIN) diff --git a/tests/components/abode/test_binary_sensor.py b/tests/components/abode/test_binary_sensor.py index e4aa08c7f5fd77..6d7ffec438b516 100644 --- a/tests/components/abode/test_binary_sensor.py +++ b/tests/components/abode/test_binary_sensor.py @@ -2,8 +2,8 @@ from homeassistant.components.abode import ATTR_DEVICE_ID from homeassistant.components.abode.const import ATTRIBUTION from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_WINDOW, DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -11,12 +11,13 @@ ATTR_FRIENDLY_NAME, STATE_OFF, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import setup_platform -async def test_entity_registry(hass): +async def test_entity_registry(hass: HomeAssistant) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, BINARY_SENSOR_DOMAIN) entity_registry = er.async_get(hass) @@ -25,7 +26,7 @@ async def test_entity_registry(hass): assert entry.unique_id == "2834013428b6035fba7d4054aa7b25a3" -async def test_attributes(hass): +async def test_attributes(hass: HomeAssistant) -> None: """Test the binary sensor attributes are correct.""" await setup_platform(hass, BINARY_SENSOR_DOMAIN) @@ -37,4 +38,4 @@ async def test_attributes(hass): assert not state.attributes.get("no_response") assert state.attributes.get("device_type") == "Door Contact" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Front Door" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_WINDOW + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.WINDOW diff --git a/tests/components/abode/test_camera.py b/tests/components/abode/test_camera.py index 7dc943a0a741a0..fd490c4a1c2a2c 100644 --- a/tests/components/abode/test_camera.py +++ b/tests/components/abode/test_camera.py @@ -4,12 +4,13 @@ from homeassistant.components.abode.const import DOMAIN as ABODE_DOMAIN from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_IDLE +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import setup_platform -async def test_entity_registry(hass): +async def test_entity_registry(hass: HomeAssistant) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, CAMERA_DOMAIN) entity_registry = er.async_get(hass) @@ -18,7 +19,7 @@ async def test_entity_registry(hass): assert entry.unique_id == "d0a3a1c316891ceb00c20118aae2a133" -async def test_attributes(hass): +async def test_attributes(hass: HomeAssistant) -> None: """Test the camera attributes are correct.""" await setup_platform(hass, CAMERA_DOMAIN) @@ -26,7 +27,7 @@ async def test_attributes(hass): assert state.state == STATE_IDLE -async def test_capture_image(hass): +async def test_capture_image(hass: HomeAssistant) -> None: """Test the camera capture image service.""" await setup_platform(hass, CAMERA_DOMAIN) @@ -41,7 +42,7 @@ async def test_capture_image(hass): mock_capture.assert_called_once() -async def test_camera_on(hass): +async def test_camera_on(hass: HomeAssistant) -> None: """Test the camera turn on service.""" await setup_platform(hass, CAMERA_DOMAIN) @@ -56,7 +57,7 @@ async def test_camera_on(hass): mock_capture.assert_called_once_with(False) -async def test_camera_off(hass): +async def test_camera_off(hass: HomeAssistant) -> None: """Test the camera turn off service.""" await setup_platform(hass, CAMERA_DOMAIN) diff --git a/tests/components/abode/test_config_flow.py b/tests/components/abode/test_config_flow.py index 44582692c7322e..adbea237d34e2b 100644 --- a/tests/components/abode/test_config_flow.py +++ b/tests/components/abode/test_config_flow.py @@ -4,19 +4,19 @@ from abodepy.exceptions import AbodeAuthenticationException from abodepy.helpers.errors import MFA_CODE_REQUIRED +from requests.exceptions import ConnectTimeout from homeassistant import data_entry_flow from homeassistant.components.abode import config_flow -from homeassistant.components.abode.const import DOMAIN +from homeassistant.components.abode.const import CONF_POLLING, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -CONF_POLLING = "polling" - -async def test_show_form(hass): +async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" flow = config_flow.AbodeFlowHandler() flow.hass = hass @@ -27,7 +27,7 @@ async def test_show_form(hass): assert result["step_id"] == "user" -async def test_one_config_allowed(hass): +async def test_one_config_allowed(hass: HomeAssistant) -> None: """Test that only one Abode configuration is allowed.""" flow = config_flow.AbodeFlowHandler() flow.hass = hass @@ -43,7 +43,7 @@ async def test_one_config_allowed(hass): assert step_user_result["reason"] == "single_instance_allowed" -async def test_invalid_credentials(hass): +async def test_invalid_credentials(hass: HomeAssistant) -> None: """Test that invalid credentials throws an error.""" conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} @@ -60,7 +60,7 @@ async def test_invalid_credentials(hass): assert result["errors"] == {"base": "invalid_auth"} -async def test_connection_error(hass): +async def test_connection_auth_error(hass: HomeAssistant) -> None: """Test other than invalid credentials throws an error.""" conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} @@ -77,7 +77,22 @@ async def test_connection_error(hass): assert result["errors"] == {"base": "cannot_connect"} -async def test_step_user(hass): +async def test_connection_error(hass: HomeAssistant) -> None: + """Test login throws an error if connection times out.""" + conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} + + flow = config_flow.AbodeFlowHandler() + flow.hass = hass + + with patch( + "homeassistant.components.abode.config_flow.Abode", + side_effect=ConnectTimeout, + ): + result = await flow.async_step_user(user_input=conf) + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_step_user(hass: HomeAssistant) -> None: """Test that the user step works.""" conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} @@ -98,7 +113,7 @@ async def test_step_user(hass): } -async def test_step_mfa(hass): +async def test_step_mfa(hass: HomeAssistant) -> None: """Test that the MFA step works.""" conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} @@ -141,7 +156,7 @@ async def test_step_mfa(hass): } -async def test_step_reauth(hass): +async def test_step_reauth(hass: HomeAssistant) -> None: """Test the reauth flow.""" conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} diff --git a/tests/components/abode/test_cover.py b/tests/components/abode/test_cover.py index edd40a867079ab..bd7104bff3fd8f 100644 --- a/tests/components/abode/test_cover.py +++ b/tests/components/abode/test_cover.py @@ -10,6 +10,7 @@ SERVICE_OPEN_COVER, STATE_CLOSED, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import setup_platform @@ -17,7 +18,7 @@ DEVICE_ID = "cover.garage_door" -async def test_entity_registry(hass): +async def test_entity_registry(hass: HomeAssistant) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, COVER_DOMAIN) entity_registry = er.async_get(hass) @@ -26,7 +27,7 @@ async def test_entity_registry(hass): assert entry.unique_id == "61cbz3b542d2o33ed2fz02721bda3324" -async def test_attributes(hass): +async def test_attributes(hass: HomeAssistant) -> None: """Test the cover attributes are correct.""" await setup_platform(hass, COVER_DOMAIN) @@ -39,7 +40,7 @@ async def test_attributes(hass): assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Garage Door" -async def test_open(hass): +async def test_open(hass: HomeAssistant) -> None: """Test the cover can be opened.""" await setup_platform(hass, COVER_DOMAIN) @@ -51,7 +52,7 @@ async def test_open(hass): mock_open.assert_called_once() -async def test_close(hass): +async def test_close(hass: HomeAssistant) -> None: """Test the cover can be closed.""" await setup_platform(hass, COVER_DOMAIN) diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py index 130f0c6791ebbf..32bb8bf7c706f2 100644 --- a/tests/components/abode/test_init.py +++ b/tests/components/abode/test_init.py @@ -14,11 +14,12 @@ from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant from .common import setup_platform -async def test_change_settings(hass): +async def test_change_settings(hass: HomeAssistant) -> None: """Test change_setting service.""" await setup_platform(hass, ALARM_DOMAIN) @@ -33,7 +34,7 @@ async def test_change_settings(hass): mock_set_setting.assert_called_once() -async def test_add_unique_id(hass): +async def test_add_unique_id(hass: HomeAssistant) -> None: """Test unique_id is set to Abode username.""" mock_entry = await setup_platform(hass, ALARM_DOMAIN) # Set unique_id to None to match previous config entries @@ -49,7 +50,7 @@ async def test_add_unique_id(hass): assert mock_entry.unique_id == mock_entry.data[CONF_USERNAME] -async def test_unload_entry(hass): +async def test_unload_entry(hass: HomeAssistant) -> None: """Test unloading the Abode entry.""" mock_entry = await setup_platform(hass, ALARM_DOMAIN) @@ -65,7 +66,7 @@ async def test_unload_entry(hass): assert not hass.services.has_service(ABODE_DOMAIN, SERVICE_TRIGGER_AUTOMATION) -async def test_invalid_credentials(hass): +async def test_invalid_credentials(hass: HomeAssistant) -> None: """Test Abode credentials changing.""" with patch( "homeassistant.components.abode.Abode", @@ -81,7 +82,7 @@ async def test_invalid_credentials(hass): mock_async_step_reauth.assert_called_once() -async def test_raise_config_entry_not_ready_when_offline(hass): +async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> None: """Config entry state is SETUP_RETRY when abode is offline.""" with patch( "homeassistant.components.abode.Abode", diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py index b5160aece2a26b..d27a07227d0f96 100644 --- a/tests/components/abode/test_light.py +++ b/tests/components/abode/test_light.py @@ -16,6 +16,7 @@ SERVICE_TURN_ON, STATE_ON, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import setup_platform @@ -23,7 +24,7 @@ DEVICE_ID = "light.living_room_lamp" -async def test_entity_registry(hass): +async def test_entity_registry(hass: HomeAssistant) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, LIGHT_DOMAIN) entity_registry = er.async_get(hass) @@ -32,7 +33,7 @@ async def test_entity_registry(hass): assert entry.unique_id == "741385f4388b2637df4c6b398fe50581" -async def test_attributes(hass): +async def test_attributes(hass: HomeAssistant) -> None: """Test the light attributes are correct.""" await setup_platform(hass, LIGHT_DOMAIN) @@ -49,7 +50,7 @@ async def test_attributes(hass): assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 19 -async def test_switch_off(hass): +async def test_switch_off(hass: HomeAssistant) -> None: """Test the light can be turned off.""" await setup_platform(hass, LIGHT_DOMAIN) @@ -61,7 +62,7 @@ async def test_switch_off(hass): mock_switch_off.assert_called_once() -async def test_switch_on(hass): +async def test_switch_on(hass: HomeAssistant) -> None: """Test the light can be turned on.""" await setup_platform(hass, LIGHT_DOMAIN) @@ -73,7 +74,7 @@ async def test_switch_on(hass): mock_switch_on.assert_called_once() -async def test_set_brightness(hass): +async def test_set_brightness(hass: HomeAssistant) -> None: """Test the brightness can be set.""" await setup_platform(hass, LIGHT_DOMAIN) @@ -89,7 +90,7 @@ async def test_set_brightness(hass): mock_set_level.assert_called_once_with(39) -async def test_set_color(hass): +async def test_set_color(hass: HomeAssistant) -> None: """Test the color can be set.""" await setup_platform(hass, LIGHT_DOMAIN) @@ -104,7 +105,7 @@ async def test_set_color(hass): mock_set_color.assert_called_once_with((240.0, 100.0)) -async def test_set_color_temp(hass): +async def test_set_color_temp(hass: HomeAssistant) -> None: """Test the color temp can be set.""" await setup_platform(hass, LIGHT_DOMAIN) diff --git a/tests/components/abode/test_lock.py b/tests/components/abode/test_lock.py index c688b6f02bcda4..837b62e06cdebc 100644 --- a/tests/components/abode/test_lock.py +++ b/tests/components/abode/test_lock.py @@ -10,6 +10,7 @@ SERVICE_UNLOCK, STATE_LOCKED, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import setup_platform @@ -17,7 +18,7 @@ DEVICE_ID = "lock.test_lock" -async def test_entity_registry(hass): +async def test_entity_registry(hass: HomeAssistant) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, LOCK_DOMAIN) entity_registry = er.async_get(hass) @@ -26,7 +27,7 @@ async def test_entity_registry(hass): assert entry.unique_id == "51cab3b545d2o34ed7fz02731bda5324" -async def test_attributes(hass): +async def test_attributes(hass: HomeAssistant) -> None: """Test the lock attributes are correct.""" await setup_platform(hass, LOCK_DOMAIN) @@ -39,7 +40,7 @@ async def test_attributes(hass): assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Test Lock" -async def test_lock(hass): +async def test_lock(hass: HomeAssistant) -> None: """Test the lock can be locked.""" await setup_platform(hass, LOCK_DOMAIN) @@ -51,7 +52,7 @@ async def test_lock(hass): mock_lock.assert_called_once() -async def test_unlock(hass): +async def test_unlock(hass: HomeAssistant) -> None: """Test the lock can be unlocked.""" await setup_platform(hass, LOCK_DOMAIN) diff --git a/tests/components/abode/test_sensor.py b/tests/components/abode/test_sensor.py index 5e3195430ab181..ba163ba87d9f04 100644 --- a/tests/components/abode/test_sensor.py +++ b/tests/components/abode/test_sensor.py @@ -1,20 +1,20 @@ """Tests for the Abode sensor device.""" from homeassistant.components.abode import ATTR_DEVICE_ID -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_HUMIDITY, PERCENTAGE, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import setup_platform -async def test_entity_registry(hass): +async def test_entity_registry(hass: HomeAssistant) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, SENSOR_DOMAIN) entity_registry = er.async_get(hass) @@ -23,7 +23,7 @@ async def test_entity_registry(hass): assert entry.unique_id == "13545b21f4bdcd33d9abd461f8443e65-humidity" -async def test_attributes(hass): +async def test_attributes(hass: HomeAssistant) -> None: """Test the sensor attributes are correct.""" await setup_platform(hass, SENSOR_DOMAIN) @@ -35,7 +35,7 @@ async def test_attributes(hass): assert state.attributes.get("device_type") == "LM" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Environment Sensor Humidity" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY state = hass.states.get("sensor.environment_sensor_lux") assert state.state == "1.0" diff --git a/tests/components/abode/test_switch.py b/tests/components/abode/test_switch.py index 829c5e8ae374e1..74fa6491f662a3 100644 --- a/tests/components/abode/test_switch.py +++ b/tests/components/abode/test_switch.py @@ -13,6 +13,7 @@ STATE_OFF, STATE_ON, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .common import setup_platform @@ -23,7 +24,7 @@ DEVICE_UID = "0012a4d3614cb7e2b8c9abea31d2fb2a" -async def test_entity_registry(hass): +async def test_entity_registry(hass: HomeAssistant) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, SWITCH_DOMAIN) entity_registry = er.async_get(hass) @@ -35,7 +36,7 @@ async def test_entity_registry(hass): assert entry.unique_id == DEVICE_UID -async def test_attributes(hass): +async def test_attributes(hass: HomeAssistant) -> None: """Test the switch attributes are correct.""" await setup_platform(hass, SWITCH_DOMAIN) @@ -43,7 +44,7 @@ async def test_attributes(hass): assert state.state == STATE_OFF -async def test_switch_on(hass): +async def test_switch_on(hass: HomeAssistant) -> None: """Test the switch can be turned on.""" await setup_platform(hass, SWITCH_DOMAIN) @@ -56,7 +57,7 @@ async def test_switch_on(hass): mock_switch_on.assert_called_once() -async def test_switch_off(hass): +async def test_switch_off(hass: HomeAssistant) -> None: """Test the switch can be turned off.""" await setup_platform(hass, SWITCH_DOMAIN) @@ -69,7 +70,7 @@ async def test_switch_off(hass): mock_switch_off.assert_called_once() -async def test_automation_attributes(hass): +async def test_automation_attributes(hass: HomeAssistant) -> None: """Test the automation attributes are correct.""" await setup_platform(hass, SWITCH_DOMAIN) @@ -78,7 +79,7 @@ async def test_automation_attributes(hass): assert state.state == STATE_ON -async def test_turn_automation_off(hass): +async def test_turn_automation_off(hass: HomeAssistant) -> None: """Test the automation can be turned off.""" with patch("abodepy.AbodeAutomation.enable") as mock_trigger: await setup_platform(hass, SWITCH_DOMAIN) @@ -94,7 +95,7 @@ async def test_turn_automation_off(hass): mock_trigger.assert_called_once_with(False) -async def test_turn_automation_on(hass): +async def test_turn_automation_on(hass: HomeAssistant) -> None: """Test the automation can be turned on.""" with patch("abodepy.AbodeAutomation.enable") as mock_trigger: await setup_platform(hass, SWITCH_DOMAIN) @@ -110,7 +111,7 @@ async def test_turn_automation_on(hass): mock_trigger.assert_called_once_with(True) -async def test_trigger_automation(hass, requests_mock): +async def test_trigger_automation(hass: HomeAssistant) -> None: """Test the trigger automation service.""" await setup_platform(hass, SWITCH_DOMAIN) diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 62f282f2cf3ffd..a1c454ad0b7d76 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -7,7 +7,8 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -16,7 +17,6 @@ ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_PARTS_PER_CUBIC_METER, - DEVICE_CLASS_TEMPERATURE, LENGTH_FEET, LENGTH_METERS, LENGTH_MILLIMETERS, @@ -47,7 +47,7 @@ async def test_sensor_without_forecast(hass): assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_METERS - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_cloud_ceiling") assert entry @@ -60,7 +60,7 @@ async def test_sensor_without_forecast(hass): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_MILLIMETERS assert state.attributes.get(ATTR_ICON) == "mdi:weather-rainy" assert state.attributes.get("type") is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_precipitation") assert entry @@ -83,8 +83,8 @@ async def test_sensor_without_forecast(hass): assert state.state == "25.1" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_realfeel_temperature") assert entry @@ -96,7 +96,7 @@ async def test_sensor_without_forecast(hass): assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX assert state.attributes.get("level") == "High" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_uv_index") assert entry @@ -125,7 +125,7 @@ async def test_sensor_with_forecast(hass): assert state.state == "29.8" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None entry = registry.async_get("sensor.home_realfeel_temperature_max_0d") @@ -136,7 +136,7 @@ async def test_sensor_with_forecast(hass): assert state.state == "15.1" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None entry = registry.async_get("sensor.home_realfeel_temperature_min_0d") @@ -190,7 +190,7 @@ async def test_sensor_disabled(hass): assert entry assert entry.unique_id == "0123456-apparenttemperature" assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity updated_entry = registry.async_update_entity( @@ -360,8 +360,8 @@ async def test_sensor_enabled_without_forecast(hass): assert state.state == "22.8" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_apparent_temperature") assert entry @@ -373,7 +373,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_cloud_cover") assert entry @@ -384,8 +384,8 @@ async def test_sensor_enabled_without_forecast(hass): assert state.state == "16.2" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_dew_point") assert entry @@ -396,8 +396,8 @@ async def test_sensor_enabled_without_forecast(hass): assert state.state == "21.1" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_realfeel_temperature_shade") assert entry @@ -408,8 +408,8 @@ async def test_sensor_enabled_without_forecast(hass): assert state.state == "18.6" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_wet_bulb_temperature") assert entry @@ -420,8 +420,8 @@ async def test_sensor_enabled_without_forecast(hass): assert state.state == "22.8" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_wind_chill_temperature") assert entry @@ -433,7 +433,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_wind_gust") assert entry @@ -445,7 +445,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_wind") assert entry @@ -537,7 +537,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.state == "28.0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None entry = registry.async_get("sensor.home_realfeel_temperature_shade_max_0d") @@ -549,7 +549,7 @@ async def test_sensor_enabled_without_forecast(hass): assert state.state == "15.1" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE entry = registry.async_get("sensor.home_realfeel_temperature_shade_min_0d") assert entry @@ -665,7 +665,7 @@ async def test_availability(hass): async def test_manual_update_entity(hass): - """Test manual update entity via service homeasasistant/update_entity.""" + """Test manual update entity via service homeassistant/update_entity.""" await init_integration(hass, forecast=True) await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index b1c87c7d404380..6c1bc76e9b1f5d 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -127,7 +127,7 @@ async def test_availability(hass): async def test_manual_update_entity(hass): - """Test manual update entity via service homeasasistant/update_entity.""" + """Test manual update entity via service homeassistant/update_entity.""" await init_integration(hass, forecast=True) await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/adax/test_config_flow.py b/tests/components/adax/test_config_flow.py index f9638e52cbf1ed..998e4df8b15a80 100644 --- a/tests/components/adax/test_config_flow.py +++ b/tests/components/adax/test_config_flow.py @@ -1,10 +1,21 @@ """Test the Adax config flow.""" from unittest.mock import patch +import adax_local + from homeassistant import config_entries -from homeassistant.components.adax.const import ACCOUNT_ID, DOMAIN +from homeassistant.components.adax.const import ( + ACCOUNT_ID, + CLOUD, + CONNECTION_TYPE, + DOMAIN, + LOCAL, + WIFI_PSWD, + WIFI_SSID, +) from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM from tests.common import MockConfigEntry @@ -19,24 +30,33 @@ async def test_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] is None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: CLOUD, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + with patch("adax.get_adax_token", return_value="test_token",), patch( "homeassistant.components.adax.async_setup_entry", return_value=True, ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], TEST_DATA, ) await hass.async_block_till_done() - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_DATA["account_id"] - assert result2["data"] == { - "account_id": TEST_DATA["account_id"], - "password": TEST_DATA["password"], + assert result3["type"] == "create_entry" + assert result3["title"] == str(TEST_DATA["account_id"]) + assert result3["data"] == { + ACCOUNT_ID: TEST_DATA["account_id"], + CONF_PASSWORD: TEST_DATA["password"], + CONNECTION_TYPE: CLOUD, } assert len(mock_setup_entry.mock_calls) == 1 @@ -47,16 +67,24 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: CLOUD, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + with patch( "adax.get_adax_token", return_value=None, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], TEST_DATA, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + assert result3["type"] == RESULT_TYPE_FORM + assert result3["errors"] == {"base": "cannot_connect"} async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: @@ -65,14 +93,270 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: first_entry = MockConfigEntry( domain="adax", data=TEST_DATA, - unique_id=TEST_DATA[ACCOUNT_ID], + unique_id=str(TEST_DATA[ACCOUNT_ID]), ) first_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: CLOUD, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + with patch("adax.get_adax_token", return_value="token"): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TEST_DATA + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result3["type"] == "abort" + assert result3["reason"] == "already_configured" + + +# local API: + + +async def test_local_create_entry(hass): + """Test create entry from user input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch( + "homeassistant.components.adax.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.adax.config_flow.adax_local.AdaxConfig", autospec=True + ) as mock_client_class: + client = mock_client_class.return_value + client.configure_device.return_value = True + client.device_ip = "192.168.1.4" + client.access_token = "token" + client.mac_id = "8383838" + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + test_data[CONNECTION_TYPE] = LOCAL + assert result["type"] == "create_entry" + assert result["title"] == "8383838" + assert result["data"] == { + "connection_type": "Local", + "ip_address": "192.168.1.4", + "token": "token", + "unique_id": "8383838", + } + + +async def test_local_flow_entry_already_exists(hass): + """Test user input for config_entry that already exists.""" + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + first_entry = MockConfigEntry( + domain="adax", + data=test_data, + unique_id="8383838", + ) + first_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch("adax_local.AdaxConfig", autospec=True) as mock_client_class: + client = mock_client_class.return_value + client.configure_device.return_value = True + client.device_ip = "192.168.1.4" + client.access_token = "token" + client.mac_id = "8383838" + + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, ) assert result["type"] == "abort" assert result["reason"] == "already_configured" + + +async def test_local_connection_error(hass): + """Test connection error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch( + "homeassistant.components.adax.config_flow.adax_local.AdaxConfig.configure_device", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_local_heater_not_available(hass): + """Test connection error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch( + "homeassistant.components.adax.config_flow.adax_local.AdaxConfig.configure_device", + side_effect=adax_local.HeaterNotAvailable, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "heater_not_available" + + +async def test_local_heater_not_found(hass): + """Test connection error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch( + "homeassistant.components.adax.config_flow.adax_local.AdaxConfig.configure_device", + side_effect=adax_local.HeaterNotFound, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "heater_not_found" + + +async def test_local_invalid_wifi_cred(hass): + """Test connection error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch( + "homeassistant.components.adax.config_flow.adax_local.AdaxConfig.configure_device", + side_effect=adax_local.InvalidWifiCred, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_auth" diff --git a/tests/components/advantage_air/test_cover.py b/tests/components/advantage_air/test_cover.py index 363db076ada2bd..2868179b3eeedd 100644 --- a/tests/components/advantage_air/test_cover.py +++ b/tests/components/advantage_air/test_cover.py @@ -8,11 +8,11 @@ ) from homeassistant.components.cover import ( ATTR_POSITION, - DEVICE_CLASS_DAMPER, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, + CoverDeviceClass, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OPEN from homeassistant.helpers import entity_registry as er @@ -49,7 +49,7 @@ async def test_cover_async_setup_entry(hass, aioclient_mock): state = hass.states.get(entity_id) assert state assert state.state == STATE_OPEN - assert state.attributes.get("device_class") == DEVICE_CLASS_DAMPER + assert state.attributes.get("device_class") == CoverDeviceClass.DAMPER assert state.attributes.get("current_position") == 100 entry = registry.async_get(entity_id) diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 434c7e5022f3af..c8ad6782ce86c9 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -95,10 +95,10 @@ async def test_config_with_turned_off_station(hass, aioclient_mock): async def test_update_interval(hass, aioclient_mock): """Test correct update interval when the number of configured instances changes.""" - REMAINING_RQUESTS = 15 + REMAINING_REQUESTS = 15 HEADERS = { "X-RateLimit-Limit-day": "100", - "X-RateLimit-Remaining-day": str(REMAINING_RQUESTS), + "X-RateLimit-Remaining-day": str(REMAINING_REQUESTS), } entry = MockConfigEntry( @@ -127,7 +127,7 @@ async def test_update_interval(hass, aioclient_mock): assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED - update_interval = set_update_interval(instances, REMAINING_RQUESTS) + update_interval = set_update_interval(instances, REMAINING_REQUESTS) future = utcnow() + update_interval with patch("homeassistant.util.dt.utcnow") as mock_utcnow: mock_utcnow.return_value = future @@ -164,7 +164,7 @@ async def test_update_interval(hass, aioclient_mock): assert len(hass.config_entries.async_entries(DOMAIN)) == 2 assert entry.state is ConfigEntryState.LOADED - update_interval = set_update_interval(instances, REMAINING_RQUESTS) + update_interval = set_update_interval(instances, REMAINING_REQUESTS) future = utcnow() + update_interval mock_utcnow.return_value = future async_fire_time_changed(hass, future) diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index 71912bb768ba93..3e8206aa76e4a2 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -2,20 +2,17 @@ from datetime import timedelta from homeassistant.components.airly.sensor import ATTRIBUTION -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - DEVICE_CLASS_AQI, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM10, - DEVICE_CLASS_PM25, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, PERCENTAGE, PRESSURE_HPA, STATE_UNAVAILABLE, @@ -41,7 +38,7 @@ async def test_sensor(hass, aioclient_mock): assert state.state == "23" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "CAQI" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_AQI + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.AQI entry = registry.async_get("sensor.home_caqi") assert entry @@ -52,8 +49,8 @@ async def test_sensor(hass, aioclient_mock): assert state.state == "92.8" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_humidity") assert entry @@ -67,8 +64,8 @@ async def test_sensor(hass, aioclient_mock): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM1 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM1 + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_pm1") assert entry @@ -82,8 +79,8 @@ async def test_sensor(hass, aioclient_mock): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_pm2_5") assert entry @@ -97,8 +94,8 @@ async def test_sensor(hass, aioclient_mock): state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_pm10") assert entry @@ -109,8 +106,8 @@ async def test_sensor(hass, aioclient_mock): assert state.state == "1001" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_HPA - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PRESSURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_pressure") assert entry @@ -121,8 +118,8 @@ async def test_sensor(hass, aioclient_mock): assert state.state == "14.2" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.home_temperature") assert entry @@ -161,7 +158,7 @@ async def test_availability(hass, aioclient_mock): async def test_manual_update_entity(hass, aioclient_mock): - """Test manual update entity via service homeasasistant/update_entity.""" + """Test manual update entity via service homeassistant/update_entity.""" await init_integration(hass, aioclient_mock) call_count = aioclient_mock.call_count diff --git a/tests/components/airvisual/conftest.py b/tests/components/airvisual/conftest.py new file mode 100644 index 00000000000000..81b22f19cc5610 --- /dev/null +++ b/tests/components/airvisual/conftest.py @@ -0,0 +1,79 @@ +"""Define test fixtures for AirVisual.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components.airvisual.const import ( + CONF_INTEGRATION_TYPE, + DOMAIN, + INTEGRATION_TYPE_GEOGRAPHY_COORDS, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_SHOW_ON_MAP, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config, config_entry_version, unique_id): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=unique_id, + data={CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS, **config}, + options={CONF_SHOW_ON_MAP: True}, + version=config_entry_version, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config_entry_version") +def config_entry_version_fixture(): + """Define a config entry version fixture.""" + return 2 + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_API_KEY: "abcde12345", + CONF_LATITUDE: 51.528308, + CONF_LONGITUDE: -0.3817765, + } + + +@pytest.fixture(name="data", scope="session") +def data_fixture(): + """Define an update coordinator data example.""" + return json.loads(load_fixture("data.json", "airvisual")) + + +@pytest.fixture(name="setup_airvisual") +async def setup_airvisual_fixture(hass, config, data): + """Define a fixture to set up AirVisual.""" + with patch("pyairvisual.air_quality.AirQuality.city"), patch( + "pyairvisual.air_quality.AirQuality.nearest_city", return_value=data + ), patch("pyairvisual.node.NodeSamba.async_connect"), patch( + "pyairvisual.node.NodeSamba.async_get_latest_measurements" + ), patch( + "pyairvisual.node.NodeSamba.async_disconnect" + ), patch( + "homeassistant.components.airvisual.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="unique_id") +def unique_id_fixture(hass): + """Define a config entry unique ID fixture.""" + return "51.528308, -0.3817765" diff --git a/tests/components/airvisual/fixtures/data.json b/tests/components/airvisual/fixtures/data.json new file mode 100644 index 00000000000000..ff955e5528e1bb --- /dev/null +++ b/tests/components/airvisual/fixtures/data.json @@ -0,0 +1,33 @@ +{ + "status": "success", + "data": { + "city": "Edgewater", + "state": "Colorado", + "country": "USA", + "location": { + "type": "Point", + "coordinates": [ + -105.06415, + 39.75304 + ] + }, + "current": { + "weather": { + "ts": "2021-09-03T21:00:00.000Z", + "tp": 23, + "pr": 999, + "hu": 45, + "ws": 0.45, + "wd": 252, + "ic": "10d" + }, + "pollution": { + "ts": "2021-09-04T00:00:00.000Z", + "aqius": 52, + "mainus": "p2", + "aqicn": 18, + "maincn": "p2" + } + } + } +} diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py index 65534f1f16c18d..f0a754174873eb 100644 --- a/tests/components/airvisual/test_config_flow.py +++ b/tests/components/airvisual/test_config_flow.py @@ -7,6 +7,7 @@ NodeProError, NotFoundError, ) +import pytest from homeassistant import data_entry_flow from homeassistant.components.airvisual.const import ( @@ -29,167 +30,129 @@ CONF_SHOW_ON_MAP, CONF_STATE, ) -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry - -async def test_duplicate_error(hass): +@pytest.mark.parametrize( + "config,data,unique_id", + [ + ( + { + CONF_API_KEY: "abcde12345", + CONF_LATITUDE: 51.528308, + CONF_LONGITUDE: -0.3817765, + }, + { + "type": INTEGRATION_TYPE_GEOGRAPHY_COORDS, + }, + "51.528308, -0.3817765", + ), + ( + { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_PASSWORD: "12345", + }, + { + "type": INTEGRATION_TYPE_NODE_PRO, + }, + "192.168.1.100", + ), + ], +) +async def test_duplicate_error(hass, config, config_entry, data): """Test that errors are shown when duplicate entries are added.""" - geography_conf = { - CONF_API_KEY: "abcde12345", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - } - - MockConfigEntry( - domain=DOMAIN, unique_id="51.528308, -0.3817765", data=geography_conf - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={"type": INTEGRATION_TYPE_GEOGRAPHY_COORDS}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=geography_conf - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - node_pro_conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "12345"} - - MockConfigEntry( - domain=DOMAIN, unique_id="192.168.1.100", data=node_pro_conf - ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={"type": "AirVisual Node/Pro"} + DOMAIN, context={"source": SOURCE_USER}, data=data ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=node_pro_conf + result["flow_id"], user_input=config ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -async def test_invalid_identifier_geography_api_key(hass): - """Test that an invalid API key throws an error.""" - with patch( - "pyairvisual.air_quality.AirQuality.nearest_city", - side_effect=InvalidKeyError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={"type": INTEGRATION_TYPE_GEOGRAPHY_COORDS}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ +@pytest.mark.parametrize( + "data,exc,errors,integration_type", + [ + ( + { CONF_API_KEY: "abcde12345", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, + CONF_CITY: "Beijing", + CONF_STATE: "Beijing", + CONF_COUNTRY: "China", }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} - - -async def test_invalid_identifier_geography_name(hass): - """Test that an invalid location name throws an error.""" - with patch( - "pyairvisual.air_quality.AirQuality.city", - side_effect=NotFoundError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={"type": INTEGRATION_TYPE_GEOGRAPHY_NAME}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ + InvalidKeyError, + {CONF_API_KEY: "invalid_api_key"}, + INTEGRATION_TYPE_GEOGRAPHY_NAME, + ), + ( + { CONF_API_KEY: "abcde12345", CONF_CITY: "Beijing", CONF_STATE: "Beijing", CONF_COUNTRY: "China", }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_CITY: "location_not_found"} - - -async def test_invalid_identifier_geography_unknown(hass): - """Test that an unknown identifier issue throws an error.""" - with patch( - "pyairvisual.air_quality.AirQuality.city", - side_effect=AirVisualError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={"type": INTEGRATION_TYPE_GEOGRAPHY_NAME}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ + NotFoundError, + {CONF_CITY: "location_not_found"}, + INTEGRATION_TYPE_GEOGRAPHY_NAME, + ), + ( + { CONF_API_KEY: "abcde12345", CONF_CITY: "Beijing", CONF_STATE: "Beijing", CONF_COUNTRY: "China", }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "unknown"} - - -async def test_invalid_identifier_node_pro(hass): - """Test that an invalid Node/Pro identifier shows an error.""" - node_pro_conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "my_password"} - - with patch( - "pyairvisual.node.NodeSamba.async_connect", - side_effect=NodeProError, - ): + AirVisualError, + {"base": "unknown"}, + INTEGRATION_TYPE_GEOGRAPHY_NAME, + ), + ( + { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_PASSWORD: "my_password", + }, + NodeProError, + {CONF_IP_ADDRESS: "cannot_connect"}, + INTEGRATION_TYPE_NODE_PRO, + ), + ], +) +async def test_errors(hass, data, exc, errors, integration_type): + """Test that an exceptions show an error.""" + with patch("pyairvisual.air_quality.AirQuality.city", side_effect=exc): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={"type": "AirVisual Node/Pro"} + DOMAIN, context={"source": SOURCE_USER}, data={"type": integration_type} ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=node_pro_conf + result["flow_id"], user_input=data ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_IP_ADDRESS: "cannot_connect"} + assert result["errors"] == errors -async def test_migration(hass): +@pytest.mark.parametrize( + "config,config_entry_version,unique_id", + [ + ( + { + CONF_API_KEY: "abcde12345", + CONF_GEOGRAPHIES: [ + {CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765}, + { + CONF_CITY: "Beijing", + CONF_STATE: "Beijing", + CONF_COUNTRY: "China", + }, + ], + }, + 1, + "abcde12345", + ) + ], +) +async def test_migration(hass, config, config_entry, setup_airvisual, unique_id): """Test migrating from version 1 to the current version.""" - conf = { - CONF_API_KEY: "abcde12345", - CONF_GEOGRAPHIES: [ - {CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765}, - {CONF_CITY: "Beijing", CONF_STATE: "Beijing", CONF_COUNTRY: "China"}, - ], - } - - entry = MockConfigEntry(domain=DOMAIN, version=1, unique_id="abcde12345", data=conf) - entry.add_to_hass(hass) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - with patch("pyairvisual.air_quality.AirQuality.city"), patch( - "pyairvisual.air_quality.AirQuality.nearest_city" - ), patch.object(hass.config_entries, "async_forward_entry_setup"): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: conf}) - await hass.async_block_till_done() - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 2 assert config_entries[0].unique_id == "51.528308, -0.3817765" @@ -212,27 +175,13 @@ async def test_migration(hass): } -async def test_options_flow(hass): +async def test_options_flow(hass, config_entry): """Test config flow options.""" - geography_conf = { - CONF_API_KEY: "abcde12345", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - } - - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="51.528308, -0.3817765", - data=geography_conf, - options={CONF_SHOW_ON_MAP: True}, - ) - entry.add_to_hass(hass) - with patch( "homeassistant.components.airvisual.async_setup_entry", return_value=True ): - await hass.config_entries.async_setup(entry.entry_id) - result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) + 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" @@ -242,112 +191,93 @@ async def test_options_flow(hass): ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert entry.options == {CONF_SHOW_ON_MAP: False} + assert config_entry.options == {CONF_SHOW_ON_MAP: False} + +async def test_step_geography_by_coords(hass, config, setup_airvisual): + """Test setting up a geography entry by latitude/longitude.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={"type": INTEGRATION_TYPE_GEOGRAPHY_COORDS}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config + ) -async def test_step_geography_by_coords(hass): - """Test setting up a geopgraphy entry by latitude/longitude.""" - conf = { + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Cloud API (51.528308, -0.3817765)" + assert result["data"] == { CONF_API_KEY: "abcde12345", CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765, + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS, } - with patch( - "homeassistant.components.airvisual.async_setup_entry", return_value=True - ), patch("pyairvisual.air_quality.AirQuality.nearest_city"): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={"type": INTEGRATION_TYPE_GEOGRAPHY_COORDS}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=conf - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Cloud API (51.528308, -0.3817765)" - assert result["data"] == { +@pytest.mark.parametrize( + "config", + [ + { CONF_API_KEY: "abcde12345", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS, + CONF_CITY: "Beijing", + CONF_STATE: "Beijing", + CONF_COUNTRY: "China", } + ], +) +async def test_step_geography_by_name(hass, config, setup_airvisual): + """Test setting up a geography entry by city/state/country.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={"type": INTEGRATION_TYPE_GEOGRAPHY_NAME}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config + ) - -async def test_step_geography_by_name(hass): - """Test setting up a geopgraphy entry by city/state/country.""" - conf = { + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Cloud API (Beijing, Beijing, China)" + assert result["data"] == { CONF_API_KEY: "abcde12345", CONF_CITY: "Beijing", CONF_STATE: "Beijing", CONF_COUNTRY: "China", + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_NAME, } - with patch( - "homeassistant.components.airvisual.async_setup_entry", return_value=True - ), patch("pyairvisual.air_quality.AirQuality.city"): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={"type": INTEGRATION_TYPE_GEOGRAPHY_NAME}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=conf - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Cloud API (Beijing, Beijing, China)" - assert result["data"] == { - CONF_API_KEY: "abcde12345", - CONF_CITY: "Beijing", - CONF_STATE: "Beijing", - CONF_COUNTRY: "China", - CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_NAME, - } - - -async def test_step_node_pro(hass): - """Test the Node/Pro step.""" - conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "my_password"} - with patch( - "homeassistant.components.airvisual.async_setup_entry", return_value=True - ), patch("pyairvisual.node.NodeSamba.async_connect"), patch( - "pyairvisual.node.NodeSamba.async_get_latest_measurements" - ), patch( - "pyairvisual.node.NodeSamba.async_disconnect" - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={"type": "AirVisual Node/Pro"} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=conf - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Node/Pro (192.168.1.100)" - assert result["data"] == { +@pytest.mark.parametrize( + "config", + [ + { CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "my_password", - CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO, } - - -async def test_step_reauth(hass): - """Test that the reauth step works.""" - entry_data = { - CONF_API_KEY: "abcde12345", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY_COORDS, + ], +) +async def test_step_node_pro(hass, config, setup_airvisual): + """Test the Node/Pro step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={"type": "AirVisual Node/Pro"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Node/Pro (192.168.1.100)" + assert result["data"] == { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_PASSWORD: "my_password", + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO, } - MockConfigEntry( - domain=DOMAIN, unique_id="51.528308, -0.3817765", data=entry_data - ).add_to_hass(hass) +async def test_step_reauth(hass, config_entry, setup_airvisual): + """Test that the reauth step works.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_REAUTH}, data=entry_data + DOMAIN, context={"source": SOURCE_REAUTH}, data=config_entry.data ) assert result["step_id"] == "reauth_confirm" @@ -359,7 +289,7 @@ async def test_step_reauth(hass): with patch( "homeassistant.components.airvisual.async_setup_entry", return_value=True - ), patch("pyairvisual.air_quality.AirQuality.nearest_city", return_value=True): + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_API_KEY: new_api_key} ) diff --git a/tests/components/airvisual/test_diagnostics.py b/tests/components/airvisual/test_diagnostics.py new file mode 100644 index 00000000000000..5b68644bb7e77b --- /dev/null +++ b/tests/components/airvisual/test_diagnostics.py @@ -0,0 +1,49 @@ +"""Test AirVisual diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_airvisual): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "title": "Mock Title", + "data": { + "api_key": REDACTED, + "integration_type": "Geographical Location by Latitude/Longitude", + "latitude": REDACTED, + "longitude": REDACTED, + }, + "options": { + "show_on_map": True, + }, + }, + "data": { + "city": REDACTED, + "country": REDACTED, + "current": { + "weather": { + "ts": "2021-09-03T21:00:00.000Z", + "tp": 23, + "pr": 999, + "hu": 45, + "ws": 0.45, + "wd": 252, + "ic": "10d", + }, + "pollution": { + "ts": "2021-09-04T00:00:00.000Z", + "aqius": 52, + "mainus": "p2", + "aqicn": 18, + "maincn": "p2", + }, + }, + "location": { + "coordinates": REDACTED, + "type": "Point", + }, + "state": REDACTED, + }, + } diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 75f1dc76aafaad..5cbe9f256bae7f 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -3,6 +3,7 @@ from homeassistant.components.alarm_control_panel import DOMAIN, const import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import ( CONF_PLATFORM, STATE_ALARM_ARMED_AWAY, @@ -92,7 +93,9 @@ async def test_get_actions( } for action in expected_action_types ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) @@ -122,7 +125,9 @@ async def test_get_actions_arm_night_only(hass, device_reg, entity_reg): "entity_id": "alarm_control_panel.test_5678", }, ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) @@ -158,12 +163,14 @@ async def test_get_action_capabilities( }, "trigger": {"extra_fields": []}, } - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert len(actions) == 6 assert {action["type"] for action in actions} == set(expected_capabilities) for action in actions: capabilities = await async_get_device_automation_capabilities( - hass, "action", action + hass, DeviceAutomationType.ACTION, action ) assert capabilities == expected_capabilities[action["type"]] @@ -208,12 +215,14 @@ async def test_get_action_capabilities_arm_code( }, "trigger": {"extra_fields": []}, } - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert len(actions) == 6 assert {action["type"] for action in actions} == set(expected_capabilities) for action in actions: capabilities = await async_get_device_automation_capabilities( - hass, "action", action + hass, DeviceAutomationType.ACTION, action ) assert capabilities == expected_capabilities[action["type"]] diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index d0644562850693..b44e6ba7e8b54a 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -3,6 +3,7 @@ from homeassistant.components.alarm_control_panel import DOMAIN, const import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, @@ -112,7 +113,9 @@ async def test_get_conditions( } for condition in expected_condition_types ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert_lists_same(conditions, expected_conditions) diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 9edda7e98e28ac..c8082e415e0ca8 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -5,6 +5,7 @@ from homeassistant.components.alarm_control_panel import DOMAIN import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -125,7 +126,9 @@ async def test_get_triggers( } for trigger in expected_trigger_types ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -142,11 +145,13 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): "alarm_control_panel.test_5678", "attributes", {"supported_features": 15} ) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 6 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == { "extra_fields": [ diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py deleted file mode 100644 index 257d764468a5cf..00000000000000 --- a/tests/components/alarm_control_panel/test_init.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Tests for Alarm control panel.""" -from homeassistant.components import alarm_control_panel - - -def test_deprecated_base_class(caplog): - """Test deprecated base class.""" - - class CustomAlarm(alarm_control_panel.AlarmControlPanel): - def supported_features(self): - pass - - CustomAlarm() - assert "AlarmControlPanel is deprecated, modify CustomAlarm" in caplog.text diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index 5b1706c15e2517..053100d2e0033a 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -1,5 +1,6 @@ """Tests for the Alexa integration.""" import re +from unittest.mock import Mock from uuid import uuid4 from homeassistant.components.alexa import config, smart_home @@ -23,6 +24,11 @@ class MockConfig(config.AbstractConfig): "camera.test": {"display_categories": "CAMERA"}, } + def __init__(self, hass): + """Mock Alexa config.""" + super().__init__(hass) + self._store = Mock(spec_set=config.AlexaConfigStore) + @property def supports_auth(self): """Return if config supports auth.""" @@ -47,6 +53,10 @@ def should_expose(self, entity_id): """If an entity should be exposed.""" return True + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + async def async_get_access_token(self): """Get an access token.""" return "thisisnotanacesstoken" @@ -55,7 +65,9 @@ async def async_accept_grant(self, code): """Accept a grant.""" -DEFAULT_CONFIG = MockConfig(None) +def get_default_config(): + """Return a MockConfig instance.""" + return MockConfig(None) def get_new_request(namespace, name, endpoint=None): @@ -104,7 +116,9 @@ async def assert_request_calls_service( domain, service_name = service.split(".") calls = async_mock_service(hass, domain, service_name) - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context) + msg = await smart_home.async_handle_message( + hass, get_default_config(), request, context + ) await hass.async_block_till_done() assert len(calls) == 1 @@ -128,7 +142,7 @@ async def assert_request_fails( domain, service_name = service_not_called.split(".") call = async_mock_service(hass, domain, service_name) - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) await hass.async_block_till_done() assert not call @@ -180,15 +194,17 @@ async def assert_scene_controller_works( assert re.search(pattern, response["event"]["payload"]["timestamp"]) -async def reported_properties(hass, endpoint): +async def reported_properties(hass, endpoint, return_full_response=False): """Use ReportState to get properties and return them. The result is a ReportedProperties instance, which has methods to make assertions about the properties. """ request = get_new_request("Alexa", "ReportState", endpoint) - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) await hass.async_block_till_done() + if return_full_response: + return msg return ReportedProperties(msg["context"]["properties"]) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index d4d6bec62a9953..8a9a40e3217180 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -4,7 +4,6 @@ import pytest from homeassistant.components.alexa import smart_home -from homeassistant.components.alexa.errors import UnsupportedProperty from homeassistant.components.climate import const as climate from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING from homeassistant.components.media_player.const import ( @@ -29,9 +28,9 @@ ) from . import ( - DEFAULT_CONFIG, assert_request_calls_service, assert_request_fails, + get_default_config, get_new_request, reported_properties, ) @@ -39,8 +38,8 @@ from tests.common import async_mock_service -@pytest.mark.parametrize("result,adjust", [(25, "-5"), (35, "5"), (0, "-80")]) -async def test_api_adjust_brightness(hass, result, adjust): +@pytest.mark.parametrize("adjust", ["-5", "5", "-80"]) +async def test_api_adjust_brightness(hass, adjust): """Test api adjust brightness process.""" request = get_new_request( "Alexa.BrightnessController", "AdjustBrightness", "light#test" @@ -56,7 +55,7 @@ async def test_api_adjust_brightness(hass, result, adjust): call_light = async_mock_service(hass, "light", "turn_on") - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) await hass.async_block_till_done() assert "event" in msg @@ -64,7 +63,7 @@ async def test_api_adjust_brightness(hass, result, adjust): assert len(call_light) == 1 assert call_light[0].data["entity_id"] == "light.test" - assert call_light[0].data["brightness_pct"] == result + assert call_light[0].data["brightness_step_pct"] == int(adjust) assert msg["header"]["name"] == "Response" @@ -86,7 +85,7 @@ async def test_api_set_color_rgb(hass): call_light = async_mock_service(hass, "light", "turn_on") - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) await hass.async_block_till_done() assert "event" in msg @@ -112,7 +111,7 @@ async def test_api_set_color_temperature(hass): call_light = async_mock_service(hass, "light", "turn_on") - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) await hass.async_block_till_done() assert "event" in msg @@ -140,7 +139,7 @@ async def test_api_decrease_color_temp(hass, result, initial): call_light = async_mock_service(hass, "light", "turn_on") - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) await hass.async_block_till_done() assert "event" in msg @@ -168,7 +167,7 @@ async def test_api_increase_color_temp(hass, result, initial): call_light = async_mock_service(hass, "light", "turn_on") - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) await hass.async_block_till_done() assert "event" in msg @@ -677,16 +676,9 @@ async def test_report_climate_state(hass): ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, }, ) - with pytest.raises(UnsupportedProperty): - properties = await reported_properties(hass, "climate.unsupported") - properties.assert_not_has_property( - "Alexa.ThermostatController", "thermostatMode" - ) - properties.assert_equal( - "Alexa.TemperatureSensor", - "temperature", - {"value": 34.0, "scale": "CELSIUS"}, - ) + msg = await reported_properties(hass, "climate.unsupported", True) + assert msg["event"]["header"]["name"] == "ErrorResponse" + assert msg["event"]["payload"]["type"] == "INTERNAL_ERROR" async def test_temperature_sensor_sensor(hass): diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 9a1ef032762779..54e48df8e8eba7 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -4,7 +4,7 @@ from homeassistant.components.alexa import smart_home from homeassistant.const import __version__ -from . import DEFAULT_CONFIG, get_new_request +from . import get_default_config, get_new_request async def test_unsupported_domain(hass): @@ -13,7 +13,7 @@ async def test_unsupported_domain(hass): hass.states.async_set("woz.boop", "on", {"friendly_name": "Boop Woz"}) - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) assert "event" in msg msg = msg["event"] @@ -27,7 +27,7 @@ async def test_serialize_discovery(hass): hass.states.async_set("switch.bla", "on", {"friendly_name": "Boop Woz"}) - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) assert "event" in msg msg = msg["event"] @@ -51,7 +51,7 @@ async def test_serialize_discovery_recovers(hass, caplog): "homeassistant.components.alexa.capabilities.AlexaPowerController.serialize_discovery", side_effect=TypeError, ): - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) assert "event" in msg msg = msg["event"] diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 12708c9b55ad4b..7ebba26113df6c 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -31,13 +31,13 @@ from homeassistant.setup import async_setup_component from . import ( - DEFAULT_CONFIG, MockConfig, ReportedProperties, assert_power_controller_works, assert_request_calls_service, assert_request_fails, assert_scene_controller_works, + get_default_config, get_new_request, reported_properties, ) @@ -68,7 +68,7 @@ async def mock_stream(hass): def test_create_api_message_defaults(hass): - """Create a API message response of a request with defaults.""" + """Create an API message response of a request with defaults.""" request = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy") directive_header = request["directive"]["header"] directive = messages.AlexaDirective(request) @@ -93,7 +93,7 @@ def test_create_api_message_defaults(hass): def test_create_api_message_special(): - """Create a API message response of a request with non defaults.""" + """Create an API message response of a request with non defaults.""" request = get_new_request("Alexa.PowerController", "TurnOn") directive_header = request["directive"]["header"] directive_header.pop("correlationToken") @@ -121,7 +121,7 @@ async def test_wrong_version(hass): msg["directive"]["header"]["payloadVersion"] = "2" with pytest.raises(AssertionError): - await smart_home.async_handle_message(hass, DEFAULT_CONFIG, msg) + await smart_home.async_handle_message(hass, get_default_config(), msg) async def discovery_test(device, hass, expected_endpoints=1): @@ -131,7 +131,7 @@ async def discovery_test(device, hass, expected_endpoints=1): # setup test devices hass.states.async_set(*device) - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) assert "event" in msg msg = msg["event"] @@ -2308,7 +2308,7 @@ async def test_api_entity_not_exists(hass): call_switch = async_mock_service(hass, "switch", "turn_on") - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) await hass.async_block_till_done() assert "event" in msg @@ -2323,7 +2323,7 @@ async def test_api_entity_not_exists(hass): async def test_api_function_not_implemented(hass): """Test api call that is not implemented to us.""" request = get_new_request("Alexa.HAHAAH", "Sweet") - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) assert "event" in msg msg = msg["event"] @@ -2347,7 +2347,7 @@ async def test_api_accept_grant(hass): } # setup test devices - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) await hass.async_block_till_done() assert "event" in msg @@ -2400,7 +2400,7 @@ async def test_logging_request(hass, events): """Test that we log requests.""" context = Context() request = get_new_request("Alexa.Discovery", "Discover") - await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context) + await smart_home.async_handle_message(hass, get_default_config(), request, context) # To trigger event listener await hass.async_block_till_done() @@ -2420,7 +2420,7 @@ async def test_logging_request_with_entity(hass, events): """Test that we log requests.""" context = Context() request = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy") - await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context) + await smart_home.async_handle_message(hass, get_default_config(), request, context) # To trigger event listener await hass.async_block_till_done() @@ -2446,7 +2446,7 @@ async def test_disabled(hass): call_switch = async_mock_service(hass, "switch", "turn_on") msg = await smart_home.async_handle_message( - hass, DEFAULT_CONFIG, request, enabled=False + hass, get_default_config(), request, enabled=False ) await hass.async_block_till_done() @@ -2629,7 +2629,9 @@ async def test_range_unsupported_domain(hass): request["directive"]["payload"] = {"rangeValue": 1} request["directive"]["header"]["instance"] = "switch.speed" - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context) + msg = await smart_home.async_handle_message( + hass, get_default_config(), request, context + ) assert "event" in msg msg = msg["event"] @@ -2648,7 +2650,9 @@ async def test_mode_unsupported_domain(hass): request["directive"]["payload"] = {"mode": "testMode"} request["directive"]["header"]["instance"] = "switch.direction" - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context) + msg = await smart_home.async_handle_message( + hass, get_default_config(), request, context + ) assert "event" in msg msg = msg["event"] @@ -3388,7 +3392,9 @@ async def test_media_player_eq_bands_not_supported(hass): "Alexa.EqualizerController", "SetBands", "media_player#test_bands" ) request["directive"]["payload"] = {"bands": [{"name": "BASS", "value": -2}]} - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context) + msg = await smart_home.async_handle_message( + hass, get_default_config(), request, context + ) assert "event" in msg msg = msg["event"] @@ -3403,7 +3409,9 @@ async def test_media_player_eq_bands_not_supported(hass): request["directive"]["payload"] = { "bands": [{"name": "BASS", "levelDelta": 3, "levelDirection": "UP"}] } - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context) + msg = await smart_home.async_handle_message( + hass, get_default_config(), request, context + ) assert "event" in msg msg = msg["event"] @@ -3418,7 +3426,9 @@ async def test_media_player_eq_bands_not_supported(hass): request["directive"]["payload"] = { "bands": [{"name": "BASS", "levelDelta": 3, "levelDirection": "UP"}] } - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request, context) + msg = await smart_home.async_handle_message( + hass, get_default_config(), request, context + ) assert "event" in msg msg = msg["event"] @@ -3918,7 +3928,7 @@ async def test_initialize_camera_stream(hass, mock_camera, mock_stream): "homeassistant.components.demo.camera.DemoCamera.stream_source", return_value="rtsp://example.local", ): - msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request) + msg = await smart_home.async_handle_message(hass, get_default_config(), request) await hass.async_block_till_done() assert "event" in msg @@ -3939,12 +3949,20 @@ async def test_initialize_camera_stream(hass, mock_camera, mock_stream): ) -async def test_button(hass): +@pytest.mark.parametrize( + "domain", + ["button", "input_button"], +) +async def test_button(hass, domain): """Test button discovery.""" - device = ("button.ring_doorbell", STATE_UNKNOWN, {"friendly_name": "Ring Doorbell"}) + device = ( + f"{domain}.ring_doorbell", + STATE_UNKNOWN, + {"friendly_name": "Ring Doorbell"}, + ) appliance = await discovery_test(device, hass) - assert appliance["endpointId"] == "button#ring_doorbell" + assert appliance["endpointId"] == f"{domain}#ring_doorbell" assert appliance["displayCategories"][0] == "ACTIVITY_TRIGGER" assert appliance["friendlyName"] == "Ring Doorbell" @@ -3955,5 +3973,16 @@ async def test_button(hass): assert scene_capability["supportsDeactivation"] is False await assert_scene_controller_works( - "button#ring_doorbell", "button.press", False, hass + f"{domain}#ring_doorbell", f"{domain}.press", False, hass ) + + +async def test_api_message_sets_authorized(hass): + """Test an incoming API messages sets the authorized flag.""" + msg = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy") + async_mock_service(hass, "switch", "turn_on") + + config = get_default_config() + config._store.set_authorized.assert_not_called() + await smart_home.async_handle_message(hass, config, msg) + config._store.set_authorized.assert_called_once_with(True) diff --git a/tests/components/alexa/test_state_report.py b/tests/components/alexa/test_state_report.py index 29624e7d1ff654..06c7d051798b2a 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -1,10 +1,14 @@ """Test report state.""" -from unittest.mock import patch +import json +from unittest.mock import AsyncMock, patch + +import aiohttp +import pytest from homeassistant import core -from homeassistant.components.alexa import state_report +from homeassistant.components.alexa import errors, state_report -from . import DEFAULT_CONFIG, TEST_URL +from . import TEST_URL, get_default_config async def test_report_state(hass, aioclient_mock): @@ -17,7 +21,7 @@ async def test_report_state(hass, aioclient_mock): {"friendly_name": "Test Contact Sensor", "device_class": "door"}, ) - await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + await state_report.async_enable_proactive_mode(hass, get_default_config()) hass.states.async_set( "binary_sensor.test_contact", @@ -41,6 +45,170 @@ async def test_report_state(hass, aioclient_mock): assert call_json["event"]["endpoint"]["endpointId"] == "binary_sensor#test_contact" +async def test_report_state_fail(hass, aioclient_mock, caplog): + """Test proactive state retries once.""" + aioclient_mock.post( + TEST_URL, + text=json.dumps( + { + "payload": { + "code": "THROTTLING_EXCEPTION", + "description": "Request could not be processed due to throttling", + } + } + ), + status=403, + ) + + hass.states.async_set( + "binary_sensor.test_contact", + "on", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + await state_report.async_enable_proactive_mode(hass, get_default_config()) + + hass.states.async_set( + "binary_sensor.test_contact", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + # To trigger event listener + await hass.async_block_till_done() + + # No retry on errors not related to expired access token + assert len(aioclient_mock.mock_calls) == 1 + + # Check we log the entity id of the failing entity + assert ( + "Error when sending ChangeReport for binary_sensor.test_contact to Alexa: " + "THROTTLING_EXCEPTION: Request could not be processed due to throttling" + ) in caplog.text + + +async def test_report_state_timeout(hass, aioclient_mock, caplog): + """Test proactive state retries once.""" + aioclient_mock.post( + TEST_URL, + exc=aiohttp.ClientError(), + ) + + hass.states.async_set( + "binary_sensor.test_contact", + "on", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + await state_report.async_enable_proactive_mode(hass, get_default_config()) + + hass.states.async_set( + "binary_sensor.test_contact", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + # To trigger event listener + await hass.async_block_till_done() + + # No retry on errors not related to expired access token + assert len(aioclient_mock.mock_calls) == 1 + + # Check we log the entity id of the failing entity + assert ( + "Timeout sending report to Alexa for binary_sensor.test_contact" in caplog.text + ) + + +async def test_report_state_retry(hass, aioclient_mock): + """Test proactive state retries once.""" + aioclient_mock.post( + TEST_URL, + text='{"payload":{"code":"INVALID_ACCESS_TOKEN_EXCEPTION","description":""}}', + status=403, + ) + + hass.states.async_set( + "binary_sensor.test_contact", + "on", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + await state_report.async_enable_proactive_mode(hass, get_default_config()) + + hass.states.async_set( + "binary_sensor.test_contact", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + # To trigger event listener + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 2 + + +async def test_report_state_unsets_authorized_on_error(hass, aioclient_mock): + """Test proactive state unsets authorized on error.""" + aioclient_mock.post( + TEST_URL, + text='{"payload":{"code":"INVALID_ACCESS_TOKEN_EXCEPTION","description":""}}', + status=403, + ) + + hass.states.async_set( + "binary_sensor.test_contact", + "on", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + config = get_default_config() + await state_report.async_enable_proactive_mode(hass, config) + + hass.states.async_set( + "binary_sensor.test_contact", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + config._store.set_authorized.assert_not_called() + + # To trigger event listener + await hass.async_block_till_done() + config._store.set_authorized.assert_called_once_with(False) + + +@pytest.mark.parametrize("exc", [errors.NoTokenAvailable, errors.RequireRelink]) +async def test_report_state_unsets_authorized_on_access_token_error( + hass, aioclient_mock, exc +): + """Test proactive state unsets authorized on error.""" + aioclient_mock.post(TEST_URL, text="", status=202) + + hass.states.async_set( + "binary_sensor.test_contact", + "on", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + config = get_default_config() + + await state_report.async_enable_proactive_mode(hass, config) + + hass.states.async_set( + "binary_sensor.test_contact", + "off", + {"friendly_name": "Test Contact Sensor", "device_class": "door"}, + ) + + config._store.set_authorized.assert_not_called() + + with patch.object(config, "async_get_access_token", AsyncMock(side_effect=exc)): + # To trigger event listener + await hass.async_block_till_done() + config._store.set_authorized.assert_called_once_with(False) + + async def test_report_state_instance(hass, aioclient_mock): """Test proactive state reports with instance.""" aioclient_mock.post(TEST_URL, text="", status=202) @@ -58,7 +226,7 @@ async def test_report_state_instance(hass, aioclient_mock): }, ) - await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + await state_report.async_enable_proactive_mode(hass, get_default_config()) hass.states.async_set( "fan.test_fan", @@ -127,7 +295,9 @@ async def test_send_add_or_update_message(hass, aioclient_mock): "binary_sensor.non_existing", # Supported, but does not exist "zwave.bla", # Unsupported ] - await state_report.async_send_add_or_update_message(hass, DEFAULT_CONFIG, entities) + await state_report.async_send_add_or_update_message( + hass, get_default_config(), entities + ) assert len(aioclient_mock.mock_calls) == 1 call = aioclient_mock.mock_calls @@ -153,7 +323,7 @@ async def test_send_delete_message(hass, aioclient_mock): ) await state_report.async_send_delete_message( - hass, DEFAULT_CONFIG, ["binary_sensor.test_contact", "zwave.bla"] + hass, get_default_config(), ["binary_sensor.test_contact", "zwave.bla"] ) assert len(aioclient_mock.mock_calls) == 1 @@ -179,7 +349,7 @@ async def test_doorbell_event(hass, aioclient_mock): {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, ) - await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + await state_report.async_enable_proactive_mode(hass, get_default_config()) hass.states.async_set( "binary_sensor.test_doorbell", @@ -216,10 +386,86 @@ async def test_doorbell_event(hass, aioclient_mock): assert len(aioclient_mock.mock_calls) == 2 +async def test_doorbell_event_fail(hass, aioclient_mock, caplog): + """Test proactive state retries once.""" + aioclient_mock.post( + TEST_URL, + text=json.dumps( + { + "payload": { + "code": "THROTTLING_EXCEPTION", + "description": "Request could not be processed due to throttling", + } + } + ), + status=403, + ) + + hass.states.async_set( + "binary_sensor.test_doorbell", + "off", + {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, + ) + + await state_report.async_enable_proactive_mode(hass, get_default_config()) + + hass.states.async_set( + "binary_sensor.test_doorbell", + "on", + {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, + ) + + # To trigger event listener + await hass.async_block_till_done() + + # No retry on errors not related to expired access token + assert len(aioclient_mock.mock_calls) == 1 + + # Check we log the entity id of the failing entity + assert ( + "Error when sending DoorbellPress event for binary_sensor.test_doorbell to Alexa: " + "THROTTLING_EXCEPTION: Request could not be processed due to throttling" + ) in caplog.text + + +async def test_doorbell_event_timeout(hass, aioclient_mock, caplog): + """Test proactive state retries once.""" + aioclient_mock.post( + TEST_URL, + exc=aiohttp.ClientError(), + ) + + hass.states.async_set( + "binary_sensor.test_doorbell", + "off", + {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, + ) + + await state_report.async_enable_proactive_mode(hass, get_default_config()) + + hass.states.async_set( + "binary_sensor.test_doorbell", + "on", + {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, + ) + + # To trigger event listener + await hass.async_block_till_done() + + # No retry on errors not related to expired access token + assert len(aioclient_mock.mock_calls) == 1 + + # Check we log the entity id of the failing entity + assert ( + "Timeout sending report to Alexa for binary_sensor.test_doorbell" in caplog.text + ) + + async def test_proactive_mode_filter_states(hass, aioclient_mock): """Test all the cases that filter states.""" aioclient_mock.post(TEST_URL, text="", status=202) - await state_report.async_enable_proactive_mode(hass, DEFAULT_CONFIG) + config = get_default_config() + await state_report.async_enable_proactive_mode(hass, config) # First state should report hass.states.async_set( @@ -270,7 +516,7 @@ async def test_proactive_mode_filter_states(hass, aioclient_mock): "off", {"friendly_name": "Test Contact Sensor", "device_class": "door"}, ) - with patch.object(DEFAULT_CONFIG, "should_expose", return_value=False): + with patch.object(config, "should_expose", return_value=False): await hass.async_block_till_done() await hass.async_block_till_done() assert len(aioclient_mock.mock_calls) == 0 diff --git a/tests/components/ambee/test_sensor.py b/tests/components/ambee/test_sensor.py index a198d420378b51..a32398139d05d5 100644 --- a/tests/components/ambee/test_sensor.py +++ b/tests/components/ambee/test_sensor.py @@ -7,7 +7,8 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -18,7 +19,6 @@ CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_CO, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -42,7 +42,7 @@ async def test_air_quality( assert entry.unique_id == f"{entry_id}_air_quality_particulate_matter_2_5" assert state.state == "3.14" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Particulate Matter < 2.5 μm" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -57,7 +57,7 @@ async def test_air_quality( assert entry.unique_id == f"{entry_id}_air_quality_particulate_matter_10" assert state.state == "5.24" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Particulate Matter < 10 μm" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -72,7 +72,7 @@ async def test_air_quality( assert entry.unique_id == f"{entry_id}_air_quality_sulphur_dioxide" assert state.state == "0.031" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Sulphur Dioxide (SO2)" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_BILLION @@ -87,7 +87,7 @@ async def test_air_quality( assert entry.unique_id == f"{entry_id}_air_quality_nitrogen_dioxide" assert state.state == "0.66" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Nitrogen Dioxide (NO2)" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_BILLION @@ -102,7 +102,7 @@ async def test_air_quality( assert entry.unique_id == f"{entry_id}_air_quality_ozone" assert state.state == "17.067" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Ozone" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_BILLION @@ -116,9 +116,9 @@ async def test_air_quality( assert state assert entry.unique_id == f"{entry_id}_air_quality_carbon_monoxide" assert state.state == "0.105" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CO + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CO assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Carbon Monoxide (CO)" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_MILLION @@ -132,7 +132,7 @@ async def test_air_quality( assert entry.unique_id == f"{entry_id}_air_quality_air_quality_index" assert state.state == "13" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Air Quality Index (AQI)" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ATTR_DEVICE_CLASS not in state.attributes assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes assert ATTR_ICON not in state.attributes @@ -165,7 +165,7 @@ async def test_pollen( assert state.state == "190" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Grass Pollen" assert state.attributes.get(ATTR_ICON) == "mdi:grass" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_CUBIC_METER @@ -180,7 +180,7 @@ async def test_pollen( assert state.state == "127" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Tree Pollen" assert state.attributes.get(ATTR_ICON) == "mdi:tree" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_CUBIC_METER @@ -195,7 +195,7 @@ async def test_pollen( assert state.state == "95" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Weed Pollen" assert state.attributes.get(ATTR_ICON) == "mdi:sprout" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_CUBIC_METER @@ -280,7 +280,7 @@ async def test_pollen_disabled_by_default( entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @pytest.mark.parametrize( @@ -337,7 +337,7 @@ async def test_pollen_enable_disable_by_defaults( assert state.state == value assert state.attributes.get(ATTR_FRIENDLY_NAME) == name assert state.attributes.get(ATTR_ICON) == icon - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_CUBIC_METER diff --git a/tests/components/amberelectric/__init__.py b/tests/components/amberelectric/__init__.py new file mode 100644 index 00000000000000..9eae18c65aaf23 --- /dev/null +++ b/tests/components/amberelectric/__init__.py @@ -0,0 +1 @@ +"""Tests for the amberelectric integration.""" diff --git a/tests/components/amberelectric/test_binary_sensor.py b/tests/components/amberelectric/test_binary_sensor.py index 9aa4782b9a4e48..856dcdc473e190 100644 --- a/tests/components/amberelectric/test_binary_sensor.py +++ b/tests/components/amberelectric/test_binary_sensor.py @@ -1,7 +1,7 @@ """Test the Amber Electric Sensors.""" from __future__ import annotations -from typing import AsyncGenerator +from collections.abc import AsyncGenerator from unittest.mock import Mock, patch from amberelectric.model.channel import ChannelType diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py index 71c40b4cf758b4..ce474be1b3d22c 100644 --- a/tests/components/amberelectric/test_config_flow.py +++ b/tests/components/amberelectric/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for the Amber config flow.""" -from typing import Generator +from collections.abc import Generator from unittest.mock import Mock, patch from amberelectric import ApiException diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 5085f9c50f86cb..bc80d3674d61c8 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -1,7 +1,7 @@ """Tests for the Amber Electric Data Coordinator.""" from __future__ import annotations -from typing import Generator +from collections.abc import Generator from unittest.mock import Mock, patch from amberelectric import ApiException diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index deafcb70fb77fe..fa8cffe2c734f4 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -1,5 +1,5 @@ """Test the Amber Electric Sensors.""" -from typing import AsyncGenerator, List +from collections.abc import AsyncGenerator from unittest.mock import Mock, patch from amberelectric.model.current_interval import CurrentInterval @@ -121,7 +121,7 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> assert attributes.get("range_min") is None assert attributes.get("range_max") is None - with_range: List[CurrentInterval] = GENERAL_CHANNEL + with_range: list[CurrentInterval] = GENERAL_CHANNEL with_range[0].range = Range(7.8, 12.4) setup_general.get_current_price.return_value = with_range @@ -208,7 +208,7 @@ async def test_general_forecast_sensor( assert first_forecast.get("range_min") is None assert first_forecast.get("range_max") is None - with_range: List[CurrentInterval] = GENERAL_CHANNEL + with_range: list[CurrentInterval] = GENERAL_CHANNEL with_range[1].range = Range(7.8, 12.4) setup_general.get_current_price.return_value = with_range diff --git a/tests/components/ambient_station/conftest.py b/tests/components/ambient_station/conftest.py new file mode 100644 index 00000000000000..680fa82303dfb5 --- /dev/null +++ b/tests/components/ambient_station/conftest.py @@ -0,0 +1,54 @@ +"""Define test fixtures for Ambient PWS.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components.ambient_station.const import CONF_APP_KEY, DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_API_KEY: "12345abcde12345abcde", + CONF_APP_KEY: "67890fghij67890fghij", + } + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config): + """Define a config entry fixture.""" + entry = MockConfigEntry(domain=DOMAIN, data=config) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="devices", scope="session") +def devices_fixture(): + """Define devices data.""" + return json.loads(load_fixture("devices.json", "ambient_station")) + + +@pytest.fixture(name="setup_ambient_station") +async def setup_ambient_station_fixture(hass, config, devices): + """Define a fixture to set up AirVisual.""" + with patch("homeassistant.components.ambient_station.PLATFORMS", []), patch( + "homeassistant.components.ambient_station.config_flow.API.get_devices", + side_effect=devices, + ), patch("aioambient.api.API.get_devices", side_effect=devices), patch( + "aioambient.websocket.Websocket.connect" + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="station_data", scope="session") +def station_data_fixture(): + """Define devices data.""" + return json.loads(load_fixture("station_data.json", "ambient_station")) diff --git a/tests/fixtures/ambient_devices.json b/tests/components/ambient_station/fixtures/devices.json similarity index 100% rename from tests/fixtures/ambient_devices.json rename to tests/components/ambient_station/fixtures/devices.json diff --git a/tests/components/ambient_station/fixtures/station_data.json b/tests/components/ambient_station/fixtures/station_data.json new file mode 100644 index 00000000000000..31b9e7b4f0f0ac --- /dev/null +++ b/tests/components/ambient_station/fixtures/station_data.json @@ -0,0 +1,43 @@ +{ + "devices": [ + { + "macAddress": "12:34:56:78:90:AB", + "lastData": { + "dateutc": 1642631880000, + "tempinf": 70.9, + "humidityin": 29, + "baromrelin": 29.953, + "baromabsin": 25.016, + "tempf": 21, + "humidity": 87, + "winddir": 25, + "windspeedmph": 0.2, + "windgustmph": 1.1, + "maxdailygust": 9.2, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0.409, + "totalrainin": 35.398, + "solarradiation": 11.62, + "uv": 0, + "batt_co2": 1, + "feelsLike": 21, + "dewPoint": 17.75, + "feelsLikein": 69.1, + "dewPointin": 37, + "lastRain": "2022-01-07T19:45:00.000Z", + "deviceId": "1234567890abcdef12345678", + "tz": "America/New York", + "date": "2022-01-19T22:38:00.000Z" + }, + "info": { + "name": "Side Yard", + "location": "Home" + }, + "apiKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ], + "method": "subscribe" +} diff --git a/tests/components/ambient_station/test_config_flow.py b/tests/components/ambient_station/test_config_flow.py index 27dbac9faed46b..a72534b8478609 100644 --- a/tests/components/ambient_station/test_config_flow.py +++ b/tests/components/ambient_station/test_config_flow.py @@ -1,102 +1,54 @@ """Define tests for the Ambient PWS config flow.""" -import json -from unittest.mock import patch +from unittest.mock import AsyncMock -import aioambient +from aioambient.errors import AmbientError import pytest from homeassistant import data_entry_flow -from homeassistant.components.ambient_station import CONF_APP_KEY, DOMAIN, config_flow +from homeassistant.components.ambient_station import CONF_APP_KEY, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY -from tests.common import MockConfigEntry, load_fixture, mock_coro - -@pytest.fixture -def get_devices_response(): - """Define a fixture for a successful /devices response.""" - return mock_coro() - - -@pytest.fixture -def mock_aioambient(get_devices_response): - """Mock the aioambient library.""" - with patch("homeassistant.components.ambient_station.config_flow.API") as API: - api = API() - api.get_devices.return_value = get_devices_response - yield api - - -async def test_duplicate_error(hass): +async def test_duplicate_error(hass, config, config_entry, setup_ambient_station): """Test that errors are shown when duplicates are added.""" - conf = {CONF_API_KEY: "12345abcde12345abcde", CONF_APP_KEY: "67890fghij67890fghij"} - - MockConfigEntry( - domain=DOMAIN, unique_id="67890fghij67890fghij", data=conf - ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" @pytest.mark.parametrize( - "get_devices_response", [mock_coro(exception=aioambient.errors.AmbientError)] + "devices,error", + [ + (AmbientError, "invalid_key"), + (AsyncMock(return_value=[]), "no_devices"), + ], ) -async def test_invalid_api_key(hass, mock_aioambient): - """Test that an invalid API/App Key throws an error.""" - conf = {CONF_API_KEY: "12345abcde12345abcde", CONF_APP_KEY: "67890fghij67890fghij"} - - 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"} - - -@pytest.mark.parametrize("get_devices_response", [mock_coro(return_value=[])]) -async def test_no_devices(hass, mock_aioambient): - """Test that an account with no associated devices throws an error.""" - conf = {CONF_API_KEY: "12345abcde12345abcde", CONF_APP_KEY: "67890fghij67890fghij"} - - 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"} +async def test_errors(hass, config, devices, error, setup_ambient_station): + """Test that various issues show the correct error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=config + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": error} 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) - + 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" -@pytest.mark.parametrize( - "get_devices_response", - [mock_coro(return_value=json.loads(load_fixture("ambient_devices.json")))], -) -async def test_step_user(hass, mock_aioambient): +async def test_step_user(hass, config, setup_ambient_station): """Test that the user step works.""" - conf = {CONF_API_KEY: "12345abcde12345abcde", CONF_APP_KEY: "67890fghij67890fghij"} - - flow = config_flow.AmbientStationFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} - - result = await flow.async_step_user(user_input=conf) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=config + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "67890fghij67" assert result["data"] == { diff --git a/tests/components/ambient_station/test_diagnostics.py b/tests/components/ambient_station/test_diagnostics.py new file mode 100644 index 00000000000000..63d5fcff7a18a3 --- /dev/null +++ b/tests/components/ambient_station/test_diagnostics.py @@ -0,0 +1,59 @@ +"""Test Ambient PWS diagnostics.""" +from homeassistant.components.ambient_station import DOMAIN +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics( + hass, config_entry, hass_client, setup_ambient_station, station_data +): + """Test config entry diagnostics.""" + ambient = hass.data[DOMAIN][config_entry.entry_id] + ambient.stations = station_data + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "data": {"api_key": REDACTED, "app_key": REDACTED}, + "title": "Mock Title", + }, + "stations": { + "devices": [ + { + "apiKey": REDACTED, + "info": {"location": REDACTED, "name": "Side Yard"}, + "lastData": { + "baromabsin": 25.016, + "baromrelin": 29.953, + "batt_co2": 1, + "dailyrainin": 0, + "date": "2022-01-19T22:38:00.000Z", + "dateutc": 1642631880000, + "deviceId": REDACTED, + "dewPoint": 17.75, + "dewPointin": 37, + "eventrainin": 0, + "feelsLike": 21, + "feelsLikein": 69.1, + "hourlyrainin": 0, + "humidity": 87, + "humidityin": 29, + "lastRain": "2022-01-07T19:45:00.000Z", + "maxdailygust": 9.2, + "monthlyrainin": 0.409, + "solarradiation": 11.62, + "tempf": 21, + "tempinf": 70.9, + "totalrainin": 35.398, + "tz": REDACTED, + "uv": 0, + "weeklyrainin": 0, + "winddir": 25, + "windgustmph": 1.1, + "windspeedmph": 0.2, + }, + "macAddress": REDACTED, + } + ], + "method": "subscribe", + }, + } diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index a781cb4c6626ea..2534c4678e8ec2 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -26,7 +26,7 @@ async def test_no_send(hass, caplog, aioclient_mock): - """Test send when no prefrences are defined.""" + """Test send when no preferences are defined.""" analytics = Analytics(hass) with patch( "homeassistant.components.hassio.is_hassio", @@ -102,7 +102,7 @@ async def test_failed_to_send_raises(hass, caplog, aioclient_mock): async def test_send_base(hass, caplog, aioclient_mock): - """Test send base prefrences are defined.""" + """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) @@ -123,7 +123,7 @@ async def test_send_base(hass, caplog, aioclient_mock): async def test_send_base_with_supervisor(hass, caplog, aioclient_mock): - """Test send base prefrences are defined.""" + """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) @@ -170,7 +170,7 @@ async def test_send_base_with_supervisor(hass, caplog, aioclient_mock): async def test_send_usage(hass, caplog, aioclient_mock): - """Test send usage prefrences are defined.""" + """Test send usage preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) @@ -187,7 +187,7 @@ async def test_send_usage(hass, caplog, aioclient_mock): async def test_send_usage_with_supervisor(hass, caplog, aioclient_mock): - """Test send usage with supervisor prefrences are defined.""" + """Test send usage with supervisor preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) @@ -240,7 +240,7 @@ async def test_send_usage_with_supervisor(hass, caplog, aioclient_mock): async def test_send_statistics(hass, caplog, aioclient_mock): - """Test send statistics prefrences are defined.""" + """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) @@ -258,7 +258,7 @@ async def test_send_statistics(hass, caplog, aioclient_mock): async def test_send_statistics_one_integration_fails(hass, caplog, aioclient_mock): - """Test send statistics prefrences are defined.""" + """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) @@ -280,7 +280,7 @@ async def test_send_statistics_one_integration_fails(hass, caplog, aioclient_moc async def test_send_statistics_async_get_integration_unknown_exception( hass, caplog, aioclient_mock ): - """Test send statistics prefrences are defined.""" + """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) @@ -296,7 +296,7 @@ async def test_send_statistics_async_get_integration_unknown_exception( async def test_send_statistics_with_supervisor(hass, caplog, aioclient_mock): - """Test send statistics prefrences are defined.""" + """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) @@ -427,7 +427,7 @@ async def test_nightly_endpoint(hass, aioclient_mock): async def test_send_with_no_energy(hass, aioclient_mock): - """Test send base prefrences are defined.""" + """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) @@ -448,7 +448,7 @@ async def test_send_with_no_energy(hass, aioclient_mock): async def test_send_with_no_energy_config(hass, aioclient_mock): - """Test send base prefrences are defined.""" + """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) @@ -472,7 +472,7 @@ async def test_send_with_no_energy_config(hass, aioclient_mock): async def test_send_with_energy_config(hass, aioclient_mock): - """Test send base prefrences are defined.""" + """Test send base preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) analytics = Analytics(hass) diff --git a/tests/components/analytics/test_init.py b/tests/components/analytics/test_init.py index e48d662594d35f..24abb63b00203f 100644 --- a/tests/components/analytics/test_init.py +++ b/tests/components/analytics/test_init.py @@ -16,7 +16,7 @@ async def test_setup(hass): async def test_websocket(hass, hass_ws_client, aioclient_mock): - """Test websocekt commands.""" + """Test WebSocket commands.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py index b8ee4aaa2cd07a..c92ac11ba4bcac 100644 --- a/tests/components/androidtv/patchers.py +++ b/tests/components/androidtv/patchers.py @@ -139,9 +139,9 @@ async def shell_fail_server(self, cmd): PATCH_ANDROIDTV_OPEN = patch( "homeassistant.components.androidtv.media_player.open", mock_open() ) -PATCH_KEYGEN = patch("homeassistant.components.androidtv.media_player.keygen") +PATCH_KEYGEN = patch("homeassistant.components.androidtv.keygen") PATCH_SIGNER = patch( - "homeassistant.components.androidtv.media_player.ADBPythonSync.load_adbkey", + "homeassistant.components.androidtv.ADBPythonSync.load_adbkey", return_value="signer for testing", ) @@ -151,10 +151,6 @@ def isfile(filepath): return filepath.endswith("adbkey") -PATCH_ISFILE = patch("os.path.isfile", isfile) -PATCH_ACCESS = patch("os.access", return_value=True) - - def patch_firetv_update(state, current_app, running_apps, hdmi_input): """Patch the `FireTV.update()` method.""" return patch( diff --git a/tests/components/androidtv/test_config_flow.py b/tests/components/androidtv/test_config_flow.py new file mode 100644 index 00000000000000..757be8f6d8d994 --- /dev/null +++ b/tests/components/androidtv/test_config_flow.py @@ -0,0 +1,573 @@ +"""Tests for the AndroidTV config flow.""" +import json +from socket import gaierror +from unittest.mock import patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.androidtv.config_flow import ( + APPS_NEW_ID, + CONF_APP_DELETE, + CONF_APP_ID, + CONF_APP_NAME, + CONF_RULE_DELETE, + CONF_RULE_ID, + CONF_RULE_VALUES, + RULES_NEW_ID, +) +from homeassistant.components.androidtv.const import ( + CONF_ADB_SERVER_IP, + CONF_ADB_SERVER_PORT, + CONF_ADBKEY, + CONF_APPS, + CONF_EXCLUDE_UNNAMED_APPS, + CONF_GET_SOURCES, + CONF_SCREENCAP, + CONF_STATE_DETECTION_RULES, + CONF_TURN_OFF_COMMAND, + CONF_TURN_ON_COMMAND, + DEFAULT_ADB_SERVER_PORT, + DEFAULT_PORT, + DOMAIN, + PROP_ETHMAC, +) +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PLATFORM, CONF_PORT +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.androidtv.patchers import isfile + +ADBKEY = "adbkey" +ETH_MAC = "a1:b1:c1:d1:e1:f1" +HOST = "127.0.0.1" +VALID_DETECT_RULE = [{"paused": {"media_session_state": 3}}] + +# Android TV device with Python ADB implementation +CONFIG_PYTHON_ADB = { + CONF_HOST: HOST, + CONF_PORT: DEFAULT_PORT, + CONF_DEVICE_CLASS: "androidtv", + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, +} + +# Android TV device with ADB server +CONFIG_ADB_SERVER = { + CONF_HOST: HOST, + CONF_PORT: DEFAULT_PORT, + CONF_DEVICE_CLASS: "androidtv", + CONF_ADB_SERVER_IP: "127.0.0.1", + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, +} + +CONNECT_METHOD = ( + "homeassistant.components.androidtv.config_flow.async_connect_androidtv" +) +PATCH_ACCESS = patch( + "homeassistant.components.androidtv.config_flow.os.access", return_value=True +) +PATCH_GET_HOST_IP = patch( + "homeassistant.components.androidtv.config_flow.socket.gethostbyname", + return_value=HOST, +) +PATCH_ISFILE = patch( + "homeassistant.components.androidtv.config_flow.os.path.isfile", isfile +) +PATCH_SETUP_ENTRY = patch( + "homeassistant.components.androidtv.async_setup_entry", + return_value=True, +) + + +class MockConfigDevice: + """Mock class to emulate Android TV device.""" + + def __init__(self, eth_mac=ETH_MAC): + """Initialize a fake device to test config flow.""" + self.available = True + self.device_properties = {PROP_ETHMAC: eth_mac} + + async def adb_close(self): + """Fake method to close connection.""" + self.available = False + + +@pytest.mark.parametrize("config", [CONFIG_PYTHON_ADB, CONFIG_ADB_SERVER]) +async def test_user(hass, config): + """Test user config.""" + flow_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True} + ) + assert flow_result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert flow_result["step_id"] == "user" + + # test with all provided + with patch( + CONNECT_METHOD, + return_value=(MockConfigDevice(), None), + ), PATCH_SETUP_ENTRY as mock_setup_entry, PATCH_GET_HOST_IP: + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=config + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == config + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass): + """Test import config.""" + + # test with all provided + with patch( + CONNECT_METHOD, + return_value=(MockConfigDevice(), None), + ), PATCH_SETUP_ENTRY as mock_setup_entry, PATCH_GET_HOST_IP: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=CONFIG_PYTHON_ADB, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == CONFIG_PYTHON_ADB + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_adbkey(hass): + """Test user step with adbkey file.""" + config_data = CONFIG_PYTHON_ADB.copy() + config_data[CONF_ADBKEY] = ADBKEY + + with patch( + CONNECT_METHOD, + return_value=(MockConfigDevice(), None), + ), PATCH_SETUP_ENTRY as mock_setup_entry, PATCH_GET_HOST_IP, PATCH_ISFILE, PATCH_ACCESS: + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + data=config_data, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == config_data + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_data(hass): + """Test import from configuration file.""" + config_data = CONFIG_PYTHON_ADB.copy() + config_data[CONF_PLATFORM] = DOMAIN + config_data[CONF_ADBKEY] = ADBKEY + config_data[CONF_TURN_OFF_COMMAND] = "off" + platform_data = {MP_DOMAIN: config_data} + + with patch( + CONNECT_METHOD, + return_value=(MockConfigDevice(), None), + ), PATCH_SETUP_ENTRY as mock_setup_entry, PATCH_GET_HOST_IP, PATCH_ISFILE, PATCH_ACCESS: + + assert await async_setup_component(hass, MP_DOMAIN, platform_data) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_error_both_key_server(hass): + """Test we abort if both adb key and server are provided.""" + config_data = CONFIG_ADB_SERVER.copy() + + config_data[CONF_ADBKEY] = ADBKEY + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + data=config_data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "key_and_server"} + + with patch( + CONNECT_METHOD, + return_value=(MockConfigDevice(), None), + ), PATCH_SETUP_ENTRY, PATCH_GET_HOST_IP: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG_ADB_SERVER + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"] == CONFIG_ADB_SERVER + + +async def test_error_invalid_key(hass): + """Test we abort if component is already setup.""" + config_data = CONFIG_PYTHON_ADB.copy() + config_data[CONF_ADBKEY] = ADBKEY + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + data=config_data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "adbkey_not_file"} + + with patch( + CONNECT_METHOD, + return_value=(MockConfigDevice(), None), + ), PATCH_SETUP_ENTRY, PATCH_GET_HOST_IP: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG_ADB_SERVER + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"] == CONFIG_ADB_SERVER + + +async def test_error_invalid_host(hass): + """Test we abort if host name is invalid.""" + with patch( + "socket.gethostbyname", + side_effect=gaierror, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + data=CONFIG_ADB_SERVER, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_host"} + + with patch( + CONNECT_METHOD, + return_value=(MockConfigDevice(), None), + ), PATCH_SETUP_ENTRY, PATCH_GET_HOST_IP: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG_ADB_SERVER + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == HOST + assert result2["data"] == CONFIG_ADB_SERVER + + +async def test_invalid_serial(hass): + """Test for invalid serialno.""" + with patch( + CONNECT_METHOD, + return_value=(MockConfigDevice(eth_mac=""), None), + ), PATCH_GET_HOST_IP: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_ADB_SERVER, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "invalid_unique_id" + + +async def test_abort_if_host_exist(hass): + """Test we abort if component is already setup.""" + MockConfigEntry( + domain=DOMAIN, data=CONFIG_ADB_SERVER, unique_id=ETH_MAC + ).add_to_hass(hass) + + config_data = CONFIG_ADB_SERVER.copy() + config_data[CONF_HOST] = "name" + # Should fail, same IP Address (by PATCH_GET_HOST_IP) + with PATCH_GET_HOST_IP: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=config_data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_abort_import_if_host_exist(hass): + """Test we abort if component is already setup.""" + MockConfigEntry( + domain=DOMAIN, data=CONFIG_ADB_SERVER, unique_id=ETH_MAC + ).add_to_hass(hass) + + # Should fail, same Host in entry + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=CONFIG_ADB_SERVER, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_abort_if_unique_exist(hass): + """Test we abort if component is already setup.""" + config_data = CONFIG_ADB_SERVER.copy() + config_data[CONF_HOST] = "127.0.0.2" + MockConfigEntry(domain=DOMAIN, data=config_data, unique_id=ETH_MAC).add_to_hass( + hass + ) + + # Should fail, same SerialNo + with patch( + CONNECT_METHOD, + return_value=(MockConfigDevice(), None), + ), PATCH_GET_HOST_IP: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_ADB_SERVER, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_on_connect_failed(hass): + """Test when we have errors connecting the router.""" + flow_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER, "show_advanced_options": True}, + ) + + with patch(CONNECT_METHOD, return_value=(None, "Error")), PATCH_GET_HOST_IP: + result = await hass.config_entries.flow.async_configure( + flow_result["flow_id"], user_input=CONFIG_ADB_SERVER + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + CONNECT_METHOD, + side_effect=TypeError, + ), PATCH_GET_HOST_IP: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG_ADB_SERVER + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + with patch( + CONNECT_METHOD, + return_value=(MockConfigDevice(), None), + ), PATCH_SETUP_ENTRY, PATCH_GET_HOST_IP: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], user_input=CONFIG_ADB_SERVER + ) + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == HOST + assert result3["data"] == CONFIG_ADB_SERVER + + +async def test_options_flow(hass): + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_ADB_SERVER, + unique_id=ETH_MAC, + options={ + CONF_APPS: {"app1": "App1"}, + CONF_STATE_DETECTION_RULES: {"com.plexapp.android": VALID_DETECT_RULE}, + }, + ) + config_entry.add_to_hass(hass) + + with PATCH_SETUP_ENTRY: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + 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" + + # test app form with existing app + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APPS: "app1", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "apps" + + # test change value in apps form + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APP_NAME: "Appl1", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # test app form with new app + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APPS: APPS_NEW_ID, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "apps" + + # test save value for new app + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APP_ID: "app2", + CONF_APP_NAME: "Appl2", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # test app form for delete + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APPS: "app1", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "apps" + + # test delete app1 + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APP_NAME: "Appl1", + CONF_APP_DELETE: True, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # test rules form with existing rule + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_STATE_DETECTION_RULES: "com.plexapp.android", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "rules" + + # test change value in rule form with invalid json rule + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RULE_VALUES: "a", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "rules" + assert result["errors"] == {"base": "invalid_det_rules"} + + # test change value in rule form with invalid rule + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RULE_VALUES: json.dumps({"a": "b"}), + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "rules" + assert result["errors"] == {"base": "invalid_det_rules"} + + # test change value in rule form with valid rule + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RULE_VALUES: json.dumps(["standby"]), + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # test rule form with new rule + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_STATE_DETECTION_RULES: RULES_NEW_ID, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "rules" + + # test save value for new rule + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RULE_ID: "rule2", + CONF_RULE_VALUES: json.dumps(VALID_DETECT_RULE), + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # test rules form with delete existing rule + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_STATE_DETECTION_RULES: "com.plexapp.android", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "rules" + + # test delete rule + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RULE_DELETE: True, + }, + ) + 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_GET_SOURCES: True, + CONF_EXCLUDE_UNNAMED_APPS: True, + CONF_SCREENCAP: True, + CONF_TURN_OFF_COMMAND: "off", + CONF_TURN_ON_COMMAND: "on", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + apps_options = config_entry.options[CONF_APPS] + assert apps_options.get("app1") is None + assert apps_options["app2"] == "Appl2" + + assert config_entry.options[CONF_GET_SOURCES] is True + assert config_entry.options[CONF_EXCLUDE_UNNAMED_APPS] is True + assert config_entry.options[CONF_SCREENCAP] is True + assert config_entry.options[CONF_TURN_OFF_COMMAND] == "off" + assert config_entry.options[CONF_TURN_ON_COMMAND] == "on" diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index d72cf36438b32e..5326e48f7b98fd 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -4,22 +4,25 @@ import logging from unittest.mock import patch -from androidtv.constants import APPS as ANDROIDTV_APPS +from androidtv.constants import APPS as ANDROIDTV_APPS, KEYS from androidtv.exceptions import LockNotAcquiredException import pytest -from homeassistant.components.androidtv.media_player import ( - ANDROIDTV_DOMAIN, - ATTR_COMMAND, - ATTR_DEVICE_PATH, - ATTR_LOCAL_PATH, +from homeassistant.components.androidtv.const import ( CONF_ADB_SERVER_IP, + CONF_ADB_SERVER_PORT, CONF_ADBKEY, CONF_APPS, CONF_EXCLUDE_UNNAMED_APPS, CONF_TURN_OFF_COMMAND, CONF_TURN_ON_COMMAND, - KEYS, + DEFAULT_ADB_SERVER_PORT, + DEFAULT_PORT, + DOMAIN, +) +from homeassistant.components.androidtv.media_player import ( + ATTR_DEVICE_PATH, + ATTR_LOCAL_PATH, SERVICE_ADB_COMMAND, SERVICE_DOWNLOAD, SERVICE_LEARN_SENDEVENT, @@ -29,7 +32,7 @@ ATTR_INPUT_SOURCE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, - DOMAIN, + DOMAIN as MP_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -46,63 +49,71 @@ ) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ( + ATTR_COMMAND, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_HOST, - CONF_NAME, - CONF_PLATFORM, + CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_PLAYING, STATE_STANDBY, STATE_UNAVAILABLE, ) -from homeassistant.setup import async_setup_component +from homeassistant.util import slugify +from tests.common import MockConfigEntry from tests.components.androidtv import patchers +CONF_OPTIONS = "options" + +PATCH_ACCESS = patch("homeassistant.components.androidtv.os.access", return_value=True) +PATCH_ISFILE = patch( + "homeassistant.components.androidtv.os.path.isfile", patchers.isfile +) + SHELL_RESPONSE_OFF = "" SHELL_RESPONSE_STANDBY = "1" # Android TV device with Python ADB implementation CONFIG_ANDROIDTV_PYTHON_ADB = { DOMAIN: { - CONF_PLATFORM: ANDROIDTV_DOMAIN, CONF_HOST: "127.0.0.1", - CONF_NAME: "Android TV", + CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: "androidtv", + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, } } # Android TV device with ADB server CONFIG_ANDROIDTV_ADB_SERVER = { DOMAIN: { - CONF_PLATFORM: ANDROIDTV_DOMAIN, CONF_HOST: "127.0.0.1", - CONF_NAME: "Android TV", + CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: "androidtv", CONF_ADB_SERVER_IP: "127.0.0.1", + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, } } # Fire TV device with Python ADB implementation CONFIG_FIRETV_PYTHON_ADB = { DOMAIN: { - CONF_PLATFORM: ANDROIDTV_DOMAIN, CONF_HOST: "127.0.0.1", - CONF_NAME: "Fire TV", + CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: "firetv", + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, } } # Fire TV device with ADB server CONFIG_FIRETV_ADB_SERVER = { DOMAIN: { - CONF_PLATFORM: ANDROIDTV_DOMAIN, CONF_HOST: "127.0.0.1", - CONF_NAME: "Fire TV", + CONF_PORT: DEFAULT_PORT, CONF_DEVICE_CLASS: "firetv", CONF_ADB_SERVER_IP: "127.0.0.1", + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, } } @@ -114,15 +125,54 @@ def _setup(config): else: patch_key = "server" + host = config[DOMAIN][CONF_HOST] if config[DOMAIN].get(CONF_DEVICE_CLASS) != "firetv": - entity_id = "media_player.android_tv" + entity_id = slugify(f"Android TV {host}") else: - entity_id = "media_player.fire_tv" + entity_id = slugify(f"Fire TV {host}") + entity_id = f"{MP_DOMAIN}.{entity_id}" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config[DOMAIN], + unique_id="a1:b1:c1:d1:e1:f1", + options=config[DOMAIN].get(CONF_OPTIONS), + ) - return patch_key, entity_id + return patch_key, entity_id, config_entry -async def _test_reconnect(hass, caplog, config): +async def test_setup_with_properties(hass): + """Test that setup succeeds with device properties. + + the response must be a string with the following info separated with line break: + "manufacturer, model, serialno, version, mac_wlan0_output, mac_eth0_output" + + """ + + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) + response = "fake\nfake\n0123456\nfake\nether a1:b1:c1:d1:e1:f1 brd\nnone" + + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ + patch_key + ], patchers.patch_shell(response)[patch_key]: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state is not None + + +@pytest.mark.parametrize( + "config", + [ + CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_FIRETV_PYTHON_ADB, + CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_FIRETV_ADB_SERVER, + ], +) +async def test_reconnect(hass, caplog, config): """Test that the error and reconnection attempts are logged correctly. "Handles device/service unavailable. Log a warning once when @@ -130,14 +180,15 @@ async def _test_reconnect(hass, caplog, config): https://developers.home-assistant.io/docs/en/integration_quality_scale_index.html """ - patch_key, entity_id = _setup(config) + patch_key, entity_id, config_entry = _setup(config) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[ patch_key ], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER: - assert await async_setup_component(hass, DOMAIN, config) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity(entity_id) @@ -182,22 +233,30 @@ async def _test_reconnect(hass, caplog, config): in caplog.record_tuples[2] ) - return True - -async def _test_adb_shell_returns_none(hass, config): +@pytest.mark.parametrize( + "config", + [ + CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_FIRETV_PYTHON_ADB, + CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_FIRETV_ADB_SERVER, + ], +) +async def test_adb_shell_returns_none(hass, config): """Test the case that the ADB shell command returns `None`. The state should be `None` and the device should be unavailable. """ - patch_key, entity_id = _setup(config) + patch_key, entity_id, config_entry = _setup(config) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[ patch_key ], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER: - assert await async_setup_component(hass, DOMAIN, config) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity(entity_id) state = hass.states.get(entity_id) @@ -212,101 +271,20 @@ async def _test_adb_shell_returns_none(hass, config): assert state is not None assert state.state == STATE_UNAVAILABLE - return True - - -async def test_reconnect_androidtv_python_adb(hass, caplog): - """Test that the error and reconnection attempts are logged correctly. - - * Device type: Android TV - * ADB connection method: Python ADB implementation - - """ - assert await _test_reconnect(hass, caplog, CONFIG_ANDROIDTV_PYTHON_ADB) - - -async def test_adb_shell_returns_none_androidtv_python_adb(hass): - """Test the case that the ADB shell command returns `None`. - - * Device type: Android TV - * ADB connection method: Python ADB implementation - - """ - assert await _test_adb_shell_returns_none(hass, CONFIG_ANDROIDTV_PYTHON_ADB) - - -async def test_reconnect_firetv_python_adb(hass, caplog): - """Test that the error and reconnection attempts are logged correctly. - - * Device type: Fire TV - * ADB connection method: Python ADB implementation - - """ - assert await _test_reconnect(hass, caplog, CONFIG_FIRETV_PYTHON_ADB) - - -async def test_adb_shell_returns_none_firetv_python_adb(hass): - """Test the case that the ADB shell command returns `None`. - - * Device type: Fire TV - * ADB connection method: Python ADB implementation - - """ - assert await _test_adb_shell_returns_none(hass, CONFIG_FIRETV_PYTHON_ADB) - - -async def test_reconnect_androidtv_adb_server(hass, caplog): - """Test that the error and reconnection attempts are logged correctly. - - * Device type: Android TV - * ADB connection method: ADB server - - """ - assert await _test_reconnect(hass, caplog, CONFIG_ANDROIDTV_ADB_SERVER) - - -async def test_adb_shell_returns_none_androidtv_adb_server(hass): - """Test the case that the ADB shell command returns `None`. - - * Device type: Android TV - * ADB connection method: ADB server - - """ - assert await _test_adb_shell_returns_none(hass, CONFIG_ANDROIDTV_ADB_SERVER) - - -async def test_reconnect_firetv_adb_server(hass, caplog): - """Test that the error and reconnection attempts are logged correctly. - - * Device type: Fire TV - * ADB connection method: ADB server - - """ - assert await _test_reconnect(hass, caplog, CONFIG_FIRETV_ADB_SERVER) - - -async def test_adb_shell_returns_none_firetv_adb_server(hass): - """Test the case that the ADB shell command returns `None`. - - * Device type: Fire TV - * ADB connection method: ADB server - - """ - assert await _test_adb_shell_returns_none(hass, CONFIG_FIRETV_ADB_SERVER) - async def test_setup_with_adbkey(hass): """Test that setup succeeds when using an ADB key.""" config = copy.deepcopy(CONFIG_ANDROIDTV_PYTHON_ADB) config[DOMAIN][CONF_ADBKEY] = hass.config.path("user_provided_adbkey") - patch_key, entity_id = _setup(config) + patch_key, entity_id, config_entry = _setup(config) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[ patch_key - ], patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER, patchers.PATCH_ISFILE, patchers.PATCH_ACCESS: - assert await async_setup_component(hass, DOMAIN, config) + ], patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER, PATCH_ISFILE, PATCH_ACCESS: + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity(entity_id) state = hass.states.get(entity_id) @@ -314,20 +292,32 @@ async def test_setup_with_adbkey(hass): assert state.state == STATE_OFF -async def _test_sources(hass, config0): +@pytest.mark.parametrize( + "config0", + [ + CONFIG_ANDROIDTV_ADB_SERVER, + CONFIG_FIRETV_ADB_SERVER, + ], +) +async def test_sources(hass, config0): """Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices.""" config = copy.deepcopy(config0) - config[DOMAIN][CONF_APPS] = { - "com.app.test1": "TEST 1", - "com.app.test3": None, - "com.app.test4": SHELL_RESPONSE_OFF, - } - patch_key, entity_id = _setup(config) + config[DOMAIN].setdefault(CONF_OPTIONS, {}).update( + { + CONF_APPS: { + "com.app.test1": "TEST 1", + "com.app.test3": None, + "com.app.test4": SHELL_RESPONSE_OFF, + } + } + ) + patch_key, entity_id, config_entry = _setup(config) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, config) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity(entity_id) state = hass.states.get(entity_id) @@ -386,33 +376,26 @@ async def _test_sources(hass, config0): assert state.attributes["source"] == "com.app.test2" assert sorted(state.attributes["source_list"]) == ["TEST 1", "com.app.test2"] - return True - - -async def test_androidtv_sources(hass): - """Test that sources (i.e., apps) are handled correctly for Android TV devices.""" - assert await _test_sources(hass, CONFIG_ANDROIDTV_ADB_SERVER) - - -async def test_firetv_sources(hass): - """Test that sources (i.e., apps) are handled correctly for Fire TV devices.""" - assert await _test_sources(hass, CONFIG_FIRETV_ADB_SERVER) - async def _test_exclude_sources(hass, config0, expected_sources): """Test that sources (i.e., apps) are handled correctly when the `exclude_unnamed_apps` config parameter is provided.""" config = copy.deepcopy(config0) - config[DOMAIN][CONF_APPS] = { - "com.app.test1": "TEST 1", - "com.app.test3": None, - "com.app.test4": SHELL_RESPONSE_OFF, - } - patch_key, entity_id = _setup(config) + config[DOMAIN].setdefault(CONF_OPTIONS, {}).update( + { + CONF_APPS: { + "com.app.test1": "TEST 1", + "com.app.test3": None, + "com.app.test4": SHELL_RESPONSE_OFF, + } + } + ) + patch_key, entity_id, config_entry = _setup(config) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, config) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity(entity_id) state = hass.states.get(entity_id) @@ -463,31 +446,36 @@ async def _test_exclude_sources(hass, config0, expected_sources): async def test_androidtv_exclude_sources(hass): """Test that sources (i.e., apps) are handled correctly for Android TV devices when the `exclude_unnamed_apps` config parameter is provided as true.""" config = copy.deepcopy(CONFIG_ANDROIDTV_ADB_SERVER) - config[DOMAIN][CONF_EXCLUDE_UNNAMED_APPS] = True + config[DOMAIN][CONF_OPTIONS] = {CONF_EXCLUDE_UNNAMED_APPS: True} assert await _test_exclude_sources(hass, config, ["TEST 1"]) async def test_firetv_exclude_sources(hass): """Test that sources (i.e., apps) are handled correctly for Fire TV devices when the `exclude_unnamed_apps` config parameter is provided as true.""" config = copy.deepcopy(CONFIG_FIRETV_ADB_SERVER) - config[DOMAIN][CONF_EXCLUDE_UNNAMED_APPS] = True + config[DOMAIN][CONF_OPTIONS] = {CONF_EXCLUDE_UNNAMED_APPS: True} assert await _test_exclude_sources(hass, config, ["TEST 1"]) async def _test_select_source(hass, config0, source, expected_arg, method_patch): """Test that the methods for launching and stopping apps are called correctly when selecting a source.""" config = copy.deepcopy(config0) - config[DOMAIN][CONF_APPS] = { - "com.app.test1": "TEST 1", - "com.app.test3": None, - "com.youtube.test": "YouTube", - } - patch_key, entity_id = _setup(config) + config[DOMAIN].setdefault(CONF_OPTIONS, {}).update( + { + CONF_APPS: { + "com.app.test1": "TEST 1", + "com.app.test3": None, + "com.youtube.test": "YouTube", + } + } + ) + patch_key, entity_id, config_entry = _setup(config) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, config) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity(entity_id) state = hass.states.get(entity_id) @@ -496,7 +484,7 @@ async def _test_select_source(hass, config0, source, expected_arg, method_patch) with method_patch as method_patch_: await hass.services.async_call( - DOMAIN, + MP_DOMAIN, SERVICE_SELECT_SOURCE, {ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: source}, blocking=True, @@ -696,205 +684,157 @@ async def test_firetv_select_source_stop_hidden(hass): ) -async def _test_setup_fail(hass, config): +@pytest.mark.parametrize( + "config", + [ + CONFIG_ANDROIDTV_PYTHON_ADB, + CONFIG_FIRETV_PYTHON_ADB, + ], +) +async def test_setup_fail(hass, config): """Test that the entity is not created when the ADB connection is not established.""" - patch_key, entity_id = _setup(config) + patch_key, entity_id, config_entry = _setup(config) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(False)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[ patch_key ], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER: - assert await async_setup_component(hass, DOMAIN, config) + assert await hass.config_entries.async_setup(config_entry.entry_id) is False await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity(entity_id) state = hass.states.get(entity_id) assert state is None - return True - - -async def test_setup_fail_androidtv(hass): - """Test that the Android TV entity is not created when the ADB connection is not established.""" - assert await _test_setup_fail(hass, CONFIG_ANDROIDTV_PYTHON_ADB) - - -async def test_setup_fail_firetv(hass): - """Test that the Fire TV entity is not created when the ADB connection is not established.""" - assert await _test_setup_fail(hass, CONFIG_FIRETV_PYTHON_ADB) - - -async def test_setup_two_devices(hass): - """Test that two devices can be set up.""" - config = { - DOMAIN: [ - CONFIG_ANDROIDTV_ADB_SERVER[DOMAIN], - copy.deepcopy(CONFIG_FIRETV_ADB_SERVER[DOMAIN]), - ] - } - config[DOMAIN][1][CONF_HOST] = "127.0.0.2" - - patch_key = "server" - with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ - patch_key - ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - for entity_id in ["media_player.android_tv", "media_player.fire_tv"]: - await hass.helpers.entity_component.async_update_entity(entity_id) - state = hass.states.get(entity_id) - assert state is not None - assert state.state == STATE_OFF - - -async def test_setup_same_device_twice(hass): - """Test that setup succeeds with a duplicated config entry.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) - - with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ - patch_key - ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state is not None - - assert hass.services.has_service(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND) - - with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ - patch_key - ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) - await hass.async_block_till_done() - async def test_adb_command(hass): """Test sending a command via the `androidtv.adb_command` service.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) command = "test command" response = "test response" with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response - ) as patch_shell: - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_ADB_COMMAND, - {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, - blocking=True, - ) + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response + ) as patch_shell: + await hass.services.async_call( + DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) - patch_shell.assert_called_with(command) - state = hass.states.get(entity_id) - assert state is not None - assert state.attributes["adb_response"] == response + patch_shell.assert_called_with(command) + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] == response async def test_adb_command_unicode_decode_error(hass): """Test sending a command via the `androidtv.adb_command` service that raises a UnicodeDecodeError exception.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) command = "test command" response = b"test response" with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", - side_effect=UnicodeDecodeError("utf-8", response, 0, len(response), "TEST"), - ): - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_ADB_COMMAND, - {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, - blocking=True, - ) + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", + side_effect=UnicodeDecodeError("utf-8", response, 0, len(response), "TEST"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) - # patch_shell.assert_called_with(command) - state = hass.states.get(entity_id) - assert state is not None - assert state.attributes["adb_response"] is None + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] is None async def test_adb_command_key(hass): """Test sending a key command via the `androidtv.adb_command` service.""" - patch_key = "server" - entity_id = "media_player.android_tv" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) command = "HOME" response = None with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response - ) as patch_shell: - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_ADB_COMMAND, - {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, - blocking=True, - ) + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", return_value=response + ) as patch_shell: + await hass.services.async_call( + DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) - patch_shell.assert_called_with(f"input keyevent {KEYS[command]}") - state = hass.states.get(entity_id) - assert state is not None - assert state.attributes["adb_response"] is None + patch_shell.assert_called_with(f"input keyevent {KEYS[command]}") + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] is None async def test_adb_command_get_properties(hass): """Test sending the "GET_PROPERTIES" command via the `androidtv.adb_command` service.""" - patch_key = "server" - entity_id = "media_player.android_tv" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) command = "GET_PROPERTIES" response = {"test key": "test value"} with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch( - "androidtv.androidtv.androidtv_async.AndroidTVAsync.get_properties_dict", - return_value=response, - ) as patch_get_props: - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_ADB_COMMAND, - {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, - blocking=True, - ) + with patch( + "androidtv.androidtv.androidtv_async.AndroidTVAsync.get_properties_dict", + return_value=response, + ) as patch_get_props: + await hass.services.async_call( + DOMAIN, + SERVICE_ADB_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: command}, + blocking=True, + ) - patch_get_props.assert_called() - state = hass.states.get(entity_id) - assert state is not None - assert state.attributes["adb_response"] == str(response) + patch_get_props.assert_called() + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["adb_response"] == str(response) async def test_learn_sendevent(hass): """Test the `androidtv.learn_sendevent` service.""" - patch_key = "server" - entity_id = "media_player.android_tv" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) response = "sendevent 1 2 3 4" with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() with patch( @@ -902,7 +842,7 @@ async def test_learn_sendevent(hass): return_value=response, ) as patch_learn_sendevent: await hass.services.async_call( - ANDROIDTV_DOMAIN, + DOMAIN, SERVICE_LEARN_SENDEVENT, {ATTR_ENTITY_ID: entity_id}, blocking=True, @@ -916,12 +856,13 @@ async def test_learn_sendevent(hass): async def test_update_lock_not_acquired(hass): """Test that the state does not get updated when a `LockNotAcquiredException` is raised.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: @@ -948,105 +889,112 @@ async def test_update_lock_not_acquired(hass): async def test_download(hass): """Test the `androidtv.download` service.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Failed download because path is not whitelisted - with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_pull") as patch_pull: - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_DOWNLOAD, - { - ATTR_ENTITY_ID: entity_id, - ATTR_DEVICE_PATH: device_path, - ATTR_LOCAL_PATH: local_path, - }, - blocking=True, - ) - patch_pull.assert_not_called() + # Failed download because path is not whitelisted + with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_pull") as patch_pull: + await hass.services.async_call( + DOMAIN, + SERVICE_DOWNLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_pull.assert_not_called() - # Successful download - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_pull" - ) as patch_pull, patch.object(hass.config, "is_allowed_path", return_value=True): - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_DOWNLOAD, - { - ATTR_ENTITY_ID: entity_id, - ATTR_DEVICE_PATH: device_path, - ATTR_LOCAL_PATH: local_path, - }, - blocking=True, - ) - patch_pull.assert_called_with(local_path, device_path) + # Successful download + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_pull" + ) as patch_pull, patch.object( + hass.config, "is_allowed_path", return_value=True + ): + await hass.services.async_call( + DOMAIN, + SERVICE_DOWNLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_pull.assert_called_with(local_path, device_path) async def test_upload(hass): """Test the `androidtv.upload` service.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) device_path = "device/path" local_path = "local/path" with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Failed upload because path is not whitelisted - with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_push") as patch_push: - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_UPLOAD, - { - ATTR_ENTITY_ID: entity_id, - ATTR_DEVICE_PATH: device_path, - ATTR_LOCAL_PATH: local_path, - }, - blocking=True, - ) - patch_push.assert_not_called() + # Failed upload because path is not whitelisted + with patch("androidtv.basetv.basetv_async.BaseTVAsync.adb_push") as patch_push: + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_push.assert_not_called() - # Successful upload - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_push" - ) as patch_push, patch.object(hass.config, "is_allowed_path", return_value=True): - await hass.services.async_call( - ANDROIDTV_DOMAIN, - SERVICE_UPLOAD, - { - ATTR_ENTITY_ID: entity_id, - ATTR_DEVICE_PATH: device_path, - ATTR_LOCAL_PATH: local_path, - }, - blocking=True, - ) - patch_push.assert_called_with(local_path, device_path) + # Successful upload + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_push" + ) as patch_push, patch.object( + hass.config, "is_allowed_path", return_value=True + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPLOAD, + { + ATTR_ENTITY_ID: entity_id, + ATTR_DEVICE_PATH: device_path, + ATTR_LOCAL_PATH: local_path, + }, + blocking=True, + ) + patch_push.assert_called_with(local_path, device_path) async def test_androidtv_volume_set(hass): """Test setting the volume for an Android TV device.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() with patch( "androidtv.basetv.basetv_async.BaseTVAsync.set_volume_level", return_value=0.5 ) as patch_set_volume_level: await hass.services.async_call( - DOMAIN, + MP_DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, blocking=True, @@ -1060,12 +1008,13 @@ async def test_get_image(hass, hass_ws_client): This is based on `test_get_image` in tests/components/media_player/test_init.py. """ - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() with patchers.patch_shell("11")[patch_key]: @@ -1126,7 +1075,7 @@ async def _test_service( f"androidtv.{androidtv_patch}.{androidtv_method}", return_value=return_value ) as service_call: await hass.services.async_call( - DOMAIN, + MP_DOMAIN, ha_service_name, service_data=service_data, blocking=True, @@ -1136,13 +1085,12 @@ async def _test_service( async def test_services_androidtv(hass): """Test media player services for an Android TV device.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[patch_key]: with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component( - hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: @@ -1163,13 +1111,6 @@ async def test_services_androidtv(hass): await _test_service( hass, entity_id, SERVICE_VOLUME_DOWN, "volume_down", return_value=0.1 ) - await _test_service( - hass, - entity_id, - SERVICE_VOLUME_MUTE, - "mute_volume", - {ATTR_MEDIA_VOLUME_MUTED: False}, - ) await _test_service( hass, entity_id, @@ -1185,14 +1126,17 @@ async def test_services_androidtv(hass): async def test_services_firetv(hass): """Test media player services for a Fire TV device.""" - patch_key, entity_id = _setup(CONFIG_FIRETV_ADB_SERVER) config = copy.deepcopy(CONFIG_FIRETV_ADB_SERVER) - config[DOMAIN][CONF_TURN_OFF_COMMAND] = "test off" - config[DOMAIN][CONF_TURN_ON_COMMAND] = "test on" + config[DOMAIN][CONF_OPTIONS] = { + CONF_TURN_OFF_COMMAND: "test off", + CONF_TURN_ON_COMMAND: "test on", + } + patch_key, entity_id, config_entry = _setup(config) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[patch_key]: with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, config) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: @@ -1201,14 +1145,58 @@ async def test_services_firetv(hass): await _test_service(hass, entity_id, SERVICE_TURN_ON, "adb_shell") +async def test_volume_mute(hass): + """Test the volume mute service.""" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) + + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: + service_data = {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_MUTED: True} + with patch( + "androidtv.androidtv.androidtv_async.AndroidTVAsync.mute_volume", + return_value=None, + ) as mute_volume: + # Don't send the mute key if the volume is already muted + with patch( + "androidtv.androidtv.androidtv_async.AndroidTVAsync.is_volume_muted", + return_value=True, + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_MUTE, + service_data=service_data, + blocking=True, + ) + assert not mute_volume.called + + # Send the mute key because the volume is not already muted + with patch( + "androidtv.androidtv.androidtv_async.AndroidTVAsync.is_volume_muted", + return_value=False, + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_MUTE, + service_data=service_data, + blocking=True, + ) + assert mute_volume.called + + async def test_connection_closed_on_ha_stop(hass): """Test that the ADB socket connection is closed when HA stops.""" - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() with patch( @@ -1224,14 +1212,15 @@ async def test_exception(hass): HA will attempt to reconnect on the next update. """ - patch_key, entity_id = _setup(CONFIG_ANDROIDTV_PYTHON_ADB) + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROIDTV_PYTHON_ADB) + config_entry.add_to_hass(hass) with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[ patch_key ], patchers.patch_shell(SHELL_RESPONSE_OFF)[ patch_key ], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER: - assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_PYTHON_ADB) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity(entity_id) @@ -1239,7 +1228,7 @@ async def test_exception(hass): assert state is not None assert state.state == STATE_OFF - # When an unforessen exception occurs, we close the ADB connection and raise the exception + # When an unforeseen exception occurs, we close the ADB connection and raise the exception with patchers.PATCH_ANDROIDTV_UPDATE_EXCEPTION, pytest.raises(Exception): await hass.helpers.entity_component.async_update_entity(entity_id) state = hass.states.get(entity_id) diff --git a/tests/components/apache_kafka/test_init.py b/tests/components/apache_kafka/test_init.py index c4285a0cc652d7..3f594b3fce3a0e 100644 --- a/tests/components/apache_kafka/test_init.py +++ b/tests/components/apache_kafka/test_init.py @@ -2,8 +2,8 @@ from __future__ import annotations from asyncio import AbstractEventLoop +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable from unittest.mock import patch import pytest diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index b9a7b80fcf69a7..d56389c7230238 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -560,20 +560,3 @@ def listener(service_call): "/api/services/test_domain/test_service", json={"hello": 5} ) assert resp.status == HTTPStatus.BAD_REQUEST - - -async def test_api_get_discovery_info(hass, mock_api_client): - """Test the return of discovery info.""" - resp = await mock_api_client.get(const.URL_API_DISCOVERY_INFO) - result = await resp.json() - - assert result == { - "base_url": "", - "external_url": "", - "installation_type": "", - "internal_url": "", - "location_name": "", - "requires_api_password": True, - "uuid": "", - "version": "", - } diff --git a/tests/components/apns/test_notify.py b/tests/components/apns/test_notify.py index 301ef1362faeb6..ad55a5697ad2bd 100644 --- a/tests/components/apns/test_notify.py +++ b/tests/components/apns/test_notify.py @@ -25,7 +25,7 @@ @pytest.fixture(scope="module", autouse=True) def mock_apns_notify_open(): - """Mock builtins.open for apns.notfiy.""" + """Mock builtins.open for apns.notify.""" with patch("homeassistant.components.apns.notify.open", mock_open(), create=True): yield diff --git a/tests/components/apple_tv/common.py b/tests/components/apple_tv/common.py index 6f13239edcb007..ddb8c1348d9dde 100644 --- a/tests/components/apple_tv/common.py +++ b/tests/components/apple_tv/common.py @@ -1,6 +1,6 @@ """Test code shared between test files.""" -from pyatv import conf, interface +from pyatv import conf, const, interface from pyatv.const import Protocol @@ -47,3 +47,37 @@ def create_conf(name, address, *services): for service in services: atv.add_service(service) return atv + + +def mrp_service(enabled=True, unique_id="mrpid"): + """Create example MRP service.""" + return conf.ManualService( + unique_id, + Protocol.MRP, + 5555, + {}, + pairing_requirement=const.PairingRequirement.Mandatory, + enabled=enabled, + ) + + +def airplay_service(): + """Create example AirPlay service.""" + return conf.ManualService( + "airplayid", + Protocol.AirPlay, + 7777, + {}, + pairing_requirement=const.PairingRequirement.Mandatory, + ) + + +def raop_service(): + """Create example RAOP service.""" + return conf.ManualService( + "AABBCCDDEEFF", + Protocol.RAOP, + 7000, + {}, + pairing_requirement=const.PairingRequirement.Mandatory, + ) diff --git a/tests/components/apple_tv/conftest.py b/tests/components/apple_tv/conftest.py index f07fa7d70bb958..f3c3148fe7280c 100644 --- a/tests/components/apple_tv/conftest.py +++ b/tests/components/apple_tv/conftest.py @@ -3,10 +3,11 @@ from unittest.mock import patch from pyatv import conf -from pyatv.support.http import create_session +from pyatv.const import PairingRequirement, Protocol +from pyatv.support import http import pytest -from .common import MockPairingHandler, create_conf +from .common import MockPairingHandler, airplay_service, create_conf, mrp_service @pytest.fixture(autouse=True, name="mock_scan") @@ -14,7 +15,9 @@ def mock_scan_fixture(): """Mock pyatv.scan.""" with patch("homeassistant.components.apple_tv.config_flow.scan") as mock_scan: - async def _scan(loop, timeout=5, identifier=None, protocol=None, hosts=None): + async def _scan( + loop, timeout=5, identifier=None, protocol=None, hosts=None, aiozc=None + ): if not mock_scan.hosts: mock_scan.hosts = hosts return mock_scan.result @@ -40,7 +43,7 @@ def pairing(): async def _pair(config, protocol, loop, session=None, **kwargs): handler = MockPairingHandler( - await create_session(session), config.get_service(protocol) + await http.create_session(session), config.get_service(protocol) ) handler.always_fail = mock_pair.always_fail return handler @@ -78,9 +81,15 @@ def full_device(mock_scan, dmap_pin): create_conf( "127.0.0.1", "MRP Device", - conf.MrpService("mrpid", 5555), - conf.DmapService("dmapid", None, port=6666), - conf.AirPlayService("airplayid", port=7777), + mrp_service(), + conf.ManualService( + "dmapid", + Protocol.DMAP, + 6666, + {}, + pairing_requirement=PairingRequirement.Mandatory, + ), + airplay_service(), ) ) yield mock_scan @@ -88,9 +97,40 @@ def full_device(mock_scan, dmap_pin): @pytest.fixture def mrp_device(mock_scan): + """Mock pyatv.scan.""" + mock_scan.result.extend( + [ + create_conf( + "127.0.0.1", + "MRP Device", + mrp_service(), + ), + create_conf( + "127.0.0.2", + "MRP Device 2", + mrp_service(unique_id="unrelated"), + ), + ] + ) + yield mock_scan + + +@pytest.fixture +def airplay_with_disabled_mrp(mock_scan): """Mock pyatv.scan.""" mock_scan.result.append( - create_conf("127.0.0.1", "MRP Device", conf.MrpService("mrpid", 5555)) + create_conf( + "127.0.0.1", + "AirPlay Device", + mrp_service(enabled=False), + conf.ManualService( + "airplayid", + Protocol.AirPlay, + 7777, + {}, + pairing_requirement=PairingRequirement.Mandatory, + ), + ) ) yield mock_scan @@ -102,7 +142,14 @@ def dmap_device(mock_scan): create_conf( "127.0.0.1", "DMAP Device", - conf.DmapService("dmapid", None, port=6666), + conf.ManualService( + "dmapid", + Protocol.DMAP, + 6666, + {}, + credentials=None, + pairing_requirement=PairingRequirement.Mandatory, + ), ) ) yield mock_scan @@ -115,14 +162,48 @@ def dmap_device_with_credentials(mock_scan): create_conf( "127.0.0.1", "DMAP Device", - conf.DmapService("dmapid", "dummy_creds", port=6666), + conf.ManualService( + "dmapid", + Protocol.DMAP, + 6666, + {}, + credentials="dummy_creds", + pairing_requirement=PairingRequirement.NotNeeded, + ), ) ) yield mock_scan @pytest.fixture -def device_with_no_services(mock_scan): +def airplay_device_with_password(mock_scan): """Mock pyatv.scan.""" - mock_scan.result.append(create_conf("127.0.0.1", "Invalid Device")) + mock_scan.result.append( + create_conf( + "127.0.0.1", + "AirPlay Device", + conf.ManualService( + "airplayid", Protocol.AirPlay, 7777, {}, requires_password=True + ), + ) + ) + yield mock_scan + + +@pytest.fixture +def dmap_with_requirement(mock_scan, pairing_requirement): + """Mock pyatv.scan.""" + mock_scan.result.append( + create_conf( + "127.0.0.1", + "DMAP Device", + conf.ManualService( + "dmapid", + Protocol.DMAP, + 6666, + {}, + pairing_requirement=pairing_requirement, + ), + ) + ) yield mock_scan diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index a99df9ad856f2e..b4811e57739892 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -1,27 +1,57 @@ """Test config flow.""" -from unittest.mock import patch +from ipaddress import IPv4Address +from unittest.mock import ANY, patch from pyatv import exceptions -from pyatv.const import Protocol +from pyatv.const import PairingRequirement, Protocol import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf -from homeassistant.components.apple_tv.const import CONF_START_OFF, DOMAIN +from homeassistant.components.apple_tv import CONF_ADDRESS, config_flow +from homeassistant.components.apple_tv.const import ( + CONF_IDENTIFIERS, + CONF_START_OFF, + DOMAIN, +) + +from .common import airplay_service, create_conf, mrp_service, raop_service from tests.common import MockConfigEntry DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( - host="mock_host", + host="127.0.0.1", hostname="mock_hostname", - name="dmapid.something", port=None, - properties={"CtlN": "Apple TV"}, type="_touch-able._tcp.local.", + name="dmapid._touch-able._tcp.local.", + properties={"CtlN": "Apple TV"}, +) + + +RAOP_SERVICE = zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + port=None, + type="_raop._tcp.local.", + name="AABBCCDDEEFF@Master Bed._raop._tcp.local.", + properties={"am": "AppleTV11,1"}, ) +@pytest.fixture(autouse=True) +def zero_aggregation_time(): + """Prevent the aggregation time from delaying the tests.""" + with patch.object(config_flow, "DISCOVERY_AGGREGATION_TIME", 0): + yield + + +@pytest.fixture(autouse=True) +def use_mocked_zeroconf(mock_async_zeroconf): + """Mock zeroconf in all tests.""" + + @pytest.fixture(autouse=True) def mock_setup_entry(): """Mock setting up a config entry.""" @@ -39,9 +69,8 @@ async def test_user_input_device_not_found(hass, mrp_device): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["description_placeholders"] == {"devices": "`MRP Device (127.0.0.1)`"} + assert result["step_id"] == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -81,7 +110,10 @@ async def test_user_adds_full_device(hass, full_device, pairing): {"device_input": "MRP Device"}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["description_placeholders"] == {"name": "MRP Device"} + assert result2["description_placeholders"] == { + "name": "MRP Device", + "type": "Unknown", + } result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -108,8 +140,8 @@ async def test_user_adds_full_device(hass, full_device, pairing): Protocol.MRP.value: "mrp_creds", Protocol.AirPlay.value: "airplay_creds", }, + "identifiers": ["mrpid", "dmapid", "airplayid"], "name": "MRP Device", - "protocol": Protocol.MRP.value, } @@ -124,7 +156,10 @@ async def test_user_adds_dmap_device(hass, dmap_device, dmap_pin, pairing): {"device_input": "DMAP Device"}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["description_placeholders"] == {"name": "DMAP Device"} + assert result2["description_placeholders"] == { + "name": "DMAP Device", + "type": "Unknown", + } result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -137,8 +172,8 @@ async def test_user_adds_dmap_device(hass, dmap_device, dmap_pin, pairing): assert result6["data"] == { "address": "127.0.0.1", "credentials": {Protocol.DMAP.value: "dmap_creds"}, + "identifiers": ["dmapid"], "name": "DMAP Device", - "protocol": Protocol.DMAP.value, } @@ -162,51 +197,46 @@ async def test_user_adds_dmap_device_failed(hass, dmap_device, dmap_pin, pairing assert result2["reason"] == "device_did_not_pair" -async def test_user_adds_device_with_credentials(hass, dmap_device_with_credentials): - """Test adding DMAP device with existing credentials (home sharing).""" +async def test_user_adds_device_with_ip_filter( + hass, dmap_device_with_credentials, mock_scan +): + """Test add device filtering by IP.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"device_input": "DMAP Device"}, + {"device_input": "127.0.0.1"}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["description_placeholders"] == {"name": "DMAP Device"} - - result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result3["type"] == "create_entry" - assert result3["data"] == { - "address": "127.0.0.1", - "credentials": {Protocol.DMAP.value: "dummy_creds"}, + assert result2["description_placeholders"] == { "name": "DMAP Device", - "protocol": Protocol.DMAP.value, + "type": "Unknown", } -async def test_user_adds_device_with_ip_filter( - hass, dmap_device_with_credentials, mock_scan -): - """Test add device filtering by IP.""" +@pytest.mark.parametrize("pairing_requirement", [(PairingRequirement.NotNeeded)]) +async def test_user_pair_no_interaction(hass, dmap_with_requirement, pairing_mock): + """Test pairing service without user interaction.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( + await hass.config_entries.flow.async_configure( result["flow_id"], - {"device_input": "127.0.0.1"}, + {"device_input": "DMAP Device"}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["description_placeholders"] == {"name": "DMAP Device"} - result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result3["type"] == "create_entry" - assert result3["data"] == { + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["data"] == { "address": "127.0.0.1", - "credentials": {Protocol.DMAP.value: "dummy_creds"}, + "credentials": {Protocol.DMAP.value: None}, + "identifiers": ["dmapid"], "name": "DMAP Device", - "protocol": Protocol.DMAP.value, } @@ -240,20 +270,6 @@ async def test_user_adds_existing_device(hass, mrp_device): assert result2["errors"] == {"base": "already_configured"} -async def test_user_adds_unusable_device(hass, device_with_no_services): - """Test that it is not possible to add device with no services.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"device_input": "Invalid Device"}, - ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "no_usable_service"} - - async def test_user_connection_failed(hass, mrp_device, pairing_mock): """Test error message when connection to device fails.""" pairing_mock.begin.side_effect = exceptions.ConnectionFailedError @@ -277,7 +293,7 @@ async def test_user_connection_failed(hass, mrp_device, pairing_mock): {}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result2["reason"] == "invalid_config" + assert result2["reason"] == "setup_failed" async def test_user_start_pair_error_failed(hass, mrp_device, pairing_mock): @@ -301,6 +317,81 @@ async def test_user_start_pair_error_failed(hass, mrp_device, pairing_mock): assert result2["reason"] == "invalid_auth" +async def test_user_pair_service_with_password( + hass, airplay_device_with_password, pairing_mock +): + """Test pairing with service requiring a password (not supported).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"device_input": "AirPlay Device"}, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "password" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result3["reason"] == "setup_failed" + + +@pytest.mark.parametrize("pairing_requirement", [(PairingRequirement.Disabled)]) +async def test_user_pair_disabled_service(hass, dmap_with_requirement, pairing_mock): + """Test pairing with disabled service (is ignored with message).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"device_input": "DMAP Device"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "protocol_disabled" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "setup_failed" + + +@pytest.mark.parametrize("pairing_requirement", [(PairingRequirement.Unsupported)]) +async def test_user_pair_ignore_unsupported(hass, dmap_with_requirement, pairing_mock): + """Test pairing with disabled service (is ignored silently).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"device_input": "DMAP Device"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "setup_failed" + + async def test_user_pair_invalid_pin(hass, mrp_device, pairing_mock): """Test pairing with invalid pin.""" pairing_mock.finish.side_effect = exceptions.PairingError @@ -395,6 +486,41 @@ async def test_user_pair_begin_unexpected_error(hass, mrp_device, pairing_mock): assert result2["reason"] == "unknown" +async def test_ignores_disabled_service(hass, airplay_with_disabled_mrp, pairing): + """Test adding device with only DMAP service.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Find based on mrpid (but do not pair that service since it's disabled) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"device_input": "mrpid"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["description_placeholders"] == { + "name": "AirPlay Device", + "type": "Unknown", + } + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["description_placeholders"] == {"protocol": "AirPlay"} + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"pin": 1111} + ) + assert result3["type"] == "create_entry" + assert result3["data"] == { + "address": "127.0.0.1", + "credentials": { + Protocol.AirPlay.value: "airplay_creds", + }, + "identifiers": ["mrpid", "airplayid"], + "name": "AirPlay Device", + } + + # Zeroconf @@ -404,12 +530,12 @@ async def test_zeroconf_unsupported_service_aborts(hass): DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", + host="127.0.0.1", hostname="mock_hostname", name="mock_name", port=None, - properties={}, type="_dummy._tcp.local.", + properties={}, ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -418,20 +544,37 @@ async def test_zeroconf_unsupported_service_aborts(hass): async def test_zeroconf_add_mrp_device(hass, mrp_device, pairing): """Test add MRP device discovered by zeroconf.""" + unrelated_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.2", + hostname="mock_hostname", + port=None, + name="Kitchen", + properties={"UniqueIdentifier": "unrelated", "Name": "Kitchen"}, + type="_mediaremotetv._tcp.local.", + ), + ) + assert unrelated_result["type"] == data_entry_flow.RESULT_TYPE_FORM + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", + host="127.0.0.1", hostname="mock_hostname", - name="mock_name", port=None, + name="Kitchen", properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"}, type="_mediaremotetv._tcp.local.", ), ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["description_placeholders"] == {"name": "MRP Device"} + assert result["description_placeholders"] == { + "name": "MRP Device", + "type": "Unknown", + } result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -447,8 +590,8 @@ async def test_zeroconf_add_mrp_device(hass, mrp_device, pairing): assert result3["data"] == { "address": "127.0.0.1", "credentials": {Protocol.MRP.value: "mrp_creds"}, + "identifiers": ["mrpid"], "name": "MRP Device", - "protocol": Protocol.MRP.value, } @@ -458,7 +601,10 @@ async def test_zeroconf_add_dmap_device(hass, dmap_device, dmap_pin, pairing): DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=DMAP_SERVICE ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["description_placeholders"] == {"name": "DMAP Device"} + assert result["description_placeholders"] == { + "name": "DMAP Device", + "type": "Unknown", + } result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -472,11 +618,83 @@ async def test_zeroconf_add_dmap_device(hass, dmap_device, dmap_pin, pairing): assert result3["data"] == { "address": "127.0.0.1", "credentials": {Protocol.DMAP.value: "dmap_creds"}, + "identifiers": ["dmapid"], "name": "DMAP Device", - "protocol": Protocol.DMAP.value, } +async def test_zeroconf_ip_change(hass, mock_scan): + """Test that the config entry gets updated when the ip changes and reloads.""" + entry = MockConfigEntry( + domain="apple_tv", unique_id="mrpid", data={CONF_ADDRESS: "127.0.0.2"} + ) + unrelated_entry = MockConfigEntry( + domain="apple_tv", unique_id="unrelated", data={CONF_ADDRESS: "127.0.0.2"} + ) + unrelated_entry.add_to_hass(hass) + entry.add_to_hass(hass) + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service() + ) + ] + + with patch( + "homeassistant.components.apple_tv.async_setup_entry", return_value=True + ) as mock_async_setup: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DMAP_SERVICE, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert len(mock_async_setup.mock_calls) == 2 + assert entry.data[CONF_ADDRESS] == "127.0.0.1" + assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2" + + +async def test_zeroconf_ip_change_via_secondary_identifier(hass, mock_scan): + """Test that the config entry gets updated when the ip changes and reloads. + + Instead of checking only the unique id, all the identifiers + in the config entry are checked + """ + entry = MockConfigEntry( + domain="apple_tv", + unique_id="aa:bb:cc:dd:ee:ff", + data={CONF_IDENTIFIERS: ["mrpid"], CONF_ADDRESS: "127.0.0.2"}, + ) + unrelated_entry = MockConfigEntry( + domain="apple_tv", unique_id="unrelated", data={CONF_ADDRESS: "127.0.0.2"} + ) + unrelated_entry.add_to_hass(hass) + entry.add_to_hass(hass) + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service() + ) + ] + + with patch( + "homeassistant.components.apple_tv.async_setup_entry", return_value=True + ) as mock_async_setup: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=DMAP_SERVICE, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert len(mock_async_setup.mock_calls) == 2 + assert entry.data[CONF_ADDRESS] == "127.0.0.1" + assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2" + + async def test_zeroconf_add_existing_aborts(hass, dmap_device): """Test start new zeroconf flow while existing flow is active aborts.""" await hass.config_entries.flow.async_init( @@ -521,17 +739,277 @@ async def test_zeroconf_unexpected_error(hass, mock_scan): assert result["reason"] == "unknown" +async def test_zeroconf_abort_if_other_in_progress(hass, mock_scan): + """Test discovering unsupported zeroconf service.""" + mock_scan.result = [ + create_conf(IPv4Address("127.0.0.1"), "Device", airplay_service()) + ] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + port=None, + type="_airplay._tcp.local.", + name="Kitchen", + properties={"deviceid": "airplayid"}, + ), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service() + ) + ] + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + port=None, + type="_mediaremotetv._tcp.local.", + name="Kitchen", + properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"}, + ), + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_in_progress" + + +async def test_zeroconf_missing_device_during_protocol_resolve( + hass, mock_scan, pairing, mock_zeroconf +): + """Test discovery after service been added to existing flow with missing device.""" + mock_scan.result = [ + create_conf(IPv4Address("127.0.0.1"), "Device", airplay_service()) + ] + + # Find device with AirPlay service and set up flow for it + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + port=None, + type="_airplay._tcp.local.", + name="Kitchen", + properties={"deviceid": "airplayid"}, + ), + ) + + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service() + ) + ] + + # Find the same device again, but now also with MRP service. The first flow should + # be updated with the MRP service. + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + port=None, + type="_mediaremotetv._tcp.local.", + name="Kitchen", + properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"}, + ), + ) + + mock_scan.result = [] + + # Number of services found during initial scan (1) will not match the updated count + # (2), so it will trigger a re-scan to find all services. This will fail as no + # device is found. + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "device_not_found" + + +async def test_zeroconf_additional_protocol_resolve_failure( + hass, mock_scan, pairing, mock_zeroconf +): + """Test discovery with missing service.""" + mock_scan.result = [ + create_conf(IPv4Address("127.0.0.1"), "Device", airplay_service()) + ] + + # Find device with AirPlay service and set up flow for it + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + port=None, + type="_airplay._tcp.local.", + name="Kitchen", + properties={"deviceid": "airplayid"}, + ), + ) + + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service() + ) + ] + + # Find the same device again, but now also with MRP service. The first flow should + # be updated with the MRP service. + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + port=None, + type="_mediaremotetv._tcp.local.", + name="Kitchen", + properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"}, + ), + ) + + mock_scan.result = [ + create_conf(IPv4Address("127.0.0.1"), "Device", airplay_service()) + ] + + # Number of services found during initial scan (1) will not match the updated count + # (2), so it will trigger a re-scan to find all services. This will however fail + # due to only one of the services found, yielding an error message. + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "inconsistent_device" + + +async def test_zeroconf_pair_additionally_found_protocols( + hass, mock_scan, pairing, mock_zeroconf +): + """Test discovered protocols are merged to original flow.""" + mock_scan.result = [ + create_conf(IPv4Address("127.0.0.1"), "Device", airplay_service()) + ] + + # Find device with AirPlay service and set up flow for it + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + port=None, + type="_airplay._tcp.local.", + name="Kitchen", + properties={"deviceid": "airplayid"}, + ), + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + await hass.async_block_till_done() + + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), "Device", raop_service(), airplay_service() + ) + ] + + # Find the same device again, but now also with RAOP service. The first flow should + # be updated with the RAOP service. + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=RAOP_SERVICE, + ) + await hass.async_block_till_done() + + mock_scan.result = [ + create_conf( + IPv4Address("127.0.0.1"), + "Device", + raop_service(), + mrp_service(), + airplay_service(), + ) + ] + + # Find the same device again, but now also with MRP service. The first flow should + # be updated with the MRP service. + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + hostname="mock_hostname", + port=None, + type="_mediaremotetv._tcp.local.", + name="Kitchen", + properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"}, + ), + ) + await hass.async_block_till_done() + + # Verify that all protocols are paired + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "pair_no_pin" + assert result2["description_placeholders"] == {"pin": ANY, "protocol": "RAOP"} + + # Verify that all protocols are paired + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["step_id"] == "pair_with_pin" + assert result3["description_placeholders"] == {"protocol": "MRP"} + + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"pin": 1234}, + ) + assert result4["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result4["step_id"] == "pair_with_pin" + assert result4["description_placeholders"] == {"protocol": "AirPlay"} + + result5 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"pin": 1234}, + ) + assert result5["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + # Re-configuration async def test_reconfigure_update_credentials(hass, mrp_device, pairing): """Test that reconfigure flow updates config entry.""" - config_entry = MockConfigEntry(domain="apple_tv", unique_id="mrpid") + config_entry = MockConfigEntry( + domain="apple_tv", unique_id="mrpid", data={"identifiers": ["mrpid"]} + ) config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_REAUTH}, + context={"source": "reauth"}, data={"identifier": "mrpid", "name": "apple tv"}, ) @@ -546,34 +1024,16 @@ async def test_reconfigure_update_credentials(hass, mrp_device, pairing): result["flow_id"], {"pin": 1111} ) assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result3["reason"] == "already_configured" + assert result3["reason"] == "reauth_successful" assert config_entry.data == { "address": "127.0.0.1", - "protocol": Protocol.MRP.value, "name": "MRP Device", "credentials": {Protocol.MRP.value: "mrp_creds"}, + "identifiers": ["mrpid"], } -async def test_reconfigure_ongoing_aborts(hass, mrp_device): - """Test start additional reconfigure flow aborts.""" - data = { - "identifier": "mrpid", - "name": "Apple TV", - } - - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=data - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=data - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_in_progress" - - # Options diff --git a/tests/components/aprs/test_device_tracker.py b/tests/components/aprs/test_device_tracker.py index 3da8cb825e49b0..6301b3d7d9bd43 100644 --- a/tests/components/aprs/test_device_tracker.py +++ b/tests/components/aprs/test_device_tracker.py @@ -4,9 +4,6 @@ import aprslib import homeassistant.components.aprs.device_tracker as device_tracker -from homeassistant.const import EVENT_HOMEASSISTANT_START - -from tests.common import get_test_home_assistant DEFAULT_PORT = 14580 @@ -299,25 +296,23 @@ def test_aprs_listener_rx_msg_no_position(): see.assert_not_called() -def test_setup_scanner(): +async def test_setup_scanner(hass): """Test setup_scanner.""" with patch( "homeassistant.components.aprs.device_tracker.AprsListenerThread" ) as listener: - hass = get_test_home_assistant() - hass.start() - config = { "username": TEST_CALLSIGN, "password": TEST_PASSWORD, "host": TEST_HOST, "callsigns": ["XX0FOO*", "YY0BAR-1"], + "timeout": device_tracker.DEFAULT_TIMEOUT, } see = Mock() - res = device_tracker.setup_scanner(hass, config, see) - hass.bus.fire(EVENT_HOMEASSISTANT_START) - hass.stop() + res = await hass.async_add_executor_job( + device_tracker.setup_scanner, hass, config, see + ) assert res listener.assert_called_with( @@ -325,12 +320,9 @@ def test_setup_scanner(): ) -def test_setup_scanner_timeout(): +async def test_setup_scanner_timeout(hass): """Test setup_scanner failure from timeout.""" with patch("aprslib.IS.connect", side_effect=TimeoutError): - hass = get_test_home_assistant() - hass.start() - config = { "username": TEST_CALLSIGN, "password": TEST_PASSWORD, @@ -340,5 +332,6 @@ def test_setup_scanner_timeout(): } see = Mock() - assert not device_tracker.setup_scanner(hass, config, see) - hass.stop() + assert not await hass.async_add_executor_job( + device_tracker.setup_scanner, hass, config, see + ) diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py index d5ab820ce46219..fe2507e3d8e8d9 100644 --- a/tests/components/arcam_fmj/test_device_trigger.py +++ b/tests/components/arcam_fmj/test_device_trigger.py @@ -3,6 +3,7 @@ from homeassistant.components.arcam_fmj.const import DOMAIN import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.setup import async_setup_component from tests.common import ( @@ -53,10 +54,14 @@ async def test_get_triggers(hass, device_reg, entity_reg): "entity_id": "media_player.arcam_fmj_5678", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) # Test triggers are either arcam_fmj specific or media_player entity triggers - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) for expected_trigger in expected_triggers: assert expected_trigger in triggers for trigger in triggers: diff --git a/tests/components/arlo/test_sensor.py b/tests/components/arlo/test_sensor.py index 4d6e8f6f228851..1948eca14a4a96 100644 --- a/tests/components/arlo/test_sensor.py +++ b/tests/components/arlo/test_sensor.py @@ -6,12 +6,8 @@ from homeassistant.components.arlo import DATA_ARLO, sensor as arlo from homeassistant.components.arlo.sensor import SENSOR_TYPES -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, - PERCENTAGE, -) +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import PERCENTAGE def _get_named_tuple(input_dict): @@ -157,12 +153,12 @@ def test_sensor_state_default(default_sensor): def test_sensor_device_class__battery(battery_sensor): """Test the battery device_class.""" - assert battery_sensor.device_class == DEVICE_CLASS_BATTERY + assert battery_sensor.device_class == SensorDeviceClass.BATTERY def test_sensor_device_class(temperature_sensor): """Test the device_class property.""" - assert temperature_sensor.device_class == DEVICE_CLASS_TEMPERATURE + assert temperature_sensor.device_class == SensorDeviceClass.TEMPERATURE def test_unit_of_measure(default_sensor, battery_sensor): @@ -174,8 +170,8 @@ def test_unit_of_measure(default_sensor, battery_sensor): def test_device_class(default_sensor, temperature_sensor, humidity_sensor): """Test the device_class property.""" assert default_sensor.device_class is None - assert temperature_sensor.device_class == DEVICE_CLASS_TEMPERATURE - assert humidity_sensor.device_class == DEVICE_CLASS_HUMIDITY + assert temperature_sensor.device_class == SensorDeviceClass.TEMPERATURE + assert humidity_sensor.device_class == SensorDeviceClass.HUMIDITY def test_attribution(default_sensor, temperature_sensor, humidity_sensor): diff --git a/tests/components/aseko_pool_live/__init__.py b/tests/components/aseko_pool_live/__init__.py new file mode 100644 index 00000000000000..6a63e0e585f470 --- /dev/null +++ b/tests/components/aseko_pool_live/__init__.py @@ -0,0 +1 @@ +"""Tests for the Aseko Pool Live integration.""" diff --git a/tests/components/aseko_pool_live/test_config_flow.py b/tests/components/aseko_pool_live/test_config_flow.py new file mode 100644 index 00000000000000..5ab85c61a8b20d --- /dev/null +++ b/tests/components/aseko_pool_live/test_config_flow.py @@ -0,0 +1,86 @@ +"""Test the Aseko Pool Live config flow.""" +from unittest.mock import AsyncMock, patch + +from aioaseko import AccountInfo, APIUnavailable, InvalidAuthCredentials +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.aseko_pool_live.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form(hass: HomeAssistant) -> None: + """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"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", + return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), + ), patch( + "homeassistant.components.aseko_pool_live.config_flow.MobileAccount", + ) as mock_mobile_account, patch( + "homeassistant.components.aseko_pool_live.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + mobile_account = mock_mobile_account.return_value + mobile_account.login = AsyncMock() + mobile_account.access_token = "any_access_token" + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "aseko@example.com", + CONF_PASSWORD: "passw0rd", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "aseko@example.com" + assert result2["data"] == {CONF_ACCESS_TOKEN: "any_access_token"} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "error_web, error_mobile, reason", + [ + (APIUnavailable, None, "cannot_connect"), + (InvalidAuthCredentials, None, "invalid_auth"), + (Exception, None, "unknown"), + (None, APIUnavailable, "cannot_connect"), + (None, InvalidAuthCredentials, "invalid_auth"), + (None, Exception, "unknown"), + ], +) +async def test_get_account_info_exceptions( + hass: HomeAssistant, error_web: Exception, error_mobile: Exception, reason: str +) -> None: + """Test we handle config flow exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", + return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), + side_effect=error_web, + ), patch( + "homeassistant.components.aseko_pool_live.config_flow.MobileAccount.login", + side_effect=error_mobile, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "aseko@example.com", + CONF_PASSWORD: "passw0rd", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": reason} diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index b8537a5e6a63c1..bfb62dae7e050e 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -19,7 +19,7 @@ STATE_HOME, STATE_NOT_HOME, ) -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import slugify from homeassistant.util.dt import utcnow @@ -41,6 +41,9 @@ MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] MOCK_LOAD_AVG = [1.1, 1.2, 1.3] MOCK_TEMPERATURES = {"2.4GHz": 40, "5.0GHz": 0, "CPU": 71.2} +MOCK_MAC_1 = "a1:b1:c1:d1:e1:f1" +MOCK_MAC_2 = "a2:b2:c2:d2:e2:f2" +MOCK_MAC_3 = "a3:b3:c3:d3:e3:f3" SENSOR_NAMES = [ "Devices Connected", @@ -61,8 +64,8 @@ def mock_devices_fixture(): """Mock a list of devices.""" return { - "a1:b1:c1:d1:e1:f1": Device("a1:b1:c1:d1:e1:f1", "192.168.1.2", "Test"), - "a2:b2:c2:d2:e2:f2": Device("a2:b2:c2:d2:e2:f2", "192.168.1.3", "TestTwo"), + MOCK_MAC_1: Device(MOCK_MAC_1, "192.168.1.2", "Test"), + MOCK_MAC_2: Device(MOCK_MAC_2, "192.168.1.3", "TestTwo"), } @@ -74,6 +77,26 @@ def mock_available_temps_list(): return [True, False] +@pytest.fixture(name="create_device_registry_devices") +def create_device_registry_devices_fixture(hass): + """Create device registry devices so the device tracker entities are enabled.""" + dev_reg = dr.async_get(hass) + config_entry = MockConfigEntry(domain="something_else") + + for idx, device in enumerate( + ( + MOCK_MAC_1, + MOCK_MAC_2, + MOCK_MAC_3, + ) + ): + dev_reg.async_get_or_create( + name=f"Device {idx}", + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, device)}, + ) + + @pytest.fixture(name="connect") def mock_controller_connect(mock_devices, mock_available_temps): """Mock a successful connection.""" @@ -109,7 +132,13 @@ def mock_controller_connect(mock_devices, mock_available_temps): yield service_mock -async def test_sensors(hass, connect, mock_devices, mock_available_temps): +async def test_sensors( + hass, + connect, + mock_devices, + mock_available_temps, + create_device_registry_devices, +): """Test creating an AsusWRT sensor.""" entity_reg = er.async_get(hass) @@ -161,10 +190,8 @@ async def test_sensors(hass, connect, mock_devices, mock_available_temps): assert not hass.states.get(f"{sensor_prefix}_cpu_temperature") # add one device and remove another - mock_devices.pop("a1:b1:c1:d1:e1:f1") - mock_devices["a3:b3:c3:d3:e3:f3"] = Device( - "a3:b3:c3:d3:e3:f3", "192.168.1.4", "TestThree" - ) + mock_devices.pop(MOCK_MAC_1) + mock_devices[MOCK_MAC_3] = Device(MOCK_MAC_3, "192.168.1.4", "TestThree") async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() diff --git a/tests/components/atag/conftest.py b/tests/components/atag/conftest.py new file mode 100644 index 00000000000000..7df3bfecf11ebf --- /dev/null +++ b/tests/components/atag/conftest.py @@ -0,0 +1,17 @@ +"""Provide common Atag fixtures.""" +import asyncio +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +async def mock_pyatag_sleep(): + """Mock out pyatag sleeps.""" + asyncio_sleep = asyncio.sleep + + async def sleep(duration, loop=None): + await asyncio_sleep(0) + + with patch("pyatag.gateway.asyncio.sleep", new=sleep): + yield diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 4d73eec7f1f14e..320461ca6e9729 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -30,6 +30,26 @@ ) +async def test_august_api_is_failing(hass): + """Config entry state is SETUP_RETRY when august api is failing.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=_mock_get_config()[DOMAIN], + title="August august", + ) + config_entry.add_to_hass(hass) + + with patch( + "yalexs.authenticator_async.AuthenticatorAsync.async_authenticate", + side_effect=ClientResponseError(None, None, status=500), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_august_is_offline(hass): """Config entry state is SETUP_RETRY when august is offline.""" diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index e9a6a100f4ad7c..e53dcf5ab06afb 100644 --- a/tests/components/aurora_abb_powerone/test_config_flow.py +++ b/tests/components/aurora_abb_powerone/test_config_flow.py @@ -1,6 +1,4 @@ """Test the Aurora ABB PowerOne Solar PV config flow.""" -from datetime import timedelta -import logging from logging import INFO from unittest.mock import patch @@ -14,24 +12,11 @@ ATTR_SERIAL_NUMBER, DOMAIN, ) -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS, CONF_PORT -from homeassistant.util.dt import utcnow - -from tests.common import MockConfigEntry, async_fire_time_changed TEST_DATA = {"device": "/dev/ttyUSB7", "address": 3, "name": "MyAuroraPV"} -def _simulated_returns(index, global_measure=None): - returns = { - 3: 45.678, # power - 21: 9.876, # temperature - 5: 12345, # energy - } - return returns[index] - - async def test_form(hass): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -164,240 +149,3 @@ async def test_form_invalid_com_ports(hass): ) assert result2["errors"] == {"base": "cannot_connect"} assert len(mock_clientclose.mock_calls) == 1 - - -async def test_import_invalid_com_ports(hass, caplog): - """Test we display correct info when the comport is invalid..""" - - caplog.set_level(logging.ERROR) - with patch( - "aurorapy.client.AuroraSerialClient.connect", - side_effect=OSError(19, "...no such device..."), - return_value=None, - ): - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TEST_DATA - ) - configs = hass.config_entries.async_entries(DOMAIN) - assert len(configs) == 1 - entry = configs[0] - assert entry.state == ConfigEntryState.SETUP_ERROR - assert "Failed to connect to inverter: " in caplog.text - - -async def test_import_com_port_wont_open(hass): - """Test we display correct info when comport won't open.""" - - with patch( - "aurorapy.client.AuroraSerialClient.connect", - side_effect=AuroraError("..could not open port..."), - ): - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TEST_DATA - ) - configs = hass.config_entries.async_entries(DOMAIN) - assert len(configs) == 1 - entry = configs[0] - assert entry.state == ConfigEntryState.SETUP_ERROR - - -async def test_import_other_oserror(hass): - """Test we display correct info when comport won't open.""" - - with patch( - "aurorapy.client.AuroraSerialClient.connect", - side_effect=OSError(18, "...another error..."), - ): - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TEST_DATA - ) - configs = hass.config_entries.async_entries(DOMAIN) - assert len(configs) == 1 - entry = configs[0] - assert entry.state == ConfigEntryState.SETUP_ERROR - - -# Tests below can be deleted after deprecation period is finished. -async def test_import_day(hass): - """Test .yaml import when the inverter is able to communicate.""" - - with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None,), patch( - "aurorapy.client.AuroraSerialClient.serial_number", - return_value="9876543", - ), patch( - "aurorapy.client.AuroraSerialClient.version", - return_value="9.8.7.6", - ), patch( - "aurorapy.client.AuroraSerialClient.pn", - return_value="A.B.C", - ), patch( - "aurorapy.client.AuroraSerialClient.firmware", - return_value="1.234", - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TEST_DATA - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"][CONF_PORT] == "/dev/ttyUSB7" - assert result["data"][CONF_ADDRESS] == 3 - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_night(hass): - """Test .yaml import when the inverter is inaccessible (e.g. darkness).""" - - # First time round, no response. - with patch( - "aurorapy.client.AuroraSerialClient.connect", - side_effect=AuroraError("No response after"), - ) as mock_connect: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TEST_DATA - ) - - configs = hass.config_entries.async_entries(DOMAIN) - assert len(configs) == 1 - entry = configs[0] - assert not entry.unique_id - assert entry.state == ConfigEntryState.SETUP_RETRY - - assert len(mock_connect.mock_calls) == 1 - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"][CONF_PORT] == "/dev/ttyUSB7" - assert result["data"][CONF_ADDRESS] == 3 - - # Second time round, talking this time. - with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None,), patch( - "aurorapy.client.AuroraSerialClient.serial_number", - return_value="9876543", - ), patch( - "aurorapy.client.AuroraSerialClient.version", - return_value="9.8.7.6", - ), patch( - "aurorapy.client.AuroraSerialClient.pn", - return_value="A.B.C", - ), patch( - "aurorapy.client.AuroraSerialClient.firmware", - return_value="1.234", - ), patch( - "aurorapy.client.AuroraSerialClient.measure", - side_effect=_simulated_returns, - ): - # Wait >5seconds for the config to auto retry. - async_fire_time_changed(hass, utcnow() + timedelta(seconds=6)) - await hass.async_block_till_done() - assert entry.state == ConfigEntryState.LOADED - assert entry.unique_id - - assert len(mock_connect.mock_calls) == 1 - power = hass.states.get("sensor.power_output") - assert power - assert power.state == "45.7" - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -async def test_import_night_then_user(hass): - """Attempt yaml import and fail (dark), but user sets up manually before auto retry.""" - - # First time round, no response. - with patch( - "aurorapy.client.AuroraSerialClient.connect", - side_effect=AuroraError("No response after"), - ) as mock_connect: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TEST_DATA - ) - - configs = hass.config_entries.async_entries(DOMAIN) - assert len(configs) == 1 - entry = configs[0] - assert not entry.unique_id - assert entry.state == ConfigEntryState.SETUP_RETRY - - assert len(mock_connect.mock_calls) == 1 - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"][CONF_PORT] == "/dev/ttyUSB7" - assert result["data"][CONF_ADDRESS] == 3 - - # Failed once, now simulate the user initiating config flow with valid settings. - fakecomports = [] - fakecomports.append(list_ports_common.ListPortInfo("/dev/ttyUSB7")) - with patch( - "serial.tools.list_ports.comports", - return_value=fakecomports, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None,), patch( - "aurorapy.client.AuroraSerialClient.serial_number", - return_value="9876543", - ), patch( - "aurorapy.client.AuroraSerialClient.version", - return_value="9.8.7.6", - ), patch( - "aurorapy.client.AuroraSerialClient.pn", - return_value="A.B.C", - ), patch( - "aurorapy.client.AuroraSerialClient.firmware", - return_value="1.234", - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PORT: "/dev/ttyUSB7", CONF_ADDRESS: 7}, - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - - # Now retry yaml - it should fail with duplicate - with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None,), patch( - "aurorapy.client.AuroraSerialClient.serial_number", - return_value="9876543", - ), patch( - "aurorapy.client.AuroraSerialClient.version", - return_value="9.8.7.6", - ), patch( - "aurorapy.client.AuroraSerialClient.pn", - return_value="A.B.C", - ), patch( - "aurorapy.client.AuroraSerialClient.firmware", - return_value="1.234", - ): - # Wait >5seconds for the config to auto retry. - async_fire_time_changed(hass, utcnow() + timedelta(seconds=6)) - await hass.async_block_till_done() - assert entry.state == ConfigEntryState.NOT_LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -async def test_import_already_existing(hass): - """Test configuration.yaml import when already configured.""" - TESTDATA = {"device": "/dev/ttyUSB7", "address": 7, "name": "MyAuroraPV"} - - entry = MockConfigEntry( - domain=DOMAIN, - title="MyAuroraPV", - unique_id="0123456", - data={ - CONF_PORT: "/dev/ttyUSB7", - CONF_ADDRESS: 7, - ATTR_FIRMWARE: "1.234", - ATTR_MODEL: "9.8.7.6 (A.B.C)", - ATTR_SERIAL_NUMBER: "9876543", - "title": "PhotoVoltaic Inverters", - }, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/aussie_broadband/__init__.py b/tests/components/aussie_broadband/__init__.py new file mode 100644 index 00000000000000..ef618b1f701db3 --- /dev/null +++ b/tests/components/aussie_broadband/__init__.py @@ -0,0 +1 @@ +"""Tests for the Aussie Broadband integration.""" diff --git a/tests/components/aussie_broadband/common.py b/tests/components/aussie_broadband/common.py new file mode 100644 index 00000000000000..abb4bce042d352 --- /dev/null +++ b/tests/components/aussie_broadband/common.py @@ -0,0 +1,58 @@ +"""Aussie Broadband common helpers for tests.""" +from unittest.mock import patch + +from homeassistant.components.aussie_broadband.const import ( + CONF_SERVICES, + DOMAIN as AUSSIE_BROADBAND_DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME + +from tests.common import MockConfigEntry + +FAKE_SERVICES = [ + { + "service_id": "12345678", + "description": "Fake ABB NBN Service", + "type": "NBN", + "name": "NBN", + }, + { + "service_id": "87654321", + "description": "Fake ABB Mobile Service", + "type": "PhoneMobile", + "name": "Mobile", + }, +] + +FAKE_DATA = { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + + +async def setup_platform(hass, platforms=[], side_effect=None, usage={}): + """Set up the Aussie Broadband platform.""" + mock_entry = MockConfigEntry( + domain=AUSSIE_BROADBAND_DOMAIN, + data=FAKE_DATA, + options={CONF_SERVICES: ["12345678", "87654321"], CONF_SCAN_INTERVAL: 30}, + ) + mock_entry.add_to_hass(hass) + + with patch("homeassistant.components.aussie_broadband.PLATFORMS", platforms), patch( + "aussiebb.asyncio.AussieBB.__init__", return_value=None + ), patch( + "aussiebb.asyncio.AussieBB.login", + return_value=True, + side_effect=side_effect, + ), patch( + "aussiebb.asyncio.AussieBB.get_services", + return_value=FAKE_SERVICES, + side_effect=side_effect, + ), patch( + "aussiebb.asyncio.AussieBB.get_usage", return_value=usage + ): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/aussie_broadband/test_config_flow.py b/tests/components/aussie_broadband/test_config_flow.py new file mode 100644 index 00000000000000..7e919636b09b24 --- /dev/null +++ b/tests/components/aussie_broadband/test_config_flow.py @@ -0,0 +1,322 @@ +"""Test the Aussie Broadband config flow.""" +from unittest.mock import patch + +from aiohttp import ClientConnectionError +from aussiebb.asyncio import AuthenticationException + +from homeassistant import config_entries, setup +from homeassistant.components.aussie_broadband.const import CONF_SERVICES, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from .common import FAKE_DATA, FAKE_SERVICES, setup_platform + +TEST_USERNAME = FAKE_DATA[CONF_USERNAME] +TEST_PASSWORD = FAKE_DATA[CONF_PASSWORD] + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result1["type"] == RESULT_TYPE_FORM + assert result1["errors"] is None + + with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( + "aussiebb.asyncio.AussieBB.login", return_value=True + ), patch( + "aussiebb.asyncio.AussieBB.get_services", return_value=[FAKE_SERVICES[0]] + ), patch( + "homeassistant.components.aussie_broadband.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + FAKE_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == TEST_USERNAME + assert result2["data"] == FAKE_DATA + assert result2["options"] == {CONF_SERVICES: ["12345678"]} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_already_configured(hass: HomeAssistant) -> None: + """Test already configured.""" + # Setup an entry + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( + "aussiebb.asyncio.AussieBB.login", return_value=True + ), patch( + "aussiebb.asyncio.AussieBB.get_services", return_value=[FAKE_SERVICES[0]] + ), patch( + "homeassistant.components.aussie_broadband.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + await hass.config_entries.flow.async_configure( + result1["flow_id"], + FAKE_DATA, + ) + await hass.async_block_till_done() + + # Test Already configured + result3 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( + "aussiebb.asyncio.AussieBB.login", return_value=True + ), patch( + "aussiebb.asyncio.AussieBB.get_services", return_value=[FAKE_SERVICES[0]] + ), patch( + "homeassistant.components.aussie_broadband.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + FAKE_DATA, + ) + await hass.async_block_till_done() + + assert result4["type"] == RESULT_TYPE_ABORT + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_no_services(hass: HomeAssistant) -> None: + """Test when there are no services.""" + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result1["type"] == RESULT_TYPE_FORM + assert result1["errors"] is None + + with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( + "aussiebb.asyncio.AussieBB.login", return_value=True + ), patch("aussiebb.asyncio.AussieBB.get_services", return_value=[]), patch( + "homeassistant.components.aussie_broadband.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + FAKE_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "no_services_found" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_form_multiple_services(hass: HomeAssistant) -> None: + """Test the config flow with multiple services.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( + "aussiebb.asyncio.AussieBB.login", return_value=True + ), patch("aussiebb.asyncio.AussieBB.get_services", return_value=FAKE_SERVICES): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FAKE_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "service" + assert result2["errors"] is None + + with patch( + "homeassistant.components.aussie_broadband.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERVICES: [FAKE_SERVICES[1]["service_id"]]}, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == TEST_USERNAME + assert result3["data"] == FAKE_DATA + assert result3["options"] == { + CONF_SERVICES: [FAKE_SERVICES[1]["service_id"]], + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test invalid auth is handled.""" + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( + "aussiebb.asyncio.AussieBB.login", side_effect=AuthenticationException() + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + FAKE_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_network_issue(hass: HomeAssistant) -> None: + """Test network issues are handled.""" + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( + "aussiebb.asyncio.AussieBB.login", side_effect=ClientConnectionError() + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + FAKE_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauth flow.""" + + await setup.async_setup_component(hass, "persistent_notification", {}) + + # Test reauth but the entry doesn't exist + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=FAKE_DATA + ) + + with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( + "aussiebb.asyncio.AussieBB.login", return_value=True + ), patch( + "aussiebb.asyncio.AussieBB.get_services", return_value=[FAKE_SERVICES[0]] + ), patch( + "homeassistant.components.aussie_broadband.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + { + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == TEST_USERNAME + assert result2["data"] == FAKE_DATA + + # Test failed reauth + result5 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH}, + data=FAKE_DATA, + ) + assert result5["step_id"] == "reauth" + + with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( + "aussiebb.asyncio.AussieBB.login", side_effect=AuthenticationException() + ), patch("aussiebb.asyncio.AussieBB.get_services", return_value=[FAKE_SERVICES[0]]): + + result6 = await hass.config_entries.flow.async_configure( + result5["flow_id"], + { + CONF_PASSWORD: "test-wrongpassword", + }, + ) + await hass.async_block_till_done() + + assert result6["step_id"] == "reauth" + + # Test successful reauth + with patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( + "aussiebb.asyncio.AussieBB.login", return_value=True + ), patch("aussiebb.asyncio.AussieBB.get_services", return_value=[FAKE_SERVICES[0]]): + + result7 = await hass.config_entries.flow.async_configure( + result6["flow_id"], + { + CONF_PASSWORD: "test-newpassword", + }, + ) + await hass.async_block_till_done() + + assert result7["type"] == "abort" + assert result7["reason"] == "reauth_successful" + + +async def test_options_flow(hass): + """Test options flow.""" + entry = await setup_platform(hass) + + with patch("aussiebb.asyncio.AussieBB.get_services", return_value=FAKE_SERVICES): + + result1 = await hass.config_entries.options.async_init(entry.entry_id) + assert result1["type"] == RESULT_TYPE_FORM + assert result1["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result1["flow_id"], + user_input={CONF_SERVICES: []}, + ) + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert entry.options == {CONF_SERVICES: []} + + +async def test_options_flow_auth_failure(hass): + """Test options flow with auth failure.""" + + entry = await setup_platform(hass) + + with patch( + "aussiebb.asyncio.AussieBB.get_services", side_effect=AuthenticationException() + ): + + result1 = await hass.config_entries.options.async_init(entry.entry_id) + assert result1["type"] == RESULT_TYPE_ABORT + assert result1["reason"] == "invalid_auth" + + +async def test_options_flow_network_failure(hass): + """Test options flow with connectivity failure.""" + + entry = await setup_platform(hass) + + with patch( + "aussiebb.asyncio.AussieBB.get_services", side_effect=ClientConnectionError() + ): + + result1 = await hass.config_entries.options.async_init(entry.entry_id) + assert result1["type"] == RESULT_TYPE_ABORT + assert result1["reason"] == "cannot_connect" + + +async def test_options_flow_not_loaded(hass): + """Test the options flow aborts when the entry has unloaded due to a reauth.""" + + entry = await setup_platform(hass) + + with patch( + "aussiebb.asyncio.AussieBB.get_services", side_effect=AuthenticationException() + ): + entry.state = config_entries.ConfigEntryState.NOT_LOADED + result1 = await hass.config_entries.options.async_init(entry.entry_id) + assert result1["type"] == RESULT_TYPE_ABORT + assert result1["reason"] == "unknown" diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py new file mode 100644 index 00000000000000..9e31aa9b737d8d --- /dev/null +++ b/tests/components/aussie_broadband/test_init.py @@ -0,0 +1,35 @@ +"""Test the Aussie Broadband init.""" +from unittest.mock import patch + +from aiohttp import ClientConnectionError +from aussiebb.asyncio import AuthenticationException + +from homeassistant import data_entry_flow +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .common import setup_platform + + +async def test_unload(hass: HomeAssistant) -> None: + """Test unload.""" + entry = await setup_platform(hass) + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_auth_failure(hass: HomeAssistant) -> None: + """Test init with an authentication failure.""" + with patch( + "homeassistant.components.aussie_broadband.config_flow.ConfigFlow.async_step_reauth", + return_value={"type": data_entry_flow.RESULT_TYPE_FORM}, + ) as mock_async_step_reauth: + await setup_platform(hass, side_effect=AuthenticationException()) + mock_async_step_reauth.assert_called_once() + + +async def test_net_failure(hass: HomeAssistant) -> None: + """Test init with a network failure.""" + entry = await setup_platform(hass, side_effect=ClientConnectionError()) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/aussie_broadband/test_sensor.py b/tests/components/aussie_broadband/test_sensor.py new file mode 100644 index 00000000000000..30fac808a2780d --- /dev/null +++ b/tests/components/aussie_broadband/test_sensor.py @@ -0,0 +1,50 @@ +"""Aussie Broadband sensor platform tests.""" +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN + +from .common import setup_platform + +MOCK_NBN_USAGE = { + "usedMb": 54321, + "downloadedMb": 50000, + "uploadedMb": 4321, + "daysTotal": 28, + "daysRemaining": 25, +} + +MOCK_MOBILE_USAGE = { + "national": {"calls": 1, "cost": 0}, + "mobile": {"calls": 2, "cost": 0}, + "international": {"calls": 3, "cost": 0}, + "sms": {"calls": 4, "cost": 0}, + "internet": {"kbytes": 512, "cost": 0}, + "voicemail": {"calls": 6, "cost": 0}, + "other": {"calls": 7, "cost": 0}, + "daysTotal": 31, + "daysRemaining": 30, + "historical": [], +} + + +async def test_nbn_sensor_states(hass): + """Tests that the sensors are correct.""" + + await setup_platform(hass, [SENSOR_DOMAIN], usage=MOCK_NBN_USAGE) + + assert hass.states.get("sensor.nbn_data_used").state == "54321" + assert hass.states.get("sensor.nbn_downloaded").state == "50000" + assert hass.states.get("sensor.nbn_uploaded").state == "4321" + assert hass.states.get("sensor.nbn_billing_cycle_length").state == "28" + assert hass.states.get("sensor.nbn_billing_cycle_remaining").state == "25" + + +async def test_phone_sensor_states(hass): + """Tests that the sensors are correct.""" + + await setup_platform(hass, [SENSOR_DOMAIN], usage=MOCK_MOBILE_USAGE) + + assert hass.states.get("sensor.mobile_national_calls").state == "1" + assert hass.states.get("sensor.mobile_mobile_calls").state == "2" + assert hass.states.get("sensor.mobile_sms_sent").state == "4" + assert hass.states.get("sensor.mobile_data_used").state == "512" + assert hass.states.get("sensor.mobile_billing_cycle_length").state == "31" + assert hass.states.get("sensor.mobile_billing_cycle_remaining").state == "30" diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 39c7c4897c468c..f6d0695d97d659 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -484,8 +484,6 @@ async def test_ws_sign_path(hass, hass_ws_client, hass_access_token): assert await async_setup_component(hass, "auth", {"http": {}}) ws_client = await hass_ws_client(hass, hass_access_token) - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) - with patch( "homeassistant.components.auth.async_sign_path", return_value="hello_world" ) as mock_sign: @@ -502,7 +500,6 @@ async def test_ws_sign_path(hass, hass_ws_client, hass_access_token): assert result["success"], result assert result["result"] == {"path": "hello_world"} assert len(mock_sign.mock_calls) == 1 - hass, p_refresh_token, path, expires = mock_sign.mock_calls[0][1] - assert p_refresh_token == refresh_token.id + hass, path, expires = mock_sign.mock_calls[0][1] assert path == "/api/hello" assert expires.total_seconds() == 20 diff --git a/tests/components/aws/test_init.py b/tests/components/aws/test_init.py index e50c0aa546b7c0..f186922dd2f337 100644 --- a/tests/components/aws/test_init.py +++ b/tests/components/aws/test_init.py @@ -1,7 +1,6 @@ """Tests for the aws component config and setup.""" from unittest.mock import AsyncMock, MagicMock, patch as async_patch -from homeassistant.components import aws from homeassistant.setup import async_setup_component @@ -29,25 +28,30 @@ def create_client(self, *args, **kwargs): # pylint: disable=no-self-use __aexit__=AsyncMock(), ) + async def get_available_regions(self, *args, **kwargs): + """Return list of available regions.""" + return ["us-east-1", "us-east-2", "us-west-1", "us-west-2"] + async def test_empty_config(hass): """Test a default config will be create for empty config.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ): await async_setup_component(hass, "aws", {"aws": {}}) await hass.async_block_till_done() - sessions = hass.data[aws.DATA_SESSIONS] - assert sessions is not None - assert len(sessions) == 1 - session = sessions.get("default") - assert isinstance(session, MockAioSession) # we don't validate auto-created default profile - session.get_user.assert_not_awaited() + mock_session.get_user.assert_not_awaited() async def test_empty_credential(hass): """Test a default config will be create for empty credential section.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ): await async_setup_component( hass, "aws", @@ -65,22 +69,20 @@ async def test_empty_credential(hass): ) await hass.async_block_till_done() - sessions = hass.data[aws.DATA_SESSIONS] - assert sessions is not None - assert len(sessions) == 1 - session = sessions.get("default") - assert isinstance(session, MockAioSession) - assert hass.services.has_service("notify", "new_lambda_test") is True await hass.services.async_call( "notify", "new_lambda_test", {"message": "test", "target": "ARN"}, blocking=True ) - session.invoke.assert_awaited_once() + mock_session.invoke.assert_awaited_once() async def test_profile_credential(hass): """Test credentials with profile name.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ): + await async_setup_component( hass, "aws", @@ -100,12 +102,6 @@ async def test_profile_credential(hass): ) await hass.async_block_till_done() - sessions = hass.data[aws.DATA_SESSIONS] - assert sessions is not None - assert len(sessions) == 1 - session = sessions.get("test") - assert isinstance(session, MockAioSession) - assert hass.services.has_service("notify", "sns_test") is True await hass.services.async_call( "notify", @@ -113,12 +109,15 @@ async def test_profile_credential(hass): {"title": "test", "message": "test", "target": "ARN"}, blocking=True, ) - session.publish.assert_awaited_once() + mock_session.publish.assert_awaited_once() async def test_access_key_credential(hass): """Test credentials with access key.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ): await async_setup_component( hass, "aws", @@ -145,12 +144,6 @@ async def test_access_key_credential(hass): ) await hass.async_block_till_done() - sessions = hass.data[aws.DATA_SESSIONS] - assert sessions is not None - assert len(sessions) == 2 - session = sessions.get("key") - assert isinstance(session, MockAioSession) - assert hass.services.has_service("notify", "sns_test") is True await hass.services.async_call( "notify", @@ -158,12 +151,17 @@ async def test_access_key_credential(hass): {"title": "test", "message": "test", "target": "ARN"}, blocking=True, ) - session.publish.assert_awaited_once() + mock_session.publish.assert_awaited_once() async def test_notify_credential(hass): """Test notify service can use access key directly.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ), async_patch( + "homeassistant.components.aws.notify.AioSession", return_value=mock_session + ): await async_setup_component( hass, "aws", @@ -184,11 +182,6 @@ async def test_notify_credential(hass): ) await hass.async_block_till_done() - sessions = hass.data[aws.DATA_SESSIONS] - assert sessions is not None - assert len(sessions) == 1 - assert isinstance(sessions.get("default"), MockAioSession) - assert hass.services.has_service("notify", "sqs_test") is True await hass.services.async_call( "notify", "sqs_test", {"message": "test", "target": "ARN"}, blocking=True @@ -197,7 +190,12 @@ async def test_notify_credential(hass): async def test_notify_credential_profile(hass): """Test notify service can use profile directly.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ), async_patch( + "homeassistant.components.aws.notify.AioSession", return_value=mock_session + ): await async_setup_component( hass, "aws", @@ -216,11 +214,6 @@ async def test_notify_credential_profile(hass): ) await hass.async_block_till_done() - sessions = hass.data[aws.DATA_SESSIONS] - assert sessions is not None - assert len(sessions) == 1 - assert isinstance(sessions.get("default"), MockAioSession) - assert hass.services.has_service("notify", "sqs_test") is True await hass.services.async_call( "notify", "sqs_test", {"message": "test", "target": "ARN"}, blocking=True @@ -229,7 +222,10 @@ async def test_notify_credential_profile(hass): async def test_credential_skip_validate(hass): """Test credential can skip validate.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ): await async_setup_component( hass, "aws", @@ -248,9 +244,48 @@ async def test_credential_skip_validate(hass): ) await hass.async_block_till_done() - sessions = hass.data[aws.DATA_SESSIONS] - assert sessions is not None - assert len(sessions) == 1 - session = sessions.get("key") - assert isinstance(session, MockAioSession) - session.get_user.assert_not_awaited() + mock_session.get_user.assert_not_awaited() + + +async def test_service_call_extra_data(hass): + """Test service call extra data are parsed properly.""" + mock_session = MockAioSession() + with async_patch( + "homeassistant.components.aws.AioSession", return_value=mock_session + ): + await async_setup_component( + hass, + "aws", + { + "aws": { + "notify": [ + { + "service": "sns", + "name": "SNS Test", + "region_name": "us-east-1", + } + ] + } + }, + ) + await hass.async_block_till_done() + + assert hass.services.has_service("notify", "sns_test") is True + await hass.services.async_call( + "notify", + "sns_test", + { + "message": "test", + "target": "ARN", + "data": {"AWS.SNS.SMS.SenderID": "HA-notify"}, + }, + blocking=True, + ) + mock_session.publish.assert_called_once_with( + TargetArn="ARN", + Message="test", + Subject="Home Assistant", + MessageAttributes={ + "AWS.SNS.SMS.SenderID": {"StringValue": "HA-notify", "DataType": "String"} + }, + ) diff --git a/tests/components/axis/test_binary_sensor.py b/tests/components/axis/test_binary_sensor.py index 2429ec618554ac..0fe862263ff811 100644 --- a/tests/components/axis/test_binary_sensor.py +++ b/tests/components/axis/test_binary_sensor.py @@ -2,8 +2,8 @@ from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, ) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component @@ -63,9 +63,9 @@ async def test_binary_sensors(hass, mock_rtsp_event): pir = hass.states.get(f"{BINARY_SENSOR_DOMAIN}.{NAME}_pir_0") assert pir.state == STATE_OFF assert pir.name == f"{NAME} PIR 0" - assert pir.attributes["device_class"] == DEVICE_CLASS_MOTION + assert pir.attributes["device_class"] == BinarySensorDeviceClass.MOTION vmd4 = hass.states.get(f"{BINARY_SENSOR_DOMAIN}.{NAME}_vmd4_profile_1") assert vmd4.state == STATE_ON assert vmd4.name == f"{NAME} VMD4 Profile 1" - assert vmd4.attributes["device_class"] == DEVICE_CLASS_MOTION + assert vmd4.attributes["device_class"] == BinarySensorDeviceClass.MOTION diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 6263c62be42f2e..bc03cb99f07b26 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -319,6 +319,10 @@ async def test_discovery_flow(hass, source: str, discovery_info: dict): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == SOURCE_USER + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0].get("context", {}).get("configuration_url") == "http://1.2.3.4:80" + with respx.mock: mock_default_vapix_requests(respx) result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/axis/test_diagnostics.py b/tests/components/axis/test_diagnostics.py new file mode 100644 index 00000000000000..4f43f1d42ff9df --- /dev/null +++ b/tests/components/axis/test_diagnostics.py @@ -0,0 +1,102 @@ +"""Test Axis diagnostics.""" + +from copy import deepcopy +from unittest.mock import patch + +from homeassistant.components.diagnostics import REDACTED + +from .test_device import ( + API_DISCOVERY_BASIC_DEVICE_INFO, + API_DISCOVERY_RESPONSE, + setup_axis_integration, +) + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, hass_client): + """Test config entry diagnostics.""" + api_discovery = deepcopy(API_DISCOVERY_RESPONSE) + api_discovery["data"]["apiList"].append(API_DISCOVERY_BASIC_DEVICE_INFO) + + with patch.dict(API_DISCOVERY_RESPONSE, api_discovery): + config_entry = await setup_axis_integration(hass) + + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "config": { + "entry_id": config_entry.entry_id, + "version": 3, + "domain": "axis", + "title": "Mock Title", + "data": { + "host": "1.2.3.4", + "username": REDACTED, + "password": REDACTED, + "port": 80, + "model": "model", + "name": "name", + }, + "options": {"events": True}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": REDACTED, + "disabled_by": None, + }, + "api_discovery": [ + { + "id": "api-discovery", + "name": "API Discovery Service", + "version": "1.0", + }, + { + "id": "param-cgi", + "name": "Legacy Parameter Handling", + "version": "1.0", + }, + { + "id": "basic-device-info", + "name": "Basic Device Information", + "version": "1.1", + }, + ], + "basic_device_info": { + "ProdNbr": "M1065-LW", + "ProdType": "Network Camera", + "SerialNumber": REDACTED, + "Version": "9.80.1", + }, + "params": { + "root.IOPort": { + "I0.Configurable": "no", + "I0.Direction": "input", + "I0.Input.Name": "PIR sensor", + "I0.Input.Trig": "closed", + }, + "root.Input": {"NbrOfInputs": "1"}, + "root.Output": {"NbrOfOutputs": "0"}, + "root.Properties": { + "API.HTTP.Version": "3", + "API.Metadata.Metadata": "yes", + "API.Metadata.Version": "1.0", + "EmbeddedDevelopment.Version": "2.16", + "Firmware.BuildDate": "Feb 15 2019 09:42", + "Firmware.BuildNumber": "26", + "Firmware.Version": "9.10.1", + "Image.Format": "jpeg,mjpeg,h264", + "Image.NbrOfViews": "2", + "Image.Resolution": "1920x1080,1280x960,1280x720,1024x768,1024x576,800x600,640x480,640x360,352x240,320x240", + "Image.Rotation": "0,180", + "System.SerialNumber": REDACTED, + }, + "root.StreamProfile": { + "MaxGroups": "26", + "S0.Description": "profile_1_description", + "S0.Name": "profile_1", + "S0.Parameters": "videocodec=h264", + "S1.Description": "profile_2_description", + "S1.Name": "profile_2", + "S1.Parameters": "videocodec=h265", + }, + }, + } diff --git a/tests/components/azure_event_hub/conftest.py b/tests/components/azure_event_hub/conftest.py new file mode 100644 index 00000000000000..18f44b10480b1b --- /dev/null +++ b/tests/components/azure_event_hub/conftest.py @@ -0,0 +1,126 @@ +"""Test fixtures for AEH.""" +from dataclasses import dataclass +from datetime import timedelta +import logging +from unittest.mock import MagicMock, patch + +from azure.eventhub.aio import EventHubProducerClient +import pytest + +from homeassistant.components.azure_event_hub.const import ( + CONF_FILTER, + CONF_SEND_INTERVAL, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_ON +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from .const import AZURE_EVENT_HUB_PATH, BASIC_OPTIONS, PRODUCER_PATH, SAS_CONFIG_FULL + +from tests.common import MockConfigEntry, async_fire_time_changed + +_LOGGER = logging.getLogger(__name__) + + +# fixtures for both init and config flow tests +@pytest.fixture(autouse=True, name="mock_get_eventhub_properties") +def mock_get_eventhub_properties_fixture(): + """Mock azure event hub properties, used to test the connection.""" + with patch(f"{PRODUCER_PATH}.get_eventhub_properties") as get_eventhub_properties: + yield get_eventhub_properties + + +@pytest.fixture(name="filter_schema") +def mock_filter_schema(): + """Return an empty filter.""" + return {} + + +@pytest.fixture(name="entry") +async def mock_entry_fixture(hass, filter_schema, mock_create_batch, mock_send_batch): + """Create the setup in HA.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=SAS_CONFIG_FULL, + title="test-instance", + options=BASIC_OPTIONS, + ) + entry.add_to_hass(hass) + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {CONF_FILTER: filter_schema}} + ) + assert entry.state == ConfigEntryState.LOADED + + # Clear the component_loaded event from the queue. + async_fire_time_changed( + hass, + utcnow() + timedelta(seconds=entry.options[CONF_SEND_INTERVAL]), + ) + await hass.async_block_till_done() + return entry + + +# fixtures for init tests +@pytest.fixture(name="entry_with_one_event") +async def mock_entry_with_one_event(hass, entry): + """Use the entry and add a single test event to the queue.""" + assert entry.state == ConfigEntryState.LOADED + hass.states.async_set("sensor.test", STATE_ON) + return entry + + +@dataclass +class FilterTest: + """Class for capturing a filter test.""" + + entity_id: str + expected_count: int + + +@pytest.fixture(name="mock_send_batch") +def mock_send_batch_fixture(): + """Mock send_batch.""" + with patch(f"{PRODUCER_PATH}.send_batch") as mock_send_batch: + yield mock_send_batch + + +@pytest.fixture(autouse=True, name="mock_client") +def mock_client_fixture(mock_send_batch): + """Mock the azure event hub producer client.""" + with patch(f"{PRODUCER_PATH}.close") as mock_close: + yield ( + mock_send_batch, + mock_close, + ) + + +@pytest.fixture(name="mock_create_batch") +def mock_create_batch_fixture(): + """Mock batch creator and return mocked batch object.""" + mock_batch = MagicMock() + with patch(f"{PRODUCER_PATH}.create_batch", return_value=mock_batch): + yield mock_batch + + +# fixtures for config flow tests +@pytest.fixture(name="mock_from_connection_string") +def mock_from_connection_string_fixture(): + """Mock AEH from connection string creation.""" + mock_aeh = MagicMock(spec=EventHubProducerClient) + mock_aeh.__aenter__.return_value = mock_aeh + with patch( + f"{PRODUCER_PATH}.from_connection_string", + return_value=mock_aeh, + ) as from_conn_string: + yield from_conn_string + + +@pytest.fixture(name="mock_setup_entry") +def mock_setup_entry(): + """Mock the setup entry call, used for config flow tests.""" + with patch( + f"{AZURE_EVENT_HUB_PATH}.async_setup_entry", return_value=True + ) as setup_entry: + yield setup_entry diff --git a/tests/components/azure_event_hub/const.py b/tests/components/azure_event_hub/const.py new file mode 100644 index 00000000000000..1daf100238cf89 --- /dev/null +++ b/tests/components/azure_event_hub/const.py @@ -0,0 +1,56 @@ +"""Constants for testing AEH.""" +from homeassistant.components.azure_event_hub.const import ( + CONF_EVENT_HUB_CON_STRING, + CONF_EVENT_HUB_INSTANCE_NAME, + CONF_EVENT_HUB_NAMESPACE, + CONF_EVENT_HUB_SAS_KEY, + CONF_EVENT_HUB_SAS_POLICY, + CONF_MAX_DELAY, + CONF_SEND_INTERVAL, + CONF_USE_CONN_STRING, +) + +AZURE_EVENT_HUB_PATH = "homeassistant.components.azure_event_hub" +PRODUCER_PATH = f"{AZURE_EVENT_HUB_PATH}.client.EventHubProducerClient" +CLIENT_PATH = f"{AZURE_EVENT_HUB_PATH}.client.AzureEventHubClient" +CONFIG_FLOW_PATH = f"{AZURE_EVENT_HUB_PATH}.config_flow" + +BASE_CONFIG_CS = { + CONF_EVENT_HUB_INSTANCE_NAME: "test-instance", + CONF_USE_CONN_STRING: True, +} +BASE_CONFIG_SAS = { + CONF_EVENT_HUB_INSTANCE_NAME: "test-instance", + CONF_USE_CONN_STRING: False, +} + +CS_CONFIG = {CONF_EVENT_HUB_CON_STRING: "test-cs"} +SAS_CONFIG = { + CONF_EVENT_HUB_NAMESPACE: "test-ns", + CONF_EVENT_HUB_SAS_POLICY: "test-policy", + CONF_EVENT_HUB_SAS_KEY: "test-key", +} +CS_CONFIG_FULL = { + CONF_EVENT_HUB_INSTANCE_NAME: "test-instance", + CONF_EVENT_HUB_CON_STRING: "test-cs", +} +SAS_CONFIG_FULL = { + CONF_EVENT_HUB_INSTANCE_NAME: "test-instance", + CONF_EVENT_HUB_NAMESPACE: "test-ns", + CONF_EVENT_HUB_SAS_POLICY: "test-policy", + CONF_EVENT_HUB_SAS_KEY: "test-key", +} + +IMPORT_CONFIG = { + CONF_EVENT_HUB_INSTANCE_NAME: "test-instance", + CONF_EVENT_HUB_NAMESPACE: "test-ns", + CONF_EVENT_HUB_SAS_POLICY: "test-policy", + CONF_EVENT_HUB_SAS_KEY: "test-key", + CONF_SEND_INTERVAL: 5, + CONF_MAX_DELAY: 10, +} + +BASIC_OPTIONS = { + CONF_SEND_INTERVAL: 5, +} +UPDATE_OPTIONS = {CONF_SEND_INTERVAL: 100} diff --git a/tests/components/azure_event_hub/test_config_flow.py b/tests/components/azure_event_hub/test_config_flow.py new file mode 100644 index 00000000000000..4e135d55555c9b --- /dev/null +++ b/tests/components/azure_event_hub/test_config_flow.py @@ -0,0 +1,188 @@ +"""Test the AEH config flow.""" +import logging + +from azure.eventhub.exceptions import EventHubError +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.azure_event_hub.const import ( + CONF_MAX_DELAY, + CONF_SEND_INTERVAL, + DOMAIN, + STEP_CONN_STRING, + STEP_SAS, +) + +from .const import ( + BASE_CONFIG_CS, + BASE_CONFIG_SAS, + CS_CONFIG, + CS_CONFIG_FULL, + IMPORT_CONFIG, + SAS_CONFIG, + SAS_CONFIG_FULL, + UPDATE_OPTIONS, +) + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.parametrize( + "step1_config, step_id, step2_config, data_config", + [ + (BASE_CONFIG_CS, STEP_CONN_STRING, CS_CONFIG, CS_CONFIG_FULL), + (BASE_CONFIG_SAS, STEP_SAS, SAS_CONFIG, SAS_CONFIG_FULL), + ], + ids=["connection_string", "sas"], +) +async def test_form( + hass, + mock_setup_entry, + mock_from_connection_string, + step1_config, + step_id, + step2_config, + data_config, +): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + ) + assert result["type"] == "form" + assert result["errors"] is None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + step1_config.copy(), + ) + + assert result2["type"] == "form" + assert result2["step_id"] == step_id + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + step2_config.copy(), + ) + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "test-instance" + assert result3["data"] == data_config + mock_setup_entry.assert_called_once() + + +async def test_import(hass, mock_setup_entry): + """Test we get the form.""" + + import_config = IMPORT_CONFIG.copy() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=IMPORT_CONFIG.copy(), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-instance" + options = { + CONF_SEND_INTERVAL: import_config.pop(CONF_SEND_INTERVAL), + CONF_MAX_DELAY: import_config.pop(CONF_MAX_DELAY), + } + assert result["data"] == import_config + assert result["options"] == options + mock_setup_entry.assert_called_once() + + +@pytest.mark.parametrize( + "source", + [config_entries.SOURCE_USER, config_entries.SOURCE_IMPORT], + ids=["user", "import"], +) +async def test_single_instance(hass, source): + """Test uniqueness of username.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CS_CONFIG_FULL, + title="test-instance", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": source}, + data=BASE_CONFIG_CS.copy(), + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +@pytest.mark.parametrize( + "side_effect, error_message", + [(EventHubError("test"), "cannot_connect"), (Exception, "unknown")], + ids=["cannot_connect", "unknown"], +) +async def test_connection_error_sas( + hass, + mock_get_eventhub_properties, + side_effect, + error_message, +): + """Test we handle connection errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=BASE_CONFIG_SAS.copy(), + ) + assert result["type"] == "form" + assert result["errors"] is None + + mock_get_eventhub_properties.side_effect = side_effect + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + SAS_CONFIG.copy(), + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": error_message} + + +@pytest.mark.parametrize( + "side_effect, error_message", + [(EventHubError("test"), "cannot_connect"), (Exception, "unknown")], + ids=["cannot_connect", "unknown"], +) +async def test_connection_error_cs( + hass, + mock_from_connection_string, + side_effect, + error_message, +): + """Test we handle connection errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=BASE_CONFIG_CS.copy(), + ) + assert result["type"] == "form" + assert result["errors"] is None + mock_from_connection_string.return_value.get_eventhub_properties.side_effect = ( + side_effect + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CS_CONFIG.copy(), + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": error_message} + + +async def test_options_flow(hass, entry): + """Test options flow.""" + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["last_step"] + + updated = await hass.config_entries.options.async_configure( + result["flow_id"], UPDATE_OPTIONS + ) + assert updated["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert updated["data"] == UPDATE_OPTIONS + await hass.async_block_till_done() diff --git a/tests/components/azure_event_hub/test_init.py b/tests/components/azure_event_hub/test_init.py index dd588ad7499185..cf7226e20b0e8e 100644 --- a/tests/components/azure_event_hub/test_init.py +++ b/tests/components/azure_event_hub/test_init.py @@ -1,83 +1,30 @@ -"""The tests for the Azure Event Hub component.""" -from dataclasses import dataclass -from unittest.mock import MagicMock, patch +"""Test the init functions for AEH.""" +from datetime import timedelta +import logging +from unittest.mock import patch +from azure.eventhub.exceptions import EventHubError import pytest -import homeassistant.components.azure_event_hub as azure_event_hub +from homeassistant.components import azure_event_hub +from homeassistant.components.azure_event_hub.const import CONF_SEND_INTERVAL, DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow -AZURE_EVENT_HUB_PATH = "homeassistant.components.azure_event_hub" -PRODUCER_PATH = f"{AZURE_EVENT_HUB_PATH}.EventHubProducerClient" -MIN_CONFIG = { - "event_hub_namespace": "namespace", - "event_hub_instance_name": "name", - "event_hub_sas_policy": "policy", - "event_hub_sas_key": "key", -} - - -@dataclass -class FilterTest: - """Class for capturing a filter test.""" - - id: str - should_pass: bool - - -@pytest.fixture(autouse=True, name="mock_client", scope="module") -def mock_client_fixture(): - """Mock the azure event hub producer client.""" - with patch(f"{PRODUCER_PATH}.send_batch") as mock_send_batch, patch( - f"{PRODUCER_PATH}.close" - ) as mock_close, patch(f"{PRODUCER_PATH}.__init__", return_value=None) as mock_init: - yield ( - mock_init, - mock_send_batch, - mock_close, - ) - - -@pytest.fixture(autouse=True, name="mock_batch") -def mock_batch_fixture(): - """Mock batch creator and return mocked batch object.""" - mock_batch = MagicMock() - with patch(f"{PRODUCER_PATH}.create_batch", return_value=mock_batch): - yield mock_batch - - -@pytest.fixture(autouse=True, name="mock_policy") -def mock_policy_fixture(): - """Mock azure shared key credential.""" - with patch(f"{AZURE_EVENT_HUB_PATH}.EventHubSharedKeyCredential") as policy: - yield policy - - -@pytest.fixture(autouse=True, name="mock_event_data") -def mock_event_data_fixture(): - """Mock the azure event data component.""" - with patch(f"{AZURE_EVENT_HUB_PATH}.EventData") as event_data: - yield event_data +from .conftest import FilterTest +from .const import AZURE_EVENT_HUB_PATH, BASIC_OPTIONS, CS_CONFIG_FULL, SAS_CONFIG_FULL +from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.fixture(autouse=True, name="mock_call_later") -def mock_call_later_fixture(): - """Mock async_call_later to allow queue processing on demand.""" - with patch(f"{AZURE_EVENT_HUB_PATH}.async_call_later") as mock_call_later: - yield mock_call_later +_LOGGER = logging.getLogger(__name__) -async def test_minimal_config(hass): - """Test the minimal config and defaults of component.""" - config = {azure_event_hub.DOMAIN: MIN_CONFIG} - assert await async_setup_component(hass, azure_event_hub.DOMAIN, config) - - -async def test_full_config(hass): - """Test the full config of component.""" +async def test_import(hass): + """Test the popping of the filter and further import of the config.""" config = { - azure_event_hub.DOMAIN: { + DOMAIN: { "send_interval": 10, "max_delay": 10, "filter": { @@ -90,128 +37,178 @@ async def test_full_config(hass): }, } } - config[azure_event_hub.DOMAIN].update(MIN_CONFIG) - assert await async_setup_component(hass, azure_event_hub.DOMAIN, config) - + config[DOMAIN].update(CS_CONFIG_FULL) + assert await async_setup_component(hass, DOMAIN, config) -async def _setup(hass, mock_call_later, filter_config): - """Shared set up for filtering tests.""" - config = {azure_event_hub.DOMAIN: {"filter": filter_config}} - config[azure_event_hub.DOMAIN].update(MIN_CONFIG) - assert await async_setup_component(hass, azure_event_hub.DOMAIN, config) - await hass.async_block_till_done() - mock_call_later.assert_called_once() - return mock_call_later.call_args[0][2] +async def test_filter_only_config(hass): + """Test the popping of the filter and further import of the config.""" + config = { + DOMAIN: { + "filter": { + "include_domains": ["light"], + "include_entity_globs": ["sensor.included_*"], + "include_entities": ["binary_sensor.included"], + "exclude_domains": ["light"], + "exclude_entity_globs": ["sensor.excluded_*"], + "exclude_entities": ["binary_sensor.excluded"], + }, + } + } + assert await async_setup_component(hass, DOMAIN, config) -async def _run_filter_tests(hass, tests, process_queue, mock_batch): - """Run a series of filter tests on azure event hub.""" - for test in tests: - hass.states.async_set(test.id, STATE_ON) - await hass.async_block_till_done() - await process_queue(None) +async def test_unload_entry(hass, entry, mock_create_batch): + """Test being able to unload an entry. - if test.should_pass: - mock_batch.add.assert_called_once() - mock_batch.add.reset_mock() - else: - mock_batch.add.assert_not_called() + Queue should be empty, so adding events to the batch should not be called, + this verifies that the unload, calls async_stop, which calls async_send and + shuts down the hub. + """ + assert await hass.config_entries.async_unload(entry.entry_id) + mock_create_batch.add.assert_not_called() + assert entry.state == ConfigEntryState.NOT_LOADED -async def test_allowlist(hass, mock_batch, mock_call_later): - """Test an allowlist only config.""" - process_queue = await _setup( - hass, - mock_call_later, - { - "include_domains": ["light"], - "include_entity_globs": ["sensor.included_*"], - "include_entities": ["binary_sensor.included"], - }, +async def test_failed_test_connection(hass, mock_get_eventhub_properties): + """Test being able to unload an entry.""" + entry = MockConfigEntry( + domain=azure_event_hub.DOMAIN, + data=SAS_CONFIG_FULL, + title="test-instance", + options=BASIC_OPTIONS, ) + entry.add_to_hass(hass) + mock_get_eventhub_properties.side_effect = EventHubError("Test") + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.SETUP_RETRY - tests = [ - FilterTest("climate.excluded", False), - FilterTest("light.included", True), - FilterTest("sensor.excluded_test", False), - FilterTest("sensor.included_test", True), - FilterTest("binary_sensor.included", True), - FilterTest("binary_sensor.excluded", False), - ] - - await _run_filter_tests(hass, tests, process_queue, mock_batch) - -async def test_denylist(hass, mock_batch, mock_call_later): - """Test a denylist only config.""" - process_queue = await _setup( +async def test_send_batch_error(hass, entry_with_one_event, mock_send_batch): + """Test a error in send_batch, including recovering at the next interval.""" + mock_send_batch.reset_mock() + mock_send_batch.side_effect = [EventHubError("Test"), None] + async_fire_time_changed( hass, - mock_call_later, - { - "exclude_domains": ["climate"], - "exclude_entity_globs": ["sensor.excluded_*"], - "exclude_entities": ["binary_sensor.excluded"], - }, + utcnow() + timedelta(seconds=entry_with_one_event.options[CONF_SEND_INTERVAL]), ) - - tests = [ - FilterTest("climate.excluded", False), - FilterTest("light.included", True), - FilterTest("sensor.excluded_test", False), - FilterTest("sensor.included_test", True), - FilterTest("binary_sensor.included", True), - FilterTest("binary_sensor.excluded", False), - ] - - await _run_filter_tests(hass, tests, process_queue, mock_batch) - - -async def test_filtered_allowlist(hass, mock_batch, mock_call_later): - """Test an allowlist config with a filtering denylist.""" - process_queue = await _setup( + await hass.async_block_till_done() + mock_send_batch.assert_called_once() + mock_send_batch.reset_mock() + hass.states.async_set("sensor.test2", STATE_ON) + async_fire_time_changed( hass, - mock_call_later, - { - "include_domains": ["light"], - "include_entity_globs": ["*.included_*"], - "exclude_domains": ["climate"], - "exclude_entity_globs": ["*.excluded_*"], - "exclude_entities": ["light.excluded"], - }, + utcnow() + timedelta(seconds=entry_with_one_event.options[CONF_SEND_INTERVAL]), ) - - tests = [ - FilterTest("light.included", True), - FilterTest("light.excluded_test", False), - FilterTest("light.excluded", False), - FilterTest("sensor.included_test", True), - FilterTest("climate.included_test", False), - ] - - await _run_filter_tests(hass, tests, process_queue, mock_batch) + await hass.async_block_till_done() + mock_send_batch.assert_called_once() + + +async def test_late_event(hass, entry_with_one_event, mock_create_batch): + """Test the check on late events.""" + with patch( + f"{AZURE_EVENT_HUB_PATH}.utcnow", + return_value=utcnow() + timedelta(hours=1), + ): + async_fire_time_changed( + hass, + utcnow() + + timedelta(seconds=entry_with_one_event.options[CONF_SEND_INTERVAL]), + ) + await hass.async_block_till_done() + mock_create_batch.add.assert_not_called() -async def test_filtered_denylist(hass, mock_batch, mock_call_later): - """Test a denylist config with a filtering allowlist.""" - process_queue = await _setup( +async def test_full_batch(hass, entry_with_one_event, mock_create_batch): + """Test the full batch behaviour.""" + mock_create_batch.add.side_effect = [ValueError, None] + async_fire_time_changed( hass, - mock_call_later, - { - "include_entities": ["climate.included", "sensor.excluded_test"], - "exclude_domains": ["climate"], - "exclude_entity_globs": ["*.excluded_*"], - "exclude_entities": ["light.excluded"], - }, + utcnow() + timedelta(seconds=entry_with_one_event.options[CONF_SEND_INTERVAL]), ) + await hass.async_block_till_done() + assert mock_create_batch.add.call_count == 2 - tests = [ - FilterTest("climate.excluded", False), - FilterTest("climate.included", True), - FilterTest("switch.excluded_test", False), - FilterTest("sensor.excluded_test", True), - FilterTest("light.excluded", False), - FilterTest("light.included", True), - ] - await _run_filter_tests(hass, tests, process_queue, mock_batch) +@pytest.mark.parametrize( + "filter_schema, tests", + [ + ( + { + "include_domains": ["light"], + "include_entity_globs": ["sensor.included_*"], + "include_entities": ["binary_sensor.included"], + }, + [ + FilterTest("climate.excluded", 0), + FilterTest("light.included", 1), + FilterTest("sensor.excluded_test", 0), + FilterTest("sensor.included_test", 1), + FilterTest("binary_sensor.included", 1), + FilterTest("binary_sensor.excluded", 0), + ], + ), + ( + { + "exclude_domains": ["climate"], + "exclude_entity_globs": ["sensor.excluded_*"], + "exclude_entities": ["binary_sensor.excluded"], + }, + [ + FilterTest("climate.excluded", 0), + FilterTest("light.included", 1), + FilterTest("sensor.excluded_test", 0), + FilterTest("sensor.included_test", 1), + FilterTest("binary_sensor.included", 1), + FilterTest("binary_sensor.excluded", 0), + ], + ), + ( + { + "include_domains": ["light"], + "include_entity_globs": ["*.included_*"], + "exclude_domains": ["climate"], + "exclude_entity_globs": ["*.excluded_*"], + "exclude_entities": ["light.excluded"], + }, + [ + FilterTest("light.included", 1), + FilterTest("light.excluded_test", 0), + FilterTest("light.excluded", 0), + FilterTest("sensor.included_test", 1), + FilterTest("climate.included_test", 0), + ], + ), + ( + { + "include_entities": ["climate.included", "sensor.excluded_test"], + "exclude_domains": ["climate"], + "exclude_entity_globs": ["*.excluded_*"], + "exclude_entities": ["light.excluded"], + }, + [ + FilterTest("climate.excluded", 0), + FilterTest("climate.included", 1), + FilterTest("switch.excluded_test", 0), + FilterTest("sensor.excluded_test", 1), + FilterTest("light.excluded", 0), + FilterTest("light.included", 1), + ], + ), + ], + ids=["allowlist", "denylist", "filtered_allowlist", "filtered_denylist"], +) +async def test_filter(hass, entry, tests, mock_create_batch): + """Test different filters. + + Filter_schema is also a fixture which is replaced by the filter_schema + in the parametrize and added to the entry fixture. + """ + for test in tests: + hass.states.async_set(test.entity_id, STATE_ON) + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=entry.options[CONF_SEND_INTERVAL]) + ) + await hass.async_block_till_done() + assert mock_create_batch.add.call_count == test.expected_count + mock_create_batch.add.reset_mock() diff --git a/tests/components/balboa/__init__.py b/tests/components/balboa/__init__.py index 13c8b6240a7df3..7cae68f2203280 100644 --- a/tests/components/balboa/__init__.py +++ b/tests/components/balboa/__init__.py @@ -1,7 +1,4 @@ """Test the Balboa Spa Client integration.""" -import asyncio -from unittest.mock import patch - from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -22,146 +19,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.balboa.BalboaSpaWifi", - new=BalboaMock, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - 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 - - -async def init_integration_mocked(hass: HomeAssistant) -> MockConfigEntry: - """Mock integration setup.""" - config_entry = MockConfigEntry( - domain=BALBOA_DOMAIN, - data={ - CONF_HOST: TEST_HOST, - }, - ) - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.connect", - new=BalboaMock.connect, - ), patch( - "homeassistant.components.balboa.BalboaSpaWifi.listen_until_configured", - new=BalboaMock.listen_until_configured, - ), patch( - "homeassistant.components.balboa.BalboaSpaWifi.listen", - new=BalboaMock.listen, - ), patch( - "homeassistant.components.balboa.BalboaSpaWifi.check_connection_status", - new=BalboaMock.check_connection_status, - ), patch( - "homeassistant.components.balboa.BalboaSpaWifi.send_panel_req", - new=BalboaMock.send_panel_req, - ), patch( - "homeassistant.components.balboa.BalboaSpaWifi.send_mod_ident_req", - new=BalboaMock.send_mod_ident_req, - ), patch( - "homeassistant.components.balboa.BalboaSpaWifi.spa_configured", - new=BalboaMock.spa_configured, - ), patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_model_name", - new=BalboaMock.get_model_name, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - return config_entry - - -class BalboaMock: - """Mock pybalboa library.""" - - def __init__(self, hostname, port=BALBOA_DEFAULT_PORT): - """Mock init.""" - self.host = hostname - self.port = port - self.connected = False - self.new_data_cb = None - self.lastupd = 0 - self.connected = False - self.fake_action = False - - async def connect(self): - """Connect to the spa.""" - self.connected = True - return True - - async def broken_connect(self): - """Connect to the spa.""" - self.connected = False - return False - - async def disconnect(self): - """Stop talking to the spa.""" - self.connected = False - - async def send_panel_req(self, arg_ba, arg_bb): - """Send a panel request, 2 bytes of data.""" - self.fake_action = False - return - - async def send_mod_ident_req(self): - """Ask for the module identification.""" - self.fake_action = False - return - - @staticmethod - def get_macaddr(): - """Return the macaddr of the spa wifi.""" - return "ef:ef:ef:c0:ff:ee" - - def get_model_name(self): - """Return the model name.""" - self.fake_action = False - return "FakeSpa" - - @staticmethod - def get_ssid(): - """Return the software version.""" - return "V0.0" - - @staticmethod - async def set_time(new_time, timescale=None): - """Set time on spa to new_time with optional timescale.""" - return - - async def listen(self): - """Listen to the spa babble forever.""" - while True: - if not self.connected: - # sleep and hope the checker fixes us - await asyncio.sleep(5) - continue - - # fake it - await asyncio.sleep(5) - - async def check_connection_status(self): - """Set this up to periodically check the spa connection and fix.""" - self.fake_action = False - while True: - # fake it - await asyncio.sleep(15) - - async def spa_configured(self): - """Check if the spa has been configured.""" - self.fake_action = False - return - - async def int_new_data_cb(self): - """Call false internal data callback.""" - - if self.new_data_cb is None: - return - await self.new_data_cb() # pylint: disable=not-callable - - async def listen_until_configured(self, maxiter=20): - """Listen to the spa babble until we are configured.""" - if not self.connected: - return False - return True diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py new file mode 100644 index 00000000000000..fbc7faf4d30f60 --- /dev/null +++ b/tests/components/balboa/conftest.py @@ -0,0 +1,94 @@ +"""Provide common fixtures.""" +from __future__ import annotations + +from collections.abc import Generator +import time +from unittest.mock import MagicMock, patch + +from pybalboa.balboa import text_heatmode +import pytest + + +@pytest.fixture(name="client") +def client_fixture() -> Generator[None, MagicMock, None]: + """Mock balboa.""" + with patch( + "homeassistant.components.balboa.BalboaSpaWifi", autospec=True + ) as mock_balboa: + # common attributes + client = mock_balboa.return_value + client.connected = True + client.lastupd = time.time() + client.new_data_cb = None + client.connect.return_value = True + client.get_macaddr.return_value = "ef:ef:ef:c0:ff:ee" + client.get_model_name.return_value = "FakeSpa" + client.get_ssid.return_value = "V0.0" + + # constants should preferably be moved in the library + # to be class attributes or further refactored + client.TSCALE_C = 1 + client.TSCALE_F = 0 + client.HEATMODE_READY = 0 + client.HEATMODE_REST = 1 + client.HEATMODE_RNR = 2 + client.TIMESCALE_12H = 0 + client.TIMESCALE_24H = 1 + client.PUMP_OFF = 0 + client.PUMP_LOW = 1 + client.PUMP_HIGH = 2 + client.TEMPRANGE_LOW = 0 + client.TEMPRANGE_HIGH = 1 + client.tmin = [ + [50.0, 10.0], + [80.0, 26.0], + ] + client.tmax = [ + [80.0, 26.0], + [104.0, 40.0], + ] + client.BLOWER_OFF = 0 + client.BLOWER_LOW = 1 + client.BLOWER_MEDIUM = 2 + client.BLOWER_HIGH = 3 + client.FILTER_OFF = 0 + client.FILTER_1 = 1 + client.FILTER_2 = 2 + client.FILTER_1_2 = 3 + client.OFF = 0 + client.ON = 1 + client.HEATSTATE_IDLE = 0 + client.HEATSTATE_HEATING = 1 + client.HEATSTATE_HEAT_WAITING = 2 + client.VOLTAGE_240 = 240 + client.VOLTAGE_UNKNOWN = 0 + client.HEATERTYPE_STANDARD = "Standard" + client.HEATERTYPE_UNKNOWN = "Unknown" + + # Climate attributes + client.heatmode = 0 + client.get_heatmode_stringlist.return_value = text_heatmode + client.get_tempscale.return_value = client.TSCALE_F + client.have_blower.return_value = False + + # Climate methods + client.get_heatstate.return_value = 0 + client.get_blower.return_value = 0 + client.get_curtemp.return_value = 20.0 + client.get_settemp.return_value = 20.0 + + def get_heatmode(text=False): + """Ask for the current heatmode.""" + if text: + return text_heatmode[client.heatmode] + return client.heatmode + + client.get_heatmode.side_effect = get_heatmode + yield client + + +@pytest.fixture(autouse=True) +def set_temperature_wait(): + """Mock set temperature wait time.""" + with patch("homeassistant.components.balboa.climate.SET_TEMPERATURE_WAIT", new=0): + yield diff --git a/tests/components/balboa/test_binary_sensor.py b/tests/components/balboa/test_binary_sensor.py index 748801b7b8f47c..4f080f29ab3f58 100644 --- a/tests/components/balboa/test_binary_sensor.py +++ b/tests/components/balboa/test_binary_sensor.py @@ -1,14 +1,10 @@ """Tests of the climate entity of the balboa integration.""" +from unittest.mock import MagicMock -from unittest.mock import patch - -from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN, SIGNAL_UPDATE from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.setup import async_setup_component -from . import init_integration_mocked +from . import init_integration ENTITY_BINARY_SENSOR = "binary_sensor.fakespa_" @@ -20,57 +16,41 @@ ] -async def test_filters(hass: HomeAssistant): +async def test_filters(hass: HomeAssistant, client: MagicMock) -> None: """Test spa filters.""" - config_entry = await _setup_binary_sensor_test(hass) + config_entry = await init_integration(hass) for filter_mode in range(4): for spa_filter in range(1, 3): - state = await _patch_filter(hass, config_entry, filter_mode, spa_filter) + state = await _patch_filter( + hass, config_entry, filter_mode, spa_filter, client + ) assert state.state == FILTER_MAP[filter_mode][spa_filter - 1] -async def test_circ_pump(hass: HomeAssistant): +async def test_circ_pump(hass: HomeAssistant, client: MagicMock) -> None: """Test spa circ pump.""" - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.have_circ_pump", - return_value=True, - ): - config_entry = await _setup_binary_sensor_test(hass) + client.have_circ_pump.return_value = (True,) + config_entry = await init_integration(hass) - state = await _patch_circ_pump(hass, config_entry, True) + state = await _patch_circ_pump(hass, config_entry, True, client) assert state.state == STATE_ON - state = await _patch_circ_pump(hass, config_entry, False) + state = await _patch_circ_pump(hass, config_entry, False, client) assert state.state == STATE_OFF -async def _patch_circ_pump(hass, config_entry, pump_state): +async def _patch_circ_pump(hass, config_entry, pump_state, client): """Patch the circ pump state.""" - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_circ_pump", - return_value=pump_state, - ): - async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) - await hass.async_block_till_done() - return hass.states.get(f"{ENTITY_BINARY_SENSOR}circ_pump") + client.get_circ_pump.return_value = pump_state + await client.new_data_cb() + await hass.async_block_till_done() + return hass.states.get(f"{ENTITY_BINARY_SENSOR}circ_pump") -async def _patch_filter(hass, config_entry, filter_mode, num): +async def _patch_filter(hass, config_entry, filter_mode, num, client): """Patch the filter state.""" - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_filtermode", - return_value=filter_mode, - ): - async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) - await hass.async_block_till_done() - return hass.states.get(f"{ENTITY_BINARY_SENSOR}filter{num}") - - -async def _setup_binary_sensor_test(hass): - """Prepare the test.""" - config_entry = await init_integration_mocked(hass) - await async_setup_component(hass, BALBOA_DOMAIN, config_entry) + client.get_filtermode.return_value = filter_mode + await client.new_data_cb() await hass.async_block_till_done() - - return config_entry + return hass.states.get(f"{ENTITY_BINARY_SENSOR}filter{num}") diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 53eb0307beb51d..94a5b612f2c242 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -1,10 +1,10 @@ """Tests of the climate entity of the balboa integration.""" +from __future__ import annotations -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN, SIGNAL_UPDATE from homeassistant.components.climate.const import ( ATTR_FAN_MODE, ATTR_HVAC_ACTION, @@ -28,11 +28,8 @@ ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.setup import async_setup_component -from . import init_integration_mocked +from . import init_integration from tests.components.climate import common @@ -52,13 +49,13 @@ ENTITY_CLIMATE = "climate.fakespa_climate" -async def test_spa_defaults(hass: HomeAssistant): +async def test_spa_defaults(hass: HomeAssistant, client: MagicMock) -> None: """Test supported features flags.""" - - await _setup_climate_test(hass) + await init_integration(hass) state = hass.states.get(ENTITY_CLIMATE) + assert state assert ( state.attributes["supported_features"] == SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE @@ -70,16 +67,15 @@ async def test_spa_defaults(hass: HomeAssistant): assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE -async def test_spa_defaults_fake_tscale(hass: HomeAssistant): +async def test_spa_defaults_fake_tscale(hass: HomeAssistant, client: MagicMock) -> None: """Test supported features flags.""" + client.get_tempscale.return_value = 1 - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_tempscale", return_value=1 - ): - await _setup_climate_test(hass) + await init_integration(hass) state = hass.states.get(ENTITY_CLIMATE) + assert state assert ( state.attributes["supported_features"] == SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE @@ -91,20 +87,19 @@ async def test_spa_defaults_fake_tscale(hass: HomeAssistant): assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE -async def test_spa_with_blower(hass: HomeAssistant): +async def test_spa_with_blower(hass: HomeAssistant, client: MagicMock) -> None: """Test supported features flags.""" + client.have_blower.return_value = True - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.have_blower", return_value=True - ): - config_entry = await _setup_climate_test(hass) + config_entry = await init_integration(hass) # force a refresh - async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) + await client.new_data_cb() await hass.async_block_till_done() state = hass.states.get(ENTITY_CLIMATE) + assert state assert ( state.attributes["supported_features"] == SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE | SUPPORT_FAN_MODE @@ -112,161 +107,144 @@ async def test_spa_with_blower(hass: HomeAssistant): for fan_state in range(4): # set blower - state = await _patch_blower(hass, config_entry, fan_state) + state = await _patch_blower(hass, config_entry, fan_state, client) + assert state assert state.attributes[ATTR_FAN_MODE] == FAN_SETTINGS[fan_state] # test the nonsense checks - for fan_state in (None, 70): - state = await _patch_blower(hass, config_entry, fan_state) + for fan_state in (None, 70): # type: ignore[assignment] + state = await _patch_blower(hass, config_entry, fan_state, client) + assert state assert state.attributes[ATTR_FAN_MODE] == FAN_OFF -async def test_spa_temperature(hass: HomeAssistant): +async def test_spa_temperature(hass: HomeAssistant, client: MagicMock) -> None: """Test spa temperature settings.""" - config_entry = await _setup_climate_test(hass) + config_entry = await init_integration(hass) # flip the spa into F # set temp to a valid number - state = await _patch_spa_settemp(hass, config_entry, 0, 100.0) + state = await _patch_spa_settemp(hass, config_entry, 0, 100.0, client) + assert state assert state.attributes.get(ATTR_TEMPERATURE) == 38.0 -async def test_spa_temperature_unit(hass: HomeAssistant): +async def test_spa_temperature_unit(hass: HomeAssistant, client: MagicMock) -> None: """Test temperature unit conversions.""" with patch.object(hass.config.units, "temperature_unit", TEMP_FAHRENHEIT): - config_entry = await _setup_climate_test(hass) + config_entry = await init_integration(hass) - state = await _patch_spa_settemp(hass, config_entry, 0, 15.4) + state = await _patch_spa_settemp(hass, config_entry, 0, 15.4, client) + assert state assert state.attributes.get(ATTR_TEMPERATURE) == 15.0 -async def test_spa_hvac_modes(hass: HomeAssistant): +async def test_spa_hvac_modes(hass: HomeAssistant, client: MagicMock) -> None: """Test hvac modes.""" - config_entry = await _setup_climate_test(hass) + config_entry = await init_integration(hass) # try out the different heat modes for heat_mode in range(2): - state = await _patch_spa_heatmode(hass, config_entry, heat_mode) + state = await _patch_spa_heatmode(hass, config_entry, heat_mode, client) + assert state modes = state.attributes.get(ATTR_HVAC_MODES) assert [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] == modes assert state.state == HVAC_SETTINGS[heat_mode] - with pytest.raises(HomeAssistantError): - await _patch_spa_heatmode(hass, config_entry, 2) + with pytest.raises(ValueError): + await _patch_spa_heatmode(hass, config_entry, 2, client) -async def test_spa_hvac_action(hass: HomeAssistant): +async def test_spa_hvac_action(hass: HomeAssistant, client: MagicMock) -> None: """Test setting of the HVAC action.""" - config_entry = await _setup_climate_test(hass) + config_entry = await init_integration(hass) # try out the different heat states - state = await _patch_spa_heatstate(hass, config_entry, 1) + state = await _patch_spa_heatstate(hass, config_entry, 1, client) + assert state assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT - state = await _patch_spa_heatstate(hass, config_entry, 0) + state = await _patch_spa_heatstate(hass, config_entry, 0, client) assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE -async def test_spa_preset_modes(hass: HomeAssistant): +async def test_spa_preset_modes(hass: HomeAssistant, client: MagicMock) -> None: """Test the various preset modes.""" - config_entry = await _setup_climate_test(hass) + await init_integration(hass) state = hass.states.get(ENTITY_CLIMATE) + assert state modes = state.attributes.get(ATTR_PRESET_MODES) assert ["Ready", "Rest", "Ready in Rest"] == modes # Put it in Ready and Rest modelist = ["Ready", "Rest"] for mode in modelist: - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_heatmode", - return_value=modelist.index(mode), - ): - await common.async_set_preset_mode(hass, mode, ENTITY_CLIMATE) - async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) - await hass.async_block_till_done() + client.heatmode = modelist.index(mode) + await common.async_set_preset_mode(hass, mode, ENTITY_CLIMATE) + await client.new_data_cb() + await hass.async_block_till_done() state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes[ATTR_PRESET_MODE] == modelist.index(mode) + assert state + assert state.attributes[ATTR_PRESET_MODE] == mode # put it in RNR and test assertion - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_heatmode", - return_value=2, - ), pytest.raises(HomeAssistantError): + client.heatmode = 2 + + with pytest.raises(ValueError): await common.async_set_preset_mode(hass, 2, ENTITY_CLIMATE) # Helpers -async def _patch_blower(hass, config_entry, fan_state): +async def _patch_blower(hass, config_entry, fan_state, client): """Patch the blower state.""" - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_blower", - return_value=fan_state, - ): - if fan_state is not None and fan_state <= len(FAN_SETTINGS): - await common.async_set_fan_mode(hass, FAN_SETTINGS[fan_state]) - async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) - await hass.async_block_till_done() + client.get_blower.return_value = fan_state + + if fan_state is not None and fan_state <= len(FAN_SETTINGS): + await common.async_set_fan_mode(hass, FAN_SETTINGS[fan_state]) + await client.new_data_cb() + await hass.async_block_till_done() return hass.states.get(ENTITY_CLIMATE) -async def _patch_spa_settemp(hass, config_entry, tscale, settemp): +async def _patch_spa_settemp(hass, config_entry, tscale, settemp, client): """Patch the settemp.""" - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_tempscale", - return_value=tscale, - ), patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_settemp", - return_value=settemp, - ): - await common.async_set_temperature( - hass, temperature=settemp, entity_id=ENTITY_CLIMATE - ) - async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) - await hass.async_block_till_done() + client.get_tempscale.return_value = tscale + client.get_settemp.return_value = settemp + + await common.async_set_temperature( + hass, temperature=settemp, entity_id=ENTITY_CLIMATE + ) + await client.new_data_cb() + await hass.async_block_till_done() return hass.states.get(ENTITY_CLIMATE) -async def _patch_spa_heatmode(hass, config_entry, heat_mode): +async def _patch_spa_heatmode(hass, config_entry, heat_mode, client): """Patch the heatmode.""" - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_heatmode", - return_value=heat_mode, - ): - await common.async_set_hvac_mode(hass, HVAC_SETTINGS[heat_mode], ENTITY_CLIMATE) - async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) - await hass.async_block_till_done() + client.heatmode = heat_mode + + await common.async_set_hvac_mode(hass, HVAC_SETTINGS[heat_mode], ENTITY_CLIMATE) + await client.new_data_cb() + await hass.async_block_till_done() return hass.states.get(ENTITY_CLIMATE) -async def _patch_spa_heatstate(hass, config_entry, heat_state): +async def _patch_spa_heatstate(hass, config_entry, heat_state, client): """Patch the heatmode.""" - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.get_heatstate", - return_value=heat_state, - ): - await common.async_set_hvac_mode( - hass, HVAC_SETTINGS[heat_state], ENTITY_CLIMATE - ) - async_dispatcher_send(hass, SIGNAL_UPDATE.format(config_entry.entry_id)) - await hass.async_block_till_done() + client.get_heatstate.return_value = heat_state - return hass.states.get(ENTITY_CLIMATE) - - -async def _setup_climate_test(hass): - """Prepare the test.""" - config_entry = await init_integration_mocked(hass) - await async_setup_component(hass, BALBOA_DOMAIN, config_entry) + await common.async_set_hvac_mode(hass, HVAC_SETTINGS[heat_state], ENTITY_CLIMATE) + await client.new_data_cb() await hass.async_block_till_done() - return config_entry + return hass.states.get(ENTITY_CLIMATE) diff --git a/tests/components/balboa/test_config_flow.py b/tests/components/balboa/test_config_flow.py index fc12289d90a159..98c2a90abe2efc 100644 --- a/tests/components/balboa/test_config_flow.py +++ b/tests/components/balboa/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Balboa Spa Client config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch from homeassistant import config_entries, data_entry_flow from homeassistant.components.balboa.const import CONF_SYNC_TIME, DOMAIN @@ -12,8 +12,6 @@ RESULT_TYPE_FORM, ) -from . import BalboaMock - from tests.common import MockConfigEntry TEST_DATA = { @@ -22,7 +20,7 @@ TEST_ID = "FakeBalboa" -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, client: MagicMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -31,23 +29,8 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi.connect", - new=BalboaMock.connect, - ), patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi.disconnect", - new=BalboaMock.disconnect, - ), patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi.listen", - new=BalboaMock.listen, - ), patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi.send_mod_ident_req", - new=BalboaMock.send_mod_ident_req, - ), patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi.send_panel_req", - new=BalboaMock.send_panel_req, - ), patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi.spa_configured", - new=BalboaMock.spa_configured, + "homeassistant.components.balboa.config_flow.BalboaSpaWifi", + return_value=client, ), patch( "homeassistant.components.balboa.async_setup_entry", return_value=True, @@ -63,19 +46,17 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_cannot_connect(hass: HomeAssistant, client: MagicMock) -> None: """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.balboa.config_flow.BalboaSpaWifi.connect", - new=BalboaMock.broken_connect, - ), patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi.disconnect", - new=BalboaMock.disconnect, + "homeassistant.components.balboa.config_flow.BalboaSpaWifi", + return_value=client, ): + client.connect.return_value = False result2 = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_DATA, @@ -85,16 +66,17 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_unknown_error(hass: HomeAssistant) -> None: +async def test_unknown_error(hass: HomeAssistant, client: MagicMock) -> None: """Test we handle unknown error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi.connect", - side_effect=Exception, + "homeassistant.components.balboa.config_flow.BalboaSpaWifi", + return_value=client, ): + client.connect.side_effect = Exception("Boom") result2 = await hass.config_entries.flow.async_configure( result["flow_id"], TEST_DATA, @@ -104,7 +86,7 @@ async def test_unknown_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_already_configured(hass: HomeAssistant) -> None: +async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> None: """Test when provided credentials are already configured.""" MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID).add_to_hass(hass) @@ -116,11 +98,8 @@ async def test_already_configured(hass: HomeAssistant) -> None: assert result["step_id"] == SOURCE_USER with patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi.connect", - new=BalboaMock.connect, - ), patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi.disconnect", - new=BalboaMock.disconnect, + "homeassistant.components.balboa.config_flow.BalboaSpaWifi", + return_value=client, ), patch( "homeassistant.components.balboa.async_setup_entry", return_value=True, @@ -135,25 +114,15 @@ async def test_already_configured(hass: HomeAssistant) -> None: assert result2["reason"] == "already_configured" -async def test_options_flow(hass): +async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None: """Test specifying non default settings using options flow.""" config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID) config_entry.add_to_hass(hass) - # Rather than mocking out 15 or so functions, we just need to mock - # the entire library, otherwise it will get stuck in a listener and - # the various loops in pybalboa. - with patch( - "homeassistant.components.balboa.config_flow.BalboaSpaWifi", - new=BalboaMock, - ), patch( - "homeassistant.components.balboa.BalboaSpaWifi", - new=BalboaMock, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + 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" @@ -164,4 +133,4 @@ async def test_options_flow(hass): ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert config_entry.options == {CONF_SYNC_TIME: True} + assert dict(config_entry.options) == {CONF_SYNC_TIME: True} diff --git a/tests/components/balboa/test_init.py b/tests/components/balboa/test_init.py index ac0dea3b007c03..a0b6e6a78bae1d 100644 --- a/tests/components/balboa/test_init.py +++ b/tests/components/balboa/test_init.py @@ -1,18 +1,18 @@ """Tests of the initialization of the balboa integration.""" -from unittest.mock import patch +from unittest.mock import MagicMock from homeassistant.components.balboa.const import DOMAIN as BALBOA_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import TEST_HOST, BalboaMock, init_integration +from . import TEST_HOST, init_integration from tests.common import MockConfigEntry -async def test_setup_entry(hass: HomeAssistant): +async def test_setup_entry(hass: HomeAssistant, client: MagicMock) -> None: """Validate that setup entry also configure the client.""" config_entry = await init_integration(hass) @@ -23,7 +23,7 @@ async def test_setup_entry(hass: HomeAssistant): assert config_entry.state == ConfigEntryState.NOT_LOADED -async def test_setup_entry_fails(hass): +async def test_setup_entry_fails(hass: HomeAssistant, client: MagicMock) -> None: """Validate that setup entry also configure the client.""" config_entry = MockConfigEntry( domain=BALBOA_DOMAIN, @@ -33,11 +33,9 @@ async def test_setup_entry_fails(hass): ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.balboa.BalboaSpaWifi.connect", - new=BalboaMock.broken_connect, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + client.connect.return_value = False + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index 5a609fbc30af5f..d06eac43e7c434 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -5,8 +5,9 @@ import pytest import homeassistant.components.automation as automation -from homeassistant.components.binary_sensor import DEVICE_CLASSES, DOMAIN +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceClass from homeassistant.components.binary_sensor.device_condition import ENTITY_CONDITIONS +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -52,7 +53,7 @@ async def test_get_conditions(hass, device_reg, entity_reg, enable_custom_integr config_entry_id=config_entry.entry_id, connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - for device_class in DEVICE_CLASSES: + for device_class in BinarySensorDeviceClass: entity_reg.async_get_or_create( DOMAIN, "test", @@ -71,10 +72,12 @@ async def test_get_conditions(hass, device_reg, entity_reg, enable_custom_integr "device_id": device_entry.id, "entity_id": platform.ENTITIES[device_class].entity_id, } - for device_class in DEVICE_CLASSES + for device_class in BinarySensorDeviceClass for condition in ENTITY_CONDITIONS[device_class] ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert conditions == expected_conditions @@ -87,7 +90,7 @@ async def test_get_conditions_no_state(hass, device_reg, entity_reg): connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity_ids = {} - for device_class in DEVICE_CLASSES: + for device_class in BinarySensorDeviceClass: entity_ids[device_class] = entity_reg.async_get_or_create( DOMAIN, "test", @@ -106,10 +109,12 @@ async def test_get_conditions_no_state(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": entity_ids[device_class], } - for device_class in DEVICE_CLASSES + for device_class in BinarySensorDeviceClass for condition in ENTITY_CONDITIONS[device_class] ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert conditions == expected_conditions @@ -127,10 +132,12 @@ async def test_get_condition_capabilities(hass, device_reg, entity_reg): {"name": "for", "optional": True, "type": "positive_time_period_dict"} ] } - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) for condition in conditions: capabilities = await async_get_device_automation_capabilities( - hass, "condition", condition + hass, DeviceAutomationType.CONDITION, condition ) assert capabilities == expected_capabilities diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 0cf76453238afe..082943e96c74db 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -4,8 +4,9 @@ import pytest import homeassistant.components.automation as automation -from homeassistant.components.binary_sensor import DEVICE_CLASSES, DOMAIN +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceClass from homeassistant.components.binary_sensor.device_trigger import ENTITY_TRIGGERS +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -52,7 +53,7 @@ async def test_get_triggers(hass, device_reg, entity_reg, enable_custom_integrat config_entry_id=config_entry.entry_id, connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - for device_class in DEVICE_CLASSES: + for device_class in BinarySensorDeviceClass: entity_reg.async_get_or_create( DOMAIN, "test", @@ -71,10 +72,12 @@ async def test_get_triggers(hass, device_reg, entity_reg, enable_custom_integrat "device_id": device_entry.id, "entity_id": platform.ENTITIES[device_class].entity_id, } - for device_class in DEVICE_CLASSES + for device_class in BinarySensorDeviceClass for trigger in ENTITY_TRIGGERS[device_class] ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert triggers == expected_triggers @@ -90,7 +93,7 @@ async def test_get_triggers_no_state(hass, device_reg, entity_reg): config_entry_id=config_entry.entry_id, connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - for device_class in DEVICE_CLASSES: + for device_class in BinarySensorDeviceClass: entity_ids[device_class] = entity_reg.async_get_or_create( DOMAIN, "test", @@ -109,10 +112,12 @@ async def test_get_triggers_no_state(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": entity_ids[device_class], } - for device_class in DEVICE_CLASSES + for device_class in BinarySensorDeviceClass for trigger in ENTITY_TRIGGERS[device_class] ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert triggers == expected_triggers @@ -130,10 +135,12 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): {"name": "for", "optional": True, "type": "positive_time_period_dict"} ] } - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == expected_capabilities diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 0c574df1569b45..5472df3f6e5dfa 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -8,7 +8,7 @@ def test_state(): """Test binary sensor state.""" sensor = binary_sensor.BinarySensorEntity() - assert sensor.state == STATE_OFF + assert sensor.state is None with mock.patch( "homeassistant.components.binary_sensor.BinarySensorEntity.is_on", new=False, @@ -19,13 +19,3 @@ def test_state(): new=True, ): assert binary_sensor.BinarySensorEntity().state == STATE_ON - - -def test_deprecated_base_class(caplog): - """Test deprecated base class.""" - - class CustomBinarySensor(binary_sensor.BinarySensorDevice): - pass - - CustomBinarySensor() - assert "BinarySensorDevice is deprecated, modify CustomBinarySensor" in caplog.text diff --git a/tests/components/blackbird/test_media_player.py b/tests/components/blackbird/test_media_player.py index 73b40fdec9765d..a2f5f9914da9df 100644 --- a/tests/components/blackbird/test_media_player.py +++ b/tests/components/blackbird/test_media_player.py @@ -1,6 +1,5 @@ """The tests for the Monoprice Blackbird media player platform.""" from collections import defaultdict -import unittest from unittest import mock import pytest @@ -19,8 +18,6 @@ ) from homeassistant.const import STATE_OFF, STATE_ON -import tests.common - class AttrDict(dict): """Helper class for mocking attributes.""" @@ -60,283 +57,301 @@ def set_all_zone_source(self, source_idx): self.zones[3].av = source_idx -class TestBlackbirdSchema(unittest.TestCase): - """Test Blackbird schema.""" - - def test_valid_serial_schema(self): - """Test valid schema.""" - valid_schema = { +def test_valid_serial_schema(): + """Test valid schema.""" + valid_schema = { + "platform": "blackbird", + "port": "/dev/ttyUSB0", + "zones": { + 1: {"name": "a"}, + 2: {"name": "a"}, + 3: {"name": "a"}, + 4: {"name": "a"}, + 5: {"name": "a"}, + 6: {"name": "a"}, + 7: {"name": "a"}, + 8: {"name": "a"}, + }, + "sources": { + 1: {"name": "a"}, + 2: {"name": "a"}, + 3: {"name": "a"}, + 4: {"name": "a"}, + 5: {"name": "a"}, + 6: {"name": "a"}, + 7: {"name": "a"}, + 8: {"name": "a"}, + }, + } + PLATFORM_SCHEMA(valid_schema) + + +def test_valid_socket_schema(): + """Test valid schema.""" + valid_schema = { + "platform": "blackbird", + "host": "192.168.1.50", + "zones": { + 1: {"name": "a"}, + 2: {"name": "a"}, + 3: {"name": "a"}, + 4: {"name": "a"}, + 5: {"name": "a"}, + }, + "sources": { + 1: {"name": "a"}, + 2: {"name": "a"}, + 3: {"name": "a"}, + 4: {"name": "a"}, + }, + } + PLATFORM_SCHEMA(valid_schema) + + +def test_invalid_schemas(): + """Test invalid schemas.""" + schemas = ( + {}, # Empty + None, # None + # Port and host used concurrently + { "platform": "blackbird", "port": "/dev/ttyUSB0", - "zones": { - 1: {"name": "a"}, - 2: {"name": "a"}, - 3: {"name": "a"}, - 4: {"name": "a"}, - 5: {"name": "a"}, - 6: {"name": "a"}, - 7: {"name": "a"}, - 8: {"name": "a"}, - }, - "sources": { - 1: {"name": "a"}, - 2: {"name": "a"}, - 3: {"name": "a"}, - 4: {"name": "a"}, - 5: {"name": "a"}, - 6: {"name": "a"}, - 7: {"name": "a"}, - 8: {"name": "a"}, - }, - } - PLATFORM_SCHEMA(valid_schema) - - def test_valid_socket_schema(self): - """Test valid schema.""" - valid_schema = { - "platform": "blackbird", "host": "192.168.1.50", - "zones": { - 1: {"name": "a"}, - 2: {"name": "a"}, - 3: {"name": "a"}, - 4: {"name": "a"}, - 5: {"name": "a"}, - }, - "sources": { - 1: {"name": "a"}, - 2: {"name": "a"}, - 3: {"name": "a"}, - 4: {"name": "a"}, - }, - } - PLATFORM_SCHEMA(valid_schema) - - def test_invalid_schemas(self): - """Test invalid schemas.""" - schemas = ( - {}, # Empty - None, # None - # Port and host used concurrently - { - "platform": "blackbird", - "port": "/dev/ttyUSB0", - "host": "192.168.1.50", - "name": "Name", - "zones": {1: {"name": "a"}}, - "sources": {1: {"name": "b"}}, - }, - # Port or host missing - { - "platform": "blackbird", - "name": "Name", - "zones": {1: {"name": "a"}}, - "sources": {1: {"name": "b"}}, - }, - # Invalid zone number - { - "platform": "blackbird", - "port": "/dev/ttyUSB0", - "name": "Name", - "zones": {11: {"name": "a"}}, - "sources": {1: {"name": "b"}}, - }, - # Invalid source number - { - "platform": "blackbird", - "port": "/dev/ttyUSB0", - "name": "Name", - "zones": {1: {"name": "a"}}, - "sources": {9: {"name": "b"}}, - }, - # Zone missing name - { - "platform": "blackbird", - "port": "/dev/ttyUSB0", - "name": "Name", - "zones": {1: {}}, - "sources": {1: {"name": "b"}}, - }, - # Source missing name + "name": "Name", + "zones": {1: {"name": "a"}}, + "sources": {1: {"name": "b"}}, + }, + # Port or host missing + { + "platform": "blackbird", + "name": "Name", + "zones": {1: {"name": "a"}}, + "sources": {1: {"name": "b"}}, + }, + # Invalid zone number + { + "platform": "blackbird", + "port": "/dev/ttyUSB0", + "name": "Name", + "zones": {11: {"name": "a"}}, + "sources": {1: {"name": "b"}}, + }, + # Invalid source number + { + "platform": "blackbird", + "port": "/dev/ttyUSB0", + "name": "Name", + "zones": {1: {"name": "a"}}, + "sources": {9: {"name": "b"}}, + }, + # Zone missing name + { + "platform": "blackbird", + "port": "/dev/ttyUSB0", + "name": "Name", + "zones": {1: {}}, + "sources": {1: {"name": "b"}}, + }, + # Source missing name + { + "platform": "blackbird", + "port": "/dev/ttyUSB0", + "name": "Name", + "zones": {1: {"name": "a"}}, + "sources": {1: {}}, + }, + ) + for value in schemas: + with pytest.raises(vol.MultipleInvalid): + PLATFORM_SCHEMA(value) + + +@pytest.fixture +def mock_blackbird(): + """Return a mock blackbird instance.""" + return MockBlackbird() + + +@pytest.fixture +async def setup_blackbird(hass, mock_blackbird): + """Set up blackbird.""" + with mock.patch( + "homeassistant.components.blackbird.media_player.get_blackbird", + new=lambda *a: mock_blackbird, + ): + await hass.async_add_executor_job( + setup_platform, + hass, { "platform": "blackbird", "port": "/dev/ttyUSB0", - "name": "Name", - "zones": {1: {"name": "a"}}, - "sources": {1: {}}, - }, - ) - for value in schemas: - with pytest.raises(vol.MultipleInvalid): - PLATFORM_SCHEMA(value) - - -class TestBlackbirdMediaPlayer(unittest.TestCase): - """Test the media_player module.""" - - def setUp(self): - """Set up the test case.""" - self.blackbird = MockBlackbird() - self.hass = tests.common.get_test_home_assistant() - self.hass.start() - # Note, source dictionary is unsorted! - with mock.patch( - "homeassistant.components.blackbird.media_player.get_blackbird", - new=lambda *a: self.blackbird, - ): - setup_platform( - self.hass, - { - "platform": "blackbird", - "port": "/dev/ttyUSB0", - "zones": {3: {"name": "Zone name"}}, - "sources": { - 1: {"name": "one"}, - 3: {"name": "three"}, - 2: {"name": "two"}, - }, + "zones": {3: {"name": "Zone name"}}, + "sources": { + 1: {"name": "one"}, + 3: {"name": "three"}, + 2: {"name": "two"}, }, - lambda *args, **kwargs: None, - {}, - ) - self.hass.block_till_done() - self.media_player = self.hass.data[DATA_BLACKBIRD]["/dev/ttyUSB0-3"] - self.media_player.hass = self.hass - self.media_player.entity_id = "media_player.zone_3" - self.addCleanup(self.tear_down_cleanup) - - def tear_down_cleanup(self): - """Tear down the test case.""" - self.hass.stop() - - def test_setup_platform(self, *args): - """Test setting up platform.""" - # One service must be registered - assert self.hass.services.has_service(DOMAIN, SERVICE_SETALLZONES) - assert len(self.hass.data[DATA_BLACKBIRD]) == 1 - assert self.hass.data[DATA_BLACKBIRD]["/dev/ttyUSB0-3"].name == "Zone name" - - def test_setallzones_service_call_with_entity_id(self): - """Test set all zone source service call with entity id.""" - self.media_player.update() - assert self.media_player.name == "Zone name" - assert self.media_player.state == STATE_ON - assert self.media_player.source == "one" - - # Call set all zones service - self.hass.services.call( - DOMAIN, - SERVICE_SETALLZONES, - {"entity_id": "media_player.zone_3", "source": "three"}, - blocking=True, + }, + lambda *args, **kwargs: None, + {}, ) + await hass.async_block_till_done() - # Check that source was changed - assert self.blackbird.zones[3].av == 3 - self.media_player.update() - assert self.media_player.source == "three" - - def test_setallzones_service_call_without_entity_id(self): - """Test set all zone source service call without entity id.""" - self.media_player.update() - assert self.media_player.name == "Zone name" - assert self.media_player.state == STATE_ON - assert self.media_player.source == "one" - - # Call set all zones service - self.hass.services.call( - DOMAIN, SERVICE_SETALLZONES, {"source": "three"}, blocking=True - ) - # Check that source was changed - assert self.blackbird.zones[3].av == 3 - self.media_player.update() - assert self.media_player.source == "three" +@pytest.fixture +def media_player_entity(hass, setup_blackbird): + """Return the media player entity.""" + media_player = hass.data[DATA_BLACKBIRD]["/dev/ttyUSB0-3"] + media_player.hass = hass + media_player.entity_id = "media_player.zone_3" + return media_player - def test_update(self): - """Test updating values from blackbird.""" - assert self.media_player.state is None - assert self.media_player.source is None - self.media_player.update() +async def test_setup_platform(hass, setup_blackbird): + """Test setting up platform.""" + # One service must be registered + assert hass.services.has_service(DOMAIN, SERVICE_SETALLZONES) + assert len(hass.data[DATA_BLACKBIRD]) == 1 + assert hass.data[DATA_BLACKBIRD]["/dev/ttyUSB0-3"].name == "Zone name" - assert self.media_player.state == STATE_ON - assert self.media_player.source == "one" - def test_name(self): - """Test name property.""" - assert self.media_player.name == "Zone name" +async def test_setallzones_service_call_with_entity_id( + hass, media_player_entity, mock_blackbird +): + """Test set all zone source service call with entity id.""" + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.name == "Zone name" + assert media_player_entity.state == STATE_ON + assert media_player_entity.source == "one" - def test_state(self): - """Test state property.""" - assert self.media_player.state is None + # Call set all zones service + await hass.services.async_call( + DOMAIN, + SERVICE_SETALLZONES, + {"entity_id": "media_player.zone_3", "source": "three"}, + blocking=True, + ) - self.media_player.update() - assert self.media_player.state == STATE_ON + # Check that source was changed + assert mock_blackbird.zones[3].av == 3 + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.source == "three" - self.blackbird.zones[3].power = False - self.media_player.update() - assert self.media_player.state == STATE_OFF - def test_supported_features(self): - """Test supported features property.""" - assert ( - SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE - == self.media_player.supported_features - ) +async def test_setallzones_service_call_without_entity_id( + mock_blackbird, hass, media_player_entity +): + """Test set all zone source service call without entity id.""" + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.name == "Zone name" + assert media_player_entity.state == STATE_ON + assert media_player_entity.source == "one" + + # Call set all zones service + await hass.services.async_call( + DOMAIN, SERVICE_SETALLZONES, {"source": "three"}, blocking=True + ) + + # Check that source was changed + assert mock_blackbird.zones[3].av == 3 + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.source == "three" + + +async def test_update(hass, media_player_entity): + """Test updating values from blackbird.""" + assert media_player_entity.state is None + assert media_player_entity.source is None + + await hass.async_add_executor_job(media_player_entity.update) + + assert media_player_entity.state == STATE_ON + assert media_player_entity.source == "one" + + +async def test_name(media_player_entity): + """Test name property.""" + assert media_player_entity.name == "Zone name" + + +async def test_state(hass, media_player_entity, mock_blackbird): + """Test state property.""" + assert media_player_entity.state is None + + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.state == STATE_ON + + mock_blackbird.zones[3].power = False + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.state == STATE_OFF + + +async def test_supported_features(media_player_entity): + """Test supported features property.""" + assert ( + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE + == media_player_entity.supported_features + ) + + +async def test_source(hass, media_player_entity): + """Test source property.""" + assert media_player_entity.source is None + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.source == "one" + + +async def test_media_title(hass, media_player_entity): + """Test media title property.""" + assert media_player_entity.media_title is None + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.media_title == "one" + + +async def test_source_list(media_player_entity): + """Test source list property.""" + # Note, the list is sorted! + assert media_player_entity.source_list == ["one", "two", "three"] + + +async def test_select_source(hass, media_player_entity, mock_blackbird): + """Test source selection methods.""" + await hass.async_add_executor_job(media_player_entity.update) + + assert media_player_entity.source == "one" + + await media_player_entity.async_select_source("two") + assert mock_blackbird.zones[3].av == 2 + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.source == "two" + + # Trying to set unknown source. + await media_player_entity.async_select_source("no name") + assert mock_blackbird.zones[3].av == 2 + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.source == "two" + + +async def test_turn_on(hass, media_player_entity, mock_blackbird): + """Testing turning on the zone.""" + mock_blackbird.zones[3].power = False + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.state == STATE_OFF + + await media_player_entity.async_turn_on() + assert mock_blackbird.zones[3].power + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.state == STATE_ON + + +async def test_turn_off(hass, media_player_entity, mock_blackbird): + """Testing turning off the zone.""" + mock_blackbird.zones[3].power = True + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.state == STATE_ON - def test_source(self): - """Test source property.""" - assert self.media_player.source is None - self.media_player.update() - assert self.media_player.source == "one" - - def test_media_title(self): - """Test media title property.""" - assert self.media_player.media_title is None - self.media_player.update() - assert self.media_player.media_title == "one" - - def test_source_list(self): - """Test source list property.""" - # Note, the list is sorted! - assert self.media_player.source_list == ["one", "two", "three"] - - def test_select_source(self): - """Test source selection methods.""" - self.media_player.update() - - assert self.media_player.source == "one" - - self.media_player.select_source("two") - assert self.blackbird.zones[3].av == 2 - self.media_player.update() - assert self.media_player.source == "two" - - # Trying to set unknown source. - self.media_player.select_source("no name") - assert self.blackbird.zones[3].av == 2 - self.media_player.update() - assert self.media_player.source == "two" - - def test_turn_on(self): - """Testing turning on the zone.""" - self.blackbird.zones[3].power = False - self.media_player.update() - assert self.media_player.state == STATE_OFF - - self.media_player.turn_on() - assert self.blackbird.zones[3].power - self.media_player.update() - assert self.media_player.state == STATE_ON - - def test_turn_off(self): - """Testing turning off the zone.""" - self.blackbird.zones[3].power = True - self.media_player.update() - assert self.media_player.state == STATE_ON - - self.media_player.turn_off() - assert not self.blackbird.zones[3].power - self.media_player.update() - assert self.media_player.state == STATE_OFF + await media_player_entity.async_turn_off() + assert not mock_blackbird.zones[3].power + await hass.async_add_executor_job(media_player_entity.update) + assert media_player_entity.state == STATE_OFF diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index e8cf67dad01958..3c77dae562a731 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -8,9 +8,6 @@ from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, - DEVICE_CLASS_DOOR, - DEVICE_CLASS_GATE, - DEVICE_CLASS_SHUTTER, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, @@ -19,6 +16,7 @@ SUPPORT_OPEN, SUPPORT_SET_POSITION, SUPPORT_STOP, + CoverDeviceClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -106,7 +104,7 @@ async def test_init_gatecontroller(gatecontroller, hass, config): state = hass.states.get(entity_id) assert state.name == "gateController-position" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_GATE + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.GATE supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] assert supported_features & SUPPORT_OPEN @@ -136,7 +134,7 @@ async def test_init_shutterbox(shutterbox, hass, config): state = hass.states.get(entity_id) assert state.name == "shutterBox-position" - assert entry.original_device_class == DEVICE_CLASS_SHUTTER + assert entry.original_device_class == CoverDeviceClass.SHUTTER supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] assert supported_features & SUPPORT_OPEN @@ -166,7 +164,7 @@ async def test_init_gatebox(gatebox, hass, config): state = hass.states.get(entity_id) assert state.name == "gateBox-position" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_DOOR + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.DOOR supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] assert supported_features & SUPPORT_OPEN diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index a73bba96fba7ae..daa4902f7fe728 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -12,7 +12,13 @@ COLOR_MODE_BRIGHTNESS, COLOR_MODE_RGBW, ) -from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON +from homeassistant.const import ( + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) from homeassistant.helpers import device_registry as dr from .conftest import async_setup_entity, mock_feature @@ -226,7 +232,7 @@ async def test_wlightbox_s_init(wlightbox_s, hass, config): assert color_modes == [COLOR_MODE_BRIGHTNESS] assert ATTR_BRIGHTNESS not in state.attributes - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) @@ -327,7 +333,7 @@ async def test_wlightbox_init(wlightbox, hass, config): assert ATTR_BRIGHTNESS not in state.attributes assert ATTR_RGBW_COLOR not in state.attributes - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) diff --git a/tests/components/blebox/test_sensor.py b/tests/components/blebox/test_sensor.py index 2281c4ea68cde2..b7f6d421a1232d 100644 --- a/tests/components/blebox/test_sensor.py +++ b/tests/components/blebox/test_sensor.py @@ -5,10 +5,10 @@ import blebox_uniapi import pytest +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_TEMPERATURE, STATE_UNKNOWN, TEMP_CELSIUS, ) @@ -45,7 +45,7 @@ async def test_init(tempsensor, hass, config): state = hass.states.get(entity_id) assert state.name == "tempSensor-0.temperature" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS assert state.state == STATE_UNKNOWN diff --git a/tests/components/blebox/test_switch.py b/tests/components/blebox/test_switch.py index e67c0479cb3f7d..b494687e5391e8 100644 --- a/tests/components/blebox/test_switch.py +++ b/tests/components/blebox/test_switch.py @@ -5,13 +5,14 @@ import blebox_uniapi import pytest -from homeassistant.components.switch import DEVICE_CLASS_SWITCH +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) from homeassistant.helpers import device_registry as dr @@ -54,7 +55,7 @@ async def test_switchbox_init(switchbox, hass, config): state = hass.states.get(entity_id) assert state.name == "switchBox-0.relay" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_SWITCH + assert state.attributes[ATTR_DEVICE_CLASS] == SwitchDeviceClass.SWITCH assert state.state == STATE_OFF @@ -201,8 +202,8 @@ async def test_switchbox_d_init(switchbox_d, hass, config): state = hass.states.get(entity_ids[0]) assert state.name == "switchBoxD-0.relay" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_SWITCH - assert state.state == STATE_OFF # NOTE: should instead be STATE_UNKNOWN? + assert state.attributes[ATTR_DEVICE_CLASS] == SwitchDeviceClass.SWITCH + assert state.state == STATE_UNKNOWN device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) @@ -218,8 +219,8 @@ async def test_switchbox_d_init(switchbox_d, hass, config): state = hass.states.get(entity_ids[1]) assert state.name == "switchBoxD-1.relay" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_SWITCH - assert state.state == STATE_OFF # NOTE: should instead be STATE_UNKNOWN? + assert state.attributes[ATTR_DEVICE_CLASS] == SwitchDeviceClass.SWITCH + assert state.state == STATE_UNKNOWN device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 6dfd445c63404e..51b8184354a162 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -48,7 +48,7 @@ async def test_list_blueprints_non_existing_domain(hass, hass_ws_client): """Test listing blueprints.""" client = await hass_ws_client(hass) await client.send_json( - {"id": 5, "type": "blueprint/list", "domain": "not_existsing"} + {"id": 5, "type": "blueprint/list", "domain": "not_existing"} ) msg = await client.receive_json() diff --git a/tests/components/bond/test_button.py b/tests/components/bond/test_button.py new file mode 100644 index 00000000000000..ee6e98b8462990 --- /dev/null +++ b/tests/components/bond/test_button.py @@ -0,0 +1,179 @@ +"""Tests for the Bond button device.""" + +from bond_api import Action, DeviceType + +from homeassistant import core +from homeassistant.components.bond.button import STEP_SIZE +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.button.const import SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry + +from .common import patch_bond_action, patch_bond_device_state, setup_platform + + +def ceiling_fan(name: str): + """Create a ceiling fan with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": [Action.SET_SPEED, Action.SET_DIRECTION, Action.STOP], + } + + +def light_brightness_increase_decrease_only(name: str): + """Create a light that can only increase or decrease brightness.""" + return { + "name": name, + "type": DeviceType.LIGHT, + "actions": [ + Action.TURN_LIGHT_ON, + Action.TURN_LIGHT_OFF, + Action.START_INCREASING_BRIGHTNESS, + Action.START_DECREASING_BRIGHTNESS, + Action.STOP, + ], + } + + +def fireplace_increase_decrease_only(name: str): + """Create a fireplace that can only increase or decrease flame.""" + return { + "name": name, + "type": DeviceType.LIGHT, + "actions": [ + Action.INCREASE_FLAME, + Action.DECREASE_FLAME, + ], + } + + +def light(name: str): + """Create a light with a given name.""" + return { + "name": name, + "type": DeviceType.LIGHT, + "actions": [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF, Action.SET_BRIGHTNESS], + } + + +async def test_entity_registry(hass: core.HomeAssistant): + """Tests that the devices are registered in the entity registry.""" + await setup_platform( + hass, + BUTTON_DOMAIN, + light_brightness_increase_decrease_only("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) + + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities["button.name_1_stop_actions"] + assert entity.unique_id == "test-hub-id_test-device-id_stop" + entity = registry.entities["button.name_1_start_increasing_brightness"] + assert entity.unique_id == "test-hub-id_test-device-id_startincreasingbrightness" + entity = registry.entities["button.name_1_start_decreasing_brightness"] + assert entity.unique_id == "test-hub-id_test-device-id_startdecreasingbrightness" + + +async def test_mutually_exclusive_actions(hass: core.HomeAssistant): + """Tests we do not create the button when there is a mutually exclusive action.""" + await setup_platform( + hass, + BUTTON_DOMAIN, + light("name-1"), + bond_device_id="test-device-id", + ) + + assert not hass.states.async_all("button") + + +async def test_stop_not_created_no_other_buttons(hass: core.HomeAssistant): + """Tests we do not create the stop button when there are no other buttons.""" + await setup_platform( + hass, + BUTTON_DOMAIN, + ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + assert not hass.states.async_all("button") + + +async def test_press_button_with_argument(hass: core.HomeAssistant): + """Tests we can press a button with an argument.""" + await setup_platform( + hass, + BUTTON_DOMAIN, + fireplace_increase_decrease_only("name-1"), + bond_device_id="test-device-id", + ) + + assert hass.states.get("button.name_1_increase_flame") + assert hass.states.get("button.name_1_decrease_flame") + + with patch_bond_action() as mock_action, patch_bond_device_state(): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.name_1_increase_flame"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_action.assert_called_once_with( + "test-device-id", Action(Action.INCREASE_FLAME, STEP_SIZE) + ) + + with patch_bond_action() as mock_action, patch_bond_device_state(): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.name_1_decrease_flame"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_action.assert_called_once_with( + "test-device-id", Action(Action.DECREASE_FLAME, STEP_SIZE) + ) + + +async def test_press_button(hass: core.HomeAssistant): + """Tests we can press a button.""" + await setup_platform( + hass, + BUTTON_DOMAIN, + light_brightness_increase_decrease_only("name-1"), + bond_device_id="test-device-id", + ) + + assert hass.states.get("button.name_1_start_increasing_brightness") + assert hass.states.get("button.name_1_start_decreasing_brightness") + + with patch_bond_action() as mock_action, patch_bond_device_state(): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.name_1_start_increasing_brightness"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_action.assert_called_once_with( + "test-device-id", Action(Action.START_INCREASING_BRIGHTNESS) + ) + + with patch_bond_action() as mock_action, patch_bond_device_state(): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.name_1_start_decreasing_brightness"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_action.assert_called_once_with( + "test-device-id", Action(Action.START_DECREASING_BRIGHTNESS) + ) diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 0c58596bb7ff0f..4168cbd35d22a6 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import call from bond_api import Action, DeviceType, Direction import pytest @@ -12,14 +13,18 @@ DOMAIN as BOND_DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE, ) +from homeassistant.components.bond.fan import PRESET_MODE_BREEZE from homeassistant.components.fan import ( ATTR_DIRECTION, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, ATTR_SPEED, ATTR_SPEED_LIST, DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN as FAN_DOMAIN, SERVICE_SET_DIRECTION, + SERVICE_SET_PRESET_MODE, SERVICE_SET_SPEED, SPEED_OFF, ) @@ -49,14 +54,26 @@ def ceiling_fan(name: str): } +def ceiling_fan_with_breeze(name: str): + """Create a ceiling fan with given name with breeze support.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": ["SetSpeed", "SetDirection", "BreezeOn"], + } + + async def turn_fan_on( hass: core.HomeAssistant, fan_id: str, speed: str | None = None, percentage: int | None = None, + preset_mode: str | None = None, ) -> None: """Turn the fan on at the specified speed.""" service_data = {ATTR_ENTITY_ID: fan_id} + if preset_mode: + service_data[fan.ATTR_PRESET_MODE] = preset_mode if speed: service_data[fan.ATTR_SPEED] = speed if percentage: @@ -205,6 +222,88 @@ async def test_turn_on_fan_with_percentage_6_speeds(hass: core.HomeAssistant): mock_set_speed.assert_called_with("test-device-id", Action.set_speed(6)) +async def test_turn_on_fan_preset_mode(hass: core.HomeAssistant): + """Tests that turn on command delegates to breeze on API.""" + await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan_with_breeze("name-1"), + bond_device_id="test-device-id", + props={"max_speed": 6}, + ) + assert hass.states.get("fan.name_1").attributes[ATTR_PRESET_MODES] == [ + PRESET_MODE_BREEZE + ] + + with patch_bond_action() as mock_set_preset_mode, patch_bond_device_state(): + await turn_fan_on(hass, "fan.name_1", preset_mode=PRESET_MODE_BREEZE) + + mock_set_preset_mode.assert_called_with("test-device-id", Action(Action.BREEZE_ON)) + + with patch_bond_action() as mock_set_preset_mode, patch_bond_device_state(): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + service_data={ + ATTR_PRESET_MODE: PRESET_MODE_BREEZE, + ATTR_ENTITY_ID: "fan.name_1", + }, + blocking=True, + ) + + mock_set_preset_mode.assert_called_with("test-device-id", Action(Action.BREEZE_ON)) + + +async def test_turn_on_fan_preset_mode_not_supported(hass: core.HomeAssistant): + """Tests calling breeze mode on a fan that does not support it raises.""" + await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_device_id="test-device-id", + props={"max_speed": 6}, + ) + + with patch_bond_action(), patch_bond_device_state(), pytest.raises( + fan.NotValidPresetModeError + ): + await turn_fan_on(hass, "fan.name_1", preset_mode=PRESET_MODE_BREEZE) + + with patch_bond_action(), patch_bond_device_state(), pytest.raises(ValueError): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + service_data={ + ATTR_PRESET_MODE: PRESET_MODE_BREEZE, + ATTR_ENTITY_ID: "fan.name_1", + }, + blocking=True, + ) + + +async def test_turn_on_fan_with_off_with_breeze(hass: core.HomeAssistant): + """Tests that turn off command delegates to turn off API.""" + await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan_with_breeze("name-1"), + bond_device_id="test-device-id", + state={"breeze": [1, 0, 0]}, + ) + + assert ( + hass.states.get("fan.name_1").attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE + ) + + with patch_bond_action() as mock_actions, patch_bond_device_state(): + await turn_fan_on(hass, "fan.name_1", fan.SPEED_OFF) + + assert mock_actions.mock_calls == [ + call("test-device-id", Action(Action.BREEZE_OFF)), + call("test-device-id", Action.turn_off()), + ] + + async def test_turn_on_fan_without_speed(hass: core.HomeAssistant): """Tests that turn on command delegates to turn on API.""" await setup_platform( @@ -218,7 +317,7 @@ async def test_turn_on_fan_without_speed(hass: core.HomeAssistant): async def test_turn_on_fan_with_off_speed(hass: core.HomeAssistant): - """Tests that turn on command delegates to turn off API.""" + """Tests that turn off command delegates to turn off API.""" await setup_platform( hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" ) diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index db54ffdf716c95..88615d98122e09 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -84,6 +84,7 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss "bondid": "test-bond-id", "target": "test-model", "fw_ver": "test-version", + "mcu_ver": "test-hw-version", } ), patch_setup_entry("cover") as mock_cover_async_setup_entry, patch_setup_entry( "fan" @@ -107,6 +108,7 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss assert hub.manufacturer == "Olibra" assert hub.model == "test-model" assert hub.sw_version == "test-version" + assert hub.hw_version == "test-hw-version" assert hub.configuration_url == "http://some host" # verify supported domains are setup diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index e0ca9a0542595b..695a98a927e4fc 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -372,7 +372,7 @@ async def test_fp_light_set_brightness_belief_api_error(hass: core.HomeAssistant await hass.async_block_till_done() -async def test_light_set_brightness_belief_brightnes_not_supported( +async def test_light_set_brightness_belief_brightness_not_supported( hass: core.HomeAssistant, ): """Tests that the set brightness belief function of a light that doesn't support setting brightness returns an error.""" @@ -527,7 +527,7 @@ async def test_fp_light_set_power_belief_api_error(hass: core.HomeAssistant): await hass.async_block_till_done() -async def test_fp_light_set_brightness_belief_brightnes_not_supported( +async def test_fp_light_set_brightness_belief_brightness_not_supported( hass: core.HomeAssistant, ): """Tests that the set brightness belief function of a fireplace light that doesn't support setting brightness returns an error.""" diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 7ac9439e711076..16d76c5d75305a 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -53,7 +53,9 @@ async def test_user_invalid_host(hass): async def test_authorize_cannot_connect(hass): """Test that errors are shown when cannot connect to host at the authorize step.""" - with patch("bravia_tv.BraviaRC.connect", return_value=True): + with patch("bravia_tv.BraviaRC.connect", return_value=True), patch( + "bravia_tv.BraviaRC.is_connected", return_value=False + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} ) @@ -199,11 +201,17 @@ async def test_options_flow(hass): with patch("bravia_tv.BraviaRC.connect", return_value=True), patch( "bravia_tv.BraviaRC.is_connected", return_value=True - ), patch("bravia_tv.BraviaRC.get_system_info", return_value=BRAVIA_SYSTEM_INFO): + ), patch("bravia_tv.BraviaRC.get_power_status"), patch( + "bravia_tv.BraviaRC.get_system_info", return_value=BRAVIA_SYSTEM_INFO + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patch("bravia_tv.BraviaRC.load_source_list", return_value=BRAVIA_SOURCE_LIST): + with patch("bravia_tv.BraviaRC.connect", return_value=True), patch( + "bravia_tv.BraviaRC.is_connected", return_value=False + ), patch("bravia_tv.BraviaRC.get_power_status"), patch( + "bravia_tv.BraviaRC.load_source_list", return_value=BRAVIA_SOURCE_LIST + ): result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -212,6 +220,7 @@ async def test_options_flow(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_IGNORED_SOURCES: ["HDMI 1", "HDMI 2"]} ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == {CONF_IGNORED_SOURCES: ["HDMI 1", "HDMI 2"]} diff --git a/tests/components/broadlink/test_remote.py b/tests/components/broadlink/test_remote.py index 1ee480636139f2..3c97f8ea47a9eb 100644 --- a/tests/components/broadlink/test_remote.py +++ b/tests/components/broadlink/test_remote.py @@ -34,10 +34,10 @@ async def test_remote_setup_works(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - remotes = {entry for entry in entries if entry.domain == Platform.REMOTE} + remotes = [entry for entry in entries if entry.domain == Platform.REMOTE] assert len(remotes) == 1 - remote = remotes.pop() + remote = remotes[0] assert remote.original_name == f"{device.name} Remote" assert hass.states.get(remote.entity_id).state == STATE_ON assert mock_setup.api.auth.call_count == 1 @@ -54,10 +54,10 @@ async def test_remote_send_command(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - remotes = {entry for entry in entries if entry.domain == Platform.REMOTE} + remotes = [entry for entry in entries if entry.domain == Platform.REMOTE] assert len(remotes) == 1 - remote = remotes.pop() + remote = remotes[0] await hass.services.async_call( Platform.REMOTE, SERVICE_SEND_COMMAND, @@ -81,10 +81,10 @@ async def test_remote_turn_off_turn_on(hass): {(DOMAIN, mock_setup.entry.unique_id)} ) entries = async_entries_for_device(entity_registry, device_entry.id) - remotes = {entry for entry in entries if entry.domain == Platform.REMOTE} + remotes = [entry for entry in entries if entry.domain == Platform.REMOTE] assert len(remotes) == 1 - remote = remotes.pop() + remote = remotes[0] await hass.services.async_call( Platform.REMOTE, SERVICE_TURN_OFF, diff --git a/tests/components/brother/fixtures/diagnostics_data.json b/tests/components/brother/fixtures/diagnostics_data.json new file mode 100644 index 00000000000000..1a8458e1cafc28 --- /dev/null +++ b/tests/components/brother/fixtures/diagnostics_data.json @@ -0,0 +1,45 @@ +{ + "b/w_counter": 709, + "belt_unit_remaining_life": 97, + "belt_unit_remaining_pages": 48436, + "black_drum_counter": 1611, + "black_drum_remaining_life": 92, + "black_drum_remaining_pages": 16389, + "black_toner": 80, + "black_toner_remaining": 75, + "black_toner_status": 1, + "color_counter": 902, + "cyan_drum_counter": 1611, + "cyan_drum_remaining_life": 92, + "cyan_drum_remaining_pages": 16389, + "cyan_toner": 10, + "cyan_toner_remaining": 10, + "cyan_toner_status": 1, + "drum_counter": 986, + "drum_remaining_life": 92, + "drum_remaining_pages": 11014, + "drum_status": 1, + "duplex_unit_pages_counter": 538, + "firmware": "1.17", + "fuser_remaining_life": 97, + "laser_unit_remaining_pages": 48389, + "magenta_drum_counter": 1611, + "magenta_drum_remaining_life": 92, + "magenta_drum_remaining_pages": 16389, + "magenta_toner": 10, + "magenta_toner_remaining": 8, + "magenta_toner_status": 2, + "model": "HL-L2340DW", + "page_counter": 986, + "pf_kit_1_remaining_life": 98, + "pf_kit_1_remaining_pages": 48741, + "serial": "0123456789", + "status": "waiting", + "uptime": "2019-09-24T12:14:56+00:00", + "yellow_drum_counter": 1611, + "yellow_drum_remaining_life": 92, + "yellow_drum_remaining_pages": 16389, + "yellow_toner": 10, + "yellow_toner_remaining": 2, + "yellow_toner_status": 2 +} diff --git a/tests/components/brother/test_diagnostics.py b/tests/components/brother/test_diagnostics.py new file mode 100644 index 00000000000000..7e25ffaa4f02b5 --- /dev/null +++ b/tests/components/brother/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Test Brother diagnostics.""" +from datetime import datetime +import json +from unittest.mock import Mock, patch + +from homeassistant.util.dt import UTC + +from tests.common import load_fixture +from tests.components.brother import init_integration +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, hass_client): + """Test config entry diagnostics.""" + entry = await init_integration(hass, skip_setup=True) + + diagnostics_data = json.loads(load_fixture("diagnostics_data.json", "brother")) + test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) + with patch("brother.datetime", utcnow=Mock(return_value=test_time)), patch( + "brother.Brother._get_data", + return_value=json.loads(load_fixture("printer_data.json", "brother")), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["info"] == {"host": "localhost", "type": "laser"} + assert result["data"] == diagnostics_data diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index a2763c3ed91dfc..7ef87a2e396efd 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -8,14 +8,14 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_TIMESTAMP, PERCENTAGE, STATE_UNAVAILABLE, ) @@ -67,7 +67,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "75" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_black_toner_remaining") assert entry @@ -78,7 +78,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "10" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_cyan_toner_remaining") assert entry @@ -89,7 +89,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "8" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_magenta_toner_remaining") assert entry @@ -100,7 +100,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "2" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_yellow_toner_remaining") assert entry @@ -113,7 +113,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_COUNTER) == 986 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_life") assert entry @@ -126,7 +126,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_life") assert entry @@ -139,7 +139,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_life") assert entry @@ -152,7 +152,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_life") assert entry @@ -165,7 +165,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_COUNTER) == 1611 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_life") assert entry @@ -176,7 +176,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:water-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "97" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_fuser_remaining_life") assert entry @@ -187,7 +187,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:current-ac" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "97" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_belt_unit_remaining_life") assert entry @@ -198,7 +198,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "98" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_pf_kit_1_remaining_life") assert entry @@ -209,7 +209,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "986" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_page_counter") assert entry @@ -220,7 +220,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "538" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_pages_counter") assert entry @@ -231,7 +231,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "709" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_b_w_counter") assert entry @@ -242,7 +242,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "902" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.hl_l2340dw_color_counter") assert entry @@ -252,7 +252,7 @@ async def test_sensors(hass): assert state assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert state.state == "2019-09-24T12:14:56+00:00" assert state.attributes.get(ATTR_STATE_CLASS) is None @@ -273,7 +273,7 @@ async def test_disabled_by_default_sensors(hass): assert entry assert entry.unique_id == "0123456789_uptime" assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_availability(hass): @@ -309,7 +309,7 @@ async def test_availability(hass): async def test_manual_update_entity(hass): - """Test manual update entity via service homeasasistant/update_entity.""" + """Test manual update entity via service homeassistant/update_entity.""" await init_integration(hass) data = json.loads(load_fixture("printer_data.json", "brother")) diff --git a/tests/components/button/test_device_action.py b/tests/components/button/test_device_action.py index 984be163d42ab6..ab1e145cdb1d8f 100644 --- a/tests/components/button/test_device_action.py +++ b/tests/components/button/test_device_action.py @@ -3,6 +3,7 @@ from homeassistant.components import automation from homeassistant.components.button import DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_registry from homeassistant.setup import async_setup_component @@ -50,7 +51,9 @@ async def test_get_actions( "entity_id": "button.test_5678", } ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) diff --git a/tests/components/button/test_device_trigger.py b/tests/components/button/test_device_trigger.py index 913aec9088e3cb..88534941aa9047 100644 --- a/tests/components/button/test_device_trigger.py +++ b/tests/components/button/test_device_trigger.py @@ -5,6 +5,7 @@ from homeassistant.components import automation from homeassistant.components.button import DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry from homeassistant.helpers.entity_registry import EntityRegistry @@ -60,7 +61,9 @@ async def test_get_triggers( "entity_id": f"{DOMAIN}.test_5678", } ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index 756a553f3c72c4..bd3841cc4e85c6 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -29,4 +29,5 @@ def mock_turbo_jpeg( (second_width, second_height, 0, 0), ] mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG + mocked_turbo_jpeg.encode.return_value = EMPTY_8_6_JPEG return mocked_turbo_jpeg diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 3f8f62449ba564..403cacec1f1047 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -16,7 +16,11 @@ from homeassistant.components.camera.prefs import CameraEntityPreferences from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.config import async_process_ha_core_config -from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_START, + STATE_UNAVAILABLE, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -24,6 +28,11 @@ from tests.components.camera import common +STREAM_SOURCE = "rtsp://127.0.0.1/stream" +HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u" +WEBRTC_OFFER = "v=0\r\n" +WEBRTC_ANSWER = "a=sendonly" + @pytest.fixture(name="mock_camera") async def mock_camera_fixture(hass): @@ -53,7 +62,7 @@ async def mock_camera_web_rtc_fixture(hass): new_callable=PropertyMock(return_value=STREAM_TYPE_WEB_RTC), ), patch( "homeassistant.components.camera.Camera.async_handle_web_rtc_offer", - return_value="a=sendonly", + return_value=WEBRTC_ANSWER, ): yield @@ -81,6 +90,50 @@ async def image_mock_url_fixture(hass): await hass.async_block_till_done() +@pytest.fixture(name="mock_stream_source") +async def mock_stream_source_fixture(): + """Fixture to create an RTSP stream source.""" + with patch( + "homeassistant.components.camera.Camera.stream_source", + return_value=STREAM_SOURCE, + ) as mock_stream_source, patch( + "homeassistant.components.camera.Camera.supported_features", + return_value=camera.SUPPORT_STREAM, + ): + yield mock_stream_source + + +@pytest.fixture(name="mock_hls_stream_source") +async def mock_hls_stream_source_fixture(): + """Fixture to create an HLS stream source.""" + with patch( + "homeassistant.components.camera.Camera.stream_source", + return_value=HLS_STREAM_SOURCE, + ) as mock_hls_stream_source, patch( + "homeassistant.components.camera.Camera.supported_features", + return_value=camera.SUPPORT_STREAM, + ): + yield mock_hls_stream_source + + +async def provide_web_rtc_answer(stream_source: str, offer: str, stream_id: str) -> str: + """Simulate an rtsp to webrtc provider.""" + assert stream_source == STREAM_SOURCE + assert offer == WEBRTC_OFFER + return WEBRTC_ANSWER + + +@pytest.fixture(name="mock_rtsp_to_web_rtc") +async def mock_rtsp_to_web_rtc_fixture(hass): + """Fixture that registers a mock rtsp to web_rtc provider.""" + mock_provider = Mock(side_effect=provide_web_rtc_answer) + unsub = camera.async_register_rtsp_to_web_rtc_provider( + hass, "mock_domain", mock_provider + ) + yield mock_provider + unsub() + + async def test_get_image_from_camera(hass, image_mock_url): """Grab an image from camera entity.""" @@ -185,17 +238,13 @@ async def test_get_image_from_camera_not_jpeg(hass, image_mock_url): assert image.content == b"png" -async def test_get_stream_source_from_camera(hass, mock_camera): +async def test_get_stream_source_from_camera(hass, mock_camera, mock_stream_source): """Fetch stream source from camera entity.""" - with patch( - "homeassistant.components.camera.Camera.stream_source", - return_value="rtsp://127.0.0.1/stream", - ) as mock_camera_stream_source: - stream_source = await camera.async_get_stream_source(hass, "camera.demo_camera") + stream_source = await camera.async_get_stream_source(hass, "camera.demo_camera") - assert mock_camera_stream_source.called - assert stream_source == "rtsp://127.0.0.1/stream" + assert mock_stream_source.called + assert stream_source == STREAM_SOURCE async def test_get_image_without_exists_camera(hass, image_mock_url): @@ -230,8 +279,7 @@ async def test_snapshot_service(hass, mock_camera): mopen = mock_open() with patch("homeassistant.components.camera.open", mopen, create=True), patch( - "homeassistant.components.camera.os.path.exists", - Mock(spec="os.path.exists", return_value=True), + "homeassistant.components.camera.os.makedirs", ), patch.object(hass.config, "is_allowed_path", return_value=True): await hass.services.async_call( camera.DOMAIN, @@ -499,7 +547,7 @@ async def test_websocket_web_rtc_offer( "id": 9, "type": "camera/web_rtc_offer", "entity_id": "camera.demo_camera", - "offer": "v=0\r\n", + "offer": WEBRTC_OFFER, } ) response = await client.receive_json() @@ -507,7 +555,7 @@ async def test_websocket_web_rtc_offer( assert response["id"] == 9 assert response["type"] == TYPE_RESULT assert response["success"] - assert response["result"]["answer"] == "a=sendonly" + assert response["result"]["answer"] == WEBRTC_ANSWER async def test_websocket_web_rtc_offer_invalid_entity( @@ -522,7 +570,7 @@ async def test_websocket_web_rtc_offer_invalid_entity( "id": 9, "type": "camera/web_rtc_offer", "entity_id": "camera.does_not_exist", - "offer": "v=0\r\n", + "offer": WEBRTC_OFFER, } ) response = await client.receive_json() @@ -571,7 +619,7 @@ async def test_websocket_web_rtc_offer_failure( "id": 9, "type": "camera/web_rtc_offer", "entity_id": "camera.demo_camera", - "offer": "v=0\r\n", + "offer": WEBRTC_OFFER, } ) response = await client.receive_json() @@ -600,7 +648,7 @@ async def test_websocket_web_rtc_offer_timeout( "id": 9, "type": "camera/web_rtc_offer", "entity_id": "camera.demo_camera", - "offer": "v=0\r\n", + "offer": WEBRTC_OFFER, } ) response = await client.receive_json() @@ -624,7 +672,7 @@ async def test_websocket_web_rtc_offer_invalid_stream_type( "id": 9, "type": "camera/web_rtc_offer", "entity_id": "camera.demo_camera", - "offer": "v=0\r\n", + "offer": WEBRTC_OFFER, } ) response = await client.receive_json() @@ -633,3 +681,198 @@ async def test_websocket_web_rtc_offer_invalid_stream_type( assert response["type"] == TYPE_RESULT assert not response["success"] assert response["error"]["code"] == "web_rtc_offer_failed" + + +async def test_state_streaming(hass, hass_ws_client, mock_camera): + """Camera state.""" + demo_camera = hass.states.get("camera.demo_camera") + assert demo_camera is not None + assert demo_camera.state == camera.STATE_STREAMING + + +async def test_stream_unavailable(hass, hass_ws_client, mock_camera, mock_stream): + """Camera state.""" + await async_setup_component(hass, "camera", {}) + + with patch( + "homeassistant.components.camera.Stream.endpoint_url", + return_value="http://home.assistant/playlist.m3u8", + ), patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value="http://example.com", + ), patch( + "homeassistant.components.camera.Stream.set_update_callback", + ) as mock_update_callback: + # Request playlist through WebSocket. We just want to create the stream + # but don't care about the result. + client = await hass_ws_client(hass) + await client.send_json( + {"id": 10, "type": "camera/stream", "entity_id": "camera.demo_camera"} + ) + await client.receive_json() + assert mock_update_callback.called + + # Simulate the stream going unavailable + callback = mock_update_callback.call_args.args[0] + with patch( + "homeassistant.components.camera.Stream.available", new_callable=lambda: False + ): + callback() + await hass.async_block_till_done() + + demo_camera = hass.states.get("camera.demo_camera") + assert demo_camera is not None + assert demo_camera.state == STATE_UNAVAILABLE + + # Simulate stream becomes available + with patch( + "homeassistant.components.camera.Stream.available", new_callable=lambda: True + ): + callback() + await hass.async_block_till_done() + + demo_camera = hass.states.get("camera.demo_camera") + assert demo_camera is not None + assert demo_camera.state == camera.STATE_STREAMING + + +async def test_rtsp_to_web_rtc_offer( + hass, + hass_ws_client, + mock_camera, + mock_stream_source, + mock_rtsp_to_web_rtc, +): + """Test creating a web_rtc offer from an rstp provider.""" + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 9, + "type": "camera/web_rtc_offer", + "entity_id": "camera.demo_camera", + "offer": WEBRTC_OFFER, + } + ) + response = await client.receive_json() + + assert response.get("id") == 9 + assert response.get("type") == TYPE_RESULT + assert response.get("success") + assert "result" in response + assert response["result"] == {"answer": WEBRTC_ANSWER} + + assert mock_rtsp_to_web_rtc.called + + +async def test_unsupported_rtsp_to_web_rtc_stream_type( + hass, + hass_ws_client, + mock_camera, + mock_hls_stream_source, # Not an RTSP stream source + mock_rtsp_to_web_rtc, +): + """Test rtsp-to-webrtc is not registered for non-RTSP streams.""" + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 10, + "type": "camera/web_rtc_offer", + "entity_id": "camera.demo_camera", + "offer": WEBRTC_OFFER, + } + ) + response = await client.receive_json() + + assert response.get("id") == 10 + assert response.get("type") == TYPE_RESULT + assert "success" in response + assert not response["success"] + + +async def test_rtsp_to_web_rtc_provider_unregistered( + hass, + hass_ws_client, + mock_camera, + mock_stream_source, +): + """Test creating a web_rtc offer from an rstp provider.""" + mock_provider = Mock(side_effect=provide_web_rtc_answer) + unsub = camera.async_register_rtsp_to_web_rtc_provider( + hass, "mock_domain", mock_provider + ) + + client = await hass_ws_client(hass) + + # Registered provider can handle the WebRTC offer + await client.send_json( + { + "id": 11, + "type": "camera/web_rtc_offer", + "entity_id": "camera.demo_camera", + "offer": WEBRTC_OFFER, + } + ) + response = await client.receive_json() + assert response["id"] == 11 + assert response["type"] == TYPE_RESULT + assert response["success"] + assert response["result"]["answer"] == WEBRTC_ANSWER + + assert mock_provider.called + mock_provider.reset_mock() + + # Unregister provider, then verify the WebRTC offer cannot be handled + unsub() + await client.send_json( + { + "id": 12, + "type": "camera/web_rtc_offer", + "entity_id": "camera.demo_camera", + "offer": WEBRTC_OFFER, + } + ) + response = await client.receive_json() + assert response.get("id") == 12 + assert response.get("type") == TYPE_RESULT + assert "success" in response + assert not response["success"] + + assert not mock_provider.called + + +async def test_rtsp_to_web_rtc_offer_not_accepted( + hass, + hass_ws_client, + mock_camera, + mock_stream_source, +): + """Test a provider that can't satisfy the rtsp to webrtc offer.""" + + async def provide_none(stream_source: str, offer: str) -> str: + """Simulate a provider that can't accept the offer.""" + return None + + mock_provider = Mock(side_effect=provide_none) + unsub = camera.async_register_rtsp_to_web_rtc_provider( + hass, "mock_domain", mock_provider + ) + client = await hass_ws_client(hass) + + # Registered provider can handle the WebRTC offer + await client.send_json( + { + "id": 11, + "type": "camera/web_rtc_offer", + "entity_id": "camera.demo_camera", + "offer": WEBRTC_OFFER, + } + ) + response = await client.receive_json() + assert response["id"] == 11 + assert response.get("type") == TYPE_RESULT + assert "success" in response + assert not response["success"] + + assert mock_provider.called + + unsub() diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index 530ca1cccc162b..0c95152338cd86 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -9,12 +9,9 @@ STATE_AIR_QUALITY_NORMAL, STATE_AIR_QUALITY_VERY_ABNORMAL, ) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, TEMP_CELSIUS, @@ -56,14 +53,14 @@ async def test_sensors_pro(hass, canary) -> None: "20_temperature", "21.12", TEMP_CELSIUS, - DEVICE_CLASS_TEMPERATURE, + SensorDeviceClass.TEMPERATURE, None, ), "home_dining_room_humidity": ( "20_humidity", "50.46", PERCENTAGE, - DEVICE_CLASS_HUMIDITY, + SensorDeviceClass.HUMIDITY, None, ), "home_dining_room_air_quality": ( @@ -182,14 +179,14 @@ async def test_sensors_flex(hass, canary) -> None: "20_battery", "70.46", PERCENTAGE, - DEVICE_CLASS_BATTERY, + SensorDeviceClass.BATTERY, None, ), "home_dining_room_wifi": ( "20_wifi", "-57.0", SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - DEVICE_CLASS_SIGNAL_STRENGTH, + SensorDeviceClass.SIGNAL_STRENGTH, None, ), } diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 9bf1175c1b9400..bd22a558314e74 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from unittest.mock import ANY, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from uuid import UUID import attr @@ -14,7 +14,10 @@ from homeassistant.components import media_player, tts from homeassistant.components.cast import media_player as cast from homeassistant.components.cast.media_player import ChromecastInfo +from homeassistant.components.media_player import BrowseMedia from homeassistant.components.media_player.const import ( + MEDIA_CLASS_APP, + MEDIA_CLASS_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -38,7 +41,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component, mock_platform from tests.components.media_player import common # pylint: disable=invalid-name @@ -592,7 +595,7 @@ async def test_entity_availability(hass: HomeAssistant): conn_status_cb(connection_status) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == "off" + assert state.state == "idle" connection_status = MagicMock() connection_status.status = "DISCONNECTED" @@ -621,7 +624,7 @@ async def test_entity_cast_status(hass: HomeAssistant): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "off" + assert state.state == "idle" assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) # No media status, pause, play, stop not supported @@ -639,8 +642,8 @@ async def test_entity_cast_status(hass: HomeAssistant): cast_status_cb(cast_status) await hass.async_block_till_done() state = hass.states.get(entity_id) - # Volume hidden if no app is active - assert state.attributes.get("volume_level") is None + # Volume not hidden even if no app is active + assert state.attributes.get("volume_level") == 0.5 assert not state.attributes.get("is_volume_muted") chromecast.app_id = "1234" @@ -744,7 +747,7 @@ async def test_supported_features( state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "off" + assert state.state == "idle" assert state.attributes.get("supported_features") == supported_features_no_media media_status = MagicMock(images=None) @@ -756,6 +759,111 @@ async def test_supported_features( assert state.attributes.get("supported_features") == supported_features +async def test_entity_browse_media(hass: HomeAssistant, hass_ws_client): + """Test we can browse media.""" + await async_setup_component(hass, "media_source", {"media_source": {}}) + + info = get_fake_chromecast_info() + + chromecast, _ = await async_setup_media_player_cast(hass, info) + _, conn_status_cb, _ = get_status_callbacks(chromecast) + + connection_status = MagicMock() + connection_status.status = "CONNECTED" + conn_status_cb(connection_status) + await hass.async_block_till_done() + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.speaker", + } + ) + response = await client.receive_json() + assert response["success"] + expected_child_1 = { + "title": "Epic Sax Guy 10 Hours.mp4", + "media_class": "video", + "media_content_type": "video/mp4", + "media_content_id": "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4", + "can_play": True, + "can_expand": False, + "children_media_class": None, + "thumbnail": None, + } + assert expected_child_1 in response["result"]["children"] + + expected_child_2 = { + "title": "test.mp3", + "media_class": "music", + "media_content_type": "audio/mpeg", + "media_content_id": "media-source://media_source/local/test.mp3", + "can_play": True, + "can_expand": False, + "children_media_class": None, + "thumbnail": None, + } + assert expected_child_2 in response["result"]["children"] + + +@pytest.mark.parametrize( + "cast_type", + [pychromecast.const.CAST_TYPE_AUDIO, pychromecast.const.CAST_TYPE_GROUP], +) +async def test_entity_browse_media_audio_only( + hass: HomeAssistant, hass_ws_client, cast_type +): + """Test we can browse media.""" + await async_setup_component(hass, "media_source", {"media_source": {}}) + + info = get_fake_chromecast_info() + + chromecast, _ = await async_setup_media_player_cast(hass, info) + chromecast.cast_type = cast_type + _, conn_status_cb, _ = get_status_callbacks(chromecast) + + connection_status = MagicMock() + connection_status.status = "CONNECTED" + conn_status_cb(connection_status) + await hass.async_block_till_done() + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.speaker", + } + ) + response = await client.receive_json() + assert response["success"] + expected_child_1 = { + "title": "Epic Sax Guy 10 Hours.mp4", + "media_class": "video", + "media_content_type": "video/mp4", + "media_content_id": "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4", + "can_play": True, + "can_expand": False, + "children_media_class": None, + "thumbnail": None, + } + assert expected_child_1 not in response["result"]["children"] + + expected_child_2 = { + "title": "test.mp3", + "media_class": "music", + "media_content_type": "audio/mpeg", + "media_content_id": "media-source://media_source/local/test.mp3", + "can_play": True, + "can_expand": False, + "children_media_class": None, + "thumbnail": None, + } + assert expected_child_2 in response["result"]["children"] + + async def test_entity_play_media(hass: HomeAssistant, quick_play_mock): """Test playing media.""" entity_id = "media_player.speaker" @@ -774,7 +882,7 @@ async def test_entity_play_media(hass: HomeAssistant, quick_play_mock): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "off" + assert state.state == "idle" assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) # Play_media @@ -820,7 +928,7 @@ async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "off" + assert state.state == "idle" assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) # Play_media - cast with app ID @@ -862,7 +970,7 @@ async def test_entity_play_media_cast_invalid(hass, caplog, quick_play_mock): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "off" + assert state.state == "idle" assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) # play_media - media_type cast with invalid JSON @@ -934,7 +1042,7 @@ async def test_entity_media_content_type(hass: HomeAssistant): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "off" + assert state.state == "idle" assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) media_status = MagicMock(images=None) @@ -1105,7 +1213,7 @@ async def test_entity_media_states(hass: HomeAssistant, app_id, state_no_media): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "off" + assert state.state == "idle" assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) # App id updated, but no media status @@ -1150,7 +1258,7 @@ async def test_entity_media_states(hass: HomeAssistant, app_id, state_no_media): cast_status_cb(cast_status) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == "off" + assert state.state == "idle" # No cast status chromecast.is_idle = False @@ -1178,7 +1286,7 @@ async def test_entity_media_states_lovelace_app(hass: HomeAssistant): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "off" + assert state.state == "idle" assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) chromecast.app_id = CAST_APP_ID_HOMEASSISTANT_LOVELACE @@ -1218,7 +1326,7 @@ async def test_entity_media_states_lovelace_app(hass: HomeAssistant): media_status_cb(media_status) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == "off" + assert state.state == "idle" chromecast.is_idle = False media_status_cb(media_status) @@ -1247,7 +1355,7 @@ async def test_group_media_states(hass, mz_mock): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "off" + assert state.state == "idle" assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) group_media_status = MagicMock(images=None) @@ -1298,7 +1406,7 @@ async def test_group_media_control(hass, mz_mock, quick_play_mock): state = hass.states.get(entity_id) assert state is not None assert state.name == "Speaker" - assert state.state == "off" + assert state.state == "idle" assert entity_id == reg.async_get_entity_id("media_player", "cast", str(info.uuid)) group_media_status = MagicMock(images=None) @@ -1530,3 +1638,189 @@ async def test_entry_setup_list_config(hass: HomeAssistant): assert set(config_entry.data["uuid"]) == {"bla", "blu"} assert set(config_entry.data["ignore_cec"]) == {"cast1", "cast2", "cast3"} assert set(pychromecast.IGNORE_CEC) == {"cast1", "cast2", "cast3"} + + +async def test_invalid_cast_platform(hass: HomeAssistant, caplog): + """Test we can play media through a cast platform.""" + cast_platform_mock = Mock() + del cast_platform_mock.async_get_media_browser_root_object + del cast_platform_mock.async_browse_media + del cast_platform_mock.async_play_media + mock_platform(hass, "test.cast", cast_platform_mock) + + await async_setup_component(hass, "test", {"test": {}}) + await hass.async_block_till_done() + + info = get_fake_chromecast_info() + await async_setup_media_player_cast(hass, info) + + assert "Invalid cast platform None: """Test we get the form.""" @@ -196,104 +193,3 @@ async def test_form_error_unexpected_data(hass: HomeAssistant) -> None: assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "unknown"} - - -async def test_import(hass: HomeAssistant) -> None: - """Test we import correctly.""" - - with patch( - "CO2Signal.get_latest", - return_value=VALID_PAYLOAD, - ): - assert await async_setup_component( - hass, "sensor", {"sensor": {"platform": "co2signal", "token": "1234"}} - ) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries("co2signal")) == 1 - - state = hass.states.get("sensor.co2_intensity") - assert state is not None - assert state.state == "45.99" - assert state.name == "CO2 intensity" - assert state.attributes["unit_of_measurement"] == "gCO2eq/kWh" - assert state.attributes["country_code"] == "FR" - - state = hass.states.get("sensor.grid_fossil_fuel_percentage") - assert state is not None - assert state.state == "5.46" - assert state.name == "Grid fossil fuel percentage" - assert state.attributes["unit_of_measurement"] == "%" - assert state.attributes["country_code"] == "FR" - - -async def test_import_abort_existing_home(hass: HomeAssistant) -> None: - """Test we abort if home entry found.""" - - MockConfigEntry(domain="co2signal", data={"api_key": "abcd"}).add_to_hass(hass) - - with patch( - "CO2Signal.get_latest", - return_value=VALID_PAYLOAD, - ): - assert await async_setup_component( - hass, "sensor", {"sensor": {"platform": "co2signal", "token": "1234"}} - ) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries("co2signal")) == 1 - - -async def test_import_abort_existing_country(hass: HomeAssistant) -> None: - """Test we abort if existing country found.""" - - MockConfigEntry( - domain="co2signal", data={"api_key": "abcd", "country_code": "nl"} - ).add_to_hass(hass) - - with patch( - "CO2Signal.get_latest", - return_value=VALID_PAYLOAD, - ): - assert await async_setup_component( - hass, - "sensor", - { - "sensor": { - "platform": "co2signal", - "token": "1234", - "country_code": "nl", - } - }, - ) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries("co2signal")) == 1 - - -async def test_import_abort_existing_coordinates(hass: HomeAssistant) -> None: - """Test we abort if existing coordinates found.""" - - MockConfigEntry( - domain="co2signal", data={"api_key": "abcd", "latitude": 1, "longitude": 2} - ).add_to_hass(hass) - - with patch( - "CO2Signal.get_latest", - return_value=VALID_PAYLOAD, - ): - assert await async_setup_component( - hass, - "sensor", - { - "sensor": { - "platform": "co2signal", - "token": "1234", - "latitude": 1, - "longitude": 2, - } - }, - ) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries("co2signal")) == 1 diff --git a/tests/components/co2signal/test_diagnostics.py b/tests/components/co2signal/test_diagnostics.py new file mode 100644 index 00000000000000..f84cb5e84dfaaf --- /dev/null +++ b/tests/components/co2signal/test_diagnostics.py @@ -0,0 +1,32 @@ +"""Test the CO2Signal diagnostics.""" +from unittest.mock import patch + +from homeassistant.components.co2signal import DOMAIN +from homeassistant.components.diagnostics import REDACTED +from homeassistant.const import CONF_API_KEY +from homeassistant.setup import async_setup_component + +from . import VALID_PAYLOAD + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, hass_client): + """Test config entry diagnostics.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "", "location": ""} + ) + config_entry.add_to_hass(hass) + with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD): + assert await async_setup_component(hass, DOMAIN, {}) + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + config_entry_dict = config_entry.as_dict() + config_entry_dict["data"][CONF_API_KEY] = REDACTED + + assert result == { + "config_entry": config_entry_dict, + "data": VALID_PAYLOAD, + } diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py index 231a512858559c..4866039f310f53 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -44,7 +44,7 @@ def __getitem__(self, item): def mocked_get_accounts(_, **kwargs): - """Return simplied accounts using mock.""" + """Return simplified accounts using mock.""" return MockGetAccounts(**kwargs) @@ -68,7 +68,7 @@ async def init_mock_coinbase(hass, currencies=None, rates=None): """Init Coinbase integration for testing.""" config_entry = MockConfigEntry( domain=DOMAIN, - unique_id="abcde12345", + unique_id=None, title="Test User", data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, options={ diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py index 082c986aa59ecf..6f9fff94421755 100644 --- a/tests/components/coinbase/const.py +++ b/tests/components/coinbase/const.py @@ -1,5 +1,7 @@ """Constants for testing the Coinbase integration.""" +from homeassistant.components.diagnostics.const import REDACTED + GOOD_CURRENCY = "BTC" GOOD_CURRENCY_2 = "USD" GOOD_CURRENCY_3 = "EUR" @@ -34,3 +36,43 @@ "type": "fiat", }, ] + +MOCK_ACCOUNTS_RESPONSE_REDACTED = [ + { + "balance": {"amount": REDACTED, "currency": GOOD_CURRENCY}, + "currency": GOOD_CURRENCY, + "id": REDACTED, + "name": "BTC Wallet", + "native_balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, + "type": "wallet", + }, + { + "balance": {"amount": REDACTED, "currency": GOOD_CURRENCY}, + "currency": GOOD_CURRENCY, + "id": REDACTED, + "name": "BTC Vault", + "native_balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, + "type": "vault", + }, + { + "balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, + "currency": "USD", + "id": REDACTED, + "name": "USD Wallet", + "native_balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, + "type": "fiat", + }, +] + +MOCK_ENTRY_REDACTED = { + "version": 1, + "domain": "coinbase", + "title": REDACTED, + "data": {"api_token": REDACTED, "api_key": REDACTED}, + "options": {"account_balance_currencies": [], "exchange_rate_currencies": []}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "unique_id": None, + "disabled_by": None, +} diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index e487cb5d837030..3eac65e04e2664 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Coinbase config flow.""" +import logging from unittest.mock import patch from coinbase.wallet.error import AuthenticationError @@ -63,15 +64,61 @@ async def test_form(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass): +async def test_form_invalid_auth(hass, caplog): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + caplog.set_level(logging.DEBUG) + response = Response() response.status_code = 401 - api_auth_error = AuthenticationError( + api_auth_error_unknown = AuthenticationError( + response, + "authentication_error", + "unknown error", + [{"id": "authentication_error", "message": "unknown error"}], + ) + with patch( + "coinbase.wallet.client.Client.get_current_user", + side_effect=api_auth_error_unknown, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "123456", + CONF_API_TOKEN: "AbCDeF", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + assert "Coinbase rejected API credentials due to an unknown error" in caplog.text + + api_auth_error_key = AuthenticationError( + response, + "authentication_error", + "invalid api key", + [{"id": "authentication_error", "message": "invalid api key"}], + ) + with patch( + "coinbase.wallet.client.Client.get_current_user", + side_effect=api_auth_error_key, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "123456", + CONF_API_TOKEN: "AbCDeF", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth_key"} + assert "Coinbase rejected API credentials due to an invalid API key" in caplog.text + + api_auth_error_secret = AuthenticationError( response, "authentication_error", "invalid signature", @@ -79,7 +126,7 @@ async def test_form_invalid_auth(hass): ) with patch( "coinbase.wallet.client.Client.get_current_user", - side_effect=api_auth_error, + side_effect=api_auth_error_secret, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -90,7 +137,10 @@ async def test_form_invalid_auth(hass): ) assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["errors"] == {"base": "invalid_auth_secret"} + assert ( + "Coinbase rejected API credentials due to an invalid API secret" in caplog.text + ) async def test_form_cannot_connect(hass): @@ -191,7 +241,7 @@ async def test_form_bad_account_currency(hass): ) assert result2["type"] == "form" - assert result2["errors"] == {"base": "currency_unavaliable"} + assert result2["errors"] == {"base": "currency_unavailable"} async def test_form_bad_exchange_rate(hass): @@ -216,7 +266,7 @@ async def test_form_bad_exchange_rate(hass): }, ) assert result2["type"] == "form" - assert result2["errors"] == {"base": "exchange_rate_unavaliable"} + assert result2["errors"] == {"base": "exchange_rate_unavailable"} async def test_option_catch_all_exception(hass): diff --git a/tests/components/coinbase/test_diagnostics.py b/tests/components/coinbase/test_diagnostics.py new file mode 100644 index 00000000000000..b19f5c94d1b5eb --- /dev/null +++ b/tests/components/coinbase/test_diagnostics.py @@ -0,0 +1,45 @@ +"""Test the Coinbase diagnostics.""" + +from unittest.mock import patch + +from aiohttp import ClientSession + +from .common import ( + init_mock_coinbase, + mock_get_current_user, + mock_get_exchange_rates, + mocked_get_accounts, +) + +from tests.components.coinbase.const import ( + MOCK_ACCOUNTS_RESPONSE_REDACTED, + MOCK_ENTRY_REDACTED, +) +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, hass_client: ClientSession): + """Test we handle a and redact a diagnostics request.""" + + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + + config_entry = await init_mock_coinbase(hass) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + # Remove the ID to match the constant + result["entry"].pop("entry_id") + + assert result == { + "entry": MOCK_ENTRY_REDACTED, + "accounts": MOCK_ACCOUNTS_RESPONSE_REDACTED, + } diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 2749fff8127627..532e14573f4059 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.binary_sensor import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry async def setup_test_entity(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: @@ -65,3 +66,47 @@ async def test_sensor_off(hass: HomeAssistant) -> None: ) entity_state = hass.states.get("binary_sensor.test") assert entity_state.state == STATE_OFF + + +async def test_unique_id(hass): + """Test unique_id option and if it only creates one binary sensor per id.""" + assert await setup.async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "platform": "command_line", + "unique_id": "unique", + "command": "echo 0", + }, + { + "platform": "command_line", + "unique_id": "not-so-unique-anymore", + "command": "echo 1", + }, + { + "platform": "command_line", + "unique_id": "not-so-unique-anymore", + "command": "echo 2", + }, + ] + }, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + + ent_reg = entity_registry.async_get(hass) + + assert len(ent_reg.entities) == 2 + assert ( + ent_reg.async_get_entity_id("binary_sensor", "command_line", "unique") + is not None + ) + assert ( + ent_reg.async_get_entity_id( + "binary_sensor", "command_line", "not-so-unique-anymore" + ) + is not None + ) diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 0a37449c184fb3..9d4f5b60c8bbb1 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -16,6 +16,7 @@ SERVICE_STOP_COVER, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path @@ -160,3 +161,41 @@ async def test_move_cover_failure(caplog: Any, hass: HomeAssistant) -> None: DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True ) assert "Command failed" in caplog.text + + +async def test_unique_id(hass): + """Test unique_id option and if it only creates one cover per id.""" + await setup_test_entity( + hass, + { + "unique": { + "command_open": "echo open", + "command_close": "echo close", + "command_stop": "echo stop", + "unique_id": "unique", + }, + "not_unique_1": { + "command_open": "echo open", + "command_close": "echo close", + "command_stop": "echo stop", + "unique_id": "not-so-unique-anymore", + }, + "not_unique_2": { + "command_open": "echo open", + "command_close": "echo close", + "command_stop": "echo stop", + "unique_id": "not-so-unique-anymore", + }, + }, + ) + + assert len(hass.states.async_all()) == 2 + + ent_reg = entity_registry.async_get(hass) + + assert len(ent_reg.entities) == 2 + assert ent_reg.async_get_entity_id("cover", "command_line", "unique") is not None + assert ( + ent_reg.async_get_entity_id("cover", "command_line", "not-so-unique-anymore") + is not None + ) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index be897fa2408f9d..c9a4860b987d17 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -7,6 +7,7 @@ from homeassistant import setup from homeassistant.components.sensor import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry async def setup_test_entities(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: @@ -223,3 +224,42 @@ async def test_update_with_unnecessary_json_attrs(caplog, hass: HomeAssistant) - assert entity_state.attributes["key"] == "some_json_value" assert entity_state.attributes["another_key"] == "another_json_value" assert "key_three" not in entity_state.attributes + + +async def test_unique_id(hass): + """Test unique_id option and if it only creates one sensor per id.""" + assert await setup.async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "platform": "command_line", + "unique_id": "unique", + "command": "echo 0", + }, + { + "platform": "command_line", + "unique_id": "not-so-unique-anymore", + "command": "echo 1", + }, + { + "platform": "command_line", + "unique_id": "not-so-unique-anymore", + "command": "echo 2", + }, + ] + }, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + + ent_reg = entity_registry.async_get(hass) + + assert len(ent_reg.entities) == 2 + assert ent_reg.async_get_entity_id("sensor", "command_line", "unique") is not None + assert ( + ent_reg.async_get_entity_id("sensor", "command_line", "not-so-unique-anymore") + is not None + ) diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 910d990d07d0ec..f918c7500ad665 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -18,6 +18,7 @@ STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed @@ -376,3 +377,38 @@ async def test_no_switches(caplog: Any, hass: HomeAssistant) -> None: await setup_test_entity(hass, {}) assert "No switches" in caplog.text + + +async def test_unique_id(hass): + """Test unique_id option and if it only creates one switch per id.""" + await setup_test_entity( + hass, + { + "unique": { + "command_on": "echo on", + "command_off": "echo off", + "unique_id": "unique", + }, + "not_unique_1": { + "command_on": "echo on", + "command_off": "echo off", + "unique_id": "not-so-unique-anymore", + }, + "not_unique_2": { + "command_on": "echo on", + "command_off": "echo off", + "unique_id": "not-so-unique-anymore", + }, + }, + ) + + assert len(hass.states.async_all()) == 2 + + ent_reg = entity_registry.async_get(hass) + + assert len(ent_reg.entities) == 2 + assert ent_reg.async_get_entity_id("switch", "command_line", "unique") is not None + assert ( + ent_reg.async_get_entity_id("switch", "command_line", "not-so-unique-anymore") + is not None + ) diff --git a/tests/components/compensation/test_sensor.py b/tests/components/compensation/test_sensor.py index 3bd86280750ff7..65741fd86ba5de 100644 --- a/tests/components/compensation/test_sensor.py +++ b/tests/components/compensation/test_sensor.py @@ -151,13 +151,6 @@ async def test_numpy_errors(hass, caplog): "compensation": { "test": { "source": "sensor.uncompensated", - "data_points": [ - [1.0, 1.0], - [1.0, 1.0], - ], - }, - "test2": { - "source": "sensor.uncompensated2", "data_points": [ [0.0, 1.0], [0.0, 1.0], @@ -170,8 +163,6 @@ async def test_numpy_errors(hass, caplog): await hass.async_start() await hass.async_block_till_done() - assert "polyfit may be poorly conditioned" in caplog.text - assert "invalid value encountered in true_divide" in caplog.text diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index 7460de6a75156f..16f6fa7336b70a 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -59,7 +59,7 @@ async def test_list(hass, hass_ws_client, hass_admin_user): result = await client.receive_json() assert result["success"], result data = result["result"] - assert len(data) == 4 + assert len(data) == 5 assert data[0] == { "id": hass_admin_user.id, "username": "admin", @@ -151,7 +151,7 @@ async def test_delete(hass, hass_ws_client, hass_access_token): client = await hass_ws_client(hass, hass_access_token) test_user = MockUser(id="efg").add_to_hass(hass) - assert len(await hass.auth.async_get_users()) == 2 + cur_users = len(await hass.auth.async_get_users()) await client.send_json( {"id": 5, "type": auth_config.WS_TYPE_DELETE, "user_id": test_user.id} @@ -159,20 +159,20 @@ async def test_delete(hass, hass_ws_client, hass_access_token): result = await client.receive_json() assert result["success"], result - assert len(await hass.auth.async_get_users()) == 1 + assert len(await hass.auth.async_get_users()) == cur_users - 1 async def test_create(hass, hass_ws_client, hass_access_token): """Test create command works.""" client = await hass_ws_client(hass, hass_access_token) - assert len(await hass.auth.async_get_users()) == 1 + cur_users = len(await hass.auth.async_get_users()) await client.send_json({"id": 5, "type": "config/auth/create", "name": "Paulus"}) result = await client.receive_json() assert result["success"], result - assert len(await hass.auth.async_get_users()) == 2 + assert len(await hass.auth.async_get_users()) == cur_users + 1 data_user = result["result"]["user"] user = await hass.auth.async_get_user(data_user["id"]) assert user is not None @@ -188,7 +188,7 @@ async def test_create_user_group(hass, hass_ws_client, hass_access_token): """Test create user with a group.""" client = await hass_ws_client(hass, hass_access_token) - assert len(await hass.auth.async_get_users()) == 1 + cur_users = len(await hass.auth.async_get_users()) await client.send_json( { @@ -201,7 +201,7 @@ async def test_create_user_group(hass, hass_ws_client, hass_access_token): result = await client.receive_json() assert result["success"], result - assert len(await hass.auth.async_get_users()) == 2 + assert len(await hass.auth.async_get_users()) == cur_users + 1 data_user = result["result"]["user"] user = await hass.auth.async_get_user(data_user["id"]) assert user is not None diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 80ee38350aa85f..6f782fdbbfff22 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -80,6 +80,43 @@ def mock_write(path, data): assert written[0] == orig_data +@pytest.mark.parametrize("automation_config", ({},)) +async def test_update_remove_key_device_config(hass, hass_client, setup_automation): + """Test updating device config while removing a key.""" + with patch.object(config, "SECTIONS", ["automation"]): + await async_setup_component(hass, "config", {}) + + client = await hass_client() + + orig_data = [{"id": "sun", "key": "value"}, {"id": "moon", "key": "value"}] + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + with patch("homeassistant.components.config._read", mock_read), patch( + "homeassistant.components.config._write", mock_write + ), patch("homeassistant.config.async_hass_config_yaml", return_value={}): + resp = await client.post( + "/api/config/automation/config/moon", + data=json.dumps({"trigger": [], "action": [], "condition": []}), + ) + + assert resp.status == HTTPStatus.OK + result = await resp.json() + assert result == {"result": "ok"} + + assert list(orig_data[1]) == ["id", "trigger", "condition", "action"] + assert orig_data[1] == {"id": "moon", "trigger": [], "condition": [], "action": []} + assert written[0] == orig_data + + @pytest.mark.parametrize("automation_config", ({},)) async def test_bad_formatted_automations(hass, hass_client, setup_automation): """Test that we handle automations without ID.""" diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 20a19495597856..6608bf3471dc98 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -79,7 +79,7 @@ def async_supports_options_flow(cls, config_entry): domain="comp3", title="Test 3", source="bla3", - disabled_by=core_ce.DISABLED_USER, + disabled_by=core_ce.ConfigEntryDisabler.USER, ).add_to_hass(hass) resp = await client.get("/api/config/config_entries/entry") @@ -121,7 +121,7 @@ def async_supports_options_flow(cls, config_entry): "supports_unload": False, "pref_disable_new_entities": False, "pref_disable_polling": False, - "disabled_by": core_ce.DISABLED_USER, + "disabled_by": core_ce.ConfigEntryDisabler.USER, "reason": None, }, ] @@ -252,6 +252,35 @@ async def async_step_user(self, user_input=None): } +async def test_initialize_flow_unmet_dependency(hass, client): + """Test unmet dependencies are listed.""" + mock_entity_platform(hass, "config_flow.test", None) + + config_schema = vol.Schema({"comp_conf": {"hello": str}}, required=True) + mock_integration( + hass, MockModule(domain="dependency_1", config_schema=config_schema) + ) + # The test2 config flow should fail because dependency_1 can't be automatically setup + mock_integration( + hass, + MockModule(domain="test2", partial_manifest={"dependencies": ["dependency_1"]}), + ) + + class TestFlow(core_ce.ConfigFlow): + async def async_step_user(self, user_input=None): + pass + + with patch.dict(HANDLERS, {"test2": TestFlow}): + resp = await client.post( + "/api/config/config_entries/flow", + json={"handler": "test2", "show_advanced_options": True}, + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.text() + assert data == "Failed dependencies dependency_1" + + async def test_initialize_flow_unauth(hass, client, hass_admin_user): """Test we can initialize a flow.""" hass_admin_user.groups = [] @@ -877,14 +906,14 @@ async def test_disable_entry(hass, hass_ws_client): "id": 5, "type": "config_entries/disable", "entry_id": entry.entry_id, - "disabled_by": core_ce.DISABLED_USER, + "disabled_by": core_ce.ConfigEntryDisabler.USER, } ) response = await ws_client.receive_json() assert response["success"] assert response["result"] == {"require_restart": True} - assert entry.disabled_by == core_ce.DISABLED_USER + assert entry.disabled_by is core_ce.ConfigEntryDisabler.USER assert entry.state is core_ce.ConfigEntryState.FAILED_UNLOAD # Enable @@ -930,7 +959,7 @@ async def test_disable_entry_nonexisting(hass, hass_ws_client): "id": 5, "type": "config_entries/disable", "entry_id": "non_existing", - "disabled_by": core_ce.DISABLED_USER, + "disabled_by": core_ce.ConfigEntryDisabler.USER, } ) response = await ws_client.receive_json() diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index ee8c933f761979..11b2f663f19b94 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -53,6 +53,7 @@ async def test_list_devices(hass, client, registry): "model": "model", "name": None, "sw_version": None, + "hw_version": None, "entry_type": None, "via_device_id": None, "area_id": None, @@ -68,6 +69,7 @@ async def test_list_devices(hass, client, registry): "model": "model", "name": None, "sw_version": None, + "hw_version": None, "entry_type": helpers_dr.DeviceEntryType.SERVICE, "via_device_id": dev1, "area_id": None, diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 17762f20df348c..b4065d855ff71a 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -4,7 +4,7 @@ from homeassistant.components.config import entity_registry from homeassistant.const import ATTR_ICON from homeassistant.helpers.device_registry import DeviceEntryDisabler -from homeassistant.helpers.entity_registry import DISABLED_USER, RegistryEntry +from homeassistant.helpers.entity_registry import RegistryEntry, RegistryEntryDisabler from tests.common import ( MockConfigEntry, @@ -215,14 +215,16 @@ async def test_update_entity(hass, client): "id": 7, "type": "config/entity_registry/update", "entity_id": "test_domain.world", - "disabled_by": DISABLED_USER, + "disabled_by": RegistryEntryDisabler.USER, } ) msg = await client.receive_json() assert hass.states.get("test_domain.world") is None - assert registry.entities["test_domain.world"].disabled_by == DISABLED_USER + assert ( + registry.entities["test_domain.world"].disabled_by is RegistryEntryDisabler.USER + ) # UPDATE DISABLED_BY TO NONE await client.send_json( diff --git a/tests/components/config/test_group.py b/tests/components/config/test_group.py deleted file mode 100644 index 4d1d28020bb661..00000000000000 --- a/tests/components/config/test_group.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Test Group config panel.""" -from http import HTTPStatus -import json -from pathlib import Path -from unittest.mock import AsyncMock, patch - -from homeassistant.bootstrap import async_setup_component -from homeassistant.components import config -from homeassistant.components.config import group -from homeassistant.util.file import write_utf8_file -from homeassistant.util.yaml import dump, load_yaml - -VIEW_NAME = "api:config:group:config" - - -async def test_get_device_config(hass, hass_client): - """Test getting device config.""" - with patch.object(config, "SECTIONS", ["group"]): - await async_setup_component(hass, "config", {}) - - client = await hass_client() - - def mock_read(path): - """Mock reading data.""" - return {"hello.beer": {"free": "beer"}, "other.entity": {"do": "something"}} - - with patch("homeassistant.components.config._read", mock_read): - resp = await client.get("/api/config/group/config/hello.beer") - - assert resp.status == HTTPStatus.OK - result = await resp.json() - - assert result == {"free": "beer"} - - -async def test_update_device_config(hass, hass_client): - """Test updating device config.""" - with patch.object(config, "SECTIONS", ["group"]): - await async_setup_component(hass, "config", {}) - - client = await hass_client() - - orig_data = { - "hello.beer": {"ignored": True}, - "other.entity": {"polling_intensity": 2}, - } - - def mock_read(path): - """Mock reading data.""" - return orig_data - - written = [] - - def mock_write(path, data): - """Mock writing data.""" - written.append(data) - - mock_call = AsyncMock() - - with patch("homeassistant.components.config._read", mock_read), patch( - "homeassistant.components.config._write", mock_write - ), patch.object(hass.services, "async_call", mock_call): - resp = await client.post( - "/api/config/group/config/hello_beer", - data=json.dumps( - {"name": "Beer", "entities": ["light.top", "light.bottom"]} - ), - ) - await hass.async_block_till_done() - - assert resp.status == HTTPStatus.OK - result = await resp.json() - assert result == {"result": "ok"} - - orig_data["hello_beer"]["name"] = "Beer" - orig_data["hello_beer"]["entities"] = ["light.top", "light.bottom"] - - assert written[0] == orig_data - mock_call.assert_called_once_with("group", "reload") - - -async def test_update_device_config_invalid_key(hass, hass_client): - """Test updating device config.""" - with patch.object(config, "SECTIONS", ["group"]): - await async_setup_component(hass, "config", {}) - - client = await hass_client() - - resp = await client.post( - "/api/config/group/config/not a slug", data=json.dumps({"name": "YO"}) - ) - - assert resp.status == HTTPStatus.BAD_REQUEST - - -async def test_update_device_config_invalid_data(hass, hass_client): - """Test updating device config.""" - with patch.object(config, "SECTIONS", ["group"]): - await async_setup_component(hass, "config", {}) - - client = await hass_client() - - resp = await client.post( - "/api/config/group/config/hello_beer", data=json.dumps({"invalid_option": 2}) - ) - - assert resp.status == HTTPStatus.BAD_REQUEST - - -async def test_update_device_config_invalid_json(hass, hass_client): - """Test updating device config.""" - with patch.object(config, "SECTIONS", ["group"]): - await async_setup_component(hass, "config", {}) - - client = await hass_client() - - resp = await client.post("/api/config/group/config/hello_beer", data="not json") - - assert resp.status == HTTPStatus.BAD_REQUEST - - -async def test_update_config_write_to_temp_file(hass, hass_client, tmpdir): - """Test config with a temp file.""" - test_dir = await hass.async_add_executor_job(tmpdir.mkdir, "files") - group_yaml = Path(test_dir / "group.yaml") - - with patch.object(group, "GROUP_CONFIG_PATH", group_yaml), patch.object( - config, "SECTIONS", ["group"] - ): - await async_setup_component(hass, "config", {}) - - client = await hass_client() - - orig_data = { - "hello.beer": {"ignored": True}, - "other.entity": {"polling_intensity": 2}, - } - contents = dump(orig_data) - await hass.async_add_executor_job(write_utf8_file, group_yaml, contents) - - mock_call = AsyncMock() - - with patch.object(hass.services, "async_call", mock_call): - resp = await client.post( - "/api/config/group/config/hello_beer", - data=json.dumps( - {"name": "Beer", "entities": ["light.top", "light.bottom"]} - ), - ) - await hass.async_block_till_done() - - assert resp.status == HTTPStatus.OK - result = await resp.json() - assert result == {"result": "ok"} - - new_data = await hass.async_add_executor_job(load_yaml, group_yaml) - - assert new_data == { - **orig_data, - "hello_beer": { - "name": "Beer", - "entities": ["light.top", "light.bottom"], - }, - } - mock_call.assert_called_once_with("group", "reload") diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index 491b59d9acbc05..e1595089d2e224 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -13,6 +13,7 @@ SUPPORT_STOP, SUPPORT_STOP_TILT, ) +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import CONF_PLATFORM from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -101,7 +102,9 @@ async def test_get_actions( } for action in expected_action_types ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) @@ -140,13 +143,15 @@ async def test_get_action_capabilities( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert len(actions) == 5 # open, close, open_tilt, close_tilt action_types = {action["type"] for action in actions} assert action_types == {"open", "close", "stop", "open_tilt", "close_tilt"} for action in actions: capabilities = await async_get_device_automation_capabilities( - hass, "action", action + hass, DeviceAutomationType.ACTION, action ) assert capabilities == {"extra_fields": []} @@ -184,13 +189,15 @@ async def test_get_action_capabilities_set_pos( } ] } - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert len(actions) == 1 # set_position action_types = {action["type"] for action in actions} assert action_types == {"set_position"} for action in actions: capabilities = await async_get_device_automation_capabilities( - hass, "action", action + hass, DeviceAutomationType.ACTION, action ) if action["type"] == "set_position": assert capabilities == expected_capabilities @@ -231,13 +238,15 @@ async def test_get_action_capabilities_set_tilt_pos( } ] } - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert len(actions) == 3 action_types = {action["type"] for action in actions} assert action_types == {"open", "close", "set_tilt_position"} for action in actions: capabilities = await async_get_device_automation_capabilities( - hass, "action", action + hass, DeviceAutomationType.ACTION, action ) if action["type"] == "set_tilt_position": assert capabilities == expected_capabilities diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index efa8e8b33835b7..8d6403f8b52e3a 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -9,6 +9,7 @@ SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, ) +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import ( CONF_PLATFORM, STATE_CLOSED, @@ -105,7 +106,9 @@ async def test_get_conditions( } for condition in expected_condition_types ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert_lists_same(conditions, expected_conditions) @@ -129,11 +132,13 @@ async def test_get_condition_capabilities( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert len(conditions) == 4 for condition in conditions: capabilities = await async_get_device_automation_capabilities( - hass, "condition", condition + hass, DeviceAutomationType.CONDITION, condition ) assert capabilities == {"extra_fields": []} @@ -178,11 +183,13 @@ async def test_get_condition_capabilities_set_pos( }, ] } - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert len(conditions) == 5 for condition in conditions: capabilities = await async_get_device_automation_capabilities( - hass, "condition", condition + hass, DeviceAutomationType.CONDITION, condition ) if condition["type"] == "is_position": assert capabilities == expected_capabilities @@ -230,11 +237,13 @@ async def test_get_condition_capabilities_set_tilt_pos( }, ] } - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert len(conditions) == 5 for condition in conditions: capabilities = await async_get_device_automation_capabilities( - hass, "condition", condition + hass, DeviceAutomationType.CONDITION, condition ) if condition["type"] == "is_tilt_position": assert capabilities == expected_capabilities diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index 0c7c99bc5216c9..3eac5d29b6120e 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -10,6 +10,7 @@ SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, ) +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import ( CONF_PLATFORM, STATE_CLOSED, @@ -125,7 +126,9 @@ async def test_get_triggers( } for trigger in expected_trigger_types ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -149,11 +152,13 @@ async def test_get_trigger_capabilities( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 4 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == { "extra_fields": [ @@ -202,11 +207,13 @@ async def test_get_trigger_capabilities_set_pos( }, ] } - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 5 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) if trigger["type"] == "position": assert capabilities == expected_capabilities @@ -262,11 +269,13 @@ async def test_get_trigger_capabilities_set_tilt_pos( }, ] } - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 5 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) if trigger["type"] == "tilt_position": assert capabilities == expected_capabilities diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index b46c0417cd2205..95d3053d03b207 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -111,13 +111,3 @@ def is_closed(hass, ent): def is_closing(hass, ent): """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(ent.entity_id, STATE_CLOSING) - - -def test_deprecated_base_class(caplog): - """Test deprecated base class.""" - - class CustomCover(cover.CoverDevice): - pass - - CustomCover() - assert "CoverDevice is deprecated, modify CustomCover" in caplog.text diff --git a/tests/components/cpuspeed/__init__.py b/tests/components/cpuspeed/__init__.py new file mode 100644 index 00000000000000..b65adbc70eb4bb --- /dev/null +++ b/tests/components/cpuspeed/__init__.py @@ -0,0 +1 @@ +"""Tests for the CPU Speed integration.""" diff --git a/tests/components/cpuspeed/conftest.py b/tests/components/cpuspeed/conftest.py new file mode 100644 index 00000000000000..a5d7d1837ba3d1 --- /dev/null +++ b/tests/components/cpuspeed/conftest.py @@ -0,0 +1,76 @@ +"""Fixtures for CPU Speed integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.cpuspeed.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="CPU Speed", + domain=DOMAIN, + data={}, + unique_id=DOMAIN, + ) + + +@pytest.fixture +def mock_cpuinfo_config_flow() -> Generator[MagicMock, None, None]: + """Return a mocked get_cpu_info. + + It is only used to check thruthy or falsy values, so it is mocked + to return True. + """ + with patch( + "homeassistant.components.cpuspeed.config_flow.cpuinfo.get_cpu_info", + return_value=True, + ) as cpuinfo_mock: + yield cpuinfo_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.cpuspeed.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_cpuinfo() -> Generator[MagicMock, None, None]: + """Return a mocked get_cpu_info.""" + info = { + "hz_actual": (3200000001, 0), + "arch_string_raw": "aargh", + "brand_raw": "Intel Ryzen 7", + "hz_advertised": (3600000001, 0), + } + + with patch( + "homeassistant.components.cpuspeed.cpuinfo.get_cpu_info", + return_value=info, + ) as cpuinfo_mock: + yield cpuinfo_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_cpuinfo: MagicMock +) -> MockConfigEntry: + """Set up the CPU Speed integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/cpuspeed/test_config_flow.py b/tests/components/cpuspeed/test_config_flow.py new file mode 100644 index 00000000000000..14563c82bffa76 --- /dev/null +++ b/tests/components/cpuspeed/test_config_flow.py @@ -0,0 +1,109 @@ +"""Tests for the CPU Speed config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.cpuspeed.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_cpuinfo_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "CPU Speed" + assert result2.get("data") == {} + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_cpuinfo_config_flow.mock_calls) == 1 + + +async def test_already_configured( + hass: HomeAssistant, + mock_cpuinfo_config_flow: MagicMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_cpuinfo_config_flow.mock_calls) == 0 + + +async def test_import_flow( + hass: HomeAssistant, + mock_cpuinfo_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_NAME: "Frenck's CPU"}, + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "Frenck's CPU" + assert result.get("data") == {} + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_cpuinfo_config_flow.mock_calls) == 1 + + +async def test_not_compatible( + hass: HomeAssistant, + mock_cpuinfo_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort the configuration flow when incompatible.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_cpuinfo_config_flow.return_value = {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result2.get("type") == RESULT_TYPE_ABORT + assert result2.get("reason") == "not_compatible" + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_cpuinfo_config_flow.mock_calls) == 1 diff --git a/tests/components/cpuspeed/test_diagnostics.py b/tests/components/cpuspeed/test_diagnostics.py new file mode 100644 index 00000000000000..c8554390ea0878 --- /dev/null +++ b/tests/components/cpuspeed/test_diagnostics.py @@ -0,0 +1,36 @@ +"""Tests for the diagnostics data provided by the CPU Speed integration.""" +from unittest.mock import patch + +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + info = { + "hz_actual": (3200000001, 0), + "arch_string_raw": "aargh", + "brand_raw": "Intel Ryzen 7", + "hz_advertised": (3600000001, 0), + } + + with patch( + "homeassistant.components.cpuspeed.diagnostics.cpuinfo.get_cpu_info", + return_value=info, + ): + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "hz_actual": [3200000001, 0], + "arch_string_raw": "aargh", + "brand_raw": "Intel Ryzen 7", + "hz_advertised": [3600000001, 0], + } diff --git a/tests/components/cpuspeed/test_init.py b/tests/components/cpuspeed/test_init.py new file mode 100644 index 00000000000000..2352e411b8e075 --- /dev/null +++ b/tests/components/cpuspeed/test_init.py @@ -0,0 +1,66 @@ +"""Tests for the CPU Speed integration.""" +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.cpuspeed.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cpuinfo: MagicMock, +) -> None: + """Test the CPU Speed configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_cpuinfo.mock_calls) == 2 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_compatible( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cpuinfo: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the CPU Speed configuration entry loading on an unsupported system.""" + mock_config_entry.add_to_hass(hass) + mock_cpuinfo.return_value = {} + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert len(mock_cpuinfo.mock_calls) == 1 + assert "is not compatible with your system" in caplog.text + + +async def test_import_config( + hass: HomeAssistant, + mock_cpuinfo: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the CPU Speed being set up from config via import.""" + assert await async_setup_component( + hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: {"platform": DOMAIN}} + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_cpuinfo.mock_calls) == 3 + assert "the CPU Speed platform in YAML is deprecated" in caplog.text diff --git a/tests/components/cpuspeed/test_sensor.py b/tests/components/cpuspeed/test_sensor.py new file mode 100644 index 00000000000000..134d19b31ea2ea --- /dev/null +++ b/tests/components/cpuspeed/test_sensor.py @@ -0,0 +1,63 @@ +"""Tests for the sensor provided by the CPU Speed integration.""" + +from unittest.mock import MagicMock + +from homeassistant.components.cpuspeed.sensor import ATTR_ARCH, ATTR_BRAND, ATTR_HZ +from homeassistant.components.homeassistant import ( + DOMAIN as HOME_ASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_sensor( + hass: HomeAssistant, + mock_cpuinfo: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test the CPU Speed sensor.""" + await async_setup_component(hass, "homeassistant", {}) + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("sensor.cpu_speed") + assert entry + assert entry.unique_id == entry.config_entry_id + assert entry.entity_category is None + + state = hass.states.get("sensor.cpu_speed") + assert state + assert state.state == "3.2" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "CPU Speed" + assert state.attributes.get(ATTR_ICON) == "mdi:pulse" + assert ATTR_DEVICE_CLASS not in state.attributes + + assert state.attributes.get(ATTR_ARCH) == "aargh" + assert state.attributes.get(ATTR_BRAND) == "Intel Ryzen 7" + assert state.attributes.get(ATTR_HZ) == 3.6 + + mock_cpuinfo.return_value = {} + await hass.services.async_call( + HOME_ASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "sensor.cpu_speed"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.cpu_speed") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ARCH) == "aargh" + assert state.attributes.get(ATTR_BRAND) == "Intel Ryzen 7" + assert state.attributes.get(ATTR_HZ) == 3.6 diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py index 05fde6109e7311..fdc0df108eee8f 100644 --- a/tests/components/crownstone/test_config_flow.py +++ b/tests/components/crownstone/test_config_flow.py @@ -1,7 +1,8 @@ """Tests for the Crownstone integration.""" from __future__ import annotations -from typing import Generator, Union +from collections.abc import Generator +from typing import Union from unittest.mock import AsyncMock, MagicMock, patch from crownstone_cloud.cloud_models.spheres import Spheres diff --git a/tests/components/deconz/test_alarm_control_panel.py b/tests/components/deconz/test_alarm_control_panel.py index 5bafdc2fbb63f3..7e83e02760733c 100644 --- a/tests/components/deconz/test_alarm_control_panel.py +++ b/tests/components/deconz/test_alarm_control_panel.py @@ -112,7 +112,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): with patch.dict(DECONZ_WEB_REQUEST, data): config_entry = await setup_deconz_integration(hass, aioclient_mock) - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 assert hass.states.get("alarm_control_panel.keypad").state == STATE_ALARM_PENDING # Event signals alarm control panel armed away @@ -298,7 +298,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): await hass.config_entries.async_unload(config_entry.entry_id) states = hass.states.async_all() - assert len(states) == 3 + assert len(states) == 4 for state in states: assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index 036e66f6e46ea2..11f9483e277234 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -2,11 +2,7 @@ from unittest.mock import patch -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, - DEVICE_CLASS_TAMPER, - DEVICE_CLASS_VIBRATION, -) +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.deconz.const import ( CONF_ALLOW_CLIP_SENSOR, CONF_ALLOW_NEW_DEVICES, @@ -14,15 +10,15 @@ DOMAIN as DECONZ_DOMAIN, ) from homeassistant.components.deconz.services import SERVICE_DEVICE_REFRESH +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, - DEVICE_CLASS_TEMPERATURE, - ENTITY_CATEGORY_DIAGNOSTIC, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_registry import async_entries_for_config_entry from .test_gateway import ( @@ -83,18 +79,23 @@ async def test_binary_sensors(hass, aioclient_mock, mock_deconz_websocket): assert len(hass.states.async_all()) == 5 presence_sensor = hass.states.get("binary_sensor.presence_sensor") assert presence_sensor.state == STATE_OFF - assert presence_sensor.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MOTION + assert ( + presence_sensor.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOTION + ) presence_temp = hass.states.get("sensor.presence_sensor_temperature") assert presence_temp.state == "0.1" - assert presence_temp.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE + assert presence_temp.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE assert hass.states.get("binary_sensor.temperature_sensor") is None assert hass.states.get("binary_sensor.clip_presence_sensor") is None vibration_sensor = hass.states.get("binary_sensor.vibration_sensor") assert vibration_sensor.state == STATE_ON - assert vibration_sensor.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_VIBRATION + assert ( + vibration_sensor.attributes[ATTR_DEVICE_CLASS] + == BinarySensorDeviceClass.VIBRATION + ) vibration_temp = hass.states.get("sensor.vibration_sensor_temperature") assert vibration_temp.state == "0.1" - assert vibration_temp.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE + assert vibration_temp.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE event_changed_sensor = { "t": "event", @@ -125,7 +126,12 @@ async def test_tampering_sensor(hass, aioclient_mock, mock_deconz_websocket): "1": { "name": "Presence sensor", "type": "ZHAPresence", - "state": {"dark": False, "presence": False, "tampered": False}, + "state": { + "dark": False, + "lowbattery": False, + "presence": False, + "tampered": False, + }, "config": {"on": True, "reachable": True, "temperature": 10}, "uniqueid": "00:00:00:00:00:00:00:00-00", }, @@ -136,10 +142,21 @@ async def test_tampering_sensor(hass, aioclient_mock, mock_deconz_websocket): ent_reg = er.async_get(hass) - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 + hass.states.get("binary_sensor.presence_sensor_low_battery").state == STATE_OFF + assert ( + ent_reg.async_get("binary_sensor.presence_sensor_low_battery").entity_category + is EntityCategory.DIAGNOSTIC + ) presence_tamper = hass.states.get("binary_sensor.presence_sensor_tampered") assert presence_tamper.state == STATE_OFF - assert presence_tamper.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TAMPER + assert ( + presence_tamper.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.TAMPER + ) + assert ( + ent_reg.async_get("binary_sensor.presence_sensor_tampered").entity_category + is EntityCategory.DIAGNOSTIC + ) event_changed_sensor = { "t": "event", @@ -152,10 +169,6 @@ async def test_tampering_sensor(hass, aioclient_mock, mock_deconz_websocket): await hass.async_block_till_done() assert hass.states.get("binary_sensor.presence_sensor_tampered").state == STATE_ON - assert ( - ent_reg.async_get("binary_sensor.presence_sensor_tampered").entity_category - == ENTITY_CATEGORY_DIAGNOSTIC - ) await hass.config_entries.async_unload(config_entry.entry_id) @@ -170,6 +183,58 @@ async def test_tampering_sensor(hass, aioclient_mock, mock_deconz_websocket): assert len(hass.states.async_all()) == 0 +async def test_fire_sensor(hass, aioclient_mock, mock_deconz_websocket): + """Verify smoke alarm sensor works.""" + data = { + "sensors": { + "1": { + "name": "Fire alarm", + "type": "ZHAFire", + "state": {"fire": False, "test": False}, + "config": {"on": True, "reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:00-00", + }, + } + } + with patch.dict(DECONZ_WEB_REQUEST, data): + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + ent_reg = er.async_get(hass) + + assert len(hass.states.async_all()) == 2 + assert hass.states.get("binary_sensor.fire_alarm").state == STATE_OFF + assert ent_reg.async_get("binary_sensor.fire_alarm").entity_category is None + + assert hass.states.get("binary_sensor.fire_alarm_test_mode").state == STATE_OFF + assert ( + ent_reg.async_get("binary_sensor.fire_alarm_test_mode").entity_category + is EntityCategory.DIAGNOSTIC + ) + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "1", + "state": {"fire": True, "test": True}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.fire_alarm").state == STATE_ON + assert hass.states.get("binary_sensor.fire_alarm_test_mode").state == STATE_ON + + await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get("binary_sensor.fire_alarm").state == STATE_UNAVAILABLE + assert ( + hass.states.get("binary_sensor.fire_alarm_test_mode").state == STATE_UNAVAILABLE + ) + + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + async def test_allow_clip_sensor(hass, aioclient_mock): """Test that CLIP sensors can be allowed.""" data = { diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index ecfb324207f6dc..8ed1e348fee405 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -17,6 +17,7 @@ CONF_ALLOW_NEW_DEVICES, CONF_MASTER_GATEWAY, DOMAIN as DECONZ_DOMAIN, + HASSIO_CONFIGURATION_URL, ) from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL @@ -425,6 +426,10 @@ async def test_flow_ssdp_discovery(hass, aioclient_mock): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "link" + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0].get("context", {}).get("configuration_url") == "http://1.2.3.4:80" + aioclient_mock.post( "http://1.2.3.4:80/api", json=[{"success": {"username": API_KEY}}], @@ -558,6 +563,12 @@ async def test_flow_hassio_discovery(hass): assert result["step_id"] == "hassio_confirm" assert result["description_placeholders"] == {"addon": "Mock Addon"} + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert ( + flows[0].get("context", {}).get("configuration_url") == HASSIO_CONFIGURATION_URL + ) + with patch( "homeassistant.components.deconz.async_setup_entry", return_value=True, diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index ca76631728e908..76babab36bebab 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -269,7 +269,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): device_registry = await hass.helpers.device_registry.async_get_registry() - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 # 1 alarm control device + 2 additional devices for deconz service and host assert ( len(async_entries_for_config_entry(device_registry, config_entry.entry_id)) == 3 @@ -404,7 +404,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): await hass.config_entries.async_unload(config_entry.entry_id) states = hass.states.async_all() - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 for state in states: assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index 265aec782708be..15e63a6a81f22b 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -8,6 +8,7 @@ from homeassistant.components.deconz import device_trigger from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.deconz.device_trigger import CONF_SUBTYPE +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -69,7 +70,9 @@ async def test_get_triggers(hass, aioclient_mock): identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")} ) - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) expected_triggers = [ { @@ -158,7 +161,9 @@ async def test_get_triggers_manage_unsupported_remotes(hass, aioclient_mock): identifiers={(DECONZ_DOMAIN, "d0:cf:5e:ff:fe:71:a4:3a")} ) - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) expected_triggers = [] diff --git a/tests/components/deconz/test_diagnostics.py b/tests/components/deconz/test_diagnostics.py new file mode 100644 index 00000000000000..17da9f1141a22c --- /dev/null +++ b/tests/components/deconz/test_diagnostics.py @@ -0,0 +1,69 @@ +"""Test deCONZ diagnostics.""" + +from pydeconz.websocket import STATE_RUNNING + +from homeassistant.components.deconz.const import CONF_MASTER_GATEWAY +from homeassistant.components.diagnostics import REDACTED +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, Platform + +from .test_gateway import HOST, PORT, setup_deconz_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics( + hass, hass_client, aioclient_mock, mock_deconz_websocket +): + """Test config entry diagnostics.""" + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + await mock_deconz_websocket(state=STATE_RUNNING) + await hass.async_block_till_done() + + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "config": { + "data": {CONF_API_KEY: REDACTED, CONF_HOST: HOST, CONF_PORT: PORT}, + "disabled_by": None, + "domain": "deconz", + "entry_id": "1", + "options": {CONF_MASTER_GATEWAY: True}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "title": "Mock Title", + "unique_id": REDACTED, + "version": 1, + }, + "deconz_config": { + "bridgeid": REDACTED, + "ipaddress": HOST, + "mac": REDACTED, + "modelid": "deCONZ", + "name": "deCONZ mock gateway", + "sw_version": "2.05.69", + "uuid": "1234", + "websocketport": 1234, + }, + "websocket_state": STATE_RUNNING, + "deconz_ids": {}, + "entities": { + str(Platform.ALARM_CONTROL_PANEL): [], + str(Platform.BINARY_SENSOR): [], + str(Platform.CLIMATE): [], + str(Platform.COVER): [], + str(Platform.FAN): [], + str(Platform.LIGHT): [], + str(Platform.LOCK): [], + str(Platform.NUMBER): [], + str(Platform.SCENE): [], + str(Platform.SENSOR): [], + str(Platform.SIREN): [], + str(Platform.SWITCH): [], + }, + "events": {}, + "alarm_systems": {}, + "groups": {}, + "lights": {}, + "scenes": {}, + "sensors": {}, + } diff --git a/tests/components/deconz/test_scene.py b/tests/components/deconz/test_scene.py index 189eb1e6eb7290..e6f74cd0529f42 100644 --- a/tests/components/deconz/test_scene.py +++ b/tests/components/deconz/test_scene.py @@ -2,8 +2,10 @@ from unittest.mock import patch +from homeassistant.components.deconz.gateway import get_gateway_from_config_entry from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, SERVICE_TURN_ON from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers.dispatcher import async_dispatcher_send from .test_gateway import ( DECONZ_WEB_REQUEST, @@ -58,3 +60,30 @@ async def test_scenes(hass, aioclient_mock): await hass.config_entries.async_unload(config_entry.entry_id) assert len(hass.states.async_all()) == 0 + + +async def test_only_new_scenes_are_created(hass, aioclient_mock): + """Test that scenes works.""" + data = { + "groups": { + "1": { + "id": "Light group id", + "name": "Light group", + "type": "LightGroup", + "state": {"all_on": False, "any_on": True}, + "action": {}, + "scenes": [{"id": "1", "name": "Scene"}], + "lights": [], + } + } + } + with patch.dict(DECONZ_WEB_REQUEST, data): + config_entry = await setup_deconz_integration(hass, aioclient_mock) + + assert len(hass.states.async_all()) == 1 + + gateway = get_gateway_from_config_entry(hass, config_entry) + async_dispatcher_send(hass, gateway.signal_new_scene) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 5f9762c339b962..ee66a159c1802d 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -5,19 +5,11 @@ from homeassistant.components.deconz.const import CONF_ALLOW_CLIP_SENSOR from homeassistant.components.deconz.sensor import ATTR_DAYLIGHT -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_UNAVAILABLE, -) +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory from homeassistant.util import dt from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration @@ -97,12 +89,17 @@ async def test_sensors(hass, aioclient_mock, mock_deconz_websocket): light_level_sensor = hass.states.get("sensor.light_level_sensor") assert light_level_sensor.state == "999.8" - assert light_level_sensor.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ILLUMINANCE + assert ( + light_level_sensor.attributes[ATTR_DEVICE_CLASS] + == SensorDeviceClass.ILLUMINANCE + ) assert light_level_sensor.attributes[ATTR_DAYLIGHT] == 6955 light_level_temp = hass.states.get("sensor.light_level_sensor_temperature") assert light_level_temp.state == "0.1" - assert light_level_temp.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE + assert ( + light_level_temp.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + ) assert not hass.states.get("sensor.presence_sensor") assert not hass.states.get("sensor.switch_1") @@ -111,21 +108,24 @@ async def test_sensors(hass, aioclient_mock, mock_deconz_websocket): switch_2_battery_level = hass.states.get("sensor.switch_2_battery_level") assert switch_2_battery_level.state == "100" - assert switch_2_battery_level.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_BATTERY + assert ( + switch_2_battery_level.attributes[ATTR_DEVICE_CLASS] + == SensorDeviceClass.BATTERY + ) assert ( ent_reg.async_get("sensor.switch_2_battery_level").entity_category - == ENTITY_CATEGORY_DIAGNOSTIC + == EntityCategory.DIAGNOSTIC ) assert not hass.states.get("sensor.daylight_sensor") power_sensor = hass.states.get("sensor.power_sensor") assert power_sensor.state == "6" - assert power_sensor.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert power_sensor.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER consumption_sensor = hass.states.get("sensor.consumption_sensor") assert consumption_sensor.state == "0.002" - assert consumption_sensor.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert consumption_sensor.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY assert not hass.states.get("sensor.clip_light_level_sensor") @@ -476,8 +476,9 @@ async def test_air_quality_sensor(hass, aioclient_mock): with patch.dict(DECONZ_WEB_REQUEST, data): await setup_deconz_integration(hass, aioclient_mock) - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 2 assert hass.states.get("sensor.air_quality").state == "poor" + assert hass.states.get("sensor.air_quality_ppb").state == "809" async def test_daylight_sensor(hass, aioclient_mock): diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index a788e69b0d347a..4767a99d0b3e36 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -321,7 +321,7 @@ async def test_set_direction(hass, fan_entity_id): assert state.attributes[fan.ATTR_DIRECTION] == fan.DIRECTION_REVERSE -@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) async def test_set_speed(hass, fan_entity_id): """Test setting the speed of the device.""" state = hass.states.get(fan_entity_id) @@ -336,6 +336,34 @@ async def test_set_speed(hass, fan_entity_id): state = hass.states.get(fan_entity_id) assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_SPEED, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_SPEED: fan.SPEED_OFF}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_OFF + + +@pytest.mark.parametrize("fan_entity_id", FANS_WITH_PRESET_MODES) +async def test_set_preset_mode_with_legacy_speed_service(hass, fan_entity_id): + """Test setting the preset mode is possible with the legacy service for backwards compat.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_SPEED, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_SPEED: PRESET_MODE_AUTO}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == PRESET_MODE_AUTO + assert state.attributes[fan.ATTR_PERCENTAGE] is None + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_AUTO + @pytest.mark.parametrize("fan_entity_id", FANS_WITH_PRESET_MODES) async def test_set_preset_mode(hass, fan_entity_id): diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py index 15e4e14524dcea..3301bf5f406511 100644 --- a/tests/components/demo/test_lock.py +++ b/tests/components/demo/test_lock.py @@ -1,9 +1,9 @@ """The tests for the Demo lock platform.""" -import asyncio +from unittest.mock import patch import pytest -from homeassistant.components.demo import DOMAIN +from homeassistant.components.demo import DOMAIN, lock as demo_lock from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, @@ -15,10 +15,10 @@ STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import async_capture_events, async_mock_service FRONT = "lock.front_door" KITCHEN = "lock.kitchen_door" @@ -35,54 +35,64 @@ async def setup_comp(hass): await hass.async_block_till_done() +@patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) async def test_locking(hass): """Test the locking of a lock.""" state = hass.states.get(KITCHEN) assert state.state == STATE_UNLOCKED + await hass.async_block_till_done() + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: KITCHEN}, blocking=False ) + await hass.async_block_till_done() - await asyncio.sleep(1) - state = hass.states.get(KITCHEN) - assert state.state == STATE_LOCKING - await asyncio.sleep(2) - state = hass.states.get(KITCHEN) - assert state.state == STATE_LOCKED + assert state_changes[0].data["entity_id"] == KITCHEN + assert state_changes[0].data["new_state"].state == STATE_LOCKING + assert state_changes[1].data["entity_id"] == KITCHEN + assert state_changes[1].data["new_state"].state == STATE_LOCKED + +@patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) async def test_unlocking(hass): """Test the unlocking of a lock.""" state = hass.states.get(FRONT) assert state.state == STATE_LOCKED + await hass.async_block_till_done() + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: FRONT}, blocking=False ) - await asyncio.sleep(1) - state = hass.states.get(FRONT) - assert state.state == STATE_UNLOCKING - await asyncio.sleep(2) - state = hass.states.get(FRONT) - assert state.state == STATE_UNLOCKED + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == FRONT + assert state_changes[0].data["new_state"].state == STATE_UNLOCKING + assert state_changes[1].data["entity_id"] == FRONT + assert state_changes[1].data["new_state"].state == STATE_UNLOCKED + +@patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) async def test_jammed_when_locking(hass): """Test the locking of a lock jams.""" state = hass.states.get(POORLY_INSTALLED) assert state.state == STATE_UNLOCKED + await hass.async_block_till_done() + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: POORLY_INSTALLED}, blocking=False ) + await hass.async_block_till_done() - await asyncio.sleep(1) - state = hass.states.get(POORLY_INSTALLED) - assert state.state == STATE_LOCKING - await asyncio.sleep(2) - state = hass.states.get(POORLY_INSTALLED) - assert state.state == STATE_JAMMED + assert state_changes[0].data["entity_id"] == POORLY_INSTALLED + assert state_changes[0].data["new_state"].state == STATE_LOCKING + + assert state_changes[1].data["entity_id"] == POORLY_INSTALLED + assert state_changes[1].data["new_state"].state == STATE_JAMMED async def test_opening_mocked(hass): diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index d07d7b3d4b2887..3c0f40069dc51c 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -1,4 +1,6 @@ """The tests for the Demo vacuum platform.""" +from datetime import timedelta + import pytest from homeassistant.components import vacuum @@ -35,8 +37,9 @@ STATE_ON, ) from homeassistant.setup import async_setup_component +from homeassistant.util import dt -from tests.common import async_mock_service +from tests.common import async_fire_time_changed, async_mock_service from tests.components.vacuum import common ENTITY_VACUUM_BASIC = f"{DOMAIN}.{DEMO_VACUUM_BASIC}".lower() @@ -175,6 +178,11 @@ async def test_methods(hass): state = hass.states.get(ENTITY_VACUUM_STATE) assert state.state == STATE_RETURNING + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + state = hass.states.get(ENTITY_VACUUM_STATE) + assert state.state == STATE_DOCKED + await common.async_set_fan_speed( hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_STATE ) diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 563611b99adb4a..f7b1339014a3ff 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -140,6 +140,13 @@ async def test_websocket_get_triggers(hass, hass_ws_client, device_reg, entity_r ) entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) expected_triggers = [ + { + "platform": "device", + "domain": "light", + "type": "changed_states", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, { "platform": "device", "domain": "light", @@ -391,11 +398,18 @@ async def test_async_get_device_automations_single_device_trigger( connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + result = await device_automation.async_get_device_automations( + hass, device_automation.DeviceAutomationType.TRIGGER, [device_entry.id] + ) + assert device_entry.id in result + assert len(result[device_entry.id]) == 3 + + # Test deprecated str automation_type works, to be removed in 2022.4 result = await device_automation.async_get_device_automations( hass, "trigger", [device_entry.id] ) assert device_entry.id in result - assert len(result[device_entry.id]) == 2 + assert len(result[device_entry.id]) == 3 # toggled, turned_on, turned_off async def test_async_get_device_automations_all_devices_trigger( @@ -410,9 +424,11 @@ async def test_async_get_device_automations_all_devices_trigger( connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) - result = await device_automation.async_get_device_automations(hass, "trigger") + result = await device_automation.async_get_device_automations( + hass, device_automation.DeviceAutomationType.TRIGGER + ) assert device_entry.id in result - assert len(result[device_entry.id]) == 2 + assert len(result[device_entry.id]) == 3 # toggled, turned_on, turned_off async def test_async_get_device_automations_all_devices_condition( @@ -427,7 +443,9 @@ async def test_async_get_device_automations_all_devices_condition( connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) - result = await device_automation.async_get_device_automations(hass, "condition") + result = await device_automation.async_get_device_automations( + hass, device_automation.DeviceAutomationType.CONDITION + ) assert device_entry.id in result assert len(result[device_entry.id]) == 2 @@ -444,7 +462,9 @@ async def test_async_get_device_automations_all_devices_action( connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) - result = await device_automation.async_get_device_automations(hass, "action") + result = await device_automation.async_get_device_automations( + hass, device_automation.DeviceAutomationType.ACTION + ) assert device_entry.id in result assert len(result[device_entry.id]) == 3 @@ -465,7 +485,9 @@ async def test_async_get_device_automations_all_devices_action_exception_throw( "homeassistant.components.light.device_trigger.async_get_triggers", side_effect=KeyError, ): - result = await device_automation.async_get_device_automations(hass, "trigger") + result = await device_automation.async_get_device_automations( + hass, device_automation.DeviceAutomationType.TRIGGER + ) assert device_entry.id in result assert len(result[device_entry.id]) == 0 assert "KeyError" in caplog.text @@ -505,7 +527,7 @@ async def test_websocket_get_trigger_capabilities( triggers = msg["result"] id = 2 - assert len(triggers) == 2 + assert len(triggers) == 3 # toggled, turned_on, turned_off for trigger in triggers: await client.send_json( { diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py new file mode 100644 index 00000000000000..4e125b9bf10770 --- /dev/null +++ b/tests/components/device_automation/test_toggle_entity.py @@ -0,0 +1,199 @@ +"""The test for device automation toggle entity helpers.""" +from datetime import timedelta + +import pytest + +import homeassistant.components.automation as automation +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed, async_mock_service +from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_if_fires_on_state_change(hass, calls, enable_custom_integrations): + """Test for turn_on and turn_off triggers firing. + + This is a sanity test for the toggle entity device automation helper, this is + tested by each integration too. + """ + platform = getattr(hass.components, "test.switch") + + platform.init() + assert await async_setup_component( + hass, "switch", {"switch": {CONF_PLATFORM: "test"}} + ) + await hass.async_block_till_done() + + ent1, ent2, ent3 = platform.ENTITIES + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": "switch", + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turned_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": "switch", + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turned_off", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": "switch", + "device_id": "", + "entity_id": ent1.entity_id, + "type": "changed_states", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on_or_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.states.async_set(ent1.entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 2 + assert {calls[0].data["some"], calls[1].data["some"]} == { + f"turn_off device - {ent1.entity_id} - on - off - None", + f"turn_on_or_off device - {ent1.entity_id} - on - off - None", + } + + hass.states.async_set(ent1.entity_id, STATE_ON) + await hass.async_block_till_done() + assert len(calls) == 4 + assert {calls[2].data["some"], calls[3].data["some"]} == { + f"turn_on device - {ent1.entity_id} - off - on - None", + f"turn_on_or_off device - {ent1.entity_id} - off - on - None", + } + + +@pytest.mark.parametrize("trigger", ["turned_off", "changed_states"]) +async def test_if_fires_on_state_change_with_for( + hass, calls, enable_custom_integrations, trigger +): + """Test for triggers firing with delay.""" + platform = getattr(hass.components, "test.switch") + + platform.init() + assert await async_setup_component( + hass, "switch", {"switch": {CONF_PLATFORM: "test"}} + ) + await hass.async_block_till_done() + + ent1, ent2, ent3 = platform.ENTITIES + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": "switch", + "device_id": "", + "entity_id": ent1.entity_id, + "type": trigger, + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + } + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.states.async_set(ent1.entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert len(calls) == 1 + await hass.async_block_till_done() + assert calls[0].data["some"] == "turn_off device - {} - on - off - 0:00:05".format( + ent1.entity_id + ) diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index 9b6a85cf8a0c8f..3c8efad5b05f35 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -1,11 +1,14 @@ """Test Device Tracker config entry things.""" -from homeassistant.components.device_tracker import config_entry +from homeassistant.components.device_tracker import DOMAIN, config_entry as ce +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry def test_tracker_entity(): """Test tracker entity.""" - class TestEntry(config_entry.TrackerEntity): + class TestEntry(ce.TrackerEntity): """Mock tracker class.""" should_poll = False @@ -17,3 +20,111 @@ class TestEntry(config_entry.TrackerEntity): instance.should_poll = True assert not instance.force_update + + +async def test_cleanup_legacy(hass, enable_custom_integrations): + """Test we clean up devices created by old device tracker.""" + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + device1 = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device1")} + ) + device2 = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device2")} + ) + device3 = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "device3")} + ) + + # Device with light + device tracker entity + entity1a = ent_reg.async_get_or_create( + DOMAIN, + "test", + "entity1a-unique", + config_entry=config_entry, + device_id=device1.id, + ) + entity1b = ent_reg.async_get_or_create( + "light", + "test", + "entity1b-unique", + config_entry=config_entry, + device_id=device1.id, + ) + # Just device tracker entity + entity2a = ent_reg.async_get_or_create( + DOMAIN, + "test", + "entity2a-unique", + config_entry=config_entry, + device_id=device2.id, + ) + # Device with no device tracker entities + entity3a = ent_reg.async_get_or_create( + "light", + "test", + "entity3a-unique", + config_entry=config_entry, + device_id=device3.id, + ) + # Device tracker but no device + entity4a = ent_reg.async_get_or_create( + DOMAIN, + "test", + "entity4a-unique", + config_entry=config_entry, + ) + # Completely different entity + entity5a = ent_reg.async_get_or_create( + "light", + "test", + "entity4a-unique", + config_entry=config_entry, + ) + + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.async_block_till_done() + + for entity in (entity1a, entity1b, entity3a, entity4a, entity5a): + assert ent_reg.async_get(entity.entity_id) is not None + + # We've removed device so device ID cleared + assert ent_reg.async_get(entity2a.entity_id).device_id is None + # Removed because only had device tracker entity + assert dev_reg.async_get(device2.id) is None + + +async def test_register_mac(hass): + """Test registering a mac.""" + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mac1 = "12:34:56:AB:CD:EF" + + entity_entry_1 = ent_reg.async_get_or_create( + "device_tracker", + "test", + mac1 + "yo1", + original_name="name 1", + config_entry=config_entry, + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + ) + + ce._async_register_mac(hass, "test", mac1, mac1 + "yo1") + + dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac1)}, + ) + + await hass.async_block_till_done() + + entity_entry_1 = ent_reg.async_get(entity_entry_1.entity_id) + + assert entity_entry_1.disabled_by is None diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 7e3f79712c43af..070d2b6fec3036 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_tracker import DOMAIN from homeassistant.const import STATE_HOME from homeassistant.helpers import device_registry @@ -61,7 +62,9 @@ async def test_get_conditions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert_lists_same(conditions, expected_conditions) diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index 0ec1ee67d3638d..dc9a5fe80fe756 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -3,6 +3,7 @@ import voluptuous_serialize import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_tracker import DOMAIN, device_trigger import homeassistant.components.zone as zone from homeassistant.helpers import config_validation as cv, device_registry @@ -87,7 +88,9 @@ async def test_get_triggers(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) diff --git a/tests/components/device_tracker/test_entities.py b/tests/components/device_tracker/test_entities.py index 88e1dccdb342c0..12059cad601f3e 100644 --- a/tests/components/device_tracker/test_entities.py +++ b/tests/components/device_tracker/test_entities.py @@ -14,25 +14,33 @@ SOURCE_TYPE_ROUTER, ) from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_HOME, STATE_NOT_HOME +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry async def test_scanner_entity_device_tracker(hass, enable_custom_integrations): """Test ScannerEntity based device tracker.""" + # Make device tied to other integration so device tracker entities get enabled + dr.async_get(hass).async_get_or_create( + name="Device from other integration", + config_entry_id=MockConfigEntry().entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "ad:de:ef:be:ed:fe")}, + ) + config_entry = MockConfigEntry(domain="test") config_entry.add_to_hass(hass) await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) await hass.async_block_till_done() - entity_id = "device_tracker.unnamed_device" + entity_id = "device_tracker.test_ad_de_ef_be_ed_fe" entity_state = hass.states.get(entity_id) assert entity_state.attributes == { ATTR_SOURCE_TYPE: SOURCE_TYPE_ROUTER, ATTR_BATTERY_LEVEL: 100, ATTR_IP: "0.0.0.0", - ATTR_MAC: "ad:de:ef:be:ed:fe:", + ATTR_MAC: "ad:de:ef:be:ed:fe", ATTR_HOST_NAME: "test.hostname.org", } assert entity_state.state == STATE_NOT_HOME diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index 693b4e7351d055..79bf94b8fc3c26 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -8,6 +8,9 @@ from devolo_home_control_api.properties.binary_sensor_property import ( BinarySensorProperty, ) +from devolo_home_control_api.properties.multi_level_switch_property import ( + MultiLevelSwitchProperty, +) from devolo_home_control_api.properties.settings_property import SettingsProperty from devolo_home_control_api.publisher.publisher import Publisher @@ -25,6 +28,19 @@ def __init__(self, **kwargs: Any) -> None: self.state = False +class SirenPropertyMock(MultiLevelSwitchProperty): + """devolo Home Control siren mock.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + self.element_uid = "Test" + self.max = 0 + self.min = 0 + self.switch_type = "tone" + self._value = 0 + self._logger = MagicMock() + + class SettingsMock(SettingsProperty): """devolo Home Control settings mock.""" @@ -33,6 +49,7 @@ def __init__(self, **kwargs: Any) -> None: self._logger = MagicMock() self.name = "Test" self.zone = "Test" + self.tone = 1 class DeviceMock(Zwave): @@ -87,6 +104,19 @@ def __init__(self) -> None: } +class SirenMock(DeviceMock): + """devolo Home Control siren device mock.""" + + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.device_model_uid = "devolo.model.Siren" + self.multi_level_switch_property = { + "devolo.SirenMultiLevelSwitch:Test": SirenPropertyMock() + } + self.settings_property["tone"] = SettingsMock() + + class HomeControlMock(HomeControl): """devolo Home Control gateway mock.""" @@ -131,3 +161,14 @@ def __init__(self, **kwargs: Any) -> None: """Initialize the mock.""" super().__init__() self.devices = {"Test": DisabledBinarySensorMock()} + + +class HomeControlMockSiren(HomeControlMock): + """devolo Home Control gateway mock with siren device.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = {"Test": SirenMock()} + self.publisher = Publisher(self.devices.keys()) + self.publisher.unregister = MagicMock() diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index 9b13220ad7c11f..4bce2ebd19e981 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -4,14 +4,10 @@ import pytest from homeassistant.components.binary_sensor import DOMAIN -from homeassistant.const import ( - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity import EntityCategory from . import configure_integration from .mocks import ( @@ -42,9 +38,7 @@ async def test_binary_sensor(hass: HomeAssistant): state = hass.states.get(f"{DOMAIN}.test_2") assert state is not None er = entity_registry.async_get(hass) - assert ( - er.async_get(f"{DOMAIN}.test_2").entity_category == ENTITY_CATEGORY_DIAGNOSTIC - ) + assert er.async_get(f"{DOMAIN}.test_2").entity_category == EntityCategory.DIAGNOSTIC # Emulate websocket message: sensor turned on test_gateway.publisher.dispatch("Test", ("Test", True)) diff --git a/tests/components/devolo_home_control/test_siren.py b/tests/components/devolo_home_control/test_siren.py new file mode 100644 index 00000000000000..97e044738a5386 --- /dev/null +++ b/tests/components/devolo_home_control/test_siren.py @@ -0,0 +1,141 @@ +"""Tests for the devolo Home Control binary sensors.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.siren import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import configure_integration +from .mocks import HomeControlMock, HomeControlMockSiren + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_siren(hass: HomeAssistant): + """Test setup and state change of a siren device.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSiren() + test_gateway.devices["Test"].status = 0 + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + assert state.state == STATE_OFF + + # Emulate websocket message: sensor turned on + test_gateway.publisher.dispatch("Test", ("devolo.SirenMultiLevelSwitch:Test", 1)) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_ON + + # Emulate websocket message: device went offline + test_gateway.devices["Test"].status = 1 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_siren_switching(hass: HomeAssistant): + """Test setup and state change via switching of a siren device.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSiren() + test_gateway.devices["Test"].status = 0 + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + assert state.state == STATE_OFF + + with patch( + "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" + ) as set: + await hass.services.async_call( + "siren", + "turn_on", + {"entity_id": f"{DOMAIN}.test"}, + blocking=True, + ) + # The real device state is changed by a websocket message + test_gateway.publisher.dispatch( + "Test", ("devolo.SirenMultiLevelSwitch:Test", 1) + ) + await hass.async_block_till_done() + set.assert_called_once_with(1) + + with patch( + "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" + ) as set: + await hass.services.async_call( + "siren", + "turn_off", + {"entity_id": f"{DOMAIN}.test"}, + blocking=True, + ) + # The real device state is changed by a websocket message + test_gateway.publisher.dispatch( + "Test", ("devolo.SirenMultiLevelSwitch:Test", 0) + ) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_OFF + set.assert_called_once_with(0) + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_siren_change_default_tone(hass: HomeAssistant): + """Test changing the default tone on message.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSiren() + test_gateway.devices["Test"].status = 0 + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + + with patch( + "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" + ) as set: + test_gateway.publisher.dispatch("Test", ("mss:Test", 2)) + await hass.services.async_call( + "siren", + "turn_on", + {"entity_id": f"{DOMAIN}.test"}, + blocking=True, + ) + set.assert_called_once_with(2) + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_remove_from_hass(hass: HomeAssistant): + """Test removing entity.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSiren() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + test_gateway.publisher.unregister.assert_called_once() diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index 7d115a26b15d18..7cd1ba5222c63f 100644 --- a/tests/components/devolo_home_network/test_config_flow.py +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -174,7 +174,7 @@ async def test_abort_if_configued(hass: HomeAssistant): @pytest.mark.usefixtures("mock_device") @pytest.mark.usefixtures("mock_zeroconf") async def test_validate_input(hass: HomeAssistant): - """Test input validaton.""" + """Test input validation.""" info = await config_flow.validate_input(hass, {CONF_IP_ADDRESS: IP}) assert SERIAL_NUMBER in info assert TITLE in info diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 6ce4c36cdb5243..a7b1be5f07dd1f 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -8,10 +8,11 @@ LONG_UPDATE_INTERVAL, SHORT_UPDATE_INTERVAL, ) -from homeassistant.components.sensor import DOMAIN, STATE_CLASS_MEASUREMENT -from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC, STATE_UNAVAILABLE +from homeassistant.components.sensor import DOMAIN, SensorStateClass +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity import EntityCategory from homeassistant.util import dt from . import configure_integration @@ -47,7 +48,7 @@ async def test_update_connected_wifi_clients(hass: HomeAssistant): state = hass.states.get(state_key) assert state is not None assert state.state == "1" - assert state.attributes["state_class"] == STATE_CLASS_MEASUREMENT + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT # Emulate device failure with patch( @@ -89,7 +90,7 @@ async def test_update_neighboring_wifi_networks(hass: HomeAssistant): assert state.state == "1" er = entity_registry.async_get(hass) - assert er.async_get(state_key).entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert er.async_get(state_key).entity_category is EntityCategory.DIAGNOSTIC # Emulate device failure with patch( @@ -131,7 +132,7 @@ async def test_update_connected_plc_devices(hass: HomeAssistant): assert state.state == "1" er = entity_registry.async_get(hass) - assert er.async_get(state_key).entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert er.async_get(state_key).entity_category is EntityCategory.DIAGNOSTIC # Emulate device failure with patch( diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 41059722113d66..fb3387aeab664c 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -3,7 +3,8 @@ import threading from unittest.mock import MagicMock, patch -from scapy import arch # pylint: unused-import # noqa: F401 +import pytest +from scapy import arch # pylint: disable=unused-import # noqa: F401 from scapy.error import Scapy_Exception from scapy.layers.dhcp import DHCP from scapy.layers.l2 import Ether @@ -845,6 +846,7 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass): ) +@pytest.mark.usefixtures("mock_integration_frame") async def test_service_info_compatibility(hass, caplog): """Test compatibility with old-style dict. @@ -856,21 +858,13 @@ async def test_service_info_compatibility(hass, caplog): macaddress="b8b7f16db533", ) - # Ensure first call get logged - assert discovery_info["ip"] == "192.168.210.56" - assert discovery_info.get("ip") == "192.168.210.56" + with patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()): + assert discovery_info["ip"] == "192.168.210.56" + assert "Detected integration that accessed discovery_info['ip']" in caplog.text + + with patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()): + assert discovery_info.get("ip") == "192.168.210.56" + assert "Detected integration that accessed discovery_info.get('ip')" in caplog.text + assert discovery_info.get("ip", "fallback_host") == "192.168.210.56" assert discovery_info.get("invalid_key", "fallback_host") == "fallback_host" - assert "Detected code that accessed discovery_info['ip']" in caplog.text - assert "Detected code that accessed discovery_info.get('ip')" not in caplog.text - - # Ensure second call doesn't get logged - caplog.clear() - assert discovery_info["ip"] == "192.168.210.56" - assert discovery_info.get("ip") == "192.168.210.56" - assert "Detected code that accessed discovery_info['ip']" not in caplog.text - assert "Detected code that accessed discovery_info.get('ip')" not in caplog.text - - discovery_info._warning_logged = False # pylint: disable=[protected-access] - assert discovery_info.get("ip") == "192.168.210.56" - assert "Detected code that accessed discovery_info.get('ip')" in caplog.text diff --git a/tests/components/diagnostics/__init__.py b/tests/components/diagnostics/__init__.py new file mode 100644 index 00000000000000..3dbbb741a9fd18 --- /dev/null +++ b/tests/components/diagnostics/__init__.py @@ -0,0 +1,40 @@ +"""Tests for the Diagnostics integration.""" +from http import HTTPStatus + +from homeassistant.setup import async_setup_component + + +async def _get_diagnostics_for_config_entry(hass, hass_client, config_entry): + """Return the diagnostics config entry for the specified domain.""" + assert await async_setup_component(hass, "diagnostics", {}) + + client = await hass_client() + response = await client.get( + f"/api/diagnostics/config_entry/{config_entry.entry_id}" + ) + assert response.status == HTTPStatus.OK + return await response.json() + + +async def get_diagnostics_for_config_entry(hass, hass_client, config_entry): + """Return the diagnostics config entry for the specified domain.""" + data = await _get_diagnostics_for_config_entry(hass, hass_client, config_entry) + return data["data"] + + +async def _get_diagnostics_for_device(hass, hass_client, config_entry, device): + """Return the diagnostics for the specified device.""" + assert await async_setup_component(hass, "diagnostics", {}) + + client = await hass_client() + response = await client.get( + f"/api/diagnostics/config_entry/{config_entry.entry_id}/device/{device.id}" + ) + assert response.status == HTTPStatus.OK + return await response.json() + + +async def get_diagnostics_for_device(hass, hass_client, config_entry, device): + """Return the diagnostics for the specified device.""" + data = await _get_diagnostics_for_device(hass, hass_client, config_entry, device) + return data["data"] diff --git a/tests/components/diagnostics/test_init.py b/tests/components/diagnostics/test_init.py new file mode 100644 index 00000000000000..11b113e30f6d01 --- /dev/null +++ b/tests/components/diagnostics/test_init.py @@ -0,0 +1,155 @@ +"""Test the Diagnostics integration.""" +from http import HTTPStatus +from unittest.mock import AsyncMock, Mock + +import pytest + +from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.helpers.device_registry import async_get +from homeassistant.helpers.system_info import async_get_system_info +from homeassistant.setup import async_setup_component + +from . import _get_diagnostics_for_config_entry, _get_diagnostics_for_device + +from tests.common import MockConfigEntry, mock_platform + + +@pytest.fixture(autouse=True) +async def mock_diagnostics_integration(hass): + """Mock a diagnostics integration.""" + hass.config.components.add("fake_integration") + mock_platform( + hass, + "fake_integration.diagnostics", + Mock( + async_get_config_entry_diagnostics=AsyncMock( + return_value={ + "config_entry": "info", + } + ), + async_get_device_diagnostics=AsyncMock( + return_value={ + "device": "info", + } + ), + ), + ) + mock_platform( + hass, + "integration_without_diagnostics.diagnostics", + Mock(), + ) + assert await async_setup_component(hass, "diagnostics", {}) + + +async def test_websocket(hass, hass_ws_client): + """Test websocket command.""" + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "diagnostics/list"}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == [ + { + "domain": "fake_integration", + "handlers": {"config_entry": True, "device": True}, + } + ] + + await client.send_json( + {"id": 6, "type": "diagnostics/get", "domain": "fake_integration"} + ) + + msg = await client.receive_json() + + assert msg["id"] == 6 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == { + "domain": "fake_integration", + "handlers": {"config_entry": True, "device": True}, + } + + +async def test_download_diagnostics(hass, hass_client): + """Test download diagnostics.""" + config_entry = MockConfigEntry(domain="fake_integration") + config_entry.add_to_hass(hass) + hass_sys_info = await async_get_system_info(hass) + hass_sys_info["run_as_root"] = hass_sys_info["user"] == "root" + del hass_sys_info["user"] + + assert await _get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "home_assistant": hass_sys_info, + "custom_components": {}, + "integration_manifest": { + "codeowners": [], + "dependencies": [], + "domain": "fake_integration", + "is_built_in": True, + "name": "fake_integration", + "requirements": [], + }, + "data": {"config_entry": "info"}, + } + + dev_reg = async_get(hass) + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("test", "test")} + ) + + assert await _get_diagnostics_for_device( + hass, hass_client, config_entry, device + ) == { + "home_assistant": hass_sys_info, + "custom_components": {}, + "integration_manifest": { + "codeowners": [], + "dependencies": [], + "domain": "fake_integration", + "is_built_in": True, + "name": "fake_integration", + "requirements": [], + }, + "data": {"device": "info"}, + } + + +async def test_failure_scenarios(hass, hass_client): + """Test failure scenarios.""" + client = await hass_client() + + # test wrong d_type + response = await client.get("/api/diagnostics/wrong_type/fake_id") + assert response.status == HTTPStatus.BAD_REQUEST + + # test wrong d_id + response = await client.get("/api/diagnostics/config_entry/fake_id") + assert response.status == HTTPStatus.NOT_FOUND + + config_entry = MockConfigEntry(domain="integration_without_diagnostics") + config_entry.add_to_hass(hass) + + # test valid d_type and d_id but no config entry diagnostics + response = await client.get( + f"/api/diagnostics/config_entry/{config_entry.entry_id}" + ) + assert response.status == HTTPStatus.NOT_FOUND + + config_entry = MockConfigEntry(domain="fake_integration") + config_entry.add_to_hass(hass) + + # test invalid sub_type + response = await client.get( + f"/api/diagnostics/config_entry/{config_entry.entry_id}/wrong_type/id" + ) + assert response.status == HTTPStatus.BAD_REQUEST + + # test invalid sub_id + response = await client.get( + f"/api/diagnostics/config_entry/{config_entry.entry_id}/device/fake_id" + ) + assert response.status == HTTPStatus.NOT_FOUND diff --git a/tests/components/diagnostics/test_util.py b/tests/components/diagnostics/test_util.py new file mode 100644 index 00000000000000..702b838334fe76 --- /dev/null +++ b/tests/components/diagnostics/test_util.py @@ -0,0 +1,33 @@ +"""Test Diagnostics utils.""" +from homeassistant.components.diagnostics import REDACTED, async_redact_data + + +def test_redact(): + """Test the async_redact_data helper.""" + data = { + "key1": "value1", + "key2": ["value2_a", "value2_b"], + "key3": [["value_3a", "value_3b"], ["value_3c", "value_3d"]], + "key4": { + "key4_1": "value4_1", + "key4_2": ["value4_2a", "value4_2b"], + "key4_3": [["value4_3a", "value4_3b"], ["value4_3c", "value4_3d"]], + }, + } + + to_redact = { + "key1", + "key3", + "key4_1", + } + + assert async_redact_data(data, to_redact) == { + "key1": REDACTED, + "key2": ["value2_a", "value2_b"], + "key3": REDACTED, + "key4": { + "key4_1": REDACTED, + "key4_2": ["value4_2a", "value4_2b"], + "key4_3": [["value4_3a", "value4_3b"], ["value4_3c", "value4_3d"]], + }, + } diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 41431748d3636f..cfade8bc4e79ec 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -12,7 +12,7 @@ ATTR_MEDIA_RECORDED, ATTR_MEDIA_START_TIME, ) -from homeassistant.components.media_player import DEVICE_CLASS_RECEIVER +from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, ATTR_MEDIA_ALBUM_NAME, @@ -160,15 +160,15 @@ async def test_unique_id( entity_registry = er.async_get(hass) main = entity_registry.async_get(MAIN_ENTITY_ID) - assert main.original_device_class == DEVICE_CLASS_RECEIVER + assert main.original_device_class == MediaPlayerDeviceClass.RECEIVER assert main.unique_id == "028877455858" client = entity_registry.async_get(CLIENT_ENTITY_ID) - assert client.original_device_class == DEVICE_CLASS_RECEIVER + assert client.original_device_class == MediaPlayerDeviceClass.RECEIVER assert client.unique_id == "2CA17D1CD30X" unavailable_client = entity_registry.async_get(UNAVAILABLE_ENTITY_ID) - assert unavailable_client.original_device_class == DEVICE_CLASS_RECEIVER + assert unavailable_client.original_device_class == MediaPlayerDeviceClass.RECEIVER assert unavailable_client.unique_id == "9XXXXXXXXXX9" diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index c497d45a4f27b0..fb0c416c086c0c 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -15,14 +15,7 @@ CONF_POLL_AVAILABILITY, DOMAIN as DLNA_DOMAIN, ) -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_HOST, - CONF_NAME, - CONF_PLATFORM, - CONF_TYPE, - CONF_URL, -) +from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant from .conftest import ( @@ -43,13 +36,6 @@ WRONG_DEVICE_TYPE = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" -IMPORTED_DEVICE_NAME = "Imported DMR device" - -MOCK_CONFIG_IMPORT_DATA = { - CONF_PLATFORM: DLNA_DOMAIN, - CONF_URL: MOCK_DEVICE_LOCATION, -} - MOCK_ROOT_DEVICE_UDN = "ROOT_DEVICE" MOCK_DISCOVERY = ssdp.SsdpServiceInfo( @@ -276,212 +262,6 @@ async def test_user_flow_wrong_st(hass: HomeAssistant, domain_data_mock: Mock) - assert result["step_id"] == "manual" -async def test_import_flow_invalid(hass: HomeAssistant, domain_data_mock: Mock) -> None: - """Test import flow of invalid YAML config.""" - # Missing CONF_URL - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_PLATFORM: DLNA_DOMAIN}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "incomplete_config" - - -async def test_import_flow_ssdp_discovered( - hass: HomeAssistant, ssdp_scanner_mock: Mock -) -> None: - """Test import of YAML config with a device also found via SSDP.""" - ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ - [MOCK_DISCOVERY], - [], - [], - ] - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_IMPORT_DATA, - ) - await hass.async_block_till_done() - - assert ssdp_scanner_mock.async_get_discovery_info_by_st.call_count >= 1 - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == MOCK_DEVICE_NAME - assert result["data"] == { - CONF_URL: MOCK_DEVICE_LOCATION, - CONF_DEVICE_ID: MOCK_DEVICE_UDN, - CONF_TYPE: MOCK_DEVICE_TYPE, - } - assert result["options"] == { - CONF_LISTEN_PORT: None, - CONF_CALLBACK_URL_OVERRIDE: None, - CONF_POLL_AVAILABILITY: False, - } - - # The config entry should not be duplicated when dlna_dmr is restarted - ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ - [MOCK_DISCOVERY], - [], - [], - ] - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_IMPORT_DATA, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - # Wait for platform to be fully setup - await hass.async_block_till_done() - - -async def test_import_flow_direct_connect( - hass: HomeAssistant, ssdp_scanner_mock: Mock -) -> None: - """Test import of YAML config with a device *not found* via SSDP.""" - ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] - - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_IMPORT_DATA, - ) - await hass.async_block_till_done() - - assert ssdp_scanner_mock.async_get_discovery_info_by_st.call_count >= 1 - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == MOCK_DEVICE_NAME - assert result["data"] == { - CONF_URL: MOCK_DEVICE_LOCATION, - CONF_DEVICE_ID: MOCK_DEVICE_UDN, - CONF_TYPE: MOCK_DEVICE_TYPE, - } - assert result["options"] == { - CONF_LISTEN_PORT: None, - CONF_CALLBACK_URL_OVERRIDE: None, - CONF_POLL_AVAILABILITY: True, - } - - # The config entry should not be duplicated when dlna_dmr is restarted - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_IMPORT_DATA, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_import_flow_offline( - hass: HomeAssistant, domain_data_mock: Mock, ssdp_scanner_mock: Mock -) -> None: - """Test import flow of offline device.""" - # Device is not yet contactable - domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError - - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_PLATFORM: DLNA_DOMAIN, - CONF_URL: MOCK_DEVICE_LOCATION, - CONF_NAME: IMPORTED_DEVICE_NAME, - CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", - CONF_LISTEN_PORT: 2222, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {} - assert result["step_id"] == "import_turn_on" - - import_flow_id = result["flow_id"] - - # User clicks submit, same form is displayed with an error - result = await hass.config_entries.flow.async_configure( - import_flow_id, user_input={} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "cannot_connect"} - assert result["step_id"] == "import_turn_on" - - # Device is discovered via SSDP, new flow should not be initialized - ssdp_scanner_mock.async_get_discovery_info_by_st.side_effect = [ - [MOCK_DISCOVERY], - [], - [], - ] - domain_data_mock.upnp_factory.async_create_device.side_effect = None - - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_DISCOVERY, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_in_progress" - - # User clicks submit, config entry should be created - result = await hass.config_entries.flow.async_configure( - import_flow_id, user_input={} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == IMPORTED_DEVICE_NAME - assert result["data"] == { - CONF_URL: MOCK_DEVICE_LOCATION, - CONF_DEVICE_ID: MOCK_DEVICE_UDN, - CONF_TYPE: MOCK_DEVICE_TYPE, - } - # Options should be retained - assert result["options"] == { - CONF_LISTEN_PORT: 2222, - CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", - CONF_POLL_AVAILABILITY: True, - } - - # Wait for platform to be fully setup - await hass.async_block_till_done() - - -async def test_import_flow_options( - hass: HomeAssistant, ssdp_scanner_mock: Mock -) -> None: - """Test import of YAML config with options set.""" - ssdp_scanner_mock.async_get_discovery_info_by_st.return_value = [] - - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_PLATFORM: DLNA_DOMAIN, - CONF_URL: MOCK_DEVICE_LOCATION, - CONF_NAME: IMPORTED_DEVICE_NAME, - CONF_LISTEN_PORT: 2222, - CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == IMPORTED_DEVICE_NAME - assert result["data"] == { - CONF_URL: MOCK_DEVICE_LOCATION, - CONF_DEVICE_ID: MOCK_DEVICE_UDN, - CONF_TYPE: MOCK_DEVICE_TYPE, - } - assert result["options"] == { - CONF_LISTEN_PORT: 2222, - CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", - CONF_POLL_AVAILABILITY: True, - } - - # Wait for platform to be fully setup - await hass.async_block_till_done() - - async def test_ssdp_flow_success(hass: HomeAssistant) -> None: """Test that SSDP discovery with an available device works.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index b8c10b47b600a9..3cb4b2a726a442 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -29,7 +29,7 @@ from homeassistant.components.dlna_dmr.data import EventListenAddr from homeassistant.components.media_player import ATTR_TO_PROPERTY, const as mp_const from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, CONF_URL +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import async_get as async_get_dr from homeassistant.helpers.entity_component import async_update_entity @@ -37,13 +37,13 @@ async_entries_for_config_entry, async_get as async_get_er, ) -from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from .conftest import ( LOCAL_IP, MOCK_DEVICE_LOCATION, MOCK_DEVICE_NAME, + MOCK_DEVICE_TYPE, MOCK_DEVICE_UDN, MOCK_DEVICE_USN, NEW_DEVICE_LOCATION, @@ -51,8 +51,6 @@ from tests.common import MockConfigEntry -MOCK_DEVICE_ST = "mock_st" - # Auto-use the domain_data_mock fixture for every test in this module pytestmark = pytest.mark.usefixtures("domain_data_mock") @@ -183,38 +181,6 @@ async def mock_disconnected_entity_id( assert dmr_device_mock.on_event is None -async def test_setup_platform_import_flow_started( - hass: HomeAssistant, domain_data_mock: Mock -) -> None: - """Test import flow of YAML config is started if there's config data.""" - # Cause connection attempts to fail - domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError - - # Run the setup - mock_config: ConfigType = { - MP_DOMAIN: [ - { - CONF_PLATFORM: DLNA_DOMAIN, - CONF_URL: MOCK_DEVICE_LOCATION, - CONF_LISTEN_PORT: 1234, - } - ] - } - - await async_setup_component(hass, MP_DOMAIN, mock_config) - await hass.async_block_till_done() - - # Check config_flow has started - flows = hass.config_entries.flow.async_progress(include_uninitialized=True) - assert len(flows) == 1 - - # It should be paused, waiting for the user to turn on the device - flow = flows[0] - assert flow["handler"] == "dlna_dmr" - assert flow["step_id"] == "import_turn_on" - assert flow["context"].get("unique_id") == MOCK_DEVICE_LOCATION - - async def test_setup_entry_no_options( hass: HomeAssistant, domain_data_mock: Mock, @@ -1057,7 +1023,7 @@ async def test_become_available( ssdp.SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.ALIVE, @@ -1121,18 +1087,90 @@ async def test_alive_but_gone( ssdp.SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, upnp={}, ), ssdp.SsdpChange.ALIVE, ) await hass.async_block_till_done() + # There should be a connection attempt to the device + domain_data_mock.upnp_factory.async_create_device.assert_awaited() + # Device should still be unavailable mock_state = hass.states.get(mock_disconnected_entity_id) assert mock_state is not None assert mock_state.state == ha_const.STATE_UNAVAILABLE + # Send the same SSDP notification, expecting no extra connection attempts + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await ssdp_callback( + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=NEW_DEVICE_LOCATION, + ssdp_st=MOCK_DEVICE_TYPE, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, + upnp={}, + ), + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + domain_data_mock.upnp_factory.async_create_device.assert_not_called() + domain_data_mock.upnp_factory.async_create_device.assert_not_awaited() + mock_state = hass.states.get(mock_disconnected_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Send an SSDP notification with a new BOOTID, indicating the device has rebooted + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await ssdp_callback( + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=NEW_DEVICE_LOCATION, + ssdp_st=MOCK_DEVICE_TYPE, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, + upnp={}, + ), + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Rebooted device (seen via BOOTID) should mean a new connection attempt + domain_data_mock.upnp_factory.async_create_device.assert_awaited() + mock_state = hass.states.get(mock_disconnected_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + + # Send byebye message to indicate device is going away. Next alive message + # should result in a reconnect attempt even with same BOOTID. + domain_data_mock.upnp_factory.async_create_device.reset_mock() + await ssdp_callback( + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_st=MOCK_DEVICE_TYPE, + upnp={}, + ), + ssdp.SsdpChange.BYEBYE, + ) + await ssdp_callback( + ssdp.SsdpServiceInfo( + ssdp_usn=MOCK_DEVICE_USN, + ssdp_location=NEW_DEVICE_LOCATION, + ssdp_st=MOCK_DEVICE_TYPE, + ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, + upnp={}, + ), + ssdp.SsdpChange.ALIVE, + ) + await hass.async_block_till_done() + + # Rebooted device (seen via byebye/alive) should mean a new connection attempt + domain_data_mock.upnp_factory.async_create_device.assert_awaited() + mock_state = hass.states.get(mock_disconnected_entity_id) + assert mock_state is not None + assert mock_state.state == ha_const.STATE_UNAVAILABLE + async def test_multiple_ssdp_alive( hass: HomeAssistant, @@ -1162,7 +1200,7 @@ async def create_device_delayed(_location): ssdp.SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.ALIVE, @@ -1171,7 +1209,7 @@ async def create_device_delayed(_location): ssdp.SsdpServiceInfo( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=NEW_DEVICE_LOCATION, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.ALIVE, @@ -1203,7 +1241,7 @@ async def test_ssdp_byebye( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.BYEBYE, @@ -1222,7 +1260,7 @@ async def test_ssdp_byebye( ssdp_usn=MOCK_DEVICE_USN, ssdp_udn=MOCK_DEVICE_UDN, ssdp_headers={"NTS": "ssdp:byebye"}, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.BYEBYE, @@ -1255,7 +1293,7 @@ async def test_ssdp_update_seen_bootid( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.ALIVE, @@ -1272,7 +1310,7 @@ async def test_ssdp_update_seen_bootid( ssdp.ATTR_SSDP_BOOTID: "1", ssdp.ATTR_SSDP_NEXTBOOTID: "2", }, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.UPDATE, @@ -1297,7 +1335,7 @@ async def test_ssdp_update_seen_bootid( ssdp.ATTR_SSDP_BOOTID: "1", ssdp.ATTR_SSDP_NEXTBOOTID: "2", }, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.UPDATE, @@ -1322,7 +1360,7 @@ async def test_ssdp_update_seen_bootid( ssdp.ATTR_SSDP_BOOTID: "2", ssdp.ATTR_SSDP_NEXTBOOTID: "7c848375-a106-4bd1-ac3c-8e50427c8e4f", }, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.UPDATE, @@ -1343,7 +1381,7 @@ async def test_ssdp_update_seen_bootid( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.ALIVE, @@ -1382,7 +1420,7 @@ async def test_ssdp_update_missed_bootid( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.ALIVE, @@ -1399,7 +1437,7 @@ async def test_ssdp_update_missed_bootid( ssdp.ATTR_SSDP_BOOTID: "2", ssdp.ATTR_SSDP_NEXTBOOTID: "3", }, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.UPDATE, @@ -1420,7 +1458,7 @@ async def test_ssdp_update_missed_bootid( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "3"}, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.ALIVE, @@ -1459,7 +1497,7 @@ async def test_ssdp_bootid( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.ALIVE, @@ -1479,7 +1517,7 @@ async def test_ssdp_bootid( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "1"}, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.ALIVE, @@ -1499,7 +1537,7 @@ async def test_ssdp_bootid( ssdp_usn=MOCK_DEVICE_USN, ssdp_location=MOCK_DEVICE_LOCATION, ssdp_headers={ssdp.ATTR_SSDP_BOOTID: "2"}, - ssdp_st=MOCK_DEVICE_ST, + ssdp_st=MOCK_DEVICE_TYPE, upnp={}, ), ssdp.SsdpChange.ALIVE, diff --git a/tests/components/dnsip/__init__.py b/tests/components/dnsip/__init__.py new file mode 100644 index 00000000000000..9fb6f529c5e094 --- /dev/null +++ b/tests/components/dnsip/__init__.py @@ -0,0 +1 @@ +"""Tests for the dnsip integration.""" diff --git a/tests/components/dnsip/test_config_flow.py b/tests/components/dnsip/test_config_flow.py new file mode 100644 index 00000000000000..59dcb81aa94a0a --- /dev/null +++ b/tests/components/dnsip/test_config_flow.py @@ -0,0 +1,311 @@ +"""Test the dnsip config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +from aiodns.error import DNSError +import pytest + +from homeassistant import config_entries +from homeassistant.components.dnsip.const import ( + CONF_HOSTNAME, + CONF_IPV4, + CONF_IPV6, + CONF_RESOLVER, + CONF_RESOLVER_IPV6, + DOMAIN, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +class RetrieveDNS: + """Return list of test information.""" + + @staticmethod + async def query(hostname, qtype) -> dict[str, str]: + """Return information.""" + return {"hostname": "1.2.3.4"} + + @property + def nameservers(self) -> list[str]: + """Return nameserver.""" + return ["1.2.3.4"] + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ), patch( + "homeassistant.components.dnsip.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTNAME: "home-assistant.io", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "home-assistant.io" + assert result2["data"] == { + "hostname": "home-assistant.io", + "name": "home-assistant.io", + "ipv4": True, + "ipv6": True, + } + assert result2["options"] == { + "resolver": "208.67.222.222", + "resolver_ipv6": "2620:0:ccc::2", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_error(hass: HomeAssistant) -> None: + """Test validate url fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + side_effect=DNSError("Did not find"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTNAME: "home-assistant.io", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_hostname"} + + +@pytest.mark.parametrize( + "p_input,p_output,p_options", + [ + ( + {CONF_HOSTNAME: "home-assistant.io"}, + { + "hostname": "home-assistant.io", + "name": "home-assistant.io", + "ipv4": True, + "ipv6": True, + }, + { + "resolver": "208.67.222.222", + "resolver_ipv6": "2620:0:ccc::2", + }, + ), + ( + {}, + { + "hostname": "myip.opendns.com", + "name": "myip", + "ipv4": True, + "ipv6": True, + }, + { + "resolver": "208.67.222.222", + "resolver_ipv6": "2620:0:ccc::2", + }, + ), + ], +) +async def test_import_flow_success( + hass: HomeAssistant, + p_input: dict[str, str], + p_output: dict[str, str], + p_options: dict[str, str], +) -> None: + """Test a successful import of YAML.""" + + with patch( + "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ), patch( + "homeassistant.components.dnsip.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=p_input, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == p_output["name"] + assert result2["data"] == p_output + assert result2["options"] == p_options + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_already_exist(hass: HomeAssistant) -> None: + """Test flow when unique id already exist.""" + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: True, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:0:ccc::2", + }, + unique_id="home-assistant.io", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.dnsip.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + return_value=RetrieveDNS, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOSTNAME: "home-assistant.io", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options config flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="12345", + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: False, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:0:ccc::2", + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ), patch( + "homeassistant.components.dnsip.async_setup_entry", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RESOLVER: "8.8.8.8", + CONF_RESOLVER_IPV6: "2001:4860:4860::8888", + }, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + "resolver": "8.8.8.8", + "resolver_ipv6": "2001:4860:4860::8888", + } + + +@pytest.mark.parametrize( + "p_input", + [ + { + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_IPV4: True, + CONF_IPV6: False, + }, + { + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_IPV4: False, + CONF_IPV6: True, + }, + ], +) +async def test_options_error(hass: HomeAssistant, p_input: dict[str, str]) -> None: + """Test validate url fails in options.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="12345", + data=p_input, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.async_setup_entry", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + with patch( + "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + side_effect=DNSError("Did not find"), + ): + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_RESOLVER: "192.168.200.34", + CONF_RESOLVER_IPV6: "2001:4860:4860::8888", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "init" + if p_input[CONF_IPV4]: + assert result2["errors"] == {"resolver": "invalid_resolver"} + if p_input[CONF_IPV6]: + assert result2["errors"] == {"resolver_ipv6": "invalid_resolver"} diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 9ef6bccfab57cf..e0299d68f2bd28 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -8,6 +8,7 @@ EQUIPMENT_IDENTIFIER_GAS, LUXEMBOURG_EQUIPMENT_IDENTIFIER, P1_MESSAGE_TIMESTAMP, + Q3D_EQUIPMENT_IDENTIFIER, ) from dsmr_parser.objects import CosemObject import pytest @@ -63,6 +64,12 @@ async def connection_factory(*args, **kwargs): protocol.telegram = { P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), } + if args[1] == "Q3D": + protocol.telegram = { + Q3D_EQUIPMENT_IDENTIFIER: CosemObject( + [{"value": "12345678", "unit": ""}] + ), + } return (transport, protocol) diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 9a2d1fe8481a3e..692870d70374f9 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -1,5 +1,4 @@ """Test the DSMR config flow.""" -import asyncio from itertools import chain, repeat import os from unittest.mock import DEFAULT, AsyncMock, MagicMock, patch, sentinel @@ -100,6 +99,84 @@ async def test_setup_serial(com_mock, hass, dsmr_connection_send_validate_fixtur assert result["data"] == {**entry_data, **SERIAL_DATA} +@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +async def test_setup_5L(com_mock, hass, dsmr_connection_send_validate_fixture): + """Test we can setup serial.""" + port = com_port() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"type": "Serial"}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "setup_serial" + assert result["errors"] == {} + + with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"port": port.device, "dsmr_version": "5L"} + ) + + entry_data = { + "port": port.device, + "dsmr_version": "5L", + "serial_id": "12345678", + "serial_id_gas": "123456789", + } + + assert result["type"] == "create_entry" + assert result["title"] == port.device + assert result["data"] == entry_data + + +@patch("serial.tools.list_ports.comports", return_value=[com_port()]) +async def test_setup_Q3D(com_mock, hass, dsmr_connection_send_validate_fixture): + """Test we can setup serial.""" + port = com_port() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"type": "Serial"}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "setup_serial" + assert result["errors"] == {} + + with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"port": port.device, "dsmr_version": "Q3D"} + ) + + entry_data = { + "port": port.device, + "dsmr_version": "Q3D", + "serial_id": "12345678", + "serial_id_gas": None, + } + + assert result["type"] == "create_entry" + assert result["title"] == port.device + assert result["data"] == entry_data + + @patch("serial.tools.list_ports.comports", return_value=[com_port()]) async def test_setup_serial_manual( com_mock, hass, dsmr_connection_send_validate_fixture @@ -225,188 +302,6 @@ async def test_setup_serial_wrong_telegram( assert result["errors"] == {"base": "cannot_communicate"} -async def test_import_usb(hass, dsmr_connection_send_validate_fixture): - """Test we can import.""" - - entry_data = { - "port": "/dev/ttyUSB0", - "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, - } - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=entry_data, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "/dev/ttyUSB0" - assert result["data"] == {**entry_data, **SERIAL_DATA} - - -async def test_import_usb_failed_connection( - hass, dsmr_connection_send_validate_fixture -): - """Test we can import.""" - (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture - - entry_data = { - "port": "/dev/ttyUSB0", - "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, - } - - # override the mock to have it fail the first time and succeed after - first_fail_connection_factory = AsyncMock( - return_value=(transport, protocol), - side_effect=chain([serial.serialutil.SerialException], repeat(DEFAULT)), - ) - - with patch( - "homeassistant.components.dsmr.async_setup_entry", return_value=True - ), patch( - "homeassistant.components.dsmr.config_flow.create_dsmr_reader", - first_fail_connection_factory, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=entry_data, - ) - - assert result["type"] == "abort" - assert result["reason"] == "cannot_connect" - - -async def test_import_usb_no_data(hass, dsmr_connection_send_validate_fixture): - """Test we can import.""" - (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture - - entry_data = { - "port": "/dev/ttyUSB0", - "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, - } - - # override the mock to have it fail the first time and succeed after - wait_closed = AsyncMock( - return_value=(transport, protocol), - side_effect=chain([asyncio.TimeoutError], repeat(DEFAULT)), - ) - - protocol.wait_closed = wait_closed - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=entry_data, - ) - - assert result["type"] == "abort" - assert result["reason"] == "cannot_communicate" - - -async def test_import_usb_wrong_telegram(hass, dsmr_connection_send_validate_fixture): - """Test we can import.""" - (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture - - entry_data = { - "port": "/dev/ttyUSB0", - "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, - } - - protocol.telegram = {} - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=entry_data, - ) - - assert result["type"] == "abort" - assert result["reason"] == "cannot_communicate" - - -async def test_import_network(hass, dsmr_connection_send_validate_fixture): - """Test we can import from network.""" - - entry_data = { - "host": "localhost", - "port": "1234", - "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, - } - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=entry_data, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "localhost:1234" - assert result["data"] == {**entry_data, **SERIAL_DATA} - - -async def test_import_update(hass, dsmr_connection_send_validate_fixture): - """Test we can import.""" - - entry_data = { - "port": "/dev/ttyUSB0", - "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, - } - - entry = MockConfigEntry( - domain=DOMAIN, - data=entry_data, - unique_id="/dev/ttyUSB0", - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.dsmr.async_setup_entry", return_value=True - ), patch("homeassistant.components.dsmr.async_unload_entry", return_value=True): - await hass.config_entries.async_setup(entry.entry_id) - - await hass.async_block_till_done() - - new_entry_data = { - "port": "/dev/ttyUSB0", - "dsmr_version": "2.2", - "precision": 3, - "reconnect_interval": 30, - } - - with patch( - "homeassistant.components.dsmr.async_setup_entry", return_value=True - ), patch("homeassistant.components.dsmr.async_unload_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=new_entry_data, - ) - - await hass.async_block_till_done() - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - assert entry.data["precision"] == 3 - - async def test_options_flow(hass): """Test options flow.""" @@ -446,50 +341,6 @@ async def test_options_flow(hass): assert entry.options == {"time_between_update": 15} -async def test_import_luxembourg(hass, dsmr_connection_send_validate_fixture): - """Test we can import.""" - - entry_data = { - "port": "/dev/ttyUSB0", - "dsmr_version": "5L", - "precision": 4, - "reconnect_interval": 30, - } - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=entry_data, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "/dev/ttyUSB0" - assert result["data"] == {**entry_data, **SERIAL_DATA} - - -async def test_import_sweden(hass, dsmr_connection_send_validate_fixture): - """Test we can import.""" - - entry_data = { - "port": "/dev/ttyUSB0", - "dsmr_version": "5S", - "precision": 4, - "reconnect_interval": 30, - } - - with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=entry_data, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "/dev/ttyUSB0" - assert result["data"] == {**entry_data, **SERIAL_DATA_SWEDEN} - - def test_get_serial_by_id_no_dir(): """Test serial by id conversion if there's no /dev/serial/by-id.""" p1 = patch("os.path.isdir", MagicMock(return_value=False)) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 19ac6dc5d1cdbf..65c52e14d39c91 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -12,10 +12,8 @@ from unittest.mock import DEFAULT, MagicMock from homeassistant import config_entries -from homeassistant.components.dsmr.const import DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) @@ -28,49 +26,10 @@ VOLUME_CUBIC_METERS, ) from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, patch -async def test_setup_platform(hass, dsmr_connection_fixture): - """Test setup of platform.""" - async_add_entities = MagicMock() - - entry_data = { - "platform": DOMAIN, - "port": "/dev/ttyUSB0", - "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, - } - - serial_data = {"serial_id": "1234", "serial_id_gas": "5678"} - - with patch( - "homeassistant.components.dsmr.async_setup_entry", return_value=True - ), patch( - "homeassistant.components.dsmr.config_flow._validate_dsmr_connection", - return_value=serial_data, - ): - assert await async_setup_component( - hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: entry_data} - ) - await hass.async_block_till_done() - - assert not async_add_entities.called - - # Check config entry - conf_entries = hass.config_entries.async_entries(DOMAIN) - - assert len(conf_entries) == 1 - - entry = conf_entries[0] - - assert entry.state == config_entries.ConfigEntryState.LOADED - assert entry.data == {**entry_data, **serial_data} - - async def test_default_setup(hass, dsmr_connection_fixture): """Test the default setup.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -350,9 +309,9 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): (connection_factory, transport, protocol) = dsmr_connection_fixture from dsmr_parser.obis_references import ( + ELECTRICITY_EXPORTED_TOTAL, + ELECTRICITY_IMPORTED_TOTAL, HOURLY_GAS_METER_READING, - LUXEMBOURG_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, - LUXEMBOURG_ELECTRICITY_USED_TARIFF_GLOBAL, ) from dsmr_parser.objects import CosemObject, MBusObject @@ -375,10 +334,10 @@ async def test_luxembourg_meter(hass, dsmr_connection_fixture): {"value": Decimal(745.695), "unit": "m3"}, ] ), - LUXEMBOURG_ELECTRICITY_USED_TARIFF_GLOBAL: CosemObject( + ELECTRICITY_IMPORTED_TOTAL: CosemObject( [{"value": Decimal(123.456), "unit": ENERGY_KILO_WATT_HOUR}] ), - LUXEMBOURG_ELECTRICITY_DELIVERED_TARIFF_GLOBAL: CosemObject( + ELECTRICITY_EXPORTED_TOTAL: CosemObject( [{"value": Decimal(654.321), "unit": ENERGY_KILO_WATT_HOUR}] ), } @@ -551,8 +510,8 @@ async def test_swedish_meter(hass, dsmr_connection_fixture): (connection_factory, transport, protocol) = dsmr_connection_fixture from dsmr_parser.obis_references import ( - SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, - SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL, + ELECTRICITY_EXPORTED_TOTAL, + ELECTRICITY_IMPORTED_TOTAL, ) from dsmr_parser.objects import CosemObject @@ -569,10 +528,10 @@ async def test_swedish_meter(hass, dsmr_connection_fixture): } telegram = { - SWEDEN_ELECTRICITY_USED_TARIFF_GLOBAL: CosemObject( + ELECTRICITY_IMPORTED_TOTAL: CosemObject( [{"value": Decimal(123.456), "unit": ENERGY_KILO_WATT_HOUR}] ), - SWEDEN_ELECTRICITY_DELIVERED_TARIFF_GLOBAL: CosemObject( + ELECTRICITY_EXPORTED_TOTAL: CosemObject( [{"value": Decimal(654.321), "unit": ENERGY_KILO_WATT_HOUR}] ), } @@ -617,6 +576,80 @@ async def test_swedish_meter(hass, dsmr_connection_fixture): ) +async def test_easymeter(hass, dsmr_connection_fixture): + """Test if Q3D meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + ELECTRICITY_EXPORTED_TOTAL, + ELECTRICITY_IMPORTED_TOTAL, + ) + from dsmr_parser.objects import CosemObject + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "Q3D", + "precision": 4, + "reconnect_interval": 30, + "serial_id": None, + "serial_id_gas": None, + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + ELECTRICITY_IMPORTED_TOTAL: CosemObject( + [{"value": Decimal(54184.6316), "unit": ENERGY_KILO_WATT_HOUR}] + ), + ELECTRICITY_EXPORTED_TOTAL: CosemObject( + [{"value": Decimal(19981.1069), "unit": ENERGY_KILO_WATT_HOUR}] + ), + } + + mock_entry = MockConfigEntry( + domain="dsmr", + unique_id="/dev/ttyUSB0", + data=entry_data, + options=entry_options, + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to update + await asyncio.sleep(0) + + power_tariff = hass.states.get("sensor.energy_consumption_total") + assert power_tariff.state == "54184.6316" + assert power_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + assert power_tariff.attributes.get(ATTR_ICON) is None + assert ( + power_tariff.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + ) + + power_tariff = hass.states.get("sensor.energy_production_total") + assert power_tariff.state == "19981.1069" + assert ( + power_tariff.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + power_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + ) + + async def test_tcp(hass, dsmr_connection_fixture): """If proper config provided TCP connection should be made.""" (connection_factory, transport, protocol) = dsmr_connection_fixture diff --git a/tests/components/ecobee/test_humidifier.py b/tests/components/ecobee/test_humidifier.py index f8a83e4c9057b1..0af5fd150e3210 100644 --- a/tests/components/ecobee/test_humidifier.py +++ b/tests/components/ecobee/test_humidifier.py @@ -55,7 +55,7 @@ async def test_attributes(hass): async def test_turn_on(hass): - """Test the humidifer can be turned on.""" + """Test the humidifier can be turned on.""" with patch("pyecobee.Ecobee.set_humidifier_mode") as mock_turn_on: await setup_platform(hass, HUMIDIFIER_DOMAIN) @@ -70,7 +70,7 @@ async def test_turn_on(hass): async def test_turn_off(hass): - """Test the humidifer can be turned off.""" + """Test the humidifier can be turned off.""" with patch("pyecobee.Ecobee.set_humidifier_mode") as mock_turn_off: await setup_platform(hass, HUMIDIFIER_DOMAIN) @@ -85,7 +85,7 @@ async def test_turn_off(hass): async def test_set_mode(hass): - """Test the humidifer can change modes.""" + """Test the humidifier can change modes.""" with patch("pyecobee.Ecobee.set_humidifier_mode") as mock_set_mode: await setup_platform(hass, HUMIDIFIER_DOMAIN) @@ -117,7 +117,7 @@ async def test_set_mode(hass): async def test_set_humidity(hass): - """Test the humidifer can set humidity level.""" + """Test the humidifier can set humidity level.""" with patch("pyecobee.Ecobee.set_humidity") as mock_set_humidity: await setup_platform(hass, HUMIDIFIER_DOMAIN) diff --git a/tests/components/ee_brightbox/__init__.py b/tests/components/ee_brightbox/__init__.py deleted file mode 100644 index 03abf6af02a168..00000000000000 --- a/tests/components/ee_brightbox/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the ee_brightbox component.""" diff --git a/tests/components/ee_brightbox/test_device_tracker.py b/tests/components/ee_brightbox/test_device_tracker.py deleted file mode 100644 index afe3897eff9efd..00000000000000 --- a/tests/components/ee_brightbox/test_device_tracker.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Tests for the EE BrightBox device scanner.""" -from datetime import datetime -from unittest.mock import patch - -# Integration is disabled -# from eebrightbox import EEBrightBoxException -import pytest - -from homeassistant.components.device_tracker import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM -from homeassistant.setup import async_setup_component - -# Integration is disabled -pytest.skip("Integration has been disabled in the manifest", allow_module_level=True) - - -def _configure_mock_get_devices(eebrightbox_mock): - eebrightbox_instance = eebrightbox_mock.return_value - eebrightbox_instance.__enter__.return_value = eebrightbox_instance - eebrightbox_instance.get_devices.return_value = [ - { - "mac": "AA:BB:CC:DD:EE:FF", - "ip": "192.168.1.10", - "hostname": "hostnameAA", - "activity_ip": True, - "port": "eth0", - "time_last_active": datetime(2019, 1, 20, 16, 4, 0), - }, - { - "mac": "11:22:33:44:55:66", - "hostname": "hostname11", - "ip": "192.168.1.11", - "activity_ip": True, - "port": "wl0", - "time_last_active": datetime(2019, 1, 20, 11, 9, 0), - }, - { - "mac": "FF:FF:FF:FF:FF:FF", - "hostname": "hostnameFF", - "ip": "192.168.1.12", - "activity_ip": False, - "port": "wl1", - "time_last_active": datetime(2019, 1, 15, 16, 9, 0), - }, - ] - - -def _configure_mock_failed_config_check(eebrightbox_mock): - eebrightbox_instance = eebrightbox_mock.return_value - # Integration is disabled - eebrightbox_instance.__enter__.side_effect = EEBrightBoxException( # noqa: F821 - "Failed to connect to the router" - ) - - -@pytest.fixture(autouse=True) -def mock_dev_track(mock_device_tracker_conf): - """Mock device tracker config loading.""" - pass - - -@patch("homeassistant.components.ee_brightbox.device_tracker.EEBrightBox") -async def test_missing_credentials(eebrightbox_mock, hass): - """Test missing credentials.""" - _configure_mock_get_devices(eebrightbox_mock) - - result = await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "ee_brightbox"}} - ) - - assert result - - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.hostnameaa") is None - assert hass.states.get("device_tracker.hostname11") is None - assert hass.states.get("device_tracker.hostnameff") is None - - -@patch("homeassistant.components.ee_brightbox.device_tracker.EEBrightBox") -async def test_invalid_credentials(eebrightbox_mock, hass): - """Test invalid credentials.""" - _configure_mock_failed_config_check(eebrightbox_mock) - - result = await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_PLATFORM: "ee_brightbox", CONF_PASSWORD: "test_password"}}, - ) - - assert result - - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.hostnameaa") is None - assert hass.states.get("device_tracker.hostname11") is None - assert hass.states.get("device_tracker.hostnameff") is None - - -@patch("homeassistant.components.ee_brightbox.device_tracker.EEBrightBox") -async def test_get_devices(eebrightbox_mock, hass): - """Test valid configuration.""" - _configure_mock_get_devices(eebrightbox_mock) - - result = await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_PLATFORM: "ee_brightbox", CONF_PASSWORD: "test_password"}}, - ) - - assert result - - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.hostnameaa") is not None - assert hass.states.get("device_tracker.hostname11") is not None - assert hass.states.get("device_tracker.hostnameff") is None - - state = hass.states.get("device_tracker.hostnameaa") - assert state.attributes["mac"] == "AA:BB:CC:DD:EE:FF" - assert state.attributes["ip"] == "192.168.1.10" - assert state.attributes["port"] == "eth0" - assert state.attributes["last_active"] == datetime(2019, 1, 20, 16, 4, 0) diff --git a/tests/components/efergy/__init__.py b/tests/components/efergy/__init__.py index c4f099df82246f..4c26e25e5f48df 100644 --- a/tests/components/efergy/__init__.py +++ b/tests/components/efergy/__init__.py @@ -17,7 +17,6 @@ CONF_DATA = {CONF_API_KEY: TOKEN} HID = "12345678901234567890123456789012" -IMPORT_DATA = {"platform": "efergy", "app_token": TOKEN} BASE_URL = "https://engage.efergy.com/mobile_proxy/" diff --git a/tests/components/efergy/test_config_flow.py b/tests/components/efergy/test_config_flow.py index d49b89984e1ee0..89f3b266b7cd13 100644 --- a/tests/components/efergy/test_config_flow.py +++ b/tests/components/efergy/test_config_flow.py @@ -4,7 +4,7 @@ from pyefergy import exceptions from homeassistant.components.efergy.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( @@ -13,14 +13,7 @@ RESULT_TYPE_FORM, ) -from . import ( - CONF_DATA, - HID, - IMPORT_DATA, - _patch_efergy, - _patch_efergy_status, - create_entry, -) +from . import CONF_DATA, HID, _patch_efergy, _patch_efergy_status, create_entry def _patch_setup(): @@ -83,30 +76,6 @@ async def test_flow_user_unknown(hass: HomeAssistant): assert result["errors"]["base"] == "unknown" -async def test_flow_import(hass: HomeAssistant): - """Test import step.""" - with _patch_efergy(), _patch_setup(): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA - ) - - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"] == CONF_DATA - assert result["result"].unique_id == HID - - -async def test_flow_import_already_configured(hass: HomeAssistant): - """Test import step already configured.""" - create_entry(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA - ) - - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - async def test_flow_reauth(hass: HomeAssistant): """Test reauth step.""" entry = create_entry(hass) diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index 4f6c7f532093d3..4df383a5487d93 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -5,15 +5,12 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_MONETARY, - DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, POWER_WATT, STATE_UNAVAILABLE, @@ -40,9 +37,9 @@ async def test_sensor_readings( state = hass.states.get("sensor.power_usage") assert state.state == "1580" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get("sensor.energy_budget") assert state.state == "ok" assert state.attributes.get(ATTR_DEVICE_CLASS) is None @@ -50,54 +47,54 @@ async def test_sensor_readings( assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get("sensor.daily_consumption") assert state.state == "38.21" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING state = hass.states.get("sensor.weekly_consumption") assert state.state == "267.47" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING state = hass.states.get("sensor.monthly_consumption") assert state.state == "1069.88" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING state = hass.states.get("sensor.yearly_consumption") assert state.state == "13373.50" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING state = hass.states.get("sensor.daily_energy_cost") assert state.state == "5.27" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING state = hass.states.get("sensor.weekly_energy_cost") assert state.state == "36.89" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING state = hass.states.get("sensor.monthly_energy_cost") assert state.state == "147.56" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING state = hass.states.get("sensor.yearly_energy_cost") assert state.state == "1844.50" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING entity = ent_reg.async_get("sensor.power_usage_728386") - assert entity.disabled_by == er.DISABLED_INTEGRATION + assert entity.disabled_by is er.RegistryEntryDisabler.INTEGRATION ent_reg.async_update_entity(entity.entity_id, **{"disabled_by": None}) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() state = hass.states.get("sensor.power_usage_728386") assert state.state == "1628" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_multi_sensor_readings( @@ -109,19 +106,19 @@ async def test_multi_sensor_readings( await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN, MULTI_SENSOR_TOKEN) state = hass.states.get("sensor.power_usage_728386") assert state.state == "218" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get("sensor.power_usage_0") assert state.state == "1808" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get("sensor.power_usage_728387") assert state.state == "312" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_failed_update_and_reconnection( diff --git a/tests/components/elgato/__init__.py b/tests/components/elgato/__init__.py index 12df481d182651..7d69fa68f736d2 100644 --- a/tests/components/elgato/__init__.py +++ b/tests/components/elgato/__init__.py @@ -1,73 +1 @@ """Tests for the Elgato Key Light integration.""" - -from homeassistant.components.elgato.const import CONF_SERIAL_NUMBER, DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT, CONTENT_TYPE_JSON -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry, load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker - - -async def init_integration( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - skip_setup: bool = False, - color: bool = False, - mode_color: bool = False, -) -> MockConfigEntry: - """Set up the Elgato Key Light integration in Home Assistant.""" - aioclient_mock.get( - "http://127.0.0.1:9123/elgato/accessory-info", - text=load_fixture("elgato/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - aioclient_mock.get( - "http://127.0.0.2:9123/elgato/accessory-info", - text=load_fixture("elgato/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - settings = "elgato/settings.json" - if color: - settings = "elgato/settings-color.json" - - aioclient_mock.get( - "http://127.0.0.1:9123/elgato/lights/settings", - text=load_fixture(settings), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - state = "elgato/state.json" - if mode_color: - state = "elgato/state-color.json" - - aioclient_mock.get( - "http://127.0.0.1:9123/elgato/lights", - text=load_fixture(state), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - aioclient_mock.put( - "http://127.0.0.1:9123/elgato/lights", - text=load_fixture("elgato/state.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="CN11A1A00001", - data={ - CONF_HOST: "127.0.0.1", - CONF_PORT: 9123, - CONF_SERIAL_NUMBER: "CN11A1A00001", - }, - ) - - entry.add_to_hass(hass) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index fe86f26b535f3d..efae0739c7bc74 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -1,2 +1,80 @@ -"""elgato conftest.""" +"""Fixtures for Elgato integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from elgato import Info, Settings, State +import pytest + +from homeassistant.components.elgato.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture from tests.components.light.conftest import mock_light_profiles # noqa: F401 + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="CN11A1A00001", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_PORT: 9123, + }, + unique_id="CN11A1A00001", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.elgato.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_elgato_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked Elgato client.""" + with patch( + "homeassistant.components.elgato.config_flow.Elgato", autospec=True + ) as elgato_mock: + elgato = elgato_mock.return_value + elgato.info.return_value = Info.parse_raw(load_fixture("info.json", DOMAIN)) + yield elgato + + +@pytest.fixture +def mock_elgato(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked Elgato client.""" + variant = {"state": "temperature", "settings": "temperature"} + if hasattr(request, "param") and request.param: + variant = request.param + + with patch("homeassistant.components.elgato.Elgato", autospec=True) as elgato_mock: + elgato = elgato_mock.return_value + elgato.info.return_value = Info.parse_raw(load_fixture("info.json", DOMAIN)) + elgato.state.return_value = State.parse_raw( + load_fixture(f"state-{variant['state']}.json", DOMAIN) + ) + elgato.settings.return_value = Settings.parse_raw( + load_fixture(f"settings-{variant['settings']}.json", DOMAIN) + ) + yield elgato + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_elgato: MagicMock +) -> MockConfigEntry: + """Set up the Elgato integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/elgato/fixtures/settings.json b/tests/components/elgato/fixtures/settings-temperature.json similarity index 100% rename from tests/components/elgato/fixtures/settings.json rename to tests/components/elgato/fixtures/settings-temperature.json diff --git a/tests/components/elgato/fixtures/state-color.json b/tests/components/elgato/fixtures/state-color.json index b49a6f6dd80d72..d9b2567928dd87 100644 --- a/tests/components/elgato/fixtures/state-color.json +++ b/tests/components/elgato/fixtures/state-color.json @@ -1,11 +1,6 @@ { - "numberOfLights": 1, - "lights": [ - { - "on": 1, - "hue": 358.0, - "saturation": 6.0, - "brightness": 50 - } - ] + "on": 1, + "hue": 358.0, + "saturation": 6.0, + "brightness": 50 } diff --git a/tests/components/elgato/fixtures/state-temperature.json b/tests/components/elgato/fixtures/state-temperature.json new file mode 100644 index 00000000000000..5b3d7690d85e75 --- /dev/null +++ b/tests/components/elgato/fixtures/state-temperature.json @@ -0,0 +1,5 @@ +{ + "on": 1, + "brightness": 21, + "temperature": 297 +} diff --git a/tests/components/elgato/fixtures/state.json b/tests/components/elgato/fixtures/state.json deleted file mode 100644 index f6180e14238cc8..00000000000000 --- a/tests/components/elgato/fixtures/state.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "numberOfLights": 1, - "lights": [ - { - "on": 1, - "brightness": 21, - "temperature": 297 - } - ] -} diff --git a/tests/components/elgato/test_button.py b/tests/components/elgato/test_button.py index 21211596d0c7a0..6f182ee191c92b 100644 --- a/tests/components/elgato/test_button.py +++ b/tests/components/elgato/test_button.py @@ -1,30 +1,27 @@ """Tests for the Elgato Light button platform.""" -from unittest.mock import patch +from unittest.mock import MagicMock from elgato import ElgatoError import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_ICON, - ENTITY_CATEGORY_CONFIG, - STATE_UNKNOWN, -) +from homeassistant.components.elgato.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory -from tests.components.elgato import init_integration -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry @pytest.mark.freeze_time("2021-11-13 11:48:00") async def test_button_identify( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_elgato: MagicMock, ) -> None: """Test the Elgato identify button.""" - await init_integration(hass, aioclient_mock) - + device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) state = hass.states.get("button.identify") @@ -35,20 +32,32 @@ async def test_button_identify( entry = entity_registry.async_get("button.identify") assert entry assert entry.unique_id == "CN11A1A00001_identify" - assert entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entry.entity_category == EntityCategory.CONFIG + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.configuration_url is None + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + } + assert device_entry.entry_type is None + assert device_entry.identifiers == {(DOMAIN, "CN11A1A00001")} + assert device_entry.manufacturer == "Elgato" + assert device_entry.model == "Elgato Key Light" + assert device_entry.name == "Frenck" + assert device_entry.sw_version == "1.0.3 (192)" + assert device_entry.hw_version == "53" - with patch( - "homeassistant.components.elgato.light.Elgato.identify" - ) as mock_identify: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.identify"}, - blocking=True, - ) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.identify"}, + blocking=True, + ) - assert len(mock_identify.mock_calls) == 1 - mock_identify.assert_called_with() + assert len(mock_elgato.identify.mock_calls) == 1 + mock_elgato.identify.assert_called_with() state = hass.states.get("button.identify") assert state @@ -56,22 +65,20 @@ async def test_button_identify( async def test_button_identify_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_elgato: MagicMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test an error occurs with the Elgato identify button.""" - await init_integration(hass, aioclient_mock) - - with patch( - "homeassistant.components.elgato.light.Elgato.identify", - side_effect=ElgatoError, - ) as mock_identify: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.identify"}, - blocking=True, - ) - + mock_elgato.identify.side_effect = ElgatoError + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.identify"}, + blocking=True, + ) await hass.async_block_till_done() - assert len(mock_identify.mock_calls) == 1 + + assert len(mock_elgato.identify.mock_calls) == 1 assert "An error occurred while identifying the Elgato Light" in caplog.text diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 2ea2dab6acf578..dffd59cedcc459 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -1,134 +1,126 @@ """Tests for the Elgato Key Light config flow.""" -import aiohttp +from unittest.mock import AsyncMock, MagicMock + +from elgato import ElgatoConnectionError -from homeassistant import data_entry_flow from homeassistant.components import zeroconf -from homeassistant.components.elgato.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.components.elgato.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE, CONTENT_TYPE_JSON +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) -from . import init_integration - -from tests.common import load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry async def test_full_user_flow_implementation( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_elgato_config_flow: MagicMock, + mock_setup_entry: AsyncMock, ) -> None: """Test the full manual user flow from start to finish.""" - aioclient_mock.get( - "http://127.0.0.1:9123/elgato/accessory-info", - text=load_fixture("elgato/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - # Start a discovered configuration flow, to guarantee a user flow doesn't abort - await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - hostname="example.local.", - name="mock_name", - port=9123, - properties={}, - type="mock_type", - ), - ) - result = await hass.config_entries.flow.async_init( DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, + context={"source": SOURCE_USER}, ) - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result - result = await hass.config_entries.flow.async_configure( + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 9123} ) - assert result["data"][CONF_HOST] == "127.0.0.1" - assert result["data"][CONF_PORT] == 9123 - assert result["data"][CONF_SERIAL_NUMBER] == "CN11A1A00001" - assert result["title"] == "CN11A1A00001" - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "CN11A1A00001" + assert result2.get("data") == { + CONF_HOST: "127.0.0.1", + CONF_MAC: None, + CONF_PORT: 9123, + } + assert "result" in result2 + assert result2["result"].unique_id == "CN11A1A00001" - entries = hass.config_entries.async_entries(DOMAIN) - assert entries[0].unique_id == "CN11A1A00001" + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_elgato_config_flow.info.mock_calls) == 1 async def test_full_zeroconf_flow_implementation( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_elgato_config_flow: MagicMock, + mock_setup_entry: AsyncMock, ) -> None: """Test the zeroconf flow from start to finish.""" - aioclient_mock.get( - "http://127.0.0.1:9123/elgato/accessory-info", - text=load_fixture("elgato/info.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, - context={CONF_SOURCE: SOURCE_ZEROCONF}, + context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( host="127.0.0.1", hostname="example.local.", name="mock_name", port=9123, - properties={}, + properties={"id": "AA:BB:CC:DD:EE:FF"}, type="mock_type", ), ) - assert result["description_placeholders"] == {CONF_SERIAL_NUMBER: "CN11A1A00001"} - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result.get("description_placeholders") == {"serial_number": "CN11A1A00001"} + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("type") == RESULT_TYPE_FORM + assert "flow_id" in result progress = hass.config_entries.flow.async_progress() assert len(progress) == 1 - assert progress[0]["flow_id"] == result["flow_id"] - assert progress[0]["context"]["confirm_only"] is True + assert progress[0].get("flow_id") == result["flow_id"] + assert "context" in progress[0] + assert progress[0]["context"].get("confirm_only") is True - result = await hass.config_entries.flow.async_configure( + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["data"][CONF_HOST] == "127.0.0.1" - assert result["data"][CONF_PORT] == 9123 - assert result["data"][CONF_SERIAL_NUMBER] == "CN11A1A00001" - assert result["title"] == "CN11A1A00001" - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "CN11A1A00001" + assert result2.get("data") == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_PORT: 9123, + } + assert "result" in result2 + assert result2["result"].unique_id == "CN11A1A00001" + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_elgato_config_flow.info.mock_calls) == 1 async def test_connection_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_elgato_config_flow: MagicMock, ) -> None: """Test we show user form on Elgato Key Light connection error.""" - aioclient_mock.get( - "http://127.0.0.1/elgato/accessory-info", exc=aiohttp.ClientError - ) - + mock_elgato_config_flow.info.side_effect = ElgatoConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, + context={"source": SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 9123}, ) - assert result["errors"] == {"base": "cannot_connect"} - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} + assert result.get("step_id") == "user" async def test_zeroconf_connection_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_elgato_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow on Elgato Key Light connection error.""" - aioclient_mock.get( - "http://127.0.0.1/elgato/accessory-info", exc=aiohttp.ClientError - ) - + mock_elgato_config_flow.info.side_effect = ElgatoConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -142,31 +134,34 @@ async def test_zeroconf_connection_error( ), ) - assert result["reason"] == "cannot_connect" - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "cannot_connect" + assert result.get("type") == RESULT_TYPE_ABORT async def test_user_device_exists_abort( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_elgato_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test we abort zeroconf flow if Elgato Key Light device already configured.""" - await init_integration(hass, aioclient_mock) - + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, + context={"source": SOURCE_USER}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 9123}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" async def test_zeroconf_device_exists_abort( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_elgato_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test we abort zeroconf flow if Elgato Key Light device already configured.""" - await init_integration(hass, aioclient_mock) - + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, @@ -180,9 +175,13 @@ async def test_zeroconf_device_exists_abort( ), ) - assert result["reason"] == "already_configured" - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].data[CONF_HOST] == "127.0.0.1" + # Check the host updates on discovery result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, @@ -196,8 +195,8 @@ async def test_zeroconf_device_exists_abort( ), ) - assert result["reason"] == "already_configured" - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" entries = hass.config_entries.async_entries(DOMAIN) assert entries[0].data[CONF_HOST] == "127.0.0.2" diff --git a/tests/components/elgato/test_diagnostics.py b/tests/components/elgato/test_diagnostics.py new file mode 100644 index 00000000000000..4ea543a487bf6e --- /dev/null +++ b/tests/components/elgato/test_diagnostics.py @@ -0,0 +1,35 @@ +"""Tests for the diagnostics data provided by the Elgato integration.""" +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "info": { + "display_name": "Frenck", + "firmware_build_number": 192, + "firmware_version": "1.0.3", + "hardware_board_type": 53, + "product_name": "Elgato Key Light", + "serial_number": "CN11A1A00001", + "features": ["lights"], + }, + "state": { + "on": True, + "brightness": 21, + "hue": None, + "saturation": None, + "temperature": 297, + }, + } diff --git a/tests/components/elgato/test_init.py b/tests/components/elgato/test_init.py index f764ecdba80a29..1b566ef8ab2a3b 100644 --- a/tests/components/elgato/test_init.py +++ b/tests/components/elgato/test_init.py @@ -1,33 +1,46 @@ """Tests for the Elgato Key Light integration.""" -import aiohttp +from unittest.mock import MagicMock + +from elgato import ElgatoConnectionError from homeassistant.components.elgato.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.components.elgato import init_integration -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry -async def test_config_entry_not_ready( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_elgato: MagicMock, ) -> None: - """Test the Elgato Key Light configuration entry not ready.""" - aioclient_mock.get( - "http://127.0.0.1:9123/elgato/accessory-info", exc=aiohttp.ClientError - ) + """Test the Elgato configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_elgato.info.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() - entry = await init_integration(hass, aioclient_mock) - assert entry.state is ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -async def test_unload_config_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_elgato: MagicMock, ) -> None: - """Test the Elgato Key Light configuration entry unloading.""" - entry = await init_integration(hass, aioclient_mock) - assert hass.data[DOMAIN] + """Test the Elgato configuration entry not ready.""" + mock_elgato.state.side_effect = ElgatoConnectionError - await hass.config_entries.async_unload(entry.entry_id) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) + + assert len(mock_elgato.state.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index c85c71aa723fca..63701721a1cc6c 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -1,5 +1,5 @@ """Tests for the Elgato Key Light light platform.""" -from unittest.mock import patch +from unittest.mock import MagicMock from elgato import ElgatoError import pytest @@ -25,19 +25,18 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import mock_coro -from tests.components.elgato import init_integration -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry async def test_light_state_temperature( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_elgato: MagicMock, ) -> None: """Test the creation and values of the Elgato Lights in temperature mode.""" - await init_integration(hass, aioclient_mock) - + device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) # First segment of the strip @@ -56,13 +55,31 @@ async def test_light_state_temperature( assert entry assert entry.unique_id == "CN11A1A00001" - + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.configuration_url is None + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + } + assert device_entry.entry_type is None + assert device_entry.identifiers == {(DOMAIN, "CN11A1A00001")} + assert device_entry.manufacturer == "Elgato" + assert device_entry.model == "Elgato Key Light" + assert device_entry.name == "Frenck" + assert device_entry.sw_version == "1.0.3 (192)" + assert device_entry.hw_version == "53" + + +@pytest.mark.parametrize( + "mock_elgato", [{"settings": "color", "state": "color"}], indirect=True +) async def test_light_state_color( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_elgato: MagicMock, ) -> None: """Test the creation and values of the Elgato Lights in temperature mode.""" - await init_integration(hass, aioclient_mock, color=True, mode_color=True) - entity_registry = er.async_get(hass) # First segment of the strip @@ -85,159 +102,135 @@ async def test_light_state_color( assert entry.unique_id == "CN11A1A00001" +@pytest.mark.parametrize( + "mock_elgato", [{"settings": "color", "state": "temperature"}], indirect=True +) async def test_light_change_state_temperature( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_elgato: MagicMock, ) -> None: """Test the change of state of a Elgato Key Light device.""" - await init_integration(hass, aioclient_mock, color=True, mode_color=False) - state = hass.states.get("light.frenck") assert state assert state.state == STATE_ON - with patch( - "homeassistant.components.elgato.light.Elgato.light", - return_value=mock_coro(), - ) as mock_light: - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: "light.frenck", - ATTR_BRIGHTNESS: 255, - ATTR_COLOR_TEMP: 100, - }, - blocking=True, - ) - await hass.async_block_till_done() - assert len(mock_light.mock_calls) == 1 - mock_light.assert_called_with( - on=True, brightness=100, temperature=100, hue=None, saturation=None - ) - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: "light.frenck", - ATTR_BRIGHTNESS: 255, - }, - blocking=True, - ) - await hass.async_block_till_done() - assert len(mock_light.mock_calls) == 2 - mock_light.assert_called_with( - on=True, brightness=100, temperature=297, hue=None, saturation=None - ) - - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.frenck"}, - blocking=True, - ) - await hass.async_block_till_done() - assert len(mock_light.mock_calls) == 3 - mock_light.assert_called_with(on=False) - - -async def test_light_change_state_color( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test the color state state of a Elgato Light device.""" - await init_integration(hass, aioclient_mock, color=True) - - state = hass.states.get("light.frenck") - assert state - assert state.state == STATE_ON - - with patch( - "homeassistant.components.elgato.light.Elgato.light", - return_value=mock_coro(), - ) as mock_light: - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: "light.frenck", - ATTR_BRIGHTNESS: 255, - ATTR_HS_COLOR: (10.1, 20.2), - }, - blocking=True, - ) - await hass.async_block_till_done() - assert len(mock_light.mock_calls) == 1 - mock_light.assert_called_with( - on=True, brightness=100, temperature=None, hue=10.1, saturation=20.2 - ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.frenck", + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_TEMP: 100, + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mock_elgato.light.mock_calls) == 1 + mock_elgato.light.assert_called_with( + on=True, brightness=100, temperature=100, hue=None, saturation=None + ) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.frenck", + ATTR_BRIGHTNESS: 255, + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mock_elgato.light.mock_calls) == 2 + mock_elgato.light.assert_called_with( + on=True, brightness=100, temperature=297, hue=None, saturation=None + ) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.frenck"}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mock_elgato.light.mock_calls) == 3 + mock_elgato.light.assert_called_with(on=False) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.frenck", + ATTR_BRIGHTNESS: 255, + ATTR_HS_COLOR: (10.1, 20.2), + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mock_elgato.light.mock_calls) == 4 + mock_elgato.light.assert_called_with( + on=True, brightness=100, temperature=None, hue=10.1, saturation=20.2 + ) @pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) async def test_light_unavailable( - service: str, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_elgato: MagicMock, + service: str, ) -> None: """Test error/unavailable handling of an Elgato Light.""" - await init_integration(hass, aioclient_mock) - with patch( - "homeassistant.components.elgato.light.Elgato.light", - side_effect=ElgatoError, - ), patch( - "homeassistant.components.elgato.light.Elgato.state", - side_effect=ElgatoError, - ): - await hass.services.async_call( - LIGHT_DOMAIN, - service, - {ATTR_ENTITY_ID: "light.frenck"}, - blocking=True, - ) - await hass.async_block_till_done() - state = hass.states.get("light.frenck") - assert state.state == STATE_UNAVAILABLE + mock_elgato.state.side_effect = ElgatoError + mock_elgato.light.side_effect = ElgatoError + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: "light.frenck"}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("light.frenck") + assert state + assert state.state == STATE_UNAVAILABLE async def test_light_identify( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_elgato: MagicMock, ) -> None: """Test identifying an Elgato Light.""" - await init_integration(hass, aioclient_mock) - - with patch( - "homeassistant.components.elgato.light.Elgato.identify", - return_value=mock_coro(), - ) as mock_identify: - await hass.services.async_call( - DOMAIN, - SERVICE_IDENTIFY, - { - ATTR_ENTITY_ID: "light.frenck", - }, - blocking=True, - ) - await hass.async_block_till_done() - assert len(mock_identify.mock_calls) == 1 - mock_identify.assert_called_with() + await hass.services.async_call( + DOMAIN, + SERVICE_IDENTIFY, + { + ATTR_ENTITY_ID: "light.frenck", + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mock_elgato.identify.mock_calls) == 1 + mock_elgato.identify.assert_called_with() async def test_light_identify_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_elgato: MagicMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test error occurred during identifying an Elgato Light.""" - await init_integration(hass, aioclient_mock) - - with patch( - "homeassistant.components.elgato.light.Elgato.identify", - side_effect=ElgatoError, - ) as mock_identify: - await hass.services.async_call( - DOMAIN, - SERVICE_IDENTIFY, - { - ATTR_ENTITY_ID: "light.frenck", - }, - blocking=True, - ) - await hass.async_block_till_done() - assert len(mock_identify.mock_calls) == 1 - + mock_elgato.identify.side_effect = ElgatoError + await hass.services.async_call( + DOMAIN, + SERVICE_IDENTIFY, + { + ATTR_ENTITY_ID: "light.frenck", + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mock_elgato.identify.mock_calls) == 1 assert "An error occurred while identifying the Elgato Light" in caplog.text diff --git a/tests/components/elmax/__init__.py b/tests/components/elmax/__init__.py new file mode 100644 index 00000000000000..cf1bce356c7fbd --- /dev/null +++ b/tests/components/elmax/__init__.py @@ -0,0 +1,15 @@ +"""Tests for the Elmax component.""" + +MOCK_USER_JWT = ( + "JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJfaWQiOiIxYjExYmIxMWJiYjExMTExYjFiMTFiMWIiLCJlbWFpbCI6InRoaXMuaXNAdGVzdC5jb20iLCJyb2xlIjoid" + "XNlciIsImlhdCI6MTYzNjE5OTk5OCwiZXhwIjoxNjM2MjM1OTk4fQ.1C7lXuKyX1HEGOfMxNwxJ2n-CjoW4rwvNRITQxLI" + "Cv0" +) +MOCK_USERNAME = "this.is@test.com" +MOCK_USER_ROLE = "user" +MOCK_USER_ID = "1b11bb11bbb11111b1b11b1b" +MOCK_PANEL_ID = "2db3dae30b9102de4d078706f94d0708" +MOCK_PANEL_NAME = "Test Panel Name" +MOCK_PANEL_PIN = "000000" +MOCK_PASSWORD = "password" diff --git a/tests/components/elmax/conftest.py b/tests/components/elmax/conftest.py new file mode 100644 index 00000000000000..17ad58b6292c35 --- /dev/null +++ b/tests/components/elmax/conftest.py @@ -0,0 +1,42 @@ +"""Configuration for Elmax tests.""" +import json + +from elmax_api.constants import ( + BASE_URL, + ENDPOINT_DEVICES, + ENDPOINT_DISCOVERY, + ENDPOINT_LOGIN, +) +from httpx import Response +import pytest +import respx + +from tests.common import load_fixture +from tests.components.elmax import MOCK_PANEL_ID, MOCK_PANEL_PIN + + +@pytest.fixture(autouse=True) +def httpx_mock_fixture(requests_mock): + """Configure httpx fixture.""" + with respx.mock(base_url=BASE_URL, assert_all_called=False) as respx_mock: + # Mock Login POST. + login_route = respx_mock.post(f"/{ENDPOINT_LOGIN}", name="login") + login_route.return_value = Response( + 200, json=json.loads(load_fixture("login.json", "elmax")) + ) + + # Mock Device list GET. + list_devices_route = respx_mock.get(f"/{ENDPOINT_DEVICES}", name="list_devices") + list_devices_route.return_value = Response( + 200, json=json.loads(load_fixture("list_devices.json", "elmax")) + ) + + # Mock Panel GET. + get_panel_route = respx_mock.get( + f"/{ENDPOINT_DISCOVERY}/{MOCK_PANEL_ID}/{MOCK_PANEL_PIN}", name="get_panel" + ) + get_panel_route.return_value = Response( + 200, json=json.loads(load_fixture("get_panel.json", "elmax")) + ) + + yield respx_mock diff --git a/tests/components/elmax/fixtures/get_panel.json b/tests/components/elmax/fixtures/get_panel.json new file mode 100644 index 00000000000000..04fcfd48605b19 --- /dev/null +++ b/tests/components/elmax/fixtures/get_panel.json @@ -0,0 +1,126 @@ +{ + "release": 11.7, + "tappFeature": true, + "sceneFeature": true, + "zone": [ + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-zona-0", + "visibile": true, + "indice": 0, + "nome": "Feed zone 0", + "aperta": false, + "esclusa": false + }, + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-zona-1", + "visibile": true, + "indice": 1, + "nome": "Feed Zone 1", + "aperta": false, + "esclusa": false + }, + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-zona-2", + "visibile": true, + "indice": 2, + "nome": "Feed Zone 2", + "aperta": false, + "esclusa": false + } + ], + "uscite": [ + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-uscita-0", + "visibile": true, + "indice": 0, + "nome": "Actuator 0", + "aperta": false + }, + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-uscita-1", + "visibile": true, + "indice": 1, + "nome": "Actuator 1", + "aperta": false + }, + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-uscita-2", + "visibile": true, + "indice": 2, + "nome": "Actuator 2", + "aperta": true + } + ], + "aree": [ + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-area-0", + "visibile": true, + "indice": 0, + "nome": "AREA 0", + "statiDisponibili": [0, 1, 2, 3, 4], + "statiSessioneDisponibili": [0, 1, 2, 3], + "stato": 0, + "statoSessione": 0 + }, + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-area-1", + "visibile": true, + "indice": 1, + "nome": "AREA 1", + "statiDisponibili": [0, 1, 2, 3, 4], + "statiSessioneDisponibili": [0, 1, 2, 3], + "stato": 0, + "statoSessione": 0 + }, + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-area-2", + "visibile": false, + "indice": 2, + "nome": "AREA 2", + "statiDisponibili": [0, 1, 2, 3, 4], + "statiSessioneDisponibili": [0, 1, 2, 3], + "stato": 0, + "statoSessione": 0 + } + ], + "tapparelle": [ + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-tapparella-0", + "visibile": true, + "indice": 0, + "stato": "stop", + "posizione": 100, + "nome": "Cover 0" + } + ], + "gruppi": [ + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-gruppo-0", + "visibile": true, + "indice": 0, + "nome": "Group 0" + }, + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-gruppo-1", + "visibile": false, + "indice": 1, + "nome": "Group 1" + } + ], + "scenari": [ + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-scenario-0", + "visibile": true, + "indice": 0, + "nome": "Automation 0" + }, + { + "endpointId": "2db3dae30b9102de4d078706f94d0708-scenario-2", + "visibile": true, + "indice": 2, + "nome": "Automation 2" + } + ], + "utente": "this.is@test.com", + "centrale": "2db3dae30b9102de4d078706f94d0708" +} \ No newline at end of file diff --git a/tests/components/elmax/fixtures/list_devices.json b/tests/components/elmax/fixtures/list_devices.json new file mode 100644 index 00000000000000..19cb1c44ed98d2 --- /dev/null +++ b/tests/components/elmax/fixtures/list_devices.json @@ -0,0 +1,11 @@ +[ + { + "centrale_online": true, + "hash": "2db3dae30b9102de4d078706f94d0708", + "username": [{"name": "this.is@test.com", "label": "Test Panel Name"}] + },{ + "centrale_online": true, + "hash": "d8e8fca2dc0f896fd7cb4cb0031ba249", + "username": [{"name": "this.is@test.com", "label": "Test Panel Name"}] + } +] \ No newline at end of file diff --git a/tests/components/elmax/fixtures/login.json b/tests/components/elmax/fixtures/login.json new file mode 100644 index 00000000000000..59f4aba559d070 --- /dev/null +++ b/tests/components/elmax/fixtures/login.json @@ -0,0 +1,8 @@ +{ + "token": "JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiIxYjExYmIxMWJiYjExMTExYjFiMTFiMWIiLCJlbWFpbCI6InRoaXMuaXNAdGVzdC5jb20iLCJyb2xlIjoidXNlciIsImlhdCI6MTYzNjE5OTk5OCwiZXhwIjoxNjM2MjM1OTk4fQ.1C7lXuKyX1HEGOfMxNwxJ2n-CjoW4rwvNRITQxLICv0", + "user": { + "_id": "1b11bb11bbb11111b1b11b1b", + "email": "this.is@test.com", + "role": "user" + } +} \ No newline at end of file diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py new file mode 100644 index 00000000000000..5b8d42799e9054 --- /dev/null +++ b/tests/components/elmax/test_config_flow.py @@ -0,0 +1,404 @@ +"""Tests for the Elmax config flow.""" +from unittest.mock import patch + +from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.elmax.const import ( + CONF_ELMAX_PANEL_ID, + CONF_ELMAX_PANEL_NAME, + CONF_ELMAX_PANEL_PIN, + CONF_ELMAX_PASSWORD, + CONF_ELMAX_USERNAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_REAUTH + +from tests.common import MockConfigEntry +from tests.components.elmax import ( + MOCK_PANEL_ID, + MOCK_PANEL_NAME, + MOCK_PANEL_PIN, + MOCK_PASSWORD, + MOCK_USERNAME, +) + +CONF_POLLING = "polling" + + +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": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_standard_setup(hass): + """Test the standard setup case.""" + # Setup once. + show_form_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.elmax.async_setup_entry", + return_value=True, + ): + login_result = await hass.config_entries.flow.async_configure( + show_form_result["flow_id"], + { + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + result = await hass.config_entries.flow.async_configure( + login_result["flow_id"], + { + CONF_ELMAX_PANEL_NAME: MOCK_PANEL_NAME, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + }, + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_one_config_allowed(hass): + """Test that only one Elmax configuration is allowed for each panel.""" + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + }, + unique_id=MOCK_PANEL_ID, + ).add_to_hass(hass) + + # Attempt to add another instance of the integration for the very same panel, it must fail. + show_form_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + login_result = await hass.config_entries.flow.async_configure( + show_form_result["flow_id"], + { + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + result = await hass.config_entries.flow.async_configure( + login_result["flow_id"], + { + CONF_ELMAX_PANEL_NAME: MOCK_PANEL_NAME, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + }, + ) + 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.""" + with patch( + "elmax_api.http.Elmax.login", + side_effect=ElmaxBadLoginError(), + ): + show_form_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + login_result = await hass.config_entries.flow.async_configure( + show_form_result["flow_id"], + { + CONF_ELMAX_USERNAME: "wrong_user_name@email.com", + CONF_ELMAX_PASSWORD: "incorrect_password", + }, + ) + assert login_result["step_id"] == "user" + assert login_result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert login_result["errors"] == {"base": "invalid_auth"} + + +async def test_connection_error(hass): + """Test other than invalid credentials throws an error.""" + with patch( + "elmax_api.http.Elmax.login", + side_effect=ElmaxNetworkError(), + ): + show_form_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + login_result = await hass.config_entries.flow.async_configure( + show_form_result["flow_id"], + { + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + assert login_result["step_id"] == "user" + assert login_result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert login_result["errors"] == {"base": "network_error"} + + +async def test_unhandled_error(hass): + """Test unhandled exceptions.""" + with patch( + "elmax_api.http.Elmax.get_panel_status", + side_effect=Exception(), + ): + show_form_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + login_result = await hass.config_entries.flow.async_configure( + show_form_result["flow_id"], + { + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + result = await hass.config_entries.flow.async_configure( + login_result["flow_id"], + { + CONF_ELMAX_PANEL_NAME: MOCK_PANEL_NAME, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + }, + ) + assert result["step_id"] == "panels" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_invalid_pin(hass): + """Test error is thrown when a wrong pin is used to pair a panel.""" + # Simulate bad pin response. + with patch( + "elmax_api.http.Elmax.get_panel_status", + side_effect=ElmaxBadPinError(), + ): + show_form_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + login_result = await hass.config_entries.flow.async_configure( + show_form_result["flow_id"], + { + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + result = await hass.config_entries.flow.async_configure( + login_result["flow_id"], + { + CONF_ELMAX_PANEL_NAME: MOCK_PANEL_NAME, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + }, + ) + assert result["step_id"] == "panels" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_pin"} + + +async def test_no_online_panel(hass): + """Test no-online panel is available.""" + # Simulate low-level api returns no panels. + with patch( + "elmax_api.http.Elmax.list_control_panels", + return_value=[], + ): + show_form_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + login_result = await hass.config_entries.flow.async_configure( + show_form_result["flow_id"], + { + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + assert login_result["step_id"] == "user" + assert login_result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert login_result["errors"] == {"base": "no_panel_online"} + + +async def test_show_reauth(hass): + """Test that the reauth form shows.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + +async def test_reauth_flow(hass): + """Test that the reauth flow works.""" + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + }, + unique_id=MOCK_PANEL_ID, + ).add_to_hass(hass) + + # Trigger reauth + with patch( + "homeassistant.components.elmax.async_setup_entry", + return_value=True, + ): + reauth_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + result = await hass.config_entries.flow.async_configure( + reauth_result["flow_id"], + { + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + await hass.async_block_till_done() + assert result["reason"] == "reauth_successful" + + +async def test_reauth_panel_disappeared(hass): + """Test that the case where panel is no longer associated with the user.""" + # Simulate a first setup + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + }, + unique_id=MOCK_PANEL_ID, + ).add_to_hass(hass) + + # Trigger reauth + with patch( + "elmax_api.http.Elmax.list_control_panels", + return_value=[], + ): + reauth_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + result = await hass.config_entries.flow.async_configure( + reauth_result["flow_id"], + { + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "reauth_panel_disappeared"} + + +async def test_reauth_invalid_pin(hass): + """Test that the case where panel is no longer associated with the user.""" + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + }, + unique_id=MOCK_PANEL_ID, + ).add_to_hass(hass) + + # Trigger reauth + with patch( + "elmax_api.http.Elmax.get_panel_status", + side_effect=ElmaxBadPinError(), + ): + reauth_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + result = await hass.config_entries.flow.async_configure( + reauth_result["flow_id"], + { + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_pin"} + + +async def test_reauth_bad_login(hass): + """Test bad login attempt at reauth time.""" + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + }, + unique_id=MOCK_PANEL_ID, + ).add_to_hass(hass) + + # Trigger reauth + with patch( + "elmax_api.http.Elmax.login", + side_effect=ElmaxBadLoginError(), + ): + reauth_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data={ + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + result = await hass.config_entries.flow.async_configure( + reauth_result["flow_id"], + { + CONF_ELMAX_PANEL_ID: MOCK_PANEL_ID, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_USERNAME: MOCK_USERNAME, + CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + }, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a2021af3e2a957..3971df86862a57 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -9,15 +9,13 @@ from homeassistant.components.sensor import ( ATTR_LAST_RESET, ATTR_STATE_CLASS, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.components.sensor.recorder import compile_statistics from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_MONETARY, ENERGY_KILO_WATT_HOUR, ENERGY_MEGA_WATT_HOUR, ENERGY_WATT_HOUR, @@ -108,7 +106,7 @@ def _compile_statistics(_): energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } await async_init_recorder_component(hass) @@ -164,10 +162,10 @@ def _compile_statistics(_): state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.MONETARY if initial_cost != "unknown": assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -182,9 +180,9 @@ def _compile_statistics(_): state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.MONETARY assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -369,10 +367,10 @@ def _compile_statistics(_): state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.MONETARY if initial_cost != "unknown": assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -387,9 +385,9 @@ def _compile_statistics(_): state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.MONETARY assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -574,10 +572,10 @@ def _compile_statistics(_): state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.MONETARY if initial_cost != "unknown": assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # Optional late setup of dependent entities @@ -592,9 +590,9 @@ def _compile_statistics(_): state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.MONETARY assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" # # Unique ID temp disabled @@ -677,7 +675,7 @@ async def test_cost_sensor_handle_energy_units( """Test energy cost price from sensor entity.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: energy_unit, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( @@ -743,11 +741,11 @@ async def test_cost_sensor_handle_price_units( """Test energy cost price from sensor entity.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } price_attributes = { ATTR_UNIT_OF_MEASUREMENT: price_unit, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, } energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( @@ -804,7 +802,7 @@ async def test_cost_sensor_handle_gas(hass, hass_storage) -> None: """Test gas cost price from sensor entity.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: VOLUME_CUBIC_METERS, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( @@ -853,7 +851,7 @@ async def test_cost_sensor_handle_gas_kwh(hass, hass_storage) -> None: """Test gas cost price from sensor entity.""" energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, } energy_data = data.EnergyManager.default_preferences() energy_data["energy_sources"].append( @@ -960,7 +958,7 @@ async def test_cost_sensor_wrong_state_class( assert state.state == STATE_UNKNOWN -@pytest.mark.parametrize("state_class", [STATE_CLASS_MEASUREMENT]) +@pytest.mark.parametrize("state_class", [SensorStateClass.MEASUREMENT]) async def test_cost_sensor_state_class_measurement_no_reset( hass, hass_storage, caplog, state_class ) -> None: diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index c1dc195a63e205..46c6a5c0fa679e 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -1,10 +1,9 @@ """Test the Energy websocket API.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock import pytest from homeassistant.components.energy import data, is_configured -from homeassistant.components.recorder import statistics from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -964,220 +963,6 @@ async def test_fossil_energy_consumption(hass, hass_ws_client): } -@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -async def test_fossil_energy_consumption_duplicate(hass, hass_ws_client): - """Test fossil_energy_consumption with co2 sensor data.""" - now = dt_util.utcnow() - later = dt_util.as_utc(dt_util.parse_datetime("2022-09-01 00:00:00")) - - await hass.async_add_executor_job(init_recorder_component, hass) - await async_setup_component(hass, "history", {}) - await async_setup_component(hass, "sensor", {}) - - period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) - period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) - period2_day_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 00:00:00")) - period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) - period4_day_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 00:00:00")) - - external_energy_statistics_1 = ( - { - "start": period1, - "last_reset": None, - "state": 0, - "sum": 2, - }, - { - "start": period2, - "last_reset": None, - "state": 1, - "sum": 3, - }, - { - "start": period3, - "last_reset": None, - "state": 2, - "sum": 4, - }, - { - "start": period4, - "last_reset": None, - "state": 3, - "sum": 5, - }, - { - "start": period4, - "last_reset": None, - "state": 3, - "sum": 5, - }, - ) - external_energy_metadata_1 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_1", - "unit_of_measurement": "kWh", - } - external_energy_statistics_2 = ( - { - "start": period1, - "last_reset": None, - "state": 0, - "sum": 20, - }, - { - "start": period2, - "last_reset": None, - "state": 1, - "sum": 30, - }, - { - "start": period3, - "last_reset": None, - "state": 2, - "sum": 40, - }, - { - "start": period4, - "last_reset": None, - "state": 3, - "sum": 50, - }, - { - "start": period4, - "last_reset": None, - "state": 3, - "sum": 50, - }, - ) - external_energy_metadata_2 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_2", - "unit_of_measurement": "kWh", - } - external_co2_statistics = ( - { - "start": period1, - "last_reset": None, - "mean": 10, - }, - { - "start": period2, - "last_reset": None, - "mean": 30, - }, - { - "start": period3, - "last_reset": None, - "mean": 60, - }, - { - "start": period4, - "last_reset": None, - "mean": 90, - }, - ) - external_co2_metadata = { - "has_mean": True, - "has_sum": False, - "name": "Fossil percentage", - "source": "test", - "statistic_id": "test:fossil_percentage", - "unit_of_measurement": "%", - } - - with patch.object( - statistics, "_statistics_exists", return_value=False - ), patch.object( - statistics, "_insert_statistics", wraps=statistics._insert_statistics - ) as insert_statistics_mock: - async_add_external_statistics( - hass, external_energy_metadata_1, external_energy_statistics_1 - ) - async_add_external_statistics( - hass, external_energy_metadata_2, external_energy_statistics_2 - ) - async_add_external_statistics( - hass, external_co2_metadata, external_co2_statistics - ) - await async_wait_recording_done_without_instance(hass) - assert insert_statistics_mock.call_count == 14 - - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "energy/fossil_energy_consumption", - "start_time": now.isoformat(), - "end_time": later.isoformat(), - "energy_statistic_ids": [ - "test:total_energy_import_tariff_1", - "test:total_energy_import_tariff_2", - ], - "co2_statistic_id": "test:fossil_percentage", - "period": "hour", - } - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == { - period2.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), - period3.isoformat(): pytest.approx((44.0 - 33.0) * 0.6), - period4.isoformat(): pytest.approx((55.0 - 44.0) * 0.9), - } - - await client.send_json( - { - "id": 2, - "type": "energy/fossil_energy_consumption", - "start_time": now.isoformat(), - "end_time": later.isoformat(), - "energy_statistic_ids": [ - "test:total_energy_import_tariff_1", - "test:total_energy_import_tariff_2", - ], - "co2_statistic_id": "test:fossil_percentage", - "period": "day", - } - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == { - period2_day_start.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), - period3.isoformat(): pytest.approx((44.0 - 33.0) * 0.6), - period4_day_start.isoformat(): pytest.approx((55.0 - 44.0) * 0.9), - } - - await client.send_json( - { - "id": 3, - "type": "energy/fossil_energy_consumption", - "start_time": now.isoformat(), - "end_time": later.isoformat(), - "energy_statistic_ids": [ - "test:total_energy_import_tariff_1", - "test:total_energy_import_tariff_2", - ], - "co2_statistic_id": "test:fossil_percentage", - "period": "month", - } - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == { - period1.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), - period3.isoformat(): pytest.approx( - ((44.0 - 33.0) * 0.6) + ((55.0 - 44.0) * 0.9) - ), - } - - async def test_fossil_energy_consumption_checks(hass, hass_ws_client): """Test fossil_energy_consumption parameter validation.""" client = await hass_ws_client(hass) diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index 41a49a7b24552d..d8b23ac9864d0f 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -204,42 +204,6 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_import(hass: HomeAssistant) -> None: - """Test we can import from yaml.""" - - with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.getData", - return_value=True, - ), patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader.get_full_serial_number", - return_value="1234", - ), patch( - "homeassistant.components.enphase_envoy.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "ip_address": "1.1.1.1", - "name": "Pool Envoy", - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == "Pool Envoy" - assert result2["data"] == { - "host": "1.1.1.1", - "name": "Pool Envoy", - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_zeroconf(hass: HomeAssistant) -> None: """Test we can setup from zeroconf.""" diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index 2614778f9b481e..de3ff516eae58a 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -11,7 +11,6 @@ CONF_STATION, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from tests.common import MockConfigEntry @@ -123,25 +122,8 @@ async def test_exception_handling(hass, error): assert result["errors"] == {"base": base_error} -async def test_import_station_not_specified(hass): - """Test that the import step works.""" - with mocked_ec(), patch( - "homeassistant.components.environment_canada.async_setup_entry", - return_value=True, - ): - fake_config = dict(FAKE_CONFIG) - del fake_config[CONF_STATION] - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=fake_config - ) - await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"] == FAKE_CONFIG - assert result["title"] == FAKE_TITLE - - -async def test_import_lat_lon_not_specified(hass): - """Test that the import step works.""" +async def test_lat_lon_not_specified(hass): + """Test that the import step works when coordinates are not specified.""" with mocked_ec(), patch( "homeassistant.components.environment_canada.async_setup_entry", return_value=True, @@ -150,29 +132,9 @@ async def test_import_lat_lon_not_specified(hass): del fake_config[CONF_LATITUDE] del fake_config[CONF_LONGITUDE] result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=fake_config + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=fake_config ) await hass.async_block_till_done() assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == FAKE_CONFIG assert result["title"] == FAKE_TITLE - - -async def test_async_step_import(hass): - """Test that the import step works.""" - with mocked_ec(), patch( - "homeassistant.components.environment_canada.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=FAKE_CONFIG - ) - await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"] == FAKE_CONFIG - assert result["title"] == FAKE_TITLE - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=FAKE_CONFIG - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 91368044723cd0..7cf25f13015a00 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -1,8 +1,42 @@ """esphome session fixtures.""" - import pytest +from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def esphome_mock_async_zeroconf(mock_async_zeroconf): """Auto mock zeroconf.""" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="ESPHome Device", + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "12345678123456781234567812345678", + }, + unique_id="esphome-device", + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the ESPHome integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py new file mode 100644 index 00000000000000..319bc2602e1bc9 --- /dev/null +++ b/tests/components/esphome/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for the diagnostics data provided by the ESPHome integration.""" + +from aiohttp import ClientSession + +from homeassistant.components.esphome import CONF_NOISE_PSK +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, hass_client: ClientSession, init_integration: MockConfigEntry +): + """Test diagnostics for config entry.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert isinstance(result, dict) + assert result["config"]["data"] == { + CONF_HOST: "192.168.1.2", + CONF_PORT: 6053, + CONF_PASSWORD: "**REDACTED**", + CONF_NOISE_PSK: "**REDACTED**", + } + assert result["config"]["unique_id"] == "esphome-device" diff --git a/tests/components/evil_genius_labs/test_config_flow.py b/tests/components/evil_genius_labs/test_config_flow.py index 55e207ba7e0e27..6a5d4ea816a1ec 100644 --- a/tests/components/evil_genius_labs/test_config_flow.py +++ b/tests/components/evil_genius_labs/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Evil Genius Labs config flow.""" +import asyncio from unittest.mock import patch import aiohttp @@ -43,7 +44,7 @@ async def test_form(hass: HomeAssistant, data_fixture, info_fixture) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_cannot_connect(hass: HomeAssistant, caplog) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -62,6 +63,28 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "cannot_connect"} + assert "Unable to connect" in caplog.text + + +async def test_form_timeout(hass: HomeAssistant) -> None: + """Test we handle timeout error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyevilgenius.EvilGeniusDevice.get_data", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "timeout"} async def test_form_unknown(hass: HomeAssistant) -> None: diff --git a/tests/components/evil_genius_labs/test_diagnostics.py b/tests/components/evil_genius_labs/test_diagnostics.py new file mode 100644 index 00000000000000..35026bf37e382d --- /dev/null +++ b/tests/components/evil_genius_labs/test_diagnostics.py @@ -0,0 +1,21 @@ +"""Test evil genius labs diagnostics.""" +import pytest + +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +@pytest.mark.parametrize("platforms", [[]]) +async def test_entry_diagnostics( + hass, hass_client, setup_evil_genius_labs, config_entry, data_fixture, info_fixture +): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "info": { + **info_fixture, + "wiFiSsidDefault": REDACTED, + "wiFiSSID": REDACTED, + }, + "data": data_fixture, + } diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 491e6afab6a16f..eb31a2ece42da1 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.fan import DOMAIN from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -52,7 +53,9 @@ async def test_get_actions(hass, device_reg, entity_reg): "entity_id": "fan.test_5678", }, ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py index 8f11d963ed3d80..d8e60e7d6ca756 100644 --- a/tests/components/fan/test_device_condition.py +++ b/tests/components/fan/test_device_condition.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.fan import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -61,7 +62,9 @@ async def test_get_conditions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert_lists_same(conditions, expected_conditions) diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index cecb4151c3f05b..ca947f1e82230b 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.fan import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -65,8 +66,17 @@ async def test_get_triggers(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": f"{DOMAIN}.test_5678", }, + { + "platform": "device", + "domain": DOMAIN, + "type": "changed_states", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -84,10 +94,12 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): {"name": "for", "optional": True, "type": "positive_time_period_dict"} ] } - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == expected_capabilities @@ -139,6 +151,25 @@ async def test_if_fires_on_state_change(hass, calls): }, }, }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "fan.entity", + "type": "changed_states", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "turn_on_or_off - {{ trigger.platform}} - " + "{{ trigger.entity_id}} - {{ trigger.from_state.state}} - " + "{{ trigger.to_state.state}} - {{ trigger.for }}" + ) + }, + }, + }, ] }, ) @@ -146,14 +177,20 @@ async def test_if_fires_on_state_change(hass, calls): # Fake that the entity is turning on. 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 - fan.entity - off - on - None" + assert len(calls) == 2 + assert {calls[0].data["some"], calls[1].data["some"]} == { + "turn_on - device - fan.entity - off - on - None", + "turn_on_or_off - 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 - fan.entity - on - off - None" + assert len(calls) == 4 + assert {calls[2].data["some"], calls[3].data["some"]} == { + "turn_off - device - fan.entity - on - off - None", + "turn_on_or_off - device - fan.entity - on - off - None", + } async def test_if_fires_on_state_change_with_for(hass, calls): diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 3167cb16e67541..d29d43789417ac 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -2,7 +2,7 @@ import pytest -from homeassistant.components.fan import FanEntity, NotValidPresetModeError +from homeassistant.components.fan import FanEntity class BaseFan(FanEntity): @@ -16,8 +16,8 @@ def test_fanentity(): """Test fan entity methods.""" fan = BaseFan() assert fan.state == "off" - assert len(fan.speed_list) == 0 - assert len(fan.preset_modes) == 0 + assert len(fan.speed_list) == 4 # legacy compat off,low,medium,high + assert fan.preset_modes is None assert fan.supported_features == 0 assert fan.percentage_step == 1 assert fan.speed_count == 100 @@ -26,10 +26,10 @@ def test_fanentity(): with pytest.raises(NotImplementedError): fan.oscillate(True) with pytest.raises(NotImplementedError): - fan.set_speed("slow") + fan.set_speed("low") with pytest.raises(NotImplementedError): fan.set_percentage(0) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotImplementedError): fan.set_preset_mode("auto") with pytest.raises(NotImplementedError): fan.turn_on() @@ -42,8 +42,8 @@ async def test_async_fanentity(hass): fan = BaseFan() fan.hass = hass assert fan.state == "off" - assert len(fan.speed_list) == 0 - assert len(fan.preset_modes) == 0 + assert len(fan.speed_list) == 4 # legacy compat off,low,medium,high + assert fan.preset_modes is None assert fan.supported_features == 0 assert fan.percentage_step == 1 assert fan.speed_count == 100 @@ -52,10 +52,10 @@ async def test_async_fanentity(hass): with pytest.raises(NotImplementedError): await fan.async_oscillate(True) with pytest.raises(NotImplementedError): - await fan.async_set_speed("slow") + await fan.async_set_speed("low") with pytest.raises(NotImplementedError): await fan.async_set_percentage(0) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotImplementedError): await fan.async_set_preset_mode("auto") with pytest.raises(NotImplementedError): await fan.async_turn_on() diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index a10018f9b2e9c2..34f27c36a6ccbf 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -1,9 +1,7 @@ """The tests for the feedreader component.""" from datetime import timedelta -from os import remove -from os.path import exists from unittest import mock -from unittest.mock import patch +from unittest.mock import mock_open, patch import pytest @@ -65,14 +63,10 @@ async def fixture_events(hass): @pytest.fixture(name="feed_storage", autouse=True) -def fixture_feed_storage(hass): - """Create storage account for feedreader.""" - data_file = hass.config.path(f"{feedreader.DOMAIN}.pickle") - - yield - - if exists(data_file): - remove(data_file) +def fixture_feed_storage(): + """Mock builtins.open for feedreader storage.""" + with patch("homeassistant.components.feedreader.open", mock_open(), create=True): + yield async def test_setup_one_feed(hass): diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index ec831b79670c7e..c2fc8cbdd06b2d 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -16,8 +16,8 @@ ) from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DEVICE_CLASS_TEMPERATURE, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -274,15 +274,15 @@ async def test_setup(hass): 1, { "icon": "mdi:test", - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, }, ) await hass.async_block_till_done() state = hass.states.get("sensor.test") assert state.attributes["icon"] == "mdi:test" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING assert state.state == "1.0" diff --git a/tests/components/flic/__init__.py b/tests/components/flic/__init__.py new file mode 100644 index 00000000000000..0e92d271a93f72 --- /dev/null +++ b/tests/components/flic/__init__.py @@ -0,0 +1 @@ +"""Tests for the flic integration.""" diff --git a/tests/components/flic/test_binary_sensor.py b/tests/components/flic/test_binary_sensor.py new file mode 100644 index 00000000000000..463dbf4a9d7501 --- /dev/null +++ b/tests/components/flic/test_binary_sensor.py @@ -0,0 +1,63 @@ +"""Tests for Flic button integration.""" +from unittest import mock + +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +class _MockFlicClient: + def __init__(self, button_addresses): + self.addresses = button_addresses + self.get_info_callback = None + self.scan_wizard = None + + def close(self): + pass + + def get_info(self, callback): + self.get_info_callback = callback + callback({"bd_addr_of_verified_buttons": self.addresses}) + + def handle_events(self): + pass + + def add_scan_wizard(self, wizard): + self.scan_wizard = wizard + + def add_connection_channel(self, channel): + self.channel = channel + + +async def test_button_uid(hass): + """Test UID assignment for Flic buttons.""" + address_to_name = { + "80:e4:da:78:6e:11": "binary_sensor.flic_80e4da786e11", + # Uppercase address should not change uid. + "80:E4:DA:78:6E:12": "binary_sensor.flic_80e4da786e12", + } + + flic_client = _MockFlicClient(tuple(address_to_name)) + + with mock.patch.multiple( + "pyflic", + FlicClient=lambda _, __: flic_client, + ButtonConnectionChannel=mock.DEFAULT, + ScanWizard=mock.DEFAULT, + ): + assert await async_setup_component( + hass, + "binary_sensor", + {"binary_sensor": [{"platform": "flic"}]}, + ) + + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + for address, name in address_to_name.items(): + state = hass.states.get(name) + assert state + assert state.attributes.get("address") == address + + entry = entity_registry.async_get(name) + assert entry + assert entry.unique_id == address.lower() diff --git a/tests/components/flume/test_config_flow.py b/tests/components/flume/test_config_flow.py index 70ee359b7b4d51..4ba7becc147ab2 100644 --- a/tests/components/flume/test_config_flow.py +++ b/tests/components/flume/test_config_flow.py @@ -64,44 +64,6 @@ async def test_form(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_import(hass): - """Test we can import the sensor platform config.""" - - mock_flume_device_list = _get_mocked_flume_device_list() - - with patch( - "homeassistant.components.flume.config_flow.FlumeAuth", - return_value=True, - ), patch( - "homeassistant.components.flume.config_flow.FlumeDeviceList", - return_value=mock_flume_device_list, - ), patch( - "homeassistant.components.flume.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_CLIENT_ID: "client_id", - CONF_CLIENT_SECRET: "client_secret", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == "test-username" - assert result["data"] == { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_CLIENT_ID: "client_id", - CONF_CLIENT_SECRET: "client_secret", - } - 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( diff --git a/tests/components/flunearyou/conftest.py b/tests/components/flunearyou/conftest.py new file mode 100644 index 00000000000000..a3de61149aadb2 --- /dev/null +++ b/tests/components/flunearyou/conftest.py @@ -0,0 +1,61 @@ +"""Define fixtures for Flu Near You tests.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components.flunearyou.const import DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config, unique_id): + """Define a config entry fixture.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_LATITUDE: 51.528308, + CONF_LONGITUDE: -0.3817765, + } + + +@pytest.fixture(name="data_cdc", scope="session") +def data_cdc_fixture(): + """Define CDC data.""" + return json.loads(load_fixture("cdc_data.json", "flunearyou")) + + +@pytest.fixture(name="data_user", scope="session") +def data_user_fixture(): + """Define user data.""" + return json.loads(load_fixture("user_data.json", "flunearyou")) + + +@pytest.fixture(name="setup_flunearyou") +async def setup_flunearyou_fixture(hass, data_cdc, data_user, config): + """Define a fixture to set up Flu Near You.""" + with patch( + "pyflunearyou.cdc.CdcReport.status_by_coordinates", return_value=data_cdc + ), patch( + "pyflunearyou.user.UserReport.status_by_coordinates", return_value=data_user + ), patch( + "homeassistant.components.flunearyou.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="unique_id") +def unique_id_fixture(hass): + """Define a config entry unique ID fixture.""" + return "51.528308, -0.3817765" diff --git a/tests/components/flunearyou/fixtures/cdc_data.json b/tests/components/flunearyou/fixtures/cdc_data.json new file mode 100644 index 00000000000000..0d0dd9dced093e --- /dev/null +++ b/tests/components/flunearyou/fixtures/cdc_data.json @@ -0,0 +1,10 @@ +{ + "level": "Low", + "level2": "None", + "week_date": "2020-05-16", + "name": "Washington State", + "fill": { + "color": "#00B7B6", + "opacity": 0.7 + } +} diff --git a/tests/components/flunearyou/fixtures/user_data.json b/tests/components/flunearyou/fixtures/user_data.json new file mode 100644 index 00000000000000..47d54d1c41ef91 --- /dev/null +++ b/tests/components/flunearyou/fixtures/user_data.json @@ -0,0 +1,51 @@ +[ + { + "id": 1, + "city": "Chester(72934)", + "place_id": "49377", + "zip": "72934", + "contained_by": "610", + "latitude": "35.687603", + "longitude": "-94.253845", + "none": 1, + "symptoms": 0, + "flu": 0, + "lepto": 0, + "dengue": 0, + "chick": 0, + "icon": "1" + }, + { + "id": 2, + "city": "Los Angeles(90046)", + "place_id": "23818", + "zip": "90046", + "contained_by": "204", + "latitude": "34.114731", + "longitude": "-118.363724", + "none": 2, + "symptoms": 0, + "flu": 0, + "lepto": 0, + "dengue": 0, + "chick": 0, + "icon": "1" + }, + { + "id": 3, + "city": "Corvallis(97330)", + "place_id": "21462", + "zip": "97330", + "contained_by": "239", + "latitude": "44.638504", + "longitude": "-123.292938", + "none": 3, + "symptoms": 0, + "flu": 0, + "lepto": 0, + "dengue": 0, + "chick": 0, + "icon": "1" + } +] + diff --git a/tests/components/flunearyou/test_config_flow.py b/tests/components/flunearyou/test_config_flow.py index fbed4d2b42667b..8c22d5fc9159bc 100644 --- a/tests/components/flunearyou/test_config_flow.py +++ b/tests/components/flunearyou/test_config_flow.py @@ -8,35 +8,23 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE -from tests.common import MockConfigEntry - -async def test_duplicate_error(hass): +async def test_duplicate_error(hass, config, config_entry, setup_flunearyou): """Test that an error is shown when duplicates are added.""" - conf = {CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"} - - MockConfigEntry( - domain=DOMAIN, unique_id="51.528308, -0.3817765", data=conf - ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -async def test_general_error(hass): - """Test that an error is shown on a library error.""" - conf = {CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"} - +async def test_errors(hass, config): + """Test that exceptions show the appropriate error.""" with patch( - "pyflunearyou.cdc.CdcReport.status_by_coordinates", - side_effect=FluNearYouError, + "pyflunearyou.cdc.CdcReport.status_by_coordinates", side_effect=FluNearYouError ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) assert result["errors"] == {"base": "unknown"} @@ -51,20 +39,14 @@ async def test_show_form(hass): assert result["step_id"] == "user" -async def test_step_user(hass): +async def test_step_user(hass, config, setup_flunearyou): """Test that the user step works.""" - conf = {CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"} - - with patch( - "homeassistant.components.flunearyou.async_setup_entry", return_value=True - ), patch("pyflunearyou.cdc.CdcReport.status_by_coordinates"): - 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"] == "51.528308, -0.3817765" - assert result["data"] == { - CONF_LATITUDE: "51.528308", - CONF_LONGITUDE: "-0.3817765", - } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=config + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "51.528308, -0.3817765" + assert result["data"] == { + CONF_LATITUDE: 51.528308, + CONF_LONGITUDE: -0.3817765, + } diff --git a/tests/components/flunearyou/test_diagnostics.py b/tests/components/flunearyou/test_diagnostics.py new file mode 100644 index 00000000000000..1775f088a1f17a --- /dev/null +++ b/tests/components/flunearyou/test_diagnostics.py @@ -0,0 +1,67 @@ +"""Test Flu Near You diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_flunearyou): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "cdc_report": { + "level": "Low", + "level2": "None", + "week_date": "2020-05-16", + "name": "Washington State", + "fill": {"color": "#00B7B6", "opacity": 0.7}, + }, + "user_report": [ + { + "id": 1, + "city": "Chester(72934)", + "place_id": "49377", + "zip": "72934", + "contained_by": "610", + "latitude": REDACTED, + "longitude": REDACTED, + "none": 1, + "symptoms": 0, + "flu": 0, + "lepto": 0, + "dengue": 0, + "chick": 0, + "icon": "1", + }, + { + "id": 2, + "city": "Los Angeles(90046)", + "place_id": "23818", + "zip": "90046", + "contained_by": "204", + "latitude": REDACTED, + "longitude": REDACTED, + "none": 2, + "symptoms": 0, + "flu": 0, + "lepto": 0, + "dengue": 0, + "chick": 0, + "icon": "1", + }, + { + "id": 3, + "city": "Corvallis(97330)", + "place_id": "21462", + "zip": "97330", + "contained_by": "239", + "latitude": REDACTED, + "longitude": REDACTED, + "none": 3, + "symptoms": 0, + "flu": 0, + "lepto": 0, + "dengue": 0, + "chick": 0, + "icon": "1", + }, + ], + } diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index e331a788e9cbf0..275c10c5592aa4 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -1043,9 +1043,7 @@ async def test_flux_with_multiple_lights( def event_date(hass, event, now=None): if event == SUN_EVENT_SUNRISE: - print(f"sunrise {sunrise_time}") return sunrise_time - print(f"sunset {sunset_time}") return sunset_time with patch( diff --git a/tests/components/flux_led/__init__.py b/tests/components/flux_led/__init__.py index 6bfe02990a2ee3..e1abebd40f1fc0 100644 --- a/tests/components/flux_led/__init__.py +++ b/tests/components/flux_led/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from contextlib import contextmanager import datetime -from typing import Callable from unittest.mock import AsyncMock, MagicMock, patch from flux_led import DeviceType @@ -12,23 +12,35 @@ from flux_led.const import ( COLOR_MODE_CCT as FLUX_COLOR_MODE_CCT, COLOR_MODE_RGB as FLUX_COLOR_MODE_RGB, + WhiteChannelType, ) from flux_led.models_db import MODEL_MAP -from flux_led.protocol import LEDENETRawState +from flux_led.protocol import ( + LEDENETRawState, + PowerRestoreState, + PowerRestoreStates, + RemoteConfig, +) from flux_led.scanner import FluxLEDDiscovery from homeassistant.components import dhcp +from homeassistant.components.flux_led.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry + MODULE = "homeassistant.components.flux_led" MODULE_CONFIG_FLOW = "homeassistant.components.flux_led.config_flow" IP_ADDRESS = "127.0.0.1" MODEL_NUM_HEX = "0x35" -MODEL = "AZ120444" +MODEL_NUM = 0x35 +MODEL = "AK001-ZJ2149" MODEL_DESCRIPTION = "Bulb RGBCW" MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" -FLUX_MAC_ADDRESS = "aabbccddeeff" -SHORT_MAC_ADDRESS = "ddeeff" +FLUX_MAC_ADDRESS = "AABBCCDDEEFF" +SHORT_MAC_ADDRESS = "DDEEFF" DEFAULT_ENTRY_TITLE = f"{MODEL_DESCRIPTION} {SHORT_MAC_ADDRESS}" @@ -52,14 +64,27 @@ ipaddr=IP_ADDRESS, model=MODEL, id=FLUX_MAC_ADDRESS, - model_num=0x25, + model_num=MODEL_NUM, version_num=0x04, firmware_date=datetime.date(2021, 5, 5), model_info=MODEL, model_description=MODEL_DESCRIPTION, + remote_access_enabled=True, + remote_access_host="the.cloud", + remote_access_port=8816, ) +def _mock_config_entry_for_bulb(hass: HomeAssistant) -> ConfigEntry: + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + return config_entry + + def _mocked_bulb() -> AIOWifiLedBulb: bulb = MagicMock(auto_spec=AIOWifiLedBulb) @@ -70,16 +95,37 @@ async def _save_setup_callback(callback: Callable) -> None: bulb.requires_turn_on = True bulb.async_setup = AsyncMock(side_effect=_save_setup_callback) bulb.effect_list = ["some_effect"] + bulb.remote_config = RemoteConfig.OPEN + bulb.async_unpair_remotes = AsyncMock() + bulb.async_set_time = AsyncMock() + bulb.async_set_music_mode = AsyncMock() bulb.async_set_custom_pattern = AsyncMock() bulb.async_set_preset_pattern = AsyncMock() bulb.async_set_effect = AsyncMock() bulb.async_set_white_temp = AsyncMock() bulb.async_set_brightness = AsyncMock() + bulb.async_set_device_config = AsyncMock() + bulb.async_config_remotes = AsyncMock() + bulb.white_channel_channel_type = WhiteChannelType.WARM + bulb.paired_remotes = 2 + bulb.pixels_per_segment = 300 + bulb.segments = 2 + bulb.music_pixels_per_segment = 150 + bulb.music_segments = 4 + bulb.operating_mode = "RGB&W" + bulb.operating_modes = ["RGB&W", "RGB/W"] + bulb.wirings = ["RGBW", "GRBW", "BGRW"] + bulb.wiring = "BGRW" + bulb.ic_types = ["WS2812B", "UCS1618"] + bulb.ic_type = "WS2812B" bulb.async_stop = AsyncMock() bulb.async_update = AsyncMock() bulb.async_turn_off = AsyncMock() bulb.async_turn_on = AsyncMock() bulb.async_set_levels = AsyncMock() + bulb.async_set_zones = AsyncMock() + bulb.async_disable_remote_access = AsyncMock() + bulb.async_enable_remote_access = AsyncMock() bulb.min_temp = 2700 bulb.max_temp = 6500 bulb.getRgb = MagicMock(return_value=[255, 0, 0]) @@ -94,8 +140,8 @@ async def _save_setup_callback(callback: Callable) -> None: bulb.color_temp = 2700 bulb.getWhiteTemperature = MagicMock(return_value=(2700, 128)) bulb.brightness = 128 - bulb.model_num = 0x35 - bulb.model_data = MODEL_MAP[0x35] + bulb.model_num = MODEL_NUM + bulb.model_data = MODEL_MAP[MODEL_NUM] bulb.effect = None bulb.speed = 50 bulb.model = "Bulb RGBCW (0x35)" @@ -117,8 +163,27 @@ async def _save_setup_callback(callback: Callable) -> None: switch.data_receive_callback = callback switch.device_type = DeviceType.Switch + switch.power_restore_states = PowerRestoreStates( + channel1=PowerRestoreState.LAST_STATE, + channel2=PowerRestoreState.LAST_STATE, + channel3=PowerRestoreState.LAST_STATE, + channel4=PowerRestoreState.LAST_STATE, + ) + switch.pixels_per_segment = None + switch.segments = None + switch.music_pixels_per_segment = None + switch.music_segments = None + switch.operating_mode = None + switch.operating_modes = None + switch.wirings = None + switch.wiring = None + switch.ic_types = None + switch.ic_type = None switch.requires_turn_on = True + switch.async_set_time = AsyncMock() + switch.async_reboot = AsyncMock() switch.async_setup = AsyncMock(side_effect=_save_setup_callback) + switch.async_set_power_restore = AsyncMock() switch.async_stop = AsyncMock() switch.async_update = AsyncMock() switch.async_turn_off = AsyncMock() @@ -168,10 +233,10 @@ async def _discovery(*args, **kwargs): @contextmanager def _patcher(): with patch( - "homeassistant.components.flux_led.AIOBulbScanner.async_scan", + "homeassistant.components.flux_led.discovery.AIOBulbScanner.async_scan", new=_discovery, ), patch( - "homeassistant.components.flux_led.AIOBulbScanner.getBulbInfo", + "homeassistant.components.flux_led.discovery.AIOBulbScanner.getBulbInfo", return_value=[] if no_device else [device or FLUX_DISCOVERY], ): yield diff --git a/tests/components/flux_led/conftest.py b/tests/components/flux_led/conftest.py index abac297da2d545..2a67c7b46f77f0 100644 --- a/tests/components/flux_led/conftest.py +++ b/tests/components/flux_led/conftest.py @@ -1,5 +1,7 @@ """Tests for the flux_led integration.""" +from unittest.mock import patch + import pytest from tests.common import mock_device_registry @@ -9,3 +11,23 @@ def device_reg_fixture(hass): """Return an empty, loaded, registry.""" return mock_device_registry(hass) + + +@pytest.fixture +def mock_single_broadcast_address(): + """Mock network's async_async_get_ipv4_broadcast_addresses.""" + with patch( + "homeassistant.components.network.async_get_ipv4_broadcast_addresses", + return_value={"10.255.255.255"}, + ): + yield + + +@pytest.fixture +def mock_multiple_broadcast_addresses(): + """Mock network's async_async_get_ipv4_broadcast_addresses to return multiple addresses.""" + with patch( + "homeassistant.components.network.async_get_ipv4_broadcast_addresses", + return_value={"10.255.255.255", "192.168.0.255"}, + ): + yield diff --git a/tests/components/flux_led/test_button.py b/tests/components/flux_led/test_button.py new file mode 100644 index 00000000000000..992d8b18ce6864 --- /dev/null +++ b/tests/components/flux_led/test_button.py @@ -0,0 +1,62 @@ +"""Tests for button platform.""" +from homeassistant.components import flux_led +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.flux_led.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + DEFAULT_ENTRY_TITLE, + FLUX_DISCOVERY, + IP_ADDRESS, + MAC_ADDRESS, + _mock_config_entry_for_bulb, + _mocked_bulb, + _mocked_switch, + _patch_discovery, + _patch_wifibulb, +) + +from tests.common import MockConfigEntry + + +async def test_button_reboot(hass: HomeAssistant) -> None: + """Test a smart plug can be rebooted.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + switch = _mocked_switch() + with _patch_discovery(), _patch_wifibulb(device=switch): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "button.bulb_rgbcw_ddeeff_restart" + + assert hass.states.get(entity_id) + + await hass.services.async_call( + BUTTON_DOMAIN, "press", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + switch.async_reboot.assert_called_once() + + +async def test_button_unpair_remotes(hass: HomeAssistant) -> None: + """Test that remotes can be unpaired.""" + _mock_config_entry_for_bulb(hass) + bulb = _mocked_bulb() + bulb.discovery = FLUX_DISCOVERY + with _patch_discovery(device=FLUX_DISCOVERY), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "button.bulb_rgbcw_ddeeff_unpair_remotes" + assert hass.states.get(entity_id) + + await hass.services.async_call( + BUTTON_DOMAIN, "press", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_unpair_remotes.assert_called_once() diff --git a/tests/components/flux_led/test_config_flow.py b/tests/components/flux_led/test_config_flow.py index a546120ae41541..dcab5cc01add36 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -11,19 +11,19 @@ CONF_CUSTOM_EFFECT_COLORS, CONF_CUSTOM_EFFECT_SPEED_PCT, CONF_CUSTOM_EFFECT_TRANSITION, + CONF_MINOR_VERSION, + CONF_MODEL, + CONF_MODEL_DESCRIPTION, + CONF_MODEL_INFO, + CONF_MODEL_NUM, + CONF_REMOTE_ACCESS_ENABLED, + CONF_REMOTE_ACCESS_HOST, + CONF_REMOTE_ACCESS_PORT, DOMAIN, - MODE_RGB, TRANSITION_JUMP, TRANSITION_STROBE, ) -from homeassistant.const import ( - CONF_DEVICE, - CONF_HOST, - CONF_MAC, - CONF_MODE, - CONF_NAME, - CONF_PROTOCOL, -) +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM @@ -34,6 +34,9 @@ FLUX_DISCOVERY_PARTIAL, IP_ADDRESS, MAC_ADDRESS, + MODEL, + MODEL_DESCRIPTION, + MODEL_NUM, MODULE, _patch_discovery, _patch_wifibulb, @@ -88,7 +91,95 @@ async def test_discovery(hass: HomeAssistant): assert result3["type"] == "create_entry" assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == {CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE} + assert result3["data"] == { + CONF_MINOR_VERSION: 4, + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + CONF_MINOR_VERSION: 0x04, + } + mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() + + # ignore configured devices + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_wifibulb(): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "no_devices_found" + + +async def test_discovery_legacy(hass: HomeAssistant): + """Test setting up discovery with a legacy device.""" + with _patch_discovery(device=FLUX_DISCOVERY_PARTIAL), _patch_wifibulb(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + # test we can try again + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + with _patch_discovery(), _patch_wifibulb(), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE: MAC_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result3["type"] == "create_entry" + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == { + CONF_MINOR_VERSION: 4, + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + CONF_MINOR_VERSION: 0x04, + } mock_setup.assert_called_once() mock_setup_entry.assert_called_once() @@ -160,8 +251,17 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant): assert result3["type"] == "create_entry" assert result3["title"] == DEFAULT_ENTRY_TITLE assert result3["data"] == { + CONF_MINOR_VERSION: 4, CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + CONF_MINOR_VERSION: 0x04, } await hass.async_block_till_done() @@ -197,57 +297,6 @@ async def test_discovery_no_device(hass: HomeAssistant): assert result2["reason"] == "no_devices_found" -async def test_import(hass: HomeAssistant): - """Test import from yaml.""" - config = { - CONF_HOST: IP_ADDRESS, - CONF_MAC: MAC_ADDRESS, - CONF_NAME: "floor lamp", - CONF_PROTOCOL: "ledenet", - CONF_MODE: MODE_RGB, - CONF_CUSTOM_EFFECT_COLORS: "[255,0,0], [0,0,255]", - CONF_CUSTOM_EFFECT_SPEED_PCT: 30, - CONF_CUSTOM_EFFECT_TRANSITION: TRANSITION_STROBE, - } - - # Success - with _patch_discovery(), _patch_wifibulb(), patch( - f"{MODULE}.async_setup", return_value=True - ) as mock_setup, patch( - f"{MODULE}.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == "floor lamp" - assert result["data"] == { - CONF_HOST: IP_ADDRESS, - CONF_NAME: "floor lamp", - CONF_PROTOCOL: "ledenet", - } - assert result["options"] == { - CONF_MODE: MODE_RGB, - CONF_CUSTOM_EFFECT_COLORS: "[255,0,0], [0,0,255]", - CONF_CUSTOM_EFFECT_SPEED_PCT: 30, - CONF_CUSTOM_EFFECT_TRANSITION: TRANSITION_STROBE, - } - mock_setup.assert_called_once() - mock_setup_entry.assert_called_once() - - # Duplicate - with _patch_discovery(), _patch_wifibulb(): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config - ) - await hass.async_block_till_done() - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - async def test_manual_working_discovery(hass: HomeAssistant): """Test manually setup.""" result = await hass.config_entries.flow.async_init( @@ -278,7 +327,19 @@ async def test_manual_working_discovery(hass: HomeAssistant): await hass.async_block_till_done() assert result4["type"] == "create_entry" assert result4["title"] == DEFAULT_ENTRY_TITLE - assert result4["data"] == {CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE} + assert result4["data"] == { + CONF_MINOR_VERSION: 4, + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + CONF_MINOR_VERSION: 0x04, + } # Duplicate result = await hass.config_entries.flow.async_init( @@ -312,7 +373,12 @@ async def test_manual_no_discovery_data(hass: HomeAssistant): await hass.async_block_till_done() assert result["type"] == "create_entry" - assert result["data"] == {CONF_HOST: IP_ADDRESS, CONF_NAME: IP_ADDRESS} + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_NAME: IP_ADDRESS, + } async def test_discovered_by_discovery_and_dhcp(hass): @@ -376,7 +442,19 @@ async def test_discovered_by_discovery(hass): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE} + assert result2["data"] == { + CONF_MINOR_VERSION: 4, + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + CONF_MINOR_VERSION: 0x04, + } assert mock_async_setup.called assert mock_async_setup_entry.called @@ -402,7 +480,19 @@ async def test_discovered_by_dhcp_udp_responds(hass): await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["data"] == {CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE} + assert result2["data"] == { + CONF_MINOR_VERSION: 4, + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + CONF_MODEL: MODEL, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_INFO: MODEL, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_HOST: "the.cloud", + CONF_REMOTE_ACCESS_PORT: 8816, + CONF_MINOR_VERSION: 0x04, + } assert mock_async_setup.called assert mock_async_setup_entry.called @@ -430,6 +520,8 @@ async def test_discovered_by_dhcp_no_udp_response(hass): assert result2["type"] == "create_entry" assert result2["data"] == { CONF_HOST: IP_ADDRESS, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, CONF_NAME: DEFAULT_ENTRY_TITLE, } assert mock_async_setup.called @@ -459,6 +551,8 @@ async def test_discovered_by_dhcp_partial_udp_response_fallback_tcp(hass): assert result2["type"] == "create_entry" assert result2["data"] == { CONF_HOST: IP_ADDRESS, + CONF_MODEL_NUM: MODEL_NUM, + CONF_MODEL_DESCRIPTION: MODEL_DESCRIPTION, CONF_NAME: DEFAULT_ENTRY_TITLE, } assert mock_async_setup.called @@ -538,7 +632,6 @@ async def test_options(hass: HomeAssistant): domain=DOMAIN, data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, options={ - CONF_MODE: MODE_RGB, CONF_CUSTOM_EFFECT_COLORS: "[255,0,0], [0,0,255]", CONF_CUSTOM_EFFECT_SPEED_PCT: 30, CONF_CUSTOM_EFFECT_TRANSITION: TRANSITION_STROBE, diff --git a/tests/components/flux_led/test_init.py b/tests/components/flux_led/test_init.py index 23a238fa812ca1..de655c2e6adb72 100644 --- a/tests/components/flux_led/test_init.py +++ b/tests/components/flux_led/test_init.py @@ -1,15 +1,22 @@ """Tests for the flux_led component.""" from __future__ import annotations +from datetime import timedelta from unittest.mock import patch import pytest from homeassistant.components import flux_led -from homeassistant.components.flux_led.const import DOMAIN +from homeassistant.components.flux_led.const import ( + CONF_REMOTE_ACCESS_ENABLED, + CONF_REMOTE_ACCESS_HOST, + CONF_REMOTE_ACCESS_PORT, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -19,6 +26,7 @@ FLUX_DISCOVERY_PARTIAL, IP_ADDRESS, MAC_ADDRESS, + _mocked_bulb, _patch_discovery, _patch_wifibulb, ) @@ -26,23 +34,50 @@ from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("mock_single_broadcast_address") async def test_configuring_flux_led_causes_discovery(hass: HomeAssistant) -> None: """Test that specifying empty config does discovery.""" with patch( - "homeassistant.components.flux_led.AIOBulbScanner.async_scan" + "homeassistant.components.flux_led.discovery.AIOBulbScanner.async_scan" + ) as scan, patch( + "homeassistant.components.flux_led.discovery.AIOBulbScanner.getBulbInfo" + ) as discover: + discover.return_value = [FLUX_DISCOVERY] + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + assert len(scan.mock_calls) == 1 + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert len(scan.mock_calls) == 2 + + async_fire_time_changed(hass, utcnow() + flux_led.DISCOVERY_INTERVAL) + await hass.async_block_till_done() + assert len(scan.mock_calls) == 3 + + +@pytest.mark.usefixtures("mock_multiple_broadcast_addresses") +async def test_configuring_flux_led_causes_discovery_multiple_addresses( + hass: HomeAssistant, +) -> None: + """Test that specifying empty config does discovery.""" + with patch( + "homeassistant.components.flux_led.discovery.AIOBulbScanner.async_scan" + ) as scan, patch( + "homeassistant.components.flux_led.discovery.AIOBulbScanner.getBulbInfo" ) as discover: discover.return_value = [FLUX_DISCOVERY] await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - assert len(discover.mock_calls) == 1 + assert len(scan.mock_calls) == 2 hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert len(discover.mock_calls) == 2 + assert len(scan.mock_calls) == 4 async_fire_time_changed(hass, utcnow() + flux_led.DISCOVERY_INTERVAL) await hass.async_block_till_done() - assert len(discover.mock_calls) == 3 + assert len(scan.mock_calls) == 6 async def test_config_entry_reload(hass: HomeAssistant) -> None: @@ -74,7 +109,7 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: "discovery,title", [ (FLUX_DISCOVERY, DEFAULT_ENTRY_TITLE), - (FLUX_DISCOVERY_PARTIAL, "AZ120444 ddeeff"), + (FLUX_DISCOVERY_PARTIAL, DEFAULT_ENTRY_TITLE), ], ) async def test_config_entry_fills_unique_id_with_directed_discovery( @@ -85,13 +120,24 @@ async def test_config_entry_fills_unique_id_with_directed_discovery( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=None ) config_entry.add_to_hass(hass) + last_address = None async def _discovery(self, *args, address=None, **kwargs): # Only return discovery results when doing directed discovery - return [discovery] if address == IP_ADDRESS else [] + nonlocal last_address + last_address = address + return [FLUX_DISCOVERY] if address == IP_ADDRESS else [] + + def _mock_getBulbInfo(*args, **kwargs): + nonlocal last_address + return [FLUX_DISCOVERY] if last_address == IP_ADDRESS else [] with patch( - "homeassistant.components.flux_led.AIOBulbScanner.async_scan", new=_discovery + "homeassistant.components.flux_led.discovery.AIOBulbScanner.async_scan", + new=_discovery, + ), patch( + "homeassistant.components.flux_led.discovery.AIOBulbScanner.getBulbInfo", + new=_mock_getBulbInfo, ), _patch_wifibulb(): await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() @@ -100,3 +146,62 @@ async def _discovery(self, *args, address=None, **kwargs): assert config_entry.unique_id == MAC_ADDRESS assert config_entry.data[CONF_NAME] == title assert config_entry.title == title + + +async def test_time_sync_startup_and_next_day(hass: HomeAssistant) -> None: + """Test that time is synced on startup and next day.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=MAC_ADDRESS) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert len(bulb.async_set_time.mock_calls) == 1 + async_fire_time_changed(hass, utcnow() + timedelta(hours=24)) + await hass.async_block_till_done() + assert len(bulb.async_set_time.mock_calls) == 2 + + +async def test_unique_id_migrate_when_mac_discovered(hass: HomeAssistant) -> None: + """Test unique id migrated when mac discovered.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_REMOTE_ACCESS_HOST: "any", + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_PORT: 1234, + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + }, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(no_device=True), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + assert not config_entry.unique_id + entity_registry = er.async_get(hass) + assert ( + entity_registry.async_get("light.bulb_rgbcw_ddeeff").unique_id + == config_entry.entry_id + ) + assert ( + entity_registry.async_get("switch.bulb_rgbcw_ddeeff_remote_access").unique_id + == f"{config_entry.entry_id}_remote_access" + ) + + with _patch_discovery(), _patch_wifibulb(device=bulb): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get("light.bulb_rgbcw_ddeeff").unique_id + == config_entry.unique_id + ) + assert ( + entity_registry.async_get("switch.bulb_rgbcw_ddeeff_remote_access").unique_id + == f"{config_entry.unique_id}_remote_access" + ) diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index a719d29737880e..67603544c5d1d5 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -10,23 +10,34 @@ COLOR_MODE_RGBW as FLUX_COLOR_MODE_RGBW, COLOR_MODE_RGBWW as FLUX_COLOR_MODE_RGBWW, COLOR_MODES_RGB_W as FLUX_COLOR_MODES_RGB_W, + MODE_MUSIC, + MultiColorEffects, + WhiteChannelType, ) +from flux_led.protocol import MusicMode import pytest from homeassistant.components import flux_led from homeassistant.components.flux_led.const import ( CONF_COLORS, - CONF_CUSTOM_EFFECT, CONF_CUSTOM_EFFECT_COLORS, CONF_CUSTOM_EFFECT_SPEED_PCT, CONF_CUSTOM_EFFECT_TRANSITION, - CONF_DEVICES, + CONF_EFFECT, CONF_SPEED_PCT, CONF_TRANSITION, + CONF_WHITE_CHANNEL_TYPE, DOMAIN, - MODE_AUTO, + MIN_CCT_BRIGHTNESS, + MIN_RGB_BRIGHTNESS, TRANSITION_JUMP, ) +from homeassistant.components.flux_led.light import ( + ATTR_BACKGROUND_COLOR, + ATTR_FOREGROUND_COLOR, + ATTR_LIGHT_SCREEN, + ATTR_SENSITIVITY, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, @@ -49,8 +60,6 @@ CONF_HOST, CONF_MODE, CONF_NAME, - CONF_PLATFORM, - CONF_PROTOCOL, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -128,8 +137,8 @@ async def test_light_goes_unavailable_and_recovers(hass: HomeAssistant) -> None: assert state.state == STATE_ON -async def test_light_no_unique_id(hass: HomeAssistant) -> None: - """Test a light without a unique id.""" +async def test_light_mac_address_not_found(hass: HomeAssistant) -> None: + """Test a light when we cannot discover the mac address.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE} ) @@ -141,7 +150,7 @@ async def test_light_no_unique_id(hass: HomeAssistant) -> None: entity_id = "light.bulb_rgbcw_ddeeff" entity_registry = er.async_get(hass) - assert entity_registry.async_get(entity_id) is None + assert entity_registry.async_get(entity_id).unique_id == config_entry.entry_id state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -345,7 +354,12 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None: # If the bulb is off and we are using existing brightness # it has to be at least 1 or the bulb won't turn on bulb.async_turn_on.assert_not_called() - bulb.async_set_levels.assert_called_with(1, 1, 1, brightness=1) + bulb.async_set_levels.assert_called_with( + MIN_RGB_BRIGHTNESS, + MIN_RGB_BRIGHTNESS, + MIN_RGB_BRIGHTNESS, + brightness=MIN_RGB_BRIGHTNESS, + ) bulb.async_set_levels.reset_mock() bulb.async_turn_on.reset_mock() @@ -491,10 +505,30 @@ async def test_rgbw_light_auto_on(hass: HomeAssistant) -> None: ) # If the bulb is on and we are using existing brightness # and brightness was 0 we need to set it to at least 1 - # or the device may not turn on + # or the device may not turn on. In this case we scale + # the current color to brightness of 1 to ensure the device + # does not switch to white since otherwise we do not have + # enough resolution to determine which color to display bulb.async_turn_on.assert_not_called() bulb.async_set_brightness.assert_not_called() - bulb.async_set_levels.assert_called_with(1, 1, 1, 0) + bulb.async_set_levels.assert_called_with(2, 0, 0, 0) + bulb.async_set_levels.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: (0, 0, 0, 56)}, + blocking=True, + ) + # If the bulb is on and we are using existing brightness + # and brightness was 0 we need to set it to at least 1 + # or the device may not turn on. In this case we scale + # the current color to brightness of 1 to ensure the device + # does not switch to white since otherwise we do not have + # enough resolution to determine which color to display + bulb.async_turn_on.assert_not_called() + bulb.async_set_brightness.assert_not_called() + bulb.async_set_levels.assert_called_with(2, 0, 0, 56) bulb.async_set_levels.reset_mock() bulb.brightness = 128 @@ -606,10 +640,13 @@ async def test_rgbww_light_auto_on(hass: HomeAssistant) -> None: ) # If the bulb is on and we are using existing brightness # and brightness was 0 we need to set it to at least 1 - # or the device may not turn on + # or the device may not turn on. In this case we scale + # the current color so we do not unexpectedly switch to white + # since other we do not have enough resolution to determine + # which color to display bulb.async_turn_on.assert_not_called() bulb.async_set_brightness.assert_not_called() - bulb.async_set_levels.assert_called_with(1, 1, 1, 0, 0) + bulb.async_set_levels.assert_called_with(2, 0, 0, 0, 0) bulb.async_set_levels.reset_mock() bulb.brightness = 128 @@ -764,11 +801,15 @@ async def test_rgb_cct_light(hass: HomeAssistant) -> None: bulb.async_set_brightness.reset_mock() -async def test_rgbw_light(hass: HomeAssistant) -> None: - """Test an rgbw light.""" +async def test_rgbw_light_cold_white(hass: HomeAssistant) -> None: + """Test an rgbw light with a cold white channel.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + CONF_WHITE_CHANNEL_TYPE: WhiteChannelType.COLD.name.lower(), + }, unique_id=MAC_ADDRESS, ) config_entry.add_to_hass(hass) @@ -866,6 +907,148 @@ async def test_rgbw_light(hass: HomeAssistant) -> None: bulb.async_set_effect.reset_mock() +async def test_rgbw_light_warm_white(hass: HomeAssistant) -> None: + """Test an rgbw light with a warm white channel.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + CONF_WHITE_CHANNEL_TYPE: WhiteChannelType.WARM.name.lower(), + }, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.color_modes = {FLUX_COLOR_MODE_RGBW, FLUX_COLOR_MODE_CCT} + bulb.color_mode = FLUX_COLOR_MODE_RGBW + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.bulb_rgbcw_ddeeff" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 128 + assert attributes[ATTR_COLOR_MODE] == "rgbw" + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "rgbw"] + assert attributes[ATTR_RGB_COLOR] == (255, 42, 42) + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_off.assert_called_once() + await async_mock_device_turn_off(hass, bulb) + + assert hass.states.get(entity_id).state == STATE_OFF + + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_on.assert_called_once() + bulb.async_turn_on.reset_mock() + bulb.is_on = True + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + { + ATTR_ENTITY_ID: entity_id, + ATTR_RGBW_COLOR: (255, 255, 255, 255), + ATTR_BRIGHTNESS: 128, + }, + blocking=True, + ) + bulb.async_set_levels.assert_called_with(128, 128, 128, 128) + bulb.async_set_levels.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + blocking=True, + ) + bulb.async_set_levels.assert_called_with(255, 255, 255, 255) + bulb.async_set_levels.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: (255, 191, 178, 0)}, + blocking=True, + ) + bulb.async_set_levels.assert_called_with(255, 191, 178, 0) + bulb.async_set_levels.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154}, + blocking=True, + ) + bulb.async_set_white_temp.assert_called_with(6493, 255) + bulb.async_set_white_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154, ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + bulb.async_set_white_temp.assert_called_with(6493, 255) + bulb.async_set_white_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 290}, + blocking=True, + ) + bulb.async_set_white_temp.assert_called_with(3448, 255) + bulb.async_set_white_temp.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: (255, 191, 178, 0)}, + blocking=True, + ) + bulb.async_set_levels.assert_called_with(255, 191, 178, 0) + bulb.async_set_levels.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "random"}, + blocking=True, + ) + bulb.async_set_effect.assert_called_once() + bulb.async_set_effect.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "purple_fade", ATTR_BRIGHTNESS: 255}, + blocking=True, + ) + bulb.async_set_effect.assert_called_with("purple_fade", 50, 100) + bulb.async_set_effect.reset_mock() + + async def test_rgb_or_w_light(hass: HomeAssistant) -> None: """Test an rgb or w light.""" config_entry = MockConfigEntry( @@ -1055,8 +1238,8 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154}, blocking=True, ) - bulb.async_set_levels.assert_called_with(r=0, b=0, g=0, w=0, w2=127) - bulb.async_set_levels.reset_mock() + bulb.async_set_white_temp.assert_called_with(6493, 255) + bulb.async_set_white_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -1064,8 +1247,8 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 154, ATTR_BRIGHTNESS: 255}, blocking=True, ) - bulb.async_set_levels.assert_called_with(r=0, b=0, g=0, w=0, w2=255) - bulb.async_set_levels.reset_mock() + bulb.async_set_white_temp.assert_called_with(6493, 255) + bulb.async_set_white_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -1073,8 +1256,8 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 290}, blocking=True, ) - bulb.async_set_levels.assert_called_with(r=0, b=0, g=0, w=102, w2=25) - bulb.async_set_levels.reset_mock() + bulb.async_set_white_temp.assert_called_with(3448, 255) + bulb.async_set_white_temp.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -1114,6 +1297,25 @@ async def test_rgbcw_light(hass: HomeAssistant) -> None: bulb.async_set_brightness.assert_called_with(255) bulb.async_set_brightness.reset_mock() + await async_mock_device_turn_off(hass, bulb) + bulb.color_mode = FLUX_COLOR_MODE_RGBWW + bulb.brightness = MIN_RGB_BRIGHTNESS + bulb.rgb = (MIN_RGB_BRIGHTNESS, MIN_RGB_BRIGHTNESS, MIN_RGB_BRIGHTNESS) + await async_mock_device_turn_on(hass, bulb) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_COLOR_MODE] == COLOR_MODE_RGBWW + assert state.attributes[ATTR_BRIGHTNESS] == 1 + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 170}, + blocking=True, + ) + bulb.async_set_white_temp.assert_called_with(5882, MIN_CCT_BRIGHTNESS) + bulb.async_set_white_temp.reset_mock() + async def test_white_light(hass: HomeAssistant) -> None: """Test a white light.""" @@ -1213,7 +1415,7 @@ async def test_rgb_light_custom_effects(hass: HomeAssistant) -> None: data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, unique_id=MAC_ADDRESS, options={ - CONF_MODE: MODE_AUTO, + CONF_MODE: "auto", CONF_CUSTOM_EFFECT_COLORS: "[0,0,255], [255,0,0]", CONF_CUSTOM_EFFECT_SPEED_PCT: 88, CONF_CUSTOM_EFFECT_TRANSITION: TRANSITION_JUMP, @@ -1289,7 +1491,7 @@ async def test_rgb_light_custom_effects_invalid_colors( ) -> None: """Test an rgb light with a invalid effect.""" options = { - CONF_MODE: MODE_AUTO, + CONF_MODE: "auto", CONF_CUSTOM_EFFECT_SPEED_PCT: 88, CONF_CUSTOM_EFFECT_TRANSITION: TRANSITION_JUMP, } @@ -1373,98 +1575,35 @@ async def test_rgb_light_custom_effect_via_service( ) bulb.async_set_custom_pattern.reset_mock() + await hass.services.async_call( + DOMAIN, + "set_zones", + { + ATTR_ENTITY_ID: entity_id, + CONF_COLORS: [[0, 0, 255], [255, 0, 0]], + CONF_EFFECT: "running_water", + }, + blocking=True, + ) + bulb.async_set_zones.assert_called_with( + [(0, 0, 255), (255, 0, 0)], 50, MultiColorEffects.RUNNING_WATER + ) + bulb.async_set_zones.reset_mock() -async def test_migrate_from_yaml_with_custom_effect(hass: HomeAssistant) -> None: - """Test migrate from yaml.""" - config = { - LIGHT_DOMAIN: [ - { - CONF_PLATFORM: DOMAIN, - CONF_DEVICES: { - IP_ADDRESS: { - CONF_NAME: "flux_lamppost", - CONF_PROTOCOL: "ledenet", - CONF_CUSTOM_EFFECT: { - CONF_SPEED_PCT: 30, - CONF_TRANSITION: "strobe", - CONF_COLORS: [[255, 0, 0], [255, 255, 0], [0, 255, 0]], - }, - } - }, - } - ], - } - with _patch_discovery(), _patch_wifibulb(): - await async_setup_component(hass, LIGHT_DOMAIN, config) - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert entries - - migrated_entry = None - for entry in entries: - if entry.unique_id == MAC_ADDRESS: - migrated_entry = entry - break - - assert migrated_entry is not None - assert migrated_entry.data == { - CONF_HOST: IP_ADDRESS, - CONF_NAME: "flux_lamppost", - CONF_PROTOCOL: "ledenet", - } - assert migrated_entry.options == { - CONF_MODE: "auto", - CONF_CUSTOM_EFFECT_COLORS: "[(255, 0, 0), (255, 255, 0), (0, 255, 0)]", - CONF_CUSTOM_EFFECT_SPEED_PCT: 30, - CONF_CUSTOM_EFFECT_TRANSITION: "strobe", - } - - -async def test_migrate_from_yaml_no_custom_effect(hass: HomeAssistant) -> None: - """Test migrate from yaml.""" - config = { - LIGHT_DOMAIN: [ - { - CONF_PLATFORM: DOMAIN, - CONF_DEVICES: { - IP_ADDRESS: { - CONF_NAME: "flux_lamppost", - CONF_PROTOCOL: "ledenet", - } - }, - } - ], - } - with _patch_discovery(), _patch_wifibulb(): - await async_setup_component(hass, LIGHT_DOMAIN, config) - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert entries - - migrated_entry = None - for entry in entries: - if entry.unique_id == MAC_ADDRESS: - migrated_entry = entry - break - - assert migrated_entry is not None - assert migrated_entry.data == { - CONF_HOST: IP_ADDRESS, - CONF_NAME: "flux_lamppost", - CONF_PROTOCOL: "ledenet", - } - assert migrated_entry.options == { - CONF_MODE: "auto", - CONF_CUSTOM_EFFECT_COLORS: None, - CONF_CUSTOM_EFFECT_SPEED_PCT: 50, - CONF_CUSTOM_EFFECT_TRANSITION: "gradual", - } + await hass.services.async_call( + DOMAIN, + "set_zones", + { + ATTR_ENTITY_ID: entity_id, + CONF_COLORS: [[0, 0, 255], [255, 0, 0]], + CONF_SPEED_PCT: 30, + }, + blocking=True, + ) + bulb.async_set_zones.assert_called_with( + [(0, 0, 255), (255, 0, 0)], 30, MultiColorEffects.STATIC + ) + bulb.async_set_zones.reset_mock() async def test_addressable_light(hass: HomeAssistant) -> None: @@ -1506,3 +1645,47 @@ async def test_addressable_light(hass: HomeAssistant) -> None: bulb.async_turn_on.assert_called_once() bulb.async_turn_on.reset_mock() await async_mock_device_turn_on(hass, bulb) + + +async def test_music_mode_service(hass: HomeAssistant) -> None: + """Test music mode service.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.raw_state = bulb.raw_state._replace(model_num=0xA3) # has music mode + bulb.microphone = True + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.bulb_rgbcw_ddeeff" + assert hass.states.get(entity_id) + + bulb.effect = MODE_MUSIC + bulb.is_on = False + await hass.services.async_call( + DOMAIN, + "set_music_mode", + { + ATTR_ENTITY_ID: entity_id, + ATTR_EFFECT: 12, + ATTR_LIGHT_SCREEN: True, + ATTR_SENSITIVITY: 50, + ATTR_BRIGHTNESS: 50, + ATTR_FOREGROUND_COLOR: [255, 0, 0], + ATTR_BACKGROUND_COLOR: [0, 255, 0], + }, + blocking=True, + ) + bulb.async_set_music_mode.assert_called_once_with( + sensitivity=50, + brightness=50, + mode=MusicMode.LIGHT_SCREEN.value, + effect=12, + foreground_color=(255, 0, 0), + background_color=(0, 255, 0), + ) diff --git a/tests/components/flux_led/test_number.py b/tests/components/flux_led/test_number.py index 325307f1f32d7a..d0d71cacbe19ed 100644 --- a/tests/components/flux_led/test_number.py +++ b/tests/components/flux_led/test_number.py @@ -1,17 +1,26 @@ """Tests for the flux_led number platform.""" +from unittest.mock import patch + from flux_led.const import COLOR_MODE_RGB as FLUX_COLOR_MODE_RGB import pytest from homeassistant.components import flux_led +from homeassistant.components.flux_led import number as flux_number from homeassistant.components.flux_led.const import DOMAIN from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, STATE_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -32,7 +41,7 @@ from tests.common import MockConfigEntry -async def test_number_unique_id(hass: HomeAssistant) -> None: +async def test_effects_speed_unique_id(hass: HomeAssistant) -> None: """Test a number unique id.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -50,6 +59,23 @@ async def test_number_unique_id(hass: HomeAssistant) -> None: assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS +async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None: + """Test a number unique id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(no_device=True), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "number.bulb_rgbcw_ddeeff_effect_speed" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == config_entry.entry_id + + async def test_rgb_light_effect_speed(hass: HomeAssistant) -> None: """Test an rgb light with an effect.""" config_entry = MockConfigEntry( @@ -225,3 +251,155 @@ async def test_addressable_light_effect_speed(hass: HomeAssistant) -> None: state = hass.states.get(number_entity_id) assert state.state == "100" + + +async def test_addressable_light_pixel_config(hass: HomeAssistant) -> None: + """Test an addressable light pixel config.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.raw_state = bulb.raw_state._replace( + model_num=0xA2 + ) # Original addressable model + bulb.color_modes = {FLUX_COLOR_MODE_RGB} + bulb.color_mode = FLUX_COLOR_MODE_RGB + with patch.object( + flux_number, "DEBOUNCE_TIME", 0 + ), _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + pixels_per_segment_entity_id = "number.bulb_rgbcw_ddeeff_pixels_per_segment" + state = hass.states.get(pixels_per_segment_entity_id) + assert state.state == "300" + + segments_entity_id = "number.bulb_rgbcw_ddeeff_segments" + state = hass.states.get(segments_entity_id) + assert state.state == "2" + + music_pixels_per_segment_entity_id = ( + "number.bulb_rgbcw_ddeeff_music_pixels_per_segment" + ) + state = hass.states.get(music_pixels_per_segment_entity_id) + assert state.state == "150" + + music_segments_entity_id = "number.bulb_rgbcw_ddeeff_music_segments" + state = hass.states.get(music_segments_entity_id) + assert state.state == "4" + + with pytest.raises(ValueError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: pixels_per_segment_entity_id, ATTR_VALUE: 5000}, + blocking=True, + ) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: pixels_per_segment_entity_id, ATTR_VALUE: 100}, + blocking=True, + ) + bulb.async_set_device_config.assert_called_with(pixels_per_segment=100) + bulb.async_set_device_config.reset_mock() + + with pytest.raises(ValueError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: music_pixels_per_segment_entity_id, ATTR_VALUE: 5000}, + blocking=True, + ) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: music_pixels_per_segment_entity_id, ATTR_VALUE: 100}, + blocking=True, + ) + bulb.async_set_device_config.assert_called_with(music_pixels_per_segment=100) + bulb.async_set_device_config.reset_mock() + + with pytest.raises(ValueError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: segments_entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: segments_entity_id, ATTR_VALUE: 5}, + blocking=True, + ) + bulb.async_set_device_config.assert_called_with(segments=5) + bulb.async_set_device_config.reset_mock() + + with pytest.raises(ValueError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: music_segments_entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: music_segments_entity_id, ATTR_VALUE: 5}, + blocking=True, + ) + bulb.async_set_device_config.assert_called_with(music_segments=5) + bulb.async_set_device_config.reset_mock() + + +async def test_addressable_light_pixel_config_music_disabled( + hass: HomeAssistant, +) -> None: + """Test an addressable light pixel config with music pixels disabled.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.pixels_per_segment = 150 + bulb.segments = 1 + bulb.music_pixels_per_segment = 150 + bulb.music_segments = 1 + bulb.raw_state = bulb.raw_state._replace( + model_num=0xA2 + ) # Original addressable model + bulb.color_modes = {FLUX_COLOR_MODE_RGB} + bulb.color_mode = FLUX_COLOR_MODE_RGB + with patch.object( + flux_number, "DEBOUNCE_TIME", 0 + ), _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + pixels_per_segment_entity_id = "number.bulb_rgbcw_ddeeff_pixels_per_segment" + state = hass.states.get(pixels_per_segment_entity_id) + assert state.state == "150" + + segments_entity_id = "number.bulb_rgbcw_ddeeff_segments" + state = hass.states.get(segments_entity_id) + assert state.state == "1" + + music_pixels_per_segment_entity_id = ( + "number.bulb_rgbcw_ddeeff_music_pixels_per_segment" + ) + state = hass.states.get(music_pixels_per_segment_entity_id) + assert state.state == STATE_UNAVAILABLE + + music_segments_entity_id = "number.bulb_rgbcw_ddeeff_music_segments" + state = hass.states.get(music_segments_entity_id) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/flux_led/test_select.py b/tests/components/flux_led/test_select.py new file mode 100644 index 00000000000000..b2a88b00fe06b6 --- /dev/null +++ b/tests/components/flux_led/test_select.py @@ -0,0 +1,301 @@ +"""Tests for select platform.""" +from unittest.mock import patch + +from flux_led.const import ( + COLOR_MODE_CCT as FLUX_COLOR_MODE_CCT, + COLOR_MODE_RGBW as FLUX_COLOR_MODE_RGBW, + WhiteChannelType, +) +from flux_led.protocol import PowerRestoreState, RemoteConfig +import pytest + +from homeassistant.components import flux_led +from homeassistant.components.flux_led.const import CONF_WHITE_CHANNEL_TYPE, DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from . import ( + DEFAULT_ENTRY_TITLE, + FLUX_DISCOVERY, + IP_ADDRESS, + MAC_ADDRESS, + _mock_config_entry_for_bulb, + _mocked_bulb, + _mocked_switch, + _patch_discovery, + _patch_wifibulb, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def no_wait_on_state_change(): + """Disable waiting for state change in tests.""" + with patch("homeassistant.components.flux_led.select.STATE_CHANGE_LATENCY", 0): + yield + + +async def test_switch_power_restore_state(hass: HomeAssistant) -> None: + """Test a smart plug power restore state.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + switch = _mocked_switch() + with _patch_discovery(), _patch_wifibulb(device=switch): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "select.bulb_rgbcw_ddeeff_power_restored" + + state = hass.states.get(entity_id) + assert state.state == "Last State" + + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Always On"}, + blocking=True, + ) + switch.async_set_power_restore.assert_called_once_with( + channel1=PowerRestoreState.ALWAYS_ON + ) + + +async def test_power_restored_unique_id(hass: HomeAssistant) -> None: + """Test a select unique id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + switch = _mocked_switch() + with _patch_discovery(), _patch_wifibulb(device=switch): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "select.bulb_rgbcw_ddeeff_power_restored" + entity_registry = er.async_get(hass) + assert ( + entity_registry.async_get(entity_id).unique_id + == f"{MAC_ADDRESS}_power_restored" + ) + + +async def test_power_restored_unique_id_no_discovery(hass: HomeAssistant) -> None: + """Test a select unique id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + ) + config_entry.add_to_hass(hass) + switch = _mocked_switch() + with _patch_discovery(no_device=True), _patch_wifibulb(device=switch): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "select.bulb_rgbcw_ddeeff_power_restored" + entity_registry = er.async_get(hass) + assert ( + entity_registry.async_get(entity_id).unique_id + == f"{config_entry.entry_id}_power_restored" + ) + + +async def test_select_addressable_strip_config(hass: HomeAssistant) -> None: + """Test selecting addressable strip configs.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.raw_state = bulb.raw_state._replace(model_num=0xA2) # addressable model + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + wiring_entity_id = "select.bulb_rgbcw_ddeeff_wiring" + state = hass.states.get(wiring_entity_id) + assert state.state == "BGRW" + + ic_type_entity_id = "select.bulb_rgbcw_ddeeff_ic_type" + state = hass.states.get(ic_type_entity_id) + assert state.state == "WS2812B" + + with pytest.raises(ValueError): + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: wiring_entity_id, ATTR_OPTION: "INVALID"}, + blocking=True, + ) + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: wiring_entity_id, ATTR_OPTION: "GRBW"}, + blocking=True, + ) + bulb.async_set_device_config.assert_called_once_with(wiring="GRBW") + bulb.async_set_device_config.reset_mock() + + with pytest.raises(ValueError): + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: ic_type_entity_id, ATTR_OPTION: "INVALID"}, + blocking=True, + ) + + with patch( + "homeassistant.components.flux_led.async_setup_entry" + ) as mock_setup_entry: + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: ic_type_entity_id, ATTR_OPTION: "UCS1618"}, + blocking=True, + ) + await hass.async_block_till_done() + bulb.async_set_device_config.assert_called_once_with(ic_type="UCS1618") + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_select_mutable_0x25_strip_config(hass: HomeAssistant) -> None: + """Test selecting mutable 0x25 strip configs.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.operating_mode = "RGBWW" + bulb.operating_modes = ["DIM", "CCT", "RGB", "RGBW", "RGBWW"] + bulb.raw_state = bulb.raw_state._replace(model_num=0x25) # addressable model + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + operating_mode_entity_id = "select.bulb_rgbcw_ddeeff_operating_mode" + state = hass.states.get(operating_mode_entity_id) + assert state.state == "RGBWW" + + with pytest.raises(ValueError): + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: operating_mode_entity_id, ATTR_OPTION: "INVALID"}, + blocking=True, + ) + + with patch( + "homeassistant.components.flux_led.async_setup_entry" + ) as mock_setup_entry: + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: operating_mode_entity_id, ATTR_OPTION: "CCT"}, + blocking=True, + ) + await hass.async_block_till_done() + bulb.async_set_device_config.assert_called_once_with(operating_mode="CCT") + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_select_24ghz_remote_config(hass: HomeAssistant) -> None: + """Test selecting 2.4ghz remote config.""" + _mock_config_entry_for_bulb(hass) + bulb = _mocked_bulb() + bulb.discovery = FLUX_DISCOVERY + with _patch_discovery(device=FLUX_DISCOVERY), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + remote_config_entity_id = "select.bulb_rgbcw_ddeeff_remote_config" + state = hass.states.get(remote_config_entity_id) + assert state.state == "Open" + + with pytest.raises(ValueError): + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: remote_config_entity_id, ATTR_OPTION: "INVALID"}, + blocking=True, + ) + + bulb.remote_config = RemoteConfig.DISABLED + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: remote_config_entity_id, ATTR_OPTION: "Disabled"}, + blocking=True, + ) + bulb.async_config_remotes.assert_called_once_with(RemoteConfig.DISABLED) + bulb.async_config_remotes.reset_mock() + + bulb.remote_config = RemoteConfig.PAIRED_ONLY + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: remote_config_entity_id, ATTR_OPTION: "Paired Only"}, + blocking=True, + ) + bulb.async_config_remotes.assert_called_once_with(RemoteConfig.PAIRED_ONLY) + bulb.async_config_remotes.reset_mock() + + +async def test_select_white_channel_type(hass: HomeAssistant) -> None: + """Test selecting the white channel type.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.color_modes = {FLUX_COLOR_MODE_RGBW, FLUX_COLOR_MODE_CCT} + bulb.color_mode = FLUX_COLOR_MODE_RGBW + bulb.raw_state = bulb.raw_state._replace(model_num=0x06) # rgbw + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + operating_mode_entity_id = "select.bulb_rgbcw_ddeeff_white_channel" + state = hass.states.get(operating_mode_entity_id) + assert state.state == WhiteChannelType.WARM.name.title() + + with pytest.raises(ValueError): + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + {ATTR_ENTITY_ID: operating_mode_entity_id, ATTR_OPTION: "INVALID"}, + blocking=True, + ) + + with patch( + "homeassistant.components.flux_led.async_setup_entry" + ) as mock_setup_entry: + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: operating_mode_entity_id, + ATTR_OPTION: WhiteChannelType.NATURAL.name.title(), + }, + blocking=True, + ) + await hass.async_block_till_done() + assert ( + config_entry.data[CONF_WHITE_CHANNEL_TYPE] + == WhiteChannelType.NATURAL.name.lower() + ) + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/flux_led/test_sensor.py b/tests/components/flux_led/test_sensor.py new file mode 100644 index 00000000000000..b06a6330fde2db --- /dev/null +++ b/tests/components/flux_led/test_sensor.py @@ -0,0 +1,25 @@ +"""Tests for flux_led sensor platform.""" +from homeassistant.components import flux_led +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import ( + FLUX_DISCOVERY, + _mock_config_entry_for_bulb, + _mocked_bulb, + _patch_discovery, + _patch_wifibulb, +) + + +async def test_paired_remotes_sensor(hass: HomeAssistant) -> None: + """Test that the paired remotes sensor has the correct value.""" + _mock_config_entry_for_bulb(hass) + bulb = _mocked_bulb() + bulb.discovery = FLUX_DISCOVERY + with _patch_discovery(device=FLUX_DISCOVERY), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "sensor.bulb_rgbcw_ddeeff_paired_remotes" + assert hass.states.get(entity_id).state == "2" diff --git a/tests/components/flux_led/test_switch.py b/tests/components/flux_led/test_switch.py index b569d51e13a506..cb0034f8d36b19 100644 --- a/tests/components/flux_led/test_switch.py +++ b/tests/components/flux_led/test_switch.py @@ -1,6 +1,13 @@ """Tests for switch platform.""" +from flux_led.const import MODE_MUSIC + from homeassistant.components import flux_led -from homeassistant.components.flux_led.const import DOMAIN +from homeassistant.components.flux_led.const import ( + CONF_REMOTE_ACCESS_ENABLED, + CONF_REMOTE_ACCESS_HOST, + CONF_REMOTE_ACCESS_PORT, + DOMAIN, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -10,12 +17,14 @@ STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, MAC_ADDRESS, + _mocked_bulb, _mocked_switch, _patch_discovery, _patch_wifibulb, @@ -27,7 +36,7 @@ async def test_switch_on_off(hass: HomeAssistant) -> None: - """Test a switch light.""" + """Test a smart plug.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, @@ -60,3 +69,138 @@ async def test_switch_on_off(hass: HomeAssistant) -> None: await async_mock_device_turn_on(hass, switch) assert hass.states.get(entity_id).state == STATE_ON + + +async def test_remote_access_unique_id(hass: HomeAssistant) -> None: + """Test a remote access switch unique id.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_REMOTE_ACCESS_HOST: "any", + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_PORT: 1234, + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + }, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.bulb_rgbcw_ddeeff_remote_access" + entity_registry = er.async_get(hass) + assert ( + entity_registry.async_get(entity_id).unique_id == f"{MAC_ADDRESS}_remote_access" + ) + + +async def test_effects_speed_unique_id_no_discovery(hass: HomeAssistant) -> None: + """Test a remote access switch unique id when discovery fails.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_REMOTE_ACCESS_HOST: "any", + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_PORT: 1234, + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + }, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(no_device=True), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.bulb_rgbcw_ddeeff_remote_access" + entity_registry = er.async_get(hass) + assert ( + entity_registry.async_get(entity_id).unique_id + == f"{config_entry.entry_id}_remote_access" + ) + + +async def test_remote_access_on_off(hass: HomeAssistant) -> None: + """Test enable/disable remote access.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_REMOTE_ACCESS_HOST: "any", + CONF_REMOTE_ACCESS_ENABLED: True, + CONF_REMOTE_ACCESS_PORT: 1234, + CONF_HOST: IP_ADDRESS, + CONF_NAME: DEFAULT_ENTRY_TITLE, + }, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(), _patch_wifibulb(bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.bulb_rgbcw_ddeeff_remote_access" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_disable_remote_access.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OFF + assert config_entry.data[CONF_REMOTE_ACCESS_ENABLED] is False + + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_enable_remote_access.assert_called_once() + + assert hass.states.get(entity_id).state == STATE_ON + assert config_entry.data[CONF_REMOTE_ACCESS_ENABLED] is True + + +async def test_music_mode_switch(hass: HomeAssistant) -> None: + """Test music mode switch.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + bulb.raw_state = bulb.raw_state._replace(model_num=0xA3) # has music mode + bulb.microphone = True + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "switch.bulb_rgbcw_ddeeff_music" + + assert hass.states.get(entity_id).state == STATE_OFF + + bulb.effect = MODE_MUSIC + bulb.is_on = False + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_set_music_mode.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OFF + + bulb.async_set_music_mode.reset_mock() + bulb.is_on = True + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_set_music_mode.assert_called_once() + assert hass.states.get(entity_id).state == STATE_ON + + bulb.effect = None + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_set_levels.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 0be3f4bde0b174..6e1adc14b7fc52 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -1,7 +1,7 @@ """Fixtures for Forecast.Solar integration tests.""" +from collections.abc import Generator from datetime import datetime, timedelta -from typing import Generator from unittest.mock import MagicMock, patch from forecast_solar import models diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index a6fb45158f8a78..ee8afe5794b018 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -7,16 +7,14 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TIMESTAMP, ENERGY_KILO_WATT_HOUR, POWER_WATT, ) @@ -47,7 +45,7 @@ async def test_sensors( ) assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ATTR_ICON not in state.attributes state = hass.states.get("sensor.energy_production_tomorrow") @@ -62,7 +60,7 @@ async def test_sensors( ) assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ATTR_ICON not in state.attributes state = hass.states.get("sensor.power_highest_peak_time_today") @@ -73,7 +71,7 @@ async def test_sensors( assert state.state == "2021-06-27T13:00:00+00:00" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Highest Power Peak Time - Today" assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes assert ATTR_ICON not in state.attributes @@ -87,7 +85,7 @@ async def test_sensors( state.attributes.get(ATTR_FRIENDLY_NAME) == "Highest Power Peak Time - Tomorrow" ) assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes assert ATTR_ICON not in state.attributes @@ -100,9 +98,9 @@ async def test_sensors( assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Power Production - Now" ) - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ATTR_ICON not in state.attributes state = hass.states.get("sensor.energy_current_hour") @@ -117,7 +115,7 @@ async def test_sensors( ) assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ATTR_ICON not in state.attributes state = hass.states.get("sensor.energy_next_hour") @@ -132,7 +130,7 @@ async def test_sensors( ) assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ATTR_ICON not in state.attributes assert entry.device_id @@ -166,7 +164,7 @@ async def test_disabled_by_default( entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @pytest.mark.parametrize( @@ -224,5 +222,5 @@ async def test_enabling_disable_by_default( assert state.attributes.get(ATTR_FRIENDLY_NAME) == name assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ATTR_ICON not in state.attributes diff --git a/tests/components/foscam/test_config_flow.py b/tests/components/foscam/test_config_flow.py index 3a108b539d818a..63c30c16babe56 100644 --- a/tests/components/foscam/test_config_flow.py +++ b/tests/components/foscam/test_config_flow.py @@ -245,176 +245,3 @@ async def test_user_unknown_exception(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "unknown"} - - -async def test_import_user_valid(hass): - """Test valid config from import.""" - - with patch( - "homeassistant.components.foscam.config_flow.FoscamCamera", - ) as mock_foscam_camera, patch( - "homeassistant.components.foscam.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - setup_mock_foscam_camera(mock_foscam_camera) - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=VALID_CONFIG, - ) - - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == CAMERA_NAME - assert result["data"] == VALID_CONFIG - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_user_valid_with_name(hass): - """Test valid config with extra name from import.""" - - with patch( - "homeassistant.components.foscam.config_flow.FoscamCamera", - ) as mock_foscam_camera, patch( - "homeassistant.components.foscam.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - setup_mock_foscam_camera(mock_foscam_camera) - - name = CAMERA_NAME + " 1234" - with_name = VALID_CONFIG.copy() - with_name[config_flow.CONF_NAME] = name - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=with_name, - ) - - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == name - assert result["data"] == VALID_CONFIG - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_invalid_auth(hass): - """Test we handle invalid auth from import.""" - - with patch( - "homeassistant.components.foscam.config_flow.FoscamCamera", - ) as mock_foscam_camera: - setup_mock_foscam_camera(mock_foscam_camera) - - invalid_user = VALID_CONFIG.copy() - invalid_user[config_flow.CONF_USERNAME] = "invalid" - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=invalid_user, - ) - - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "invalid_auth" - - -async def test_import_cannot_connect(hass): - """Test we handle cannot connect error from import.""" - - with patch( - "homeassistant.components.foscam.config_flow.FoscamCamera", - ) as mock_foscam_camera: - setup_mock_foscam_camera(mock_foscam_camera) - - invalid_host = VALID_CONFIG.copy() - invalid_host[config_flow.CONF_HOST] = "127.0.0.1" - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=invalid_host, - ) - - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_invalid_response(hass): - """Test we handle invalid response error from import.""" - - with patch( - "homeassistant.components.foscam.config_flow.FoscamCamera", - ) as mock_foscam_camera: - setup_mock_foscam_camera(mock_foscam_camera) - - invalid_response = VALID_CONFIG.copy() - invalid_response[config_flow.CONF_USERNAME] = INVALID_RESPONSE_CONFIG[ - config_flow.CONF_USERNAME - ] - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=invalid_response, - ) - - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "invalid_response" - - -async def test_import_already_configured(hass): - """Test we handle already configured from import.""" - - entry = MockConfigEntry( - domain=config_flow.DOMAIN, - data=VALID_CONFIG, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.foscam.config_flow.FoscamCamera", - ) as mock_foscam_camera: - setup_mock_foscam_camera(mock_foscam_camera) - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=VALID_CONFIG, - ) - - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_import_unknown_exception(hass): - """Test we handle unknown exceptions from import.""" - - with patch( - "homeassistant.components.foscam.config_flow.FoscamCamera", - ) as mock_foscam_camera: - mock_foscam_camera.side_effect = Exception("test") - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=VALID_CONFIG, - ) - - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "unknown" diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 3220552b6cf9d4..2d9f0844115f26 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -3,6 +3,8 @@ import pytest +from homeassistant.helpers import device_registry as dr + from .const import ( DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, @@ -12,6 +14,8 @@ WIFI_GET_GLOBAL_CONFIG, ) +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def mock_path(): @@ -20,8 +24,30 @@ def mock_path(): yield +@pytest.fixture +def mock_device_registry_devices(hass): + """Create device registry devices so the device tracker entities are enabled.""" + dev_reg = dr.async_get(hass) + config_entry = MockConfigEntry(domain="something_else") + + for idx, device in enumerate( + ( + "68:A3:78:00:00:00", + "8C:97:EA:00:00:00", + "DE:00:B0:00:00:00", + "DC:00:B0:00:00:00", + "5E:65:55:00:00:00", + ) + ): + dev_reg.async_get_or_create( + name=f"Device {idx}", + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, device)}, + ) + + @pytest.fixture(name="router") -def mock_router(): +def mock_router(mock_device_registry_devices): """Mock a successful connection.""" with patch("homeassistant.components.freebox.router.Freepybox") as service_mock: instance = service_mock.return_value diff --git a/tests/components/freedompro/conftest.py b/tests/components/freedompro/conftest.py index 36070c1a0d5831..804dc6d193365b 100644 --- a/tests/components/freedompro/conftest.py +++ b/tests/components/freedompro/conftest.py @@ -1,4 +1,8 @@ """Fixtures for Freedompro integration tests.""" +from __future__ import annotations + +from copy import deepcopy +from typing import Any from unittest.mock import patch import pytest @@ -71,3 +75,8 @@ async def init_integration_no_state(hass) -> MockConfigEntry: await hass.async_block_till_done() return entry + + +def get_states_response_for_uid(uid: str) -> list[dict[str, Any]]: + """Return a deepcopy of the device state list for specific uid.""" + return deepcopy([resp for resp in DEVICES_STATE if resp["uid"] == uid]) diff --git a/tests/components/freedompro/test_binary_sensor.py b/tests/components/freedompro/test_binary_sensor.py index 785a6b0321267c..459484dbc457a8 100644 --- a/tests/components/freedompro/test_binary_sensor.py +++ b/tests/components/freedompro/test_binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed -from tests.components.freedompro.const import DEVICES_STATE +from tests.components.freedompro.conftest import get_states_response_for_uid @pytest.mark.parametrize( @@ -84,20 +84,18 @@ async def test_binary_sensor_get_state( assert state.state == STATE_OFF - get_states_response = list(DEVICES_STATE) - for state_response in get_states_response: - if state_response["uid"] == uid: - if state_response["type"] == "smokeSensor": - state_response["state"]["smokeDetected"] = True - if state_response["type"] == "occupancySensor": - state_response["state"]["occupancyDetected"] = True - if state_response["type"] == "motionSensor": - state_response["state"]["motionDetected"] = True - if state_response["type"] == "contactSensor": - state_response["state"]["contactSensorState"] = True + states_response = get_states_response_for_uid(uid) + if states_response[0]["type"] == "smokeSensor": + states_response[0]["state"]["smokeDetected"] = True + elif states_response[0]["type"] == "occupancySensor": + states_response[0]["state"]["occupancyDetected"] = True + elif states_response[0]["type"] == "motionSensor": + states_response[0]["state"]["motionDetected"] = True + elif states_response[0]["type"] == "contactSensor": + states_response[0]["state"]["contactSensorState"] = True with patch( "homeassistant.components.freedompro.get_states", - return_value=get_states_response, + return_value=states_response, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) diff --git a/tests/components/freedompro/test_climate.py b/tests/components/freedompro/test_climate.py index 36ec3309d24e6b..95c13a46766f6e 100644 --- a/tests/components/freedompro/test_climate.py +++ b/tests/components/freedompro/test_climate.py @@ -25,7 +25,7 @@ from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed -from tests.components.freedompro.const import DEVICES_STATE +from tests.components.freedompro.conftest import get_states_response_for_uid uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*TWMYQKL3UVED4HSIIB9GXJWJZBQCXG-9VE-N2IUAIWI" @@ -64,14 +64,12 @@ async def test_climate_get_state(hass, init_integration): assert entry assert entry.unique_id == uid - get_states_response = list(DEVICES_STATE) - for state_response in get_states_response: - if state_response["uid"] == uid: - state_response["state"]["currentTemperature"] = 20 - state_response["state"]["targetTemperature"] = 21 + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["currentTemperature"] = 20 + states_response[0]["state"]["targetTemperature"] = 21 with patch( "homeassistant.components.freedompro.get_states", - return_value=get_states_response, + return_value=states_response, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) await hass.async_block_till_done() @@ -172,7 +170,16 @@ async def test_climate_set_temperature(hass, init_integration): ANY, ANY, ANY, '{"heatingCoolingState": 0, "targetTemperature": 25.0}' ) - await hass.async_block_till_done() + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["currentTemperature"] = 20 + states_response[0]["state"]["targetTemperature"] = 21 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) assert state.attributes[ATTR_TEMPERATURE] == 21 diff --git a/tests/components/freedompro/test_cover.py b/tests/components/freedompro/test_cover.py index d0338dec82c3c2..11dc35b374cbd8 100644 --- a/tests/components/freedompro/test_cover.py +++ b/tests/components/freedompro/test_cover.py @@ -17,7 +17,7 @@ from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed -from tests.components.freedompro.const import DEVICES_STATE +from tests.components.freedompro.conftest import get_states_response_for_uid @pytest.mark.parametrize( @@ -55,13 +55,11 @@ async def test_cover_get_state( assert entry assert entry.unique_id == uid - get_states_response = list(DEVICES_STATE) - for state_response in get_states_response: - if state_response["uid"] == uid: - state_response["state"]["position"] = 100 + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["position"] = 100 with patch( "homeassistant.components.freedompro.get_states", - return_value=get_states_response, + return_value=states_response, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) await hass.async_block_till_done() @@ -97,7 +95,7 @@ async def test_cover_set_position( state = hass.states.get(entity_id) assert state - assert state.state == STATE_OPEN + assert state.state == STATE_CLOSED assert state.attributes.get("friendly_name") == name entry = registry.async_get(entity_id) @@ -113,9 +111,18 @@ async def test_cover_set_position( ) mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"position": 33}') - await hass.async_block_till_done() + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["position"] = 33 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) assert state.state == STATE_OPEN + assert state.attributes["current_position"] == 33 @pytest.mark.parametrize( @@ -136,6 +143,16 @@ async def test_cover_close( init_integration registry = er.async_get(hass) + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["position"] = 100 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + await hass.helpers.entity_component.async_update_entity(entity_id) + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) assert state assert state.state == STATE_OPEN @@ -154,9 +171,16 @@ async def test_cover_close( ) mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"position": 0}') - await hass.async_block_till_done() + states_response[0]["state"]["position"] = 0 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) - assert state.state == STATE_OPEN + assert state.state == STATE_CLOSED @pytest.mark.parametrize( @@ -179,7 +203,7 @@ async def test_cover_open( state = hass.states.get(entity_id) assert state - assert state.state == STATE_OPEN + assert state.state == STATE_CLOSED assert state.attributes.get("friendly_name") == name entry = registry.async_get(entity_id) @@ -195,6 +219,14 @@ async def test_cover_open( ) mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"position": 100}') - await hass.async_block_till_done() + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["position"] = 100 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) assert state.state == STATE_OPEN diff --git a/tests/components/freedompro/test_fan.py b/tests/components/freedompro/test_fan.py index 6bf4bbe1e0416e..3404c5d17e44a5 100644 --- a/tests/components/freedompro/test_fan.py +++ b/tests/components/freedompro/test_fan.py @@ -13,7 +13,7 @@ from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed -from tests.components.freedompro.const import DEVICES_STATE +from tests.components.freedompro.conftest import get_states_response_for_uid uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*ILYH1E3DWZOVMNEUIMDYMNLOW-LFRQFDPWWJOVHVDOS" @@ -42,14 +42,12 @@ async def test_fan_get_state(hass, init_integration): assert entry assert entry.unique_id == uid - get_states_response = list(DEVICES_STATE) - for state_response in get_states_response: - if state_response["uid"] == uid: - state_response["state"]["on"] = True - state_response["state"]["rotationSpeed"] = 50 + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["on"] = True + states_response[0]["state"]["rotationSpeed"] = 50 with patch( "homeassistant.components.freedompro.get_states", - return_value=get_states_response, + return_value=states_response, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) await hass.async_block_till_done() @@ -72,6 +70,18 @@ async def test_fan_set_off(hass, init_integration): registry = er.async_get(hass) entity_id = "fan.bedroom" + + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["on"] = True + states_response[0]["state"]["rotationSpeed"] = 50 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + await hass.helpers.entity_component.async_update_entity(entity_id) + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON @@ -91,10 +101,20 @@ async def test_fan_set_off(hass, init_integration): ) mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"on": false}') + states_response[0]["state"]["on"] = False + states_response[0]["state"]["rotationSpeed"] = 0 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + await hass.helpers.entity_component.async_update_entity(entity_id) + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes[ATTR_PERCENTAGE] == 50 - assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 0 + assert state.state == STATE_OFF async def test_fan_set_on(hass, init_integration): @@ -105,8 +125,8 @@ async def test_fan_set_on(hass, init_integration): entity_id = "fan.bedroom" state = hass.states.get(entity_id) assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 0 assert state.attributes.get("friendly_name") == "bedroom" entry = registry.async_get(entity_id) @@ -122,7 +142,16 @@ async def test_fan_set_on(hass, init_integration): ) mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"on": true}') - await hass.async_block_till_done() + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["on"] = True + states_response[0]["state"]["rotationSpeed"] = 50 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) assert state.attributes[ATTR_PERCENTAGE] == 50 assert state.state == STATE_ON @@ -136,8 +165,8 @@ async def test_fan_set_percent(hass, init_integration): entity_id = "fan.bedroom" state = hass.states.get(entity_id) assert state - assert state.state == STATE_ON - assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 0 assert state.attributes.get("friendly_name") == "bedroom" entry = registry.async_get(entity_id) @@ -153,7 +182,17 @@ async def test_fan_set_percent(hass, init_integration): ) mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"rotationSpeed": 40}') + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["on"] = True + states_response[0]["state"]["rotationSpeed"] = 40 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes[ATTR_PERCENTAGE] == 40 assert state.state == STATE_ON diff --git a/tests/components/freedompro/test_lock.py b/tests/components/freedompro/test_lock.py index 5c30909e08115e..e0d25ce91d990a 100644 --- a/tests/components/freedompro/test_lock.py +++ b/tests/components/freedompro/test_lock.py @@ -12,7 +12,7 @@ from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed -from tests.components.freedompro.const import DEVICES_STATE +from tests.components.freedompro.conftest import get_states_response_for_uid uid = "2WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*2VAS3HTWINNZ5N6HVEIPDJ6NX85P2-AM-GSYWUCNPU0" @@ -40,13 +40,11 @@ async def test_lock_get_state(hass, init_integration): assert entry assert entry.unique_id == uid - get_states_response = list(DEVICES_STATE) - for state_response in get_states_response: - if state_response["uid"] == uid: - state_response["state"]["lock"] = 1 + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["lock"] = 1 with patch( "homeassistant.components.freedompro.get_states", - return_value=get_states_response, + return_value=states_response, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) await hass.async_block_till_done() @@ -68,6 +66,17 @@ async def test_lock_set_unlock(hass, init_integration): registry = er.async_get(hass) entity_id = "lock.lock" + + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["lock"] = 1 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + await hass.helpers.entity_component.async_update_entity(entity_id) + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) assert state assert state.state == STATE_LOCKED @@ -86,9 +95,17 @@ async def test_lock_set_unlock(hass, init_integration): ) mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"lock": 0}') - await hass.async_block_till_done() + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["lock"] = 0 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) - assert state.state == STATE_LOCKED + assert state.state == STATE_UNLOCKED async def test_lock_set_lock(hass, init_integration): @@ -99,7 +116,7 @@ async def test_lock_set_lock(hass, init_integration): entity_id = "lock.lock" state = hass.states.get(entity_id) assert state - assert state.state == STATE_LOCKED + assert state.state == STATE_UNLOCKED assert state.attributes.get("friendly_name") == "lock" entry = registry.async_get(entity_id) @@ -115,6 +132,15 @@ async def test_lock_set_lock(hass, init_integration): ) mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"lock": 1}') + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["lock"] = 1 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state.state == STATE_LOCKED diff --git a/tests/components/freedompro/test_sensor.py b/tests/components/freedompro/test_sensor.py index b6f809569f10c0..d73506e2c6f2a2 100644 --- a/tests/components/freedompro/test_sensor.py +++ b/tests/components/freedompro/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed -from tests.components.freedompro.const import DEVICES_STATE +from tests.components.freedompro.conftest import get_states_response_for_uid @pytest.mark.parametrize( @@ -48,18 +48,16 @@ async def test_sensor_get_state( assert state.state == "0" - get_states_response = list(DEVICES_STATE) - for state_response in get_states_response: - if state_response["uid"] == uid: - if state_response["type"] == "lightSensor": - state_response["state"]["currentAmbientLightLevel"] = "1" - if state_response["type"] == "temperatureSensor": - state_response["state"]["currentTemperature"] = "1" - if state_response["type"] == "humiditySensor": - state_response["state"]["currentRelativeHumidity"] = "1" + states_response = get_states_response_for_uid(uid) + if states_response[0]["type"] == "lightSensor": + states_response[0]["state"]["currentAmbientLightLevel"] = "1" + elif states_response[0]["type"] == "temperatureSensor": + states_response[0]["state"]["currentTemperature"] = "1" + elif states_response[0]["type"] == "humiditySensor": + states_response[0]["state"]["currentRelativeHumidity"] = "1" with patch( "homeassistant.components.freedompro.get_states", - return_value=get_states_response, + return_value=states_response, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) diff --git a/tests/components/freedompro/test_switch.py b/tests/components/freedompro/test_switch.py index 4674b684c413c3..6b62e028d72850 100644 --- a/tests/components/freedompro/test_switch.py +++ b/tests/components/freedompro/test_switch.py @@ -8,7 +8,7 @@ from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed -from tests.components.freedompro.const import DEVICES_STATE +from tests.components.freedompro.conftest import get_states_response_for_uid uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*1JKU1MVWHQL-Z9SCUS85VFXMRGNDCDNDDUVVDKBU31W" @@ -28,13 +28,11 @@ async def test_switch_get_state(hass, init_integration): assert entry assert entry.unique_id == uid - get_states_response = list(DEVICES_STATE) - for state_response in get_states_response: - if state_response["uid"] == uid: - state_response["state"]["on"] = True + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["on"] = True with patch( "homeassistant.components.freedompro.get_states", - return_value=get_states_response, + return_value=states_response, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) await hass.async_block_till_done() @@ -56,6 +54,17 @@ async def test_switch_set_off(hass, init_integration): registry = er.async_get(hass) entity_id = "switch.irrigation_switch" + + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["on"] = True + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + await hass.helpers.entity_component.async_update_entity(entity_id) + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON @@ -76,9 +85,17 @@ async def test_switch_set_off(hass, init_integration): ) mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"on": false}') - await hass.async_block_till_done() + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["on"] = False + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) - assert state.state == STATE_ON + assert state.state == STATE_OFF async def test_switch_set_on(hass, init_integration): @@ -89,7 +106,7 @@ async def test_switch_set_on(hass, init_integration): entity_id = "switch.irrigation_switch" state = hass.states.get(entity_id) assert state - assert state.state == STATE_ON + assert state.state == STATE_OFF assert state.attributes.get("friendly_name") == "Irrigation switch" entry = registry.async_get(entity_id) @@ -107,6 +124,14 @@ async def test_switch_set_on(hass, init_integration): ) mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"on": true}') - await hass.async_block_till_done() + states_response = get_states_response_for_uid(uid) + states_response[0]["state"]["on"] = True + with patch( + "homeassistant.components.freedompro.get_states", + return_value=states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) assert state.state == STATE_ON diff --git a/tests/components/fritz/__init__.py b/tests/components/fritz/__init__.py index a1fd1ce42fb1d4..1462ec77b8f1db 100644 --- a/tests/components/fritz/__init__.py +++ b/tests/components/fritz/__init__.py @@ -1,127 +1 @@ """Tests for the AVM Fritz!Box integration.""" -from unittest import mock - -from homeassistant.components.fritz.const import DOMAIN -from homeassistant.const import ( - CONF_DEVICES, - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, -) - -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_PORT: "1234", - CONF_PASSWORD: "fake_pass", - CONF_USERNAME: "fake_user", - } - ] - } -} - - -class FritzConnectionMock: # pylint: disable=too-few-public-methods - """FritzConnection mocking.""" - - FRITZBOX_DATA = { - ("WANIPConn:1", "GetStatusInfo"): { - "NewConnectionStatus": "Connected", - "NewUptime": 35307, - }, - ("WANIPConnection:1", "GetStatusInfo"): {}, - ("WANCommonIFC:1", "GetCommonLinkProperties"): { - "NewLayer1DownstreamMaxBitRate": 10087000, - "NewLayer1UpstreamMaxBitRate": 2105000, - "NewPhysicalLinkStatus": "Up", - }, - ("WANCommonIFC:1", "GetAddonInfos"): { - "NewByteSendRate": 3438, - "NewByteReceiveRate": 67649, - "NewTotalBytesSent": 1712232562, - "NewTotalBytesReceived": 5221019883, - }, - ("LANEthernetInterfaceConfig:1", "GetStatistics"): { - "NewBytesSent": 23004321, - "NewBytesReceived": 12045, - }, - ("DeviceInfo:1", "GetInfo"): { - "NewSerialNumber": "abcdefgh", - "NewName": "TheName", - "NewModelName": "FRITZ!Box 7490", - }, - } - - FRITZBOX_DATA_INDEXED = { - ("X_AVM-DE_Homeauto:1", "GetGenericDeviceInfos"): [ - { - "NewSwitchIsValid": "VALID", - "NewMultimeterIsValid": "VALID", - "NewTemperatureIsValid": "VALID", - "NewDeviceId": 16, - "NewAIN": "08761 0114116", - "NewDeviceName": "FRITZ!DECT 200 #1", - "NewTemperatureOffset": "0", - "NewSwitchLock": "0", - "NewProductName": "FRITZ!DECT 200", - "NewPresent": "CONNECTED", - "NewMultimeterPower": 1673, - "NewHkrComfortTemperature": "0", - "NewSwitchMode": "AUTO", - "NewManufacturer": "AVM", - "NewMultimeterIsEnabled": "ENABLED", - "NewHkrIsTemperature": "0", - "NewFunctionBitMask": 2944, - "NewTemperatureIsEnabled": "ENABLED", - "NewSwitchState": "ON", - "NewSwitchIsEnabled": "ENABLED", - "NewFirmwareVersion": "03.87", - "NewHkrSetVentilStatus": "CLOSED", - "NewMultimeterEnergy": 5182, - "NewHkrComfortVentilStatus": "CLOSED", - "NewHkrReduceTemperature": "0", - "NewHkrReduceVentilStatus": "CLOSED", - "NewHkrIsEnabled": "DISABLED", - "NewHkrSetTemperature": "0", - "NewTemperatureCelsius": "225", - "NewHkrIsValid": "INVALID", - }, - {}, - ], - ("Hosts1", "GetGenericHostEntry"): [ - { - "NewSerialNumber": 1234, - "NewName": "TheName", - "NewModelName": "FRITZ!Box 7490", - }, - {}, - ], - } - - MODELNAME = "FRITZ!Box 7490" - - def __init__(self): - """Inint Mocking class.""" - type(self).modelname = mock.PropertyMock(return_value=self.MODELNAME) - self.call_action = mock.Mock(side_effect=self._side_effect_call_action) - type(self).action_names = mock.PropertyMock( - side_effect=self._side_effect_action_names - ) - services = { - srv: None - for srv, _ in list(self.FRITZBOX_DATA) + list(self.FRITZBOX_DATA_INDEXED) - } - type(self).services = mock.PropertyMock(side_effect=[services]) - - def _side_effect_call_action(self, service, action, **kwargs): - if kwargs: - index = next(iter(kwargs.values())) - return self.FRITZBOX_DATA_INDEXED[(service, action)][index] - - return self.FRITZBOX_DATA[(service, action)] - - def _side_effect_action_names(self): - return list(self.FRITZBOX_DATA) + list(self.FRITZBOX_DATA_INDEXED) diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py new file mode 100644 index 00000000000000..1dc60f4a59ee0f --- /dev/null +++ b/tests/components/fritz/conftest.py @@ -0,0 +1,115 @@ +"""Common stuff for AVM Fritz!Box tests.""" +from unittest import mock +from unittest.mock import patch + +import pytest + + +@pytest.fixture() +def fc_class_mock(): + """Fixture that sets up a mocked FritzConnection class.""" + with patch("fritzconnection.FritzConnection", autospec=True) as result: + result.return_value = FritzConnectionMock() + yield result + + +class FritzConnectionMock: # pylint: disable=too-few-public-methods + """FritzConnection mocking.""" + + FRITZBOX_DATA = { + ("WANIPConn:1", "GetStatusInfo"): { + "NewConnectionStatus": "Connected", + "NewUptime": 35307, + }, + ("WANIPConnection:1", "GetStatusInfo"): {}, + ("WANCommonIFC:1", "GetCommonLinkProperties"): { + "NewLayer1DownstreamMaxBitRate": 10087000, + "NewLayer1UpstreamMaxBitRate": 2105000, + "NewPhysicalLinkStatus": "Up", + }, + ("WANCommonIFC:1", "GetAddonInfos"): { + "NewByteSendRate": 3438, + "NewByteReceiveRate": 67649, + "NewTotalBytesSent": 1712232562, + "NewTotalBytesReceived": 5221019883, + }, + ("LANEthernetInterfaceConfig:1", "GetStatistics"): { + "NewBytesSent": 23004321, + "NewBytesReceived": 12045, + }, + ("DeviceInfo:1", "GetInfo"): { + "NewSerialNumber": "abcdefgh", + "NewName": "TheName", + "NewModelName": "FRITZ!Box 7490", + }, + } + + FRITZBOX_DATA_INDEXED = { + ("X_AVM-DE_Homeauto:1", "GetGenericDeviceInfos"): [ + { + "NewSwitchIsValid": "VALID", + "NewMultimeterIsValid": "VALID", + "NewTemperatureIsValid": "VALID", + "NewDeviceId": 16, + "NewAIN": "08761 0114116", + "NewDeviceName": "FRITZ!DECT 200 #1", + "NewTemperatureOffset": "0", + "NewSwitchLock": "0", + "NewProductName": "FRITZ!DECT 200", + "NewPresent": "CONNECTED", + "NewMultimeterPower": 1673, + "NewHkrComfortTemperature": "0", + "NewSwitchMode": "AUTO", + "NewManufacturer": "AVM", + "NewMultimeterIsEnabled": "ENABLED", + "NewHkrIsTemperature": "0", + "NewFunctionBitMask": 2944, + "NewTemperatureIsEnabled": "ENABLED", + "NewSwitchState": "ON", + "NewSwitchIsEnabled": "ENABLED", + "NewFirmwareVersion": "03.87", + "NewHkrSetVentilStatus": "CLOSED", + "NewMultimeterEnergy": 5182, + "NewHkrComfortVentilStatus": "CLOSED", + "NewHkrReduceTemperature": "0", + "NewHkrReduceVentilStatus": "CLOSED", + "NewHkrIsEnabled": "DISABLED", + "NewHkrSetTemperature": "0", + "NewTemperatureCelsius": "225", + "NewHkrIsValid": "INVALID", + }, + {}, + ], + ("Hosts1", "GetGenericHostEntry"): [ + { + "NewSerialNumber": 1234, + "NewName": "TheName", + "NewModelName": "FRITZ!Box 7490", + }, + {}, + ], + } + + MODELNAME = "FRITZ!Box 7490" + + def __init__(self): + """Inint Mocking class.""" + self.modelname = self.MODELNAME + self.call_action = mock.Mock(side_effect=self._side_effect_call_action) + type(self).action_names = mock.PropertyMock( + side_effect=self._side_effect_action_names + ) + self.services = { + srv: None + for srv, _ in list(self.FRITZBOX_DATA) + list(self.FRITZBOX_DATA_INDEXED) + } + + def _side_effect_call_action(self, service, action, **kwargs): + if kwargs: + index = next(iter(kwargs.values())) + return self.FRITZBOX_DATA_INDEXED[(service, action)][index] + + return self.FRITZBOX_DATA[(service, action)] + + def _side_effect_action_names(self): + return list(self.FRITZBOX_DATA) + list(self.FRITZBOX_DATA_INDEXED) diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py new file mode 100644 index 00000000000000..3212794fc85ec9 --- /dev/null +++ b/tests/components/fritz/const.py @@ -0,0 +1,48 @@ +"""Common stuff for AVM Fritz!Box tests.""" +from homeassistant.components import ssdp +from homeassistant.components.fritz.const import DOMAIN +from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN +from homeassistant.const import ( + CONF_DEVICES, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) + +ATTR_HOST = "host" +ATTR_NEW_SERIAL_NUMBER = "NewSerialNumber" + +MOCK_CONFIG = { + DOMAIN: { + CONF_DEVICES: [ + { + CONF_HOST: "fake_host", + CONF_PORT: "1234", + CONF_PASSWORD: "fake_pass", + CONF_USERNAME: "fake_user", + } + ] + } +} +MOCK_HOST = "fake_host" +MOCK_IP = "192.168.178.1" +MOCK_SERIAL_NUMBER = "fake_serial_number" +MOCK_FIRMWARE_INFO = [True, "1.1.1"] + +MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] +MOCK_DEVICE_INFO = { + ATTR_HOST: MOCK_HOST, + ATTR_NEW_SERIAL_NUMBER: MOCK_SERIAL_NUMBER, +} +MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"https://{MOCK_IP}:12345/test", + upnp={ + ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ATTR_UPNP_UDN: "uuid:only-a-test", + }, +) + +MOCK_REQUEST = b'xxxxxxxxxxxxxxxxxxxxxxxx0Dial2App2HomeAuto2BoxAdmin2Phone2NAS2FakeFritzUser\n' diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 3981a3d2685d59..6505ee2bcaaf72 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -3,9 +3,7 @@ from unittest.mock import patch from fritzconnection.core.exceptions import FritzConnectionException, FritzSecurityError -import pytest -from homeassistant.components import ssdp from homeassistant.components.device_tracker.const import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -16,14 +14,9 @@ ERROR_CANNOT_CONNECT, ERROR_UNKNOWN, ) -from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN -from homeassistant.config_entries import ( - SOURCE_IMPORT, - SOURCE_REAUTH, - SOURCE_SSDP, - SOURCE_USER, -) -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.ssdp import ATTR_UPNP_UDN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -31,43 +24,15 @@ RESULT_TYPE_FORM, ) -from . import MOCK_CONFIG, FritzConnectionMock - -from tests.common import MockConfigEntry - -ATTR_HOST = "host" -ATTR_NEW_SERIAL_NUMBER = "NewSerialNumber" - -MOCK_HOST = "fake_host" -MOCK_IP = "192.168.178.1" -MOCK_SERIAL_NUMBER = "fake_serial_number" -MOCK_FIRMWARE_INFO = [True, "1.1.1"] - -MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] -MOCK_DEVICE_INFO = { - ATTR_HOST: MOCK_HOST, - ATTR_NEW_SERIAL_NUMBER: MOCK_SERIAL_NUMBER, -} -MOCK_IMPORT_CONFIG = {CONF_HOST: MOCK_HOST, CONF_USERNAME: "username"} -MOCK_SSDP_DATA = ssdp.SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location=f"https://{MOCK_IP}:12345/test", - upnp={ - ATTR_UPNP_FRIENDLY_NAME: "fake_name", - ATTR_UPNP_UDN: "uuid:only-a-test", - }, +from .const import ( + MOCK_FIRMWARE_INFO, + MOCK_IP, + MOCK_REQUEST, + MOCK_SSDP_DATA, + MOCK_USER_DATA, ) -MOCK_REQUEST = b'xxxxxxxxxxxxxxxxxxxxxxxx0Dial2App2HomeAuto2BoxAdmin2Phone2NAS2FakeFritzUser\n' - - -@pytest.fixture() -def fc_class_mock(): - """Fixture that sets up a mocked FritzConnection class.""" - with patch("fritzconnection.FritzConnection", autospec=True) as result: - result.return_value = FritzConnectionMock() - yield result +from tests.common import MockConfigEntry async def test_user(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): @@ -487,43 +452,6 @@ async def test_ssdp_exception(hass: HomeAssistant, mock_get_source_ip): assert result["step_id"] == "confirm" -async def test_import(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): - """Test importing.""" - with patch( - "homeassistant.components.fritz.common.FritzConnection", - side_effect=fc_class_mock, - ), patch("homeassistant.components.fritz.common.FritzStatus"), patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", - return_value=MOCK_FIRMWARE_INFO, - ), patch( - "homeassistant.components.fritz.async_setup_entry" - ) as mock_setup_entry, patch( - "requests.get" - ) as mock_request_get, patch( - "requests.post" - ) as mock_request_post, patch( - "homeassistant.components.fritz.config_flow.socket.gethostbyname", - return_value=MOCK_IP, - ): - - mock_request_get.return_value.status_code = 200 - mock_request_get.return_value.content = MOCK_REQUEST - mock_request_post.return_value.status_code = 200 - mock_request_post.return_value.text = MOCK_REQUEST - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_CONFIG - ) - - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_PASSWORD] is None - assert result["data"][CONF_USERNAME] == "username" - await hass.async_block_till_done() - - assert mock_setup_entry.called - - async def test_options_flow(hass: HomeAssistant, fc_class_mock, mock_get_source_ip): """Test options flow.""" diff --git a/tests/components/fritzbox/const.py b/tests/components/fritzbox/const.py index 1b8bc927800c23..58ad5ae177cafb 100644 --- a/tests/components/fritzbox/const.py +++ b/tests/components/fritzbox/const.py @@ -15,6 +15,6 @@ } CONF_FAKE_NAME = "fake_name" -CONF_FAKE_AIN = "fake_ain" +CONF_FAKE_AIN = "12345 1234567" CONF_FAKE_MANUFACTURER = "fake_manufacturer" CONF_FAKE_PRODUCTNAME = "fake_productname" diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index f010c8499ea306..2f1aaf65e07c3f 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -5,7 +5,7 @@ from requests.exceptions import HTTPError -from homeassistant.components.binary_sensor import DOMAIN +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceClass from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( @@ -14,6 +14,7 @@ ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, PERCENTAGE, + STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) @@ -35,11 +36,30 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(f"{ENTITY_ID}_alarm") assert state assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.attributes[ATTR_DEVICE_CLASS] == "window" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Alarm" + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get(f"{ENTITY_ID}_button_lock_on_device") + assert state + assert state.state == STATE_OFF + assert ( + state.attributes[ATTR_FRIENDLY_NAME] + == f"{CONF_FAKE_NAME} Button Lock on Device" + ) + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get(f"{ENTITY_ID}_button_lock_via_ui") + assert state + assert state.state == STATE_OFF + assert ( + state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Button Lock via UI" + ) + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.LOCK assert ATTR_STATE_CLASS not in state.attributes state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") @@ -58,7 +78,15 @@ async def test_is_off(hass: HomeAssistant, fritz: Mock): hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(f"{ENTITY_ID}_alarm") + assert state + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get(f"{ENTITY_ID}_button_lock_on_device") + assert state + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get(f"{ENTITY_ID}_button_lock_via_ui") assert state assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index fc3b15c4199ee8..993c1009432e1a 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -23,9 +23,7 @@ ) from homeassistant.components.fritzbox.const import ( ATTR_STATE_BATTERY_LOW, - ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_HOLIDAY_MODE, - ATTR_STATE_LOCKED, ATTR_STATE_SUMMER_MODE, ATTR_STATE_WINDOW_OPEN, DOMAIN as FB_DOMAIN, @@ -70,9 +68,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_PRESET_MODE] is None assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] assert state.attributes[ATTR_STATE_BATTERY_LOW] is True - assert state.attributes[ATTR_STATE_DEVICE_LOCKED] == "fake_locked_device" assert state.attributes[ATTR_STATE_HOLIDAY_MODE] == "fake_holiday" - assert state.attributes[ATTR_STATE_LOCKED] == "fake_locked" assert state.attributes[ATTR_STATE_SUMMER_MODE] == "fake_summer" assert state.attributes[ATTR_STATE_WINDOW_OPEN] == "fake_window" assert state.attributes[ATTR_TEMPERATURE] == 19.5 @@ -201,7 +197,7 @@ async def test_update(hass: HomeAssistant, fritz: Mock): async def test_automatic_offset(hass: HomeAssistant, fritz: Mock): - """Test when automtaic offset is configured on fritz!box device.""" + """Test when automatic offset is configured on fritz!box device.""" device = FritzDeviceClimateMock() device.temperature = 18 device.actual_temperature = 19 @@ -226,15 +222,15 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock): hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert fritz().update_devices.call_count == 1 - assert fritz().login.call_count == 1 + assert fritz().update_devices.call_count == 2 + assert fritz().login.call_count == 2 next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() - assert fritz().update_devices.call_count == 2 - assert fritz().login.call_count == 2 + assert fritz().update_devices.call_count == 4 + assert fritz().login.call_count == 4 async def test_set_temperature_temperature(hass: HomeAssistant, fritz: Mock): diff --git a/tests/components/fritzbox/test_diagnostics.py b/tests/components/fritzbox/test_diagnostics.py new file mode 100644 index 00000000000000..9efe6c23902b28 --- /dev/null +++ b/tests/components/fritzbox/test_diagnostics.py @@ -0,0 +1,33 @@ +"""Tests for the AVM Fritz!Box integration.""" +from __future__ import annotations + +from unittest.mock import Mock + +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.components.fritzbox.diagnostics import TO_REDACT +from homeassistant.const import CONF_DEVICES +from homeassistant.core import HomeAssistant + +from . import setup_config_entry +from .const import MOCK_CONFIG + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics( + hass: HomeAssistant, hass_client: ClientSession, fritz: Mock +): + """Test config entry diagnostics.""" + assert await setup_config_entry(hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0]) + + entries = hass.config_entries.async_entries(FB_DOMAIN) + entry_dict = entries[0].as_dict() + for key in TO_REDACT: + entry_dict["data"][key] = REDACTED + + result = await get_diagnostics_for_config_entry(hass, hass_client, entries[0]) + + assert result == {"entry": entry_dict, "data": {}} diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 60828e83801c1d..f69d7256e238d3 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -4,8 +4,10 @@ from unittest.mock import Mock, call, patch from pyfritzhome import LoginError +import pytest from requests.exceptions import ConnectionError, HTTPError +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -42,7 +44,37 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): ] -async def test_update_unique_id(hass: HomeAssistant, fritz: Mock): +@pytest.mark.parametrize( + "entitydata,old_unique_id,new_unique_id", + [ + ( + { + "domain": SENSOR_DOMAIN, + "platform": FB_DOMAIN, + "unique_id": CONF_FAKE_AIN, + "unit_of_measurement": TEMP_CELSIUS, + }, + CONF_FAKE_AIN, + f"{CONF_FAKE_AIN}_temperature", + ), + ( + { + "domain": BINARY_SENSOR_DOMAIN, + "platform": FB_DOMAIN, + "unique_id": CONF_FAKE_AIN, + }, + CONF_FAKE_AIN, + f"{CONF_FAKE_AIN}_alarm", + ), + ], +) +async def test_update_unique_id( + hass: HomeAssistant, + fritz: Mock, + entitydata: dict, + old_unique_id: str, + new_unique_id: str, +): """Test unique_id update of integration.""" entry = MockConfigEntry( domain=FB_DOMAIN, @@ -52,23 +84,55 @@ async def test_update_unique_id(hass: HomeAssistant, fritz: Mock): entry.add_to_hass(hass) entity_registry = er.async_get(hass) - entity = entity_registry.async_get_or_create( - SENSOR_DOMAIN, - FB_DOMAIN, - CONF_FAKE_AIN, - unit_of_measurement=TEMP_CELSIUS, + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entitydata, config_entry=entry, ) - assert entity.unique_id == CONF_FAKE_AIN + assert entity.unique_id == old_unique_id assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated - assert entity_migrated.unique_id == f"{CONF_FAKE_AIN}_temperature" - - -async def test_update_unique_id_no_change(hass: HomeAssistant, fritz: Mock): + assert entity_migrated.unique_id == new_unique_id + + +@pytest.mark.parametrize( + "entitydata,unique_id", + [ + ( + { + "domain": SENSOR_DOMAIN, + "platform": FB_DOMAIN, + "unique_id": f"{CONF_FAKE_AIN}_temperature", + "unit_of_measurement": TEMP_CELSIUS, + }, + f"{CONF_FAKE_AIN}_temperature", + ), + ( + { + "domain": BINARY_SENSOR_DOMAIN, + "platform": FB_DOMAIN, + "unique_id": f"{CONF_FAKE_AIN}_alarm", + }, + f"{CONF_FAKE_AIN}_alarm", + ), + ( + { + "domain": BINARY_SENSOR_DOMAIN, + "platform": FB_DOMAIN, + "unique_id": f"{CONF_FAKE_AIN}_other", + }, + f"{CONF_FAKE_AIN}_other", + ), + ], +) +async def test_update_unique_id_no_change( + hass: HomeAssistant, + fritz: Mock, + entitydata: dict, + unique_id: str, +): """Test unique_id is not updated of integration.""" entry = MockConfigEntry( domain=FB_DOMAIN, @@ -79,19 +143,16 @@ async def test_update_unique_id_no_change(hass: HomeAssistant, fritz: Mock): entity_registry = er.async_get(hass) entity = entity_registry.async_get_or_create( - SENSOR_DOMAIN, - FB_DOMAIN, - f"{CONF_FAKE_AIN}_temperature", - unit_of_measurement=TEMP_CELSIUS, + **entitydata, config_entry=entry, ) - assert entity.unique_id == f"{CONF_FAKE_AIN}_temperature" + assert entity.unique_id == unique_id assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated - assert entity_migrated.unique_id == f"{CONF_FAKE_AIN}_temperature" + assert entity_migrated.unique_id == unique_id async def test_coordinator_update_after_reboot(hass: HomeAssistant, fritz: Mock): @@ -102,10 +163,11 @@ async def test_coordinator_update_after_reboot(hass: HomeAssistant, fritz: Mock) unique_id="any", ) entry.add_to_hass(hass) - fritz().get_devices.side_effect = [HTTPError(), ""] + fritz().update_devices.side_effect = [HTTPError(), ""] assert await hass.config_entries.async_setup(entry.entry_id) - assert fritz().get_devices.call_count == 2 + assert fritz().update_devices.call_count == 2 + assert fritz().get_devices.call_count == 1 assert fritz().login.call_count == 2 @@ -119,11 +181,12 @@ async def test_coordinator_update_after_password_change( unique_id="any", ) entry.add_to_hass(hass) - fritz().get_devices.side_effect = HTTPError() + fritz().update_devices.side_effect = HTTPError() fritz().login.side_effect = ["", LoginError("some_user")] assert not await hass.config_entries.async_setup(entry.entry_id) - assert fritz().get_devices.call_count == 1 + assert fritz().update_devices.call_count == 1 + assert fritz().get_devices.call_count == 0 assert fritz().login.call_count == 2 diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 24077306647eee..b63d19da7e0413 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -174,12 +174,12 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock): assert not await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert fritz().update_devices.call_count == 1 - assert fritz().login.call_count == 1 + assert fritz().update_devices.call_count == 2 + assert fritz().login.call_count == 2 next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() - assert fritz().update_devices.call_count == 2 - assert fritz().login.call_count == 2 + assert fritz().update_devices.call_count == 4 + assert fritz().login.call_count == 4 diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 42cf90bba58a0a..dfb480459da2ba 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -4,16 +4,8 @@ from requests.exceptions import HTTPError -from homeassistant.components.fritzbox.const import ( - ATTR_STATE_DEVICE_LOCKED, - ATTR_STATE_LOCKED, - DOMAIN as FB_DOMAIN, -) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN, - STATE_CLASS_MEASUREMENT, -) +from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN, SensorStateClass from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, @@ -44,17 +36,15 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state assert state.state == "1.23" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Temperature" - assert state.attributes[ATTR_STATE_DEVICE_LOCKED] == "fake_locked_device" - assert state.attributes[ATTR_STATE_LOCKED] == "fake_locked" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT state = hass.states.get(f"{ENTITY_ID}_humidity") assert state assert state.state == "42" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Humidity" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT state = hass.states.get(f"{ENTITY_ID}_battery") assert state @@ -88,12 +78,12 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock): assert not await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert fritz().update_devices.call_count == 1 - assert fritz().login.call_count == 1 + assert fritz().update_devices.call_count == 2 + assert fritz().login.call_count == 2 next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() - assert fritz().update_devices.call_count == 2 - assert fritz().login.call_count == 2 + assert fritz().update_devices.call_count == 4 + assert fritz().login.call_count == 4 diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 73bb7a1110be6f..a3135dd61f3a7c 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -4,16 +4,11 @@ from requests.exceptions import HTTPError -from homeassistant.components.fritzbox.const import ( - ATTR_STATE_DEVICE_LOCKED, - ATTR_STATE_LOCKED, - DOMAIN as FB_DOMAIN, -) +from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorStateClass, ) from homeassistant.components.switch import DOMAIN from homeassistant.const import ( @@ -51,18 +46,14 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state assert state.state == STATE_ON assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME - assert state.attributes[ATTR_STATE_DEVICE_LOCKED] == "fake_locked_device" - assert state.attributes[ATTR_STATE_LOCKED] == "fake_locked" assert ATTR_STATE_CLASS not in state.attributes state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_temperature") assert state assert state.state == "1.23" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Temperature" - assert state.attributes[ATTR_STATE_DEVICE_LOCKED] == "fake_locked_device" - assert state.attributes[ATTR_STATE_LOCKED] == "fake_locked" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT state = hass.states.get(f"{ENTITY_ID}_humidity") assert state is None @@ -72,14 +63,14 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state.state == "5.678" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Power Consumption" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_total_energy") assert state assert state.state == "1.234" assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Total Energy" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING async def test_turn_on(hass: HomeAssistant, fritz: Mock): @@ -132,15 +123,15 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock): assert not await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - assert fritz().update_devices.call_count == 1 - assert fritz().login.call_count == 1 + assert fritz().update_devices.call_count == 2 + assert fritz().login.call_count == 2 next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() - assert fritz().update_devices.call_count == 2 - assert fritz().login.call_count == 2 + assert fritz().update_devices.call_count == 4 + assert fritz().login.call_count == 4 async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock): diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 7930f6c01f4465..4222a2037aa564 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -90,7 +90,9 @@ async def enable_all_entities(hass, config_entry_id, time_till_next_update): registry = er.async_get(hass) entities = er.async_entries_for_config_entry(registry, config_entry_id) for entry in [ - entry for entry in entities if entry.disabled_by == er.DISABLED_INTEGRATION + entry + for entry in entities + if entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION ]: registry.async_update_entity(entry.entity_id, **{"disabled_by": None}) await hass.async_block_till_done() diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py index 427c8e4a163fc4..c6f2f69ce5fd80 100644 --- a/tests/components/fronius/test_config_flow.py +++ b/tests/components/fronius/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from pyfronius import FroniusError +import pytest from homeassistant import config_entries from homeassistant.components.dhcp import DhcpServiceInfo @@ -20,6 +21,17 @@ from tests.common import MockConfigEntry + +@pytest.fixture(autouse=True) +def no_setup(): + """Disable setting up the whole integration in config_flow tests.""" + with patch( + "homeassistant.components.fronius.async_setup_entry", + return_value=True, + ): + yield + + INVERTER_INFO_RETURN_VALUE = { "inverters": [ { @@ -172,7 +184,7 @@ async def test_form_unexpected(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_form_already_existing(hass): +async def test_form_already_existing(hass: HomeAssistant) -> None: """Test existing entry.""" MockConfigEntry( domain=DOMAIN, @@ -224,17 +236,22 @@ async def test_form_updates_host(hass, aioclient_mock): ) mock_responses(aioclient_mock, host=new_host) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": new_host, - }, - ) - await hass.async_block_till_done() + with patch( + "homeassistant.components.fronius.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": new_host, + }, + ) + await hass.async_block_till_done() assert result2["type"] == RESULT_TYPE_ABORT assert result2["reason"] == "already_configured" + mock_unload_entry.assert_called_with(hass, entry) entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].data == { diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 2e48faf606a930..0f3e8f28a56e5f 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -6,7 +6,6 @@ FroniusPowerFlowUpdateCoordinator, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers import device_registry as dr from homeassistant.util import dt @@ -26,11 +25,11 @@ def assert_state(entity_id, expected_state): mock_responses(aioclient_mock, night=True) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 23 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 await enable_all_entities( hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 55 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 0) assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", 10828) assert_state("sensor.energy_total_fronius_inverter_1_http_fronius", 44186900) @@ -43,11 +42,11 @@ def assert_state(entity_id, expected_state): hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 57 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 await enable_all_entities( hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 # 4 additional AC entities assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 2.19) assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", 1113) @@ -81,13 +80,7 @@ def assert_state(entity_id, expected_state): mock_responses(aioclient_mock) await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 - - # ignored constant entities: - # hardware_platform, hardware_version, product_type - # software_version, time_zone, time_zone_location - # time_stamp, unique_identifier, utc_offset - # + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 # states are rounded to 4 decimals assert_state( "sensor.cash_factor_fronius_logger_info_0_http_fronius", @@ -114,14 +107,11 @@ def assert_state(entity_id, expected_state): mock_responses(aioclient_mock) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 await enable_all_entities( hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59 - # ignored entities: - # manufacturer, model, serial, enable, timestamp, visible, meter_location - # + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 # states are rounded to 4 decimals assert_state("sensor.current_ac_phase_1_fronius_meter_0_http_fronius", 7.755) assert_state("sensor.current_ac_phase_2_fronius_meter_0_http_fronius", 6.68) @@ -179,13 +169,11 @@ def assert_state(entity_id, expected_state): mock_responses(aioclient_mock, night=True) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 23 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 await enable_all_entities( hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 55 - # ignored: location, mode, timestamp - # + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 # states are rounded to 4 decimals assert_state( "sensor.energy_day_fronius_power_flow_0_http_fronius", @@ -199,10 +187,6 @@ def assert_state(entity_id, expected_state): "sensor.energy_year_fronius_power_flow_0_http_fronius", 25507686, ) - assert_state( - "sensor.power_battery_fronius_power_flow_0_http_fronius", - STATE_UNKNOWN, - ) assert_state( "sensor.power_grid_fronius_power_flow_0_http_fronius", 975.31, @@ -211,18 +195,10 @@ def assert_state(entity_id, expected_state): "sensor.power_load_fronius_power_flow_0_http_fronius", -975.31, ) - assert_state( - "sensor.power_photovoltaics_fronius_power_flow_0_http_fronius", - STATE_UNKNOWN, - ) assert_state( "sensor.relative_autonomy_fronius_power_flow_0_http_fronius", 0, ) - assert_state( - "sensor.relative_self_consumption_fronius_power_flow_0_http_fronius", - STATE_UNKNOWN, - ) # Second test at daytime when inverter is producing mock_responses(aioclient_mock, night=False) @@ -230,8 +206,8 @@ def assert_state(entity_id, expected_state): hass, dt.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval ) await hass.async_block_till_done() - # still 55 because power_flow update interval is shorter than others - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 55 + # 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 assert_state( "sensor.energy_day_fronius_power_flow_0_http_fronius", 1101.7001, @@ -244,10 +220,6 @@ def assert_state(entity_id, expected_state): "sensor.energy_year_fronius_power_flow_0_http_fronius", 25508788, ) - assert_state( - "sensor.power_battery_fronius_power_flow_0_http_fronius", - STATE_UNKNOWN, - ) assert_state( "sensor.power_grid_fronius_power_flow_0_http_fronius", 1703.74, @@ -281,17 +253,15 @@ def assert_state(entity_id, expected_state): mock_responses(aioclient_mock, fixture_set="gen24") config_entry = await setup_fronius_integration(hass, is_logger=False) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 await enable_all_entities( hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 57 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 # inverter 1 - assert_state("sensor.energy_year_fronius_inverter_1_http_fronius", STATE_UNKNOWN) assert_state("sensor.current_ac_fronius_inverter_1_http_fronius", 0.1589) assert_state("sensor.current_dc_2_fronius_inverter_1_http_fronius", 0.0754) assert_state("sensor.status_code_fronius_inverter_1_http_fronius", 7) - assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", STATE_UNKNOWN) assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 0.0783) assert_state("sensor.voltage_dc_2_fronius_inverter_1_http_fronius", 403.4312) assert_state("sensor.power_ac_fronius_inverter_1_http_fronius", 37.3204) @@ -356,11 +326,6 @@ def assert_state(entity_id, expected_state): assert_state("sensor.power_load_fronius_power_flow_0_http_fronius", -695.6827) assert_state("sensor.meter_mode_fronius_power_flow_0_http_fronius", "meter") assert_state("sensor.relative_autonomy_fronius_power_flow_0_http_fronius", 5.3592) - assert_state( - "sensor.power_battery_fronius_power_flow_0_http_fronius", STATE_UNKNOWN - ) - assert_state("sensor.energy_year_fronius_power_flow_0_http_fronius", STATE_UNKNOWN) - assert_state("sensor.energy_day_fronius_power_flow_0_http_fronius", STATE_UNKNOWN) assert_state("sensor.energy_total_fronius_power_flow_0_http_fronius", 1530193.42) @@ -377,19 +342,17 @@ def assert_state(entity_id, expected_state): hass, is_logger=False, unique_id="12345678" ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 36 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 34 await enable_all_entities( hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 68 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 # inverter 1 assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 0.3952) assert_state("sensor.voltage_dc_2_fronius_inverter_1_http_fronius", 318.8103) assert_state("sensor.current_dc_2_fronius_inverter_1_http_fronius", 0.3564) - assert_state("sensor.energy_year_fronius_inverter_1_http_fronius", STATE_UNKNOWN) assert_state("sensor.current_ac_fronius_inverter_1_http_fronius", 1.1087) assert_state("sensor.power_ac_fronius_inverter_1_http_fronius", 250.9093) - assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", STATE_UNKNOWN) assert_state("sensor.error_code_fronius_inverter_1_http_fronius", 0) assert_state("sensor.status_code_fronius_inverter_1_http_fronius", 7) assert_state("sensor.energy_total_fronius_inverter_1_http_fronius", 7512794.0117) @@ -463,8 +426,6 @@ def assert_state(entity_id, expected_state): ) assert_state("sensor.relative_autonomy_fronius_power_flow_0_http_fronius", 7.4984) assert_state("sensor.meter_mode_fronius_power_flow_0_http_fronius", "bidirectional") - assert_state("sensor.energy_year_fronius_power_flow_0_http_fronius", STATE_UNKNOWN) - assert_state("sensor.energy_day_fronius_power_flow_0_http_fronius", STATE_UNKNOWN) assert_state("sensor.energy_total_fronius_power_flow_0_http_fronius", 7512664.4042) # storage assert_state("sensor.current_dc_fronius_storage_0_http_fronius", 0.0) @@ -519,11 +480,11 @@ def assert_state(entity_id, expected_state): mock_responses(aioclient_mock, fixture_set="primo_s0", inverter_ids=[1, 2]) config_entry = await setup_fronius_integration(hass, is_logger=True) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 30 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 29 await enable_all_entities( hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 41 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 40 # logger assert_state("sensor.cash_factor_fronius_logger_info_0_http_fronius", 1) assert_state("sensor.co2_factor_fronius_logger_info_0_http_fronius", 0.53) @@ -561,9 +522,6 @@ def assert_state(entity_id, expected_state): assert_state("sensor.power_real_fronius_meter_0_http_fronius", -2216.7487) # power_flow assert_state("sensor.power_load_fronius_power_flow_0_http_fronius", -2218.9349) - assert_state( - "sensor.power_battery_fronius_power_flow_0_http_fronius", STATE_UNKNOWN - ) assert_state("sensor.meter_mode_fronius_power_flow_0_http_fronius", "vague-meter") assert_state("sensor.power_photovoltaics_fronius_power_flow_0_http_fronius", 1834) assert_state("sensor.power_grid_fronius_power_flow_0_http_fronius", 384.9349) diff --git a/tests/components/garages_amsterdam/conftest.py b/tests/components/garages_amsterdam/conftest.py index 49d242dabd55c6..aced2894d67746 100644 --- a/tests/components/garages_amsterdam/conftest.py +++ b/tests/components/garages_amsterdam/conftest.py @@ -9,7 +9,7 @@ def mock_cases(): """Mock garages_amsterdam garages.""" with patch( - "garages_amsterdam.get_garages", + "garages_amsterdam.GaragesAmsterdam.all_garages", return_value=[ Mock( garage_name="IJDok", diff --git a/tests/components/garages_amsterdam/test_config_flow.py b/tests/components/garages_amsterdam/test_config_flow.py index a9f5f2c58ad6ca..3749cf039db3f7 100644 --- a/tests/components/garages_amsterdam/test_config_flow.py +++ b/tests/components/garages_amsterdam/test_config_flow.py @@ -57,7 +57,7 @@ async def test_error_handling( """Test we get the form.""" with patch( - "homeassistant.components.garages_amsterdam.config_flow.garages_amsterdam.get_garages", + "homeassistant.components.garages_amsterdam.config_flow.GaragesAmsterdam.all_garages", side_effect=side_effect, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py new file mode 100644 index 00000000000000..04de1aedca913d --- /dev/null +++ b/tests/components/generic/conftest.py @@ -0,0 +1,31 @@ +"""Test fixtures for the generic component.""" + +from io import BytesIO + +from PIL import Image +import pytest + + +@pytest.fixture(scope="package") +def fakeimgbytes_png(): + """Fake image in RAM for testing.""" + buf = BytesIO() + Image.new("RGB", (1, 1)).save(buf, format="PNG") + yield bytes(buf.getbuffer()) + + +@pytest.fixture(scope="package") +def fakeimgbytes_jpg(): + """Fake image in RAM for testing.""" + buf = BytesIO() # fake image in ram for testing. + Image.new("RGB", (1, 1)).save(buf, format="jpeg") + yield bytes(buf.getbuffer()) + + +@pytest.fixture(scope="package") +def fakeimgbytes_svg(): + """Fake image in RAM for testing.""" + yield bytes( + '', + encoding="utf-8", + ) diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index c52b4bf6e8fdde..60e68a1e7b1e6d 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -14,13 +14,13 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.setup import async_setup_component -from tests.common import get_fixture_path +from tests.common import AsyncMock, Mock, get_fixture_path @respx.mock -async def test_fetching_url(hass, hass_client): +async def test_fetching_url(hass, hass_client, fakeimgbytes_png): """Test that it fetches the given url.""" - respx.get("http://example.com").respond(text="hello world") + respx.get("http://example.com").respond(stream=fakeimgbytes_png) await async_setup_component( hass, @@ -43,17 +43,17 @@ async def test_fetching_url(hass, hass_client): assert resp.status == HTTPStatus.OK assert respx.calls.call_count == 1 - body = await resp.text() - assert body == "hello world" + body = await resp.read() + assert body == fakeimgbytes_png resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 2 @respx.mock -async def test_fetching_without_verify_ssl(hass, hass_client): +async def test_fetching_without_verify_ssl(hass, hass_client, fakeimgbytes_png): """Test that it fetches the given url when ssl verify is off.""" - respx.get("https://example.com").respond(text="hello world") + respx.get("https://example.com").respond(stream=fakeimgbytes_png) await async_setup_component( hass, @@ -79,9 +79,9 @@ async def test_fetching_without_verify_ssl(hass, hass_client): @respx.mock -async def test_fetching_url_with_verify_ssl(hass, hass_client): +async def test_fetching_url_with_verify_ssl(hass, hass_client, fakeimgbytes_png): """Test that it fetches the given url when ssl verify is explicitly on.""" - respx.get("https://example.com").respond(text="hello world") + respx.get("https://example.com").respond(stream=fakeimgbytes_png) await async_setup_component( hass, @@ -107,11 +107,11 @@ async def test_fetching_url_with_verify_ssl(hass, hass_client): @respx.mock -async def test_limit_refetch(hass, hass_client): +async def test_limit_refetch(hass, hass_client, fakeimgbytes_png, fakeimgbytes_jpg): """Test that it fetches the given url.""" - respx.get("http://example.com/5a").respond(text="hello world") - respx.get("http://example.com/10a").respond(text="hello world") - respx.get("http://example.com/15a").respond(text="hello planet") + respx.get("http://example.com/5a").respond(stream=fakeimgbytes_png) + respx.get("http://example.com/10a").respond(stream=fakeimgbytes_png) + respx.get("http://example.com/15a").respond(stream=fakeimgbytes_jpg) respx.get("http://example.com/20a").respond(status_code=HTTPStatus.NOT_FOUND) await async_setup_component( @@ -147,14 +147,14 @@ async def test_limit_refetch(hass, hass_client): resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 1 assert resp.status == HTTPStatus.OK - body = await resp.text() - assert body == "hello world" + body = await resp.read() + assert body == fakeimgbytes_png resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 1 assert resp.status == HTTPStatus.OK - body = await resp.text() - assert body == "hello world" + body = await resp.read() + assert body == fakeimgbytes_png hass.states.async_set("sensor.temp", "15") @@ -162,20 +162,22 @@ async def test_limit_refetch(hass, hass_client): resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 2 assert resp.status == HTTPStatus.OK - body = await resp.text() - assert body == "hello planet" + body = await resp.read() + assert body == fakeimgbytes_jpg # Cause a template render error hass.states.async_remove("sensor.temp") resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 2 assert resp.status == HTTPStatus.OK - body = await resp.text() - assert body == "hello planet" + body = await resp.read() + assert body == fakeimgbytes_jpg -async def test_stream_source(hass, hass_client, hass_ws_client): +async def test_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png): """Test that the stream source is rendered.""" + respx.get("http://example.com").respond(stream=fakeimgbytes_png) + assert await async_setup_component( hass, "camera", @@ -214,8 +216,10 @@ async def test_stream_source(hass, hass_client, hass_ws_client): assert msg["result"]["url"][-13:] == "playlist.m3u8" -async def test_stream_source_error(hass, hass_client, hass_ws_client): +async def test_stream_source_error(hass, hass_client, hass_ws_client, fakeimgbytes_png): """Test that the stream source has an error.""" + respx.get("http://example.com").respond(stream=fakeimgbytes_png) + assert await async_setup_component( hass, "camera", @@ -278,8 +282,10 @@ async def test_setup_alternative_options(hass, hass_ws_client): assert hass.data["camera"].get_entity("camera.config_test") -async def test_no_stream_source(hass, hass_client, hass_ws_client): +async def test_no_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png): """Test a stream request without stream source option set.""" + respx.get("http://example.com").respond(stream=fakeimgbytes_png) + assert await async_setup_component( hass, "camera", @@ -318,24 +324,29 @@ async def test_no_stream_source(hass, hass_client, hass_ws_client): @respx.mock -async def test_camera_content_type(hass, hass_client): +async def test_camera_content_type( + hass, hass_client, fakeimgbytes_svg, fakeimgbytes_jpg +): """Test generic camera with custom content_type.""" - svg_image = "" urlsvg = "https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg" - respx.get(urlsvg).respond(text=svg_image) - + respx.get(urlsvg).respond(stream=fakeimgbytes_svg) + urljpg = "https://upload.wikimedia.org/wikipedia/commons/0/0e/Felis_silvestris_silvestris.jpg" + respx.get(urljpg).respond(stream=fakeimgbytes_jpg) cam_config_svg = { "name": "config_test_svg", "platform": "generic", "still_image_url": urlsvg, "content_type": "image/svg+xml", } - cam_config_normal = cam_config_svg.copy() - cam_config_normal.pop("content_type") - cam_config_normal["name"] = "config_test_jpg" + cam_config_jpg = { + "name": "config_test_jpg", + "platform": "generic", + "still_image_url": urljpg, + "content_type": "image/jpeg", + } await async_setup_component( - hass, "camera", {"camera": [cam_config_svg, cam_config_normal]} + hass, "camera", {"camera": [cam_config_svg, cam_config_jpg]} ) await hass.async_block_till_done() @@ -345,15 +356,15 @@ async def test_camera_content_type(hass, hass_client): assert respx.calls.call_count == 1 assert resp_1.status == HTTPStatus.OK assert resp_1.content_type == "image/svg+xml" - body = await resp_1.text() - assert body == svg_image + body = await resp_1.read() + assert body == fakeimgbytes_svg resp_2 = await client.get("/api/camera_proxy/camera.config_test_jpg") assert respx.calls.call_count == 2 assert resp_2.status == HTTPStatus.OK assert resp_2.content_type == "image/jpeg" - body = await resp_2.text() - assert body == svg_image + body = await resp_2.read() + assert body == fakeimgbytes_jpg @respx.mock @@ -411,10 +422,10 @@ async def test_reloading(hass, hass_client): @respx.mock -async def test_timeout_cancelled(hass, hass_client): +async def test_timeout_cancelled(hass, hass_client, fakeimgbytes_png, fakeimgbytes_jpg): """Test that timeouts and cancellations return last image.""" - respx.get("http://example.com").respond(text="hello world") + respx.get("http://example.com").respond(stream=fakeimgbytes_png) await async_setup_component( hass, @@ -437,9 +448,9 @@ async def test_timeout_cancelled(hass, hass_client): assert resp.status == HTTPStatus.OK assert respx.calls.call_count == 1 - assert await resp.text() == "hello world" + assert await resp.read() == fakeimgbytes_png - respx.get("http://example.com").respond(text="not hello world") + respx.get("http://example.com").respond(stream=fakeimgbytes_jpg) with patch( "homeassistant.components.generic.camera.GenericCamera.async_camera_image", @@ -454,8 +465,53 @@ async def test_timeout_cancelled(hass, hass_client): httpx.TimeoutException, ] - for total_calls in range(2, 4): + for total_calls in range(2, 3): resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == total_calls assert resp.status == HTTPStatus.OK - assert await resp.text() == "hello world" + assert await resp.read() == fakeimgbytes_png + + +async def test_no_still_image_url(hass, hass_client): + """Test that the component can grab images from stream with no still_image_url.""" + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "stream_source": "rtsp://example.com:554/rtsp/", + }, + }, + ) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.generic.camera.GenericCamera.stream_source", + return_value=None, + ) as mock_stream_source: + + # First test when there is no stream_source should fail + resp = await client.get("/api/camera_proxy/camera.config_test") + await hass.async_block_till_done() + mock_stream_source.assert_called_once() + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch("homeassistant.components.camera.create_stream") as mock_create_stream: + + # Now test when creating the stream succeeds + mock_stream = Mock() + mock_stream.async_get_image = AsyncMock() + mock_stream.async_get_image.return_value = b"stream_keyframe_image" + mock_create_stream.return_value = mock_stream + + # should start the stream and get the image + resp = await client.get("/api/camera_proxy/camera.config_test") + await hass.async_block_till_done() + mock_create_stream.assert_called_once() + mock_stream.async_get_image.assert_called_once() + assert resp.status == HTTPStatus.OK + assert await resp.read() == b"stream_keyframe_image" diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index a1896c94d2f484..1720a54d97304d 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -13,8 +13,12 @@ HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF, + PRESET_ACTIVITY, PRESET_AWAY, + PRESET_COMFORT, + PRESET_HOME, PRESET_NONE, + PRESET_SLEEP, ) from homeassistant.components.generic_thermostat import ( DOMAIN as GENERIC_THERMOSTAT_DOMAIN, @@ -209,6 +213,10 @@ async def setup_comp_2(hass): "heater": ENT_SWITCH, "target_sensor": ENT_SENSOR, "away_temp": 16, + "sleep_temp": 17, + "home_temp": 19, + "comfort_temp": 20, + "activity_temp": 21, "initial_hvac_mode": HVAC_MODE_HEAT, } }, @@ -288,38 +296,73 @@ async def test_set_target_temp(hass, setup_comp_2): assert state.attributes.get("temperature") == 30.0 -async def test_set_away_mode(hass, setup_comp_2): +@pytest.mark.parametrize( + "preset,temp", + [ + (PRESET_NONE, 23), + (PRESET_AWAY, 16), + (PRESET_COMFORT, 20), + (PRESET_HOME, 19), + (PRESET_SLEEP, 17), + (PRESET_ACTIVITY, 21), + ], +) +async def test_set_away_mode(hass, setup_comp_2, preset, temp): """Test the setting away mode.""" await common.async_set_temperature(hass, 23) - await common.async_set_preset_mode(hass, PRESET_AWAY) + await common.async_set_preset_mode(hass, preset) state = hass.states.get(ENTITY) - assert state.attributes.get("temperature") == 16 - - -async def test_set_away_mode_and_restore_prev_temp(hass, setup_comp_2): + assert state.attributes.get("temperature") == temp + + +@pytest.mark.parametrize( + "preset,temp", + [ + (PRESET_NONE, 23), + (PRESET_AWAY, 16), + (PRESET_COMFORT, 20), + (PRESET_HOME, 19), + (PRESET_SLEEP, 17), + (PRESET_ACTIVITY, 21), + ], +) +async def test_set_away_mode_and_restore_prev_temp(hass, setup_comp_2, preset, temp): """Test the setting and removing away mode. Verify original temperature is restored. """ await common.async_set_temperature(hass, 23) - await common.async_set_preset_mode(hass, PRESET_AWAY) + await common.async_set_preset_mode(hass, preset) state = hass.states.get(ENTITY) - assert state.attributes.get("temperature") == 16 + assert state.attributes.get("temperature") == temp await common.async_set_preset_mode(hass, PRESET_NONE) state = hass.states.get(ENTITY) assert state.attributes.get("temperature") == 23 -async def test_set_away_mode_twice_and_restore_prev_temp(hass, setup_comp_2): +@pytest.mark.parametrize( + "preset,temp", + [ + (PRESET_NONE, 23), + (PRESET_AWAY, 16), + (PRESET_COMFORT, 20), + (PRESET_HOME, 19), + (PRESET_SLEEP, 17), + (PRESET_ACTIVITY, 21), + ], +) +async def test_set_away_mode_twice_and_restore_prev_temp( + hass, setup_comp_2, preset, temp +): """Test the setting away mode twice in a row. Verify original temperature is restored. """ await common.async_set_temperature(hass, 23) - await common.async_set_preset_mode(hass, PRESET_AWAY) - await common.async_set_preset_mode(hass, PRESET_AWAY) + await common.async_set_preset_mode(hass, preset) + await common.async_set_preset_mode(hass, preset) state = hass.states.get(ENTITY) - assert state.attributes.get("temperature") == 16 + assert state.attributes.get("temperature") == temp await common.async_set_preset_mode(hass, PRESET_NONE) state = hass.states.get(ENTITY) assert state.attributes.get("temperature") == 23 diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index bc74f01f6f1527..bbf5f42ed60c78 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -273,6 +273,64 @@ async def test_if_fires_on_zone_appear(hass, calls): ) +async def test_if_fires_on_zone_appear_2(hass, calls): + """Test for firing if entity appears in zone.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "geo_location", + "source": "test_source", + "zone": "zone.test", + "event": "enter", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "zone.name", + ) + ) + }, + }, + } + }, + ) + + # Entity appears in zone without previously existing outside the zone. + context = Context() + hass.states.async_set( + "geo_location.entity", + "goodbye", + {"latitude": 32.881011, "longitude": -117.234758, "source": "test_source"}, + context=context, + ) + await hass.async_block_till_done() + + hass.states.async_set( + "geo_location.entity", + "hello", + {"latitude": 32.880586, "longitude": -117.237564, "source": "test_source"}, + context=context, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].context.parent_id == context.id + assert ( + calls[0].data["some"] + == "geo_location - geo_location.entity - goodbye - hello - test" + ) + + async def test_if_fires_on_zone_disappear(hass, calls): """Test for firing if entity disappears from zone.""" hass.states.async_set( diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index adf151f4819abc..6603f67e3595aa 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -14,7 +14,8 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as PLATFORM, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -22,13 +23,6 @@ ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - DEVICE_CLASS_AQI, - DEVICE_CLASS_CO, - DEVICE_CLASS_NITROGEN_DIOXIDE, - DEVICE_CLASS_OZONE, - DEVICE_CLASS_PM10, - DEVICE_CLASS_PM25, - DEVICE_CLASS_SULPHUR_DIOXIDE, STATE_UNAVAILABLE, ) from homeassistant.helpers import entity_registry as er @@ -48,7 +42,7 @@ async def test_sensor(hass): assert state.state == "0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -65,8 +59,8 @@ async def test_sensor(hass): assert state.state == "252" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CO - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CO + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -82,8 +76,8 @@ async def test_sensor(hass): assert state.state == "7" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_NITROGEN_DIOXIDE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.NITROGEN_DIOXIDE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -99,8 +93,8 @@ async def test_sensor(hass): assert state.state == "96" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_OZONE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.OZONE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -116,8 +110,8 @@ async def test_sensor(hass): assert state.state == "17" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -133,8 +127,8 @@ async def test_sensor(hass): assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -150,8 +144,8 @@ async def test_sensor(hass): assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SULPHUR_DIOXIDE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SULPHUR_DIOXIDE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -167,7 +161,7 @@ async def test_sensor(hass): assert state.state == "dobry" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_AQI + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.AQI assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None @@ -228,7 +222,7 @@ async def test_invalid_indexes(hass): assert state.state == "0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -245,7 +239,7 @@ async def test_invalid_indexes(hass): assert state.state == "252" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -261,7 +255,7 @@ async def test_invalid_indexes(hass): assert state.state == "7" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -277,7 +271,7 @@ async def test_invalid_indexes(hass): assert state.state == "96" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -293,7 +287,7 @@ async def test_invalid_indexes(hass): assert state.state == "17" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -309,7 +303,7 @@ async def test_invalid_indexes(hass): assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -325,7 +319,7 @@ async def test_invalid_indexes(hass): assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER diff --git a/tests/components/github/__init__.py b/tests/components/github/__init__.py new file mode 100644 index 00000000000000..55c9fb8699456b --- /dev/null +++ b/tests/components/github/__init__.py @@ -0,0 +1 @@ +"""Tests for the GitHub integration.""" diff --git a/tests/components/github/common.py b/tests/components/github/common.py new file mode 100644 index 00000000000000..a99834f0cbdf13 --- /dev/null +++ b/tests/components/github/common.py @@ -0,0 +1,46 @@ +"""Common helpers for GitHub integration tests.""" +from __future__ import annotations + +import json + +from homeassistant import config_entries +from homeassistant.components.github.const import CONF_REPOSITORIES, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +MOCK_ACCESS_TOKEN = "gho_16C7e42F292c6912E7710c838347Ae178B4a" +TEST_REPOSITORY = "octocat/Hello-World" + + +async def setup_github_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Mock setting up the integration.""" + headers = json.loads(load_fixture("base_headers.json", DOMAIN)) + for idx, repository in enumerate(mock_config_entry.options[CONF_REPOSITORIES]): + aioclient_mock.get( + f"https://api.github.com/repos/{repository}", + json={ + **json.loads(load_fixture("repository.json", DOMAIN)), + "full_name": repository, + "id": idx, + }, + headers=headers, + ) + for endpoint in ("issues", "pulls", "releases", "commits"): + aioclient_mock.get( + f"https://api.github.com/repos/{repository}/{endpoint}", + json=json.loads(load_fixture(f"{endpoint}.json", DOMAIN)), + headers=headers, + ) + mock_config_entry.add_to_hass(hass) + + setup_result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert setup_result + assert mock_config_entry.state == config_entries.ConfigEntryState.LOADED diff --git a/tests/components/github/conftest.py b/tests/components/github/conftest.py new file mode 100644 index 00000000000000..04b53da6b91a5e --- /dev/null +++ b/tests/components/github/conftest.py @@ -0,0 +1,46 @@ +"""conftest for the GitHub integration.""" +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.github.const import ( + CONF_ACCESS_TOKEN, + CONF_REPOSITORIES, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from .common import MOCK_ACCESS_TOKEN, TEST_REPOSITORY, setup_github_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="", + domain=DOMAIN, + data={CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN}, + options={CONF_REPOSITORIES: [TEST_REPOSITORY]}, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.github.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> MockConfigEntry: + """Set up the GitHub integration for testing.""" + await setup_github_integration(hass, mock_config_entry, aioclient_mock) + return mock_config_entry diff --git a/tests/components/github/fixtures/base_headers.json b/tests/components/github/fixtures/base_headers.json new file mode 100644 index 00000000000000..4360f319cf1cb1 --- /dev/null +++ b/tests/components/github/fixtures/base_headers.json @@ -0,0 +1,29 @@ +{ + "Server": "GitHub.com", + "Date": "Mon, 1 Jan 1970 00:00:00 GMT", + "Content-Type": "application/json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Cache-Control": "private, max-age=60, s-maxage=60", + "Vary": "Accept, Authorization, Cookie, X-GitHub-OTP", + "Etag": "W/\"1234567890abcdefghijklmnopqrstuvwxyz\"", + "X-OAuth-Scopes": "", + "X-Accepted-OAuth-Scopes": "", + "github-authentication-token-expiration": "1970-01-01 01:00:00 UTC", + "X-GitHub-Media-Type": "github.v3; param=raw; format=json", + "X-RateLimit-Limit": "5000", + "X-RateLimit-Remaining": "4999", + "X-RateLimit-Reset": "1", + "X-RateLimit-Used": "1", + "X-RateLimit-Resource": "core", + "Access-Control-Expose-Headers": "ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, Deprecation, Sunset", + "Access-Control-Allow-Origin": "*", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "X-Frame-Options": "deny", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "0", + "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'none'", + "Content-Encoding": "gzip", + "Permissions-Policy": "", + "X-GitHub-Request-Id": "12A3:45BC:6D7890:12EF34:5678G901" +} \ No newline at end of file diff --git a/tests/components/github/fixtures/commits.json b/tests/components/github/fixtures/commits.json new file mode 100644 index 00000000000000..c0deeaf51eaf2c --- /dev/null +++ b/tests/components/github/fixtures/commits.json @@ -0,0 +1,80 @@ +[ + { + "url": "https://api.github.com/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e", + "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", + "node_id": "MDY6Q29tbWl0NmRjYjA5YjViNTc4NzVmMzM0ZjYxYWViZWQ2OTVlMmU0MTkzZGI1ZQ==", + "html_url": "https://github.com/octocat/Hello-World/commit/6dcb09b5b57875f334f61aebed695e2e4193db5e", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e/comments", + "commit": { + "url": "https://api.github.com/repos/octocat/Hello-World/git/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e", + "author": { + "name": "Monalisa Octocat", + "email": "support@github.com", + "date": "2011-04-14T16:00:49Z" + }, + "committer": { + "name": "Monalisa Octocat", + "email": "support@github.com", + "date": "2011-04-14T16:00:49Z" + }, + "message": "Fix all the bugs", + "tree": { + "url": "https://api.github.com/repos/octocat/Hello-World/tree/6dcb09b5b57875f334f61aebed695e2e4193db5e", + "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e" + }, + "comment_count": 0, + "verification": { + "verified": false, + "reason": "unsigned", + "signature": null, + "payload": null + } + }, + "author": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "committer": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "parents": [ + { + "url": "https://api.github.com/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e", + "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e" + } + ] + } +] \ No newline at end of file diff --git a/tests/components/github/fixtures/issues.json b/tests/components/github/fixtures/issues.json new file mode 100644 index 00000000000000..d59f1f5c79675c --- /dev/null +++ b/tests/components/github/fixtures/issues.json @@ -0,0 +1,159 @@ +[ + { + "id": 1, + "node_id": "MDU6SXNzdWUx", + "url": "https://api.github.com/repos/octocat/Hello-World/issues/1347", + "repository_url": "https://api.github.com/repos/octocat/Hello-World", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/labels{/name}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments", + "events_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/events", + "html_url": "https://github.com/octocat/Hello-World/issues/1347", + "number": 1347, + "state": "open", + "title": "Found a bug", + "body": "I'm having a problem with this.", + "user": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "labels": [ + { + "id": 208045946, + "node_id": "MDU6TGFiZWwyMDgwNDU5NDY=", + "url": "https://api.github.com/repos/octocat/Hello-World/labels/bug", + "name": "bug", + "description": "Something isn't working", + "color": "f29513", + "default": true + } + ], + "assignee": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "assignees": [ + { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + } + ], + "milestone": { + "url": "https://api.github.com/repos/octocat/Hello-World/milestones/1", + "html_url": "https://github.com/octocat/Hello-World/milestones/v1.0", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/milestones/1/labels", + "id": 1002604, + "node_id": "MDk6TWlsZXN0b25lMTAwMjYwNA==", + "number": 1, + "state": "open", + "title": "v1.0", + "description": "Tracking milestone for version 1.0", + "creator": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "open_issues": 4, + "closed_issues": 8, + "created_at": "2011-04-10T20:09:31Z", + "updated_at": "2014-03-03T18:58:10Z", + "closed_at": "2013-02-12T13:22:01Z", + "due_on": "2012-10-09T23:39:01Z" + }, + "locked": true, + "active_lock_reason": "too heated", + "comments": 0, + "pull_request": { + "url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347", + "html_url": "https://github.com/octocat/Hello-World/pull/1347", + "diff_url": "https://github.com/octocat/Hello-World/pull/1347.diff", + "patch_url": "https://github.com/octocat/Hello-World/pull/1347.patch" + }, + "closed_at": null, + "created_at": "2011-04-22T13:33:48Z", + "updated_at": "2011-04-22T13:33:48Z", + "closed_by": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "author_association": "COLLABORATOR" + } +] \ No newline at end of file diff --git a/tests/components/github/fixtures/pulls.json b/tests/components/github/fixtures/pulls.json new file mode 100644 index 00000000000000..a42763b18d8a90 --- /dev/null +++ b/tests/components/github/fixtures/pulls.json @@ -0,0 +1,520 @@ +[ + { + "url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347", + "id": 1, + "node_id": "MDExOlB1bGxSZXF1ZXN0MQ==", + "html_url": "https://github.com/octocat/Hello-World/pull/1347", + "diff_url": "https://github.com/octocat/Hello-World/pull/1347.diff", + "patch_url": "https://github.com/octocat/Hello-World/pull/1347.patch", + "issue_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/commits", + "review_comments_url": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/comments", + "review_comment_url": "https://api.github.com/repos/octocat/Hello-World/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e", + "number": 1347, + "state": "open", + "locked": true, + "title": "Amazing new feature", + "user": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "body": "Please pull these awesome changes in!", + "labels": [ + { + "id": 208045946, + "node_id": "MDU6TGFiZWwyMDgwNDU5NDY=", + "url": "https://api.github.com/repos/octocat/Hello-World/labels/bug", + "name": "bug", + "description": "Something isn't working", + "color": "f29513", + "default": true + } + ], + "milestone": { + "url": "https://api.github.com/repos/octocat/Hello-World/milestones/1", + "html_url": "https://github.com/octocat/Hello-World/milestones/v1.0", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/milestones/1/labels", + "id": 1002604, + "node_id": "MDk6TWlsZXN0b25lMTAwMjYwNA==", + "number": 1, + "state": "open", + "title": "v1.0", + "description": "Tracking milestone for version 1.0", + "creator": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "open_issues": 4, + "closed_issues": 8, + "created_at": "2011-04-10T20:09:31Z", + "updated_at": "2014-03-03T18:58:10Z", + "closed_at": "2013-02-12T13:22:01Z", + "due_on": "2012-10-09T23:39:01Z" + }, + "active_lock_reason": "too heated", + "created_at": "2011-01-26T19:01:12Z", + "updated_at": "2011-01-26T19:01:12Z", + "closed_at": "2011-01-26T19:01:12Z", + "merged_at": "2011-01-26T19:01:12Z", + "merge_commit_sha": "e5bd3914e2e596debea16f433f57875b5b90bcd6", + "assignee": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "assignees": [ + { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + { + "login": "hubot", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/hubot_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/hubot", + "html_url": "https://github.com/hubot", + "followers_url": "https://api.github.com/users/hubot/followers", + "following_url": "https://api.github.com/users/hubot/following{/other_user}", + "gists_url": "https://api.github.com/users/hubot/gists{/gist_id}", + "starred_url": "https://api.github.com/users/hubot/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/hubot/subscriptions", + "organizations_url": "https://api.github.com/users/hubot/orgs", + "repos_url": "https://api.github.com/users/hubot/repos", + "events_url": "https://api.github.com/users/hubot/events{/privacy}", + "received_events_url": "https://api.github.com/users/hubot/received_events", + "type": "User", + "site_admin": true + } + ], + "requested_reviewers": [ + { + "login": "other_user", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/other_user_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/other_user", + "html_url": "https://github.com/other_user", + "followers_url": "https://api.github.com/users/other_user/followers", + "following_url": "https://api.github.com/users/other_user/following{/other_user}", + "gists_url": "https://api.github.com/users/other_user/gists{/gist_id}", + "starred_url": "https://api.github.com/users/other_user/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/other_user/subscriptions", + "organizations_url": "https://api.github.com/users/other_user/orgs", + "repos_url": "https://api.github.com/users/other_user/repos", + "events_url": "https://api.github.com/users/other_user/events{/privacy}", + "received_events_url": "https://api.github.com/users/other_user/received_events", + "type": "User", + "site_admin": false + } + ], + "requested_teams": [ + { + "id": 1, + "node_id": "MDQ6VGVhbTE=", + "url": "https://api.github.com/teams/1", + "html_url": "https://github.com/orgs/github/teams/justice-league", + "name": "Justice League", + "slug": "justice-league", + "description": "A great team.", + "privacy": "closed", + "permission": "admin", + "members_url": "https://api.github.com/teams/1/members{/member}", + "repositories_url": "https://api.github.com/teams/1/repos", + "parent": null + } + ], + "head": { + "label": "octocat:new-topic", + "ref": "new-topic", + "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", + "user": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/octocat/Hello-World", + "description": "This your first repo!", + "fork": false, + "url": "https://api.github.com/repos/octocat/Hello-World", + "archive_url": "https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", + "git_url": "git:github.com/octocat/Hello-World.git", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World/releases{/id}", + "ssh_url": "git@github.com:octocat/Hello-World.git", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", + "clone_url": "https://github.com/octocat/Hello-World.git", + "mirror_url": "git:git.example.com/octocat/Hello-World", + "hooks_url": "https://api.github.com/repos/octocat/Hello-World/hooks", + "svn_url": "https://svn.github.com/octocat/Hello-World", + "homepage": "https://github.com", + "language": null, + "forks_count": 9, + "stargazers_count": 80, + "watchers_count": 80, + "size": 108, + "default_branch": "master", + "open_issues_count": 0, + "is_template": true, + "topics": [ + "octocat", + "atom", + "electron", + "api" + ], + "has_issues": true, + "has_projects": true, + "has_wiki": true, + "has_pages": false, + "has_downloads": true, + "archived": false, + "disabled": false, + "visibility": "public", + "pushed_at": "2011-01-26T19:06:43Z", + "created_at": "2011-01-26T19:01:12Z", + "updated_at": "2011-01-26T19:14:43Z", + "permissions": { + "admin": false, + "push": false, + "pull": true + }, + "allow_rebase_merge": true, + "template_repository": null, + "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O", + "allow_squash_merge": true, + "allow_auto_merge": false, + "delete_branch_on_merge": true, + "allow_merge_commit": true, + "subscribers_count": 42, + "network_count": 0, + "license": { + "key": "mit", + "name": "MIT License", + "url": "https://api.github.com/licenses/mit", + "spdx_id": "MIT", + "node_id": "MDc6TGljZW5zZW1pdA==", + "html_url": "https://github.com/licenses/mit" + }, + "forks": 1, + "open_issues": 1, + "watchers": 1 + } + }, + "base": { + "label": "octocat:master", + "ref": "master", + "sha": "6dcb09b5b57875f334f61aebed695e2e4193db5e", + "user": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "repo": { + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/octocat/Hello-World", + "description": "This your first repo!", + "fork": false, + "url": "https://api.github.com/repos/octocat/Hello-World", + "archive_url": "https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", + "git_url": "git:github.com/octocat/Hello-World.git", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World/releases{/id}", + "ssh_url": "git@github.com:octocat/Hello-World.git", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", + "clone_url": "https://github.com/octocat/Hello-World.git", + "mirror_url": "git:git.example.com/octocat/Hello-World", + "hooks_url": "https://api.github.com/repos/octocat/Hello-World/hooks", + "svn_url": "https://svn.github.com/octocat/Hello-World", + "homepage": "https://github.com", + "language": null, + "forks_count": 9, + "stargazers_count": 80, + "watchers_count": 80, + "size": 108, + "default_branch": "master", + "open_issues_count": 0, + "is_template": true, + "topics": [ + "octocat", + "atom", + "electron", + "api" + ], + "has_issues": true, + "has_projects": true, + "has_wiki": true, + "has_pages": false, + "has_downloads": true, + "archived": false, + "disabled": false, + "visibility": "public", + "pushed_at": "2011-01-26T19:06:43Z", + "created_at": "2011-01-26T19:01:12Z", + "updated_at": "2011-01-26T19:14:43Z", + "permissions": { + "admin": false, + "push": false, + "pull": true + }, + "allow_rebase_merge": true, + "template_repository": null, + "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O", + "allow_squash_merge": true, + "allow_auto_merge": false, + "delete_branch_on_merge": true, + "allow_merge_commit": true, + "subscribers_count": 42, + "network_count": 0, + "license": { + "key": "mit", + "name": "MIT License", + "url": "https://api.github.com/licenses/mit", + "spdx_id": "MIT", + "node_id": "MDc6TGljZW5zZW1pdA==", + "html_url": "https://github.com/licenses/mit" + }, + "forks": 1, + "open_issues": 1, + "watchers": 1 + } + }, + "_links": { + "self": { + "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347" + }, + "html": { + "href": "https://github.com/octocat/Hello-World/pull/1347" + }, + "issue": { + "href": "https://api.github.com/repos/octocat/Hello-World/issues/1347" + }, + "comments": { + "href": "https://api.github.com/repos/octocat/Hello-World/issues/1347/comments" + }, + "review_comments": { + "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/comments" + }, + "review_comment": { + "href": "https://api.github.com/repos/octocat/Hello-World/pulls/comments{/number}" + }, + "commits": { + "href": "https://api.github.com/repos/octocat/Hello-World/pulls/1347/commits" + }, + "statuses": { + "href": "https://api.github.com/repos/octocat/Hello-World/statuses/6dcb09b5b57875f334f61aebed695e2e4193db5e" + } + }, + "author_association": "OWNER", + "auto_merge": null, + "draft": false + } +] \ No newline at end of file diff --git a/tests/components/github/fixtures/releases.json b/tests/components/github/fixtures/releases.json new file mode 100644 index 00000000000000..e69206ae78401a --- /dev/null +++ b/tests/components/github/fixtures/releases.json @@ -0,0 +1,76 @@ +[ + { + "url": "https://api.github.com/repos/octocat/Hello-World/releases/1", + "html_url": "https://github.com/octocat/Hello-World/releases/v1.0.0", + "assets_url": "https://api.github.com/repos/octocat/Hello-World/releases/1/assets", + "upload_url": "https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}", + "tarball_url": "https://api.github.com/repos/octocat/Hello-World/tarball/v1.0.0", + "zipball_url": "https://api.github.com/repos/octocat/Hello-World/zipball/v1.0.0", + "id": 1, + "node_id": "MDc6UmVsZWFzZTE=", + "tag_name": "v1.0.0", + "target_commitish": "master", + "name": "v1.0.0", + "body": "Description of the release", + "draft": false, + "prerelease": false, + "created_at": "2013-02-27T19:35:32Z", + "published_at": "2013-02-27T19:35:32Z", + "author": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "assets": [ + { + "url": "https://api.github.com/repos/octocat/Hello-World/releases/assets/1", + "browser_download_url": "https://github.com/octocat/Hello-World/releases/download/v1.0.0/example.zip", + "id": 1, + "node_id": "MDEyOlJlbGVhc2VBc3NldDE=", + "name": "example.zip", + "label": "short description", + "state": "uploaded", + "content_type": "application/zip", + "size": 1024, + "download_count": 42, + "created_at": "2013-02-27T19:35:32Z", + "updated_at": "2013-02-27T19:35:32Z", + "uploader": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + } + } + ] + } +] \ No newline at end of file diff --git a/tests/components/github/fixtures/repository.json b/tests/components/github/fixtures/repository.json new file mode 100644 index 00000000000000..7007db68593eb2 --- /dev/null +++ b/tests/components/github/fixtures/repository.json @@ -0,0 +1,507 @@ +{ + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/octocat/Hello-World", + "description": "This your first repo!", + "fork": false, + "url": "https://api.github.com/repos/octocat/Hello-World", + "archive_url": "https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", + "git_url": "git:github.com/octocat/Hello-World.git", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World/releases{/id}", + "ssh_url": "git@github.com:octocat/Hello-World.git", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", + "clone_url": "https://github.com/octocat/Hello-World.git", + "mirror_url": "git:git.example.com/octocat/Hello-World", + "hooks_url": "https://api.github.com/repos/octocat/Hello-World/hooks", + "svn_url": "https://svn.github.com/octocat/Hello-World", + "homepage": "https://github.com", + "language": null, + "forks_count": 9, + "forks": 9, + "stargazers_count": 80, + "watchers_count": 80, + "watchers": 80, + "size": 108, + "default_branch": "master", + "open_issues_count": 0, + "open_issues": 0, + "is_template": false, + "topics": [ + "octocat", + "atom", + "electron", + "api" + ], + "has_issues": true, + "has_projects": true, + "has_wiki": true, + "has_pages": false, + "has_downloads": true, + "archived": false, + "disabled": false, + "visibility": "public", + "pushed_at": "2011-01-26T19:06:43Z", + "created_at": "2011-01-26T19:01:12Z", + "updated_at": "2011-01-26T19:14:43Z", + "permissions": { + "pull": true, + "push": false, + "admin": false + }, + "allow_rebase_merge": true, + "template_repository": { + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World-Template", + "full_name": "octocat/Hello-World-Template", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/octocat/Hello-World-Template", + "description": "This your first repo!", + "fork": false, + "url": "https://api.github.com/repos/octocat/Hello-World-Template", + "archive_url": "https://api.github.com/repos/octocat/Hello-World-Template/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World-Template/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World-Template/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World-Template/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World-Template/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World-Template/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World-Template/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World-Template/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World-Template/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World-Template/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World-Template/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World-Template/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World-Template/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World-Template/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World-Template/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World-Template/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World-Template/git/tags{/sha}", + "git_url": "git:github.com/octocat/Hello-World-Template.git", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World-Template/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World-Template/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World-Template/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World-Template/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World-Template/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World-Template/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World-Template/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World-Template/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World-Template/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World-Template/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World-Template/releases{/id}", + "ssh_url": "git@github.com:octocat/Hello-World-Template.git", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World-Template/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World-Template/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World-Template/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World-Template/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World-Template/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World-Template/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World-Template/git/trees{/sha}", + "clone_url": "https://github.com/octocat/Hello-World-Template.git", + "mirror_url": "git:git.example.com/octocat/Hello-World-Template", + "hooks_url": "https://api.github.com/repos/octocat/Hello-World-Template/hooks", + "svn_url": "https://svn.github.com/octocat/Hello-World-Template", + "homepage": "https://github.com", + "language": null, + "forks": 9, + "forks_count": 9, + "stargazers_count": 80, + "watchers_count": 80, + "watchers": 80, + "size": 108, + "default_branch": "master", + "open_issues": 0, + "open_issues_count": 0, + "is_template": true, + "license": { + "key": "mit", + "name": "MIT License", + "url": "https://api.github.com/licenses/mit", + "spdx_id": "MIT", + "node_id": "MDc6TGljZW5zZW1pdA==", + "html_url": "https://api.github.com/licenses/mit" + }, + "topics": [ + "octocat", + "atom", + "electron", + "api" + ], + "has_issues": true, + "has_projects": true, + "has_wiki": true, + "has_pages": false, + "has_downloads": true, + "archived": false, + "disabled": false, + "visibility": "public", + "pushed_at": "2011-01-26T19:06:43Z", + "created_at": "2011-01-26T19:01:12Z", + "updated_at": "2011-01-26T19:14:43Z", + "permissions": { + "admin": false, + "push": false, + "pull": true + }, + "allow_rebase_merge": true, + "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O", + "allow_squash_merge": true, + "allow_auto_merge": false, + "delete_branch_on_merge": true, + "allow_merge_commit": true, + "subscribers_count": 42, + "network_count": 0 + }, + "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O", + "allow_squash_merge": true, + "allow_auto_merge": false, + "delete_branch_on_merge": true, + "allow_merge_commit": true, + "subscribers_count": 42, + "network_count": 0, + "license": { + "key": "mit", + "name": "MIT License", + "spdx_id": "MIT", + "url": "https://api.github.com/licenses/mit", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + "organization": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "Organization", + "site_admin": false + }, + "parent": { + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/octocat/Hello-World", + "description": "This your first repo!", + "fork": false, + "url": "https://api.github.com/repos/octocat/Hello-World", + "archive_url": "https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", + "git_url": "git:github.com/octocat/Hello-World.git", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World/releases{/id}", + "ssh_url": "git@github.com:octocat/Hello-World.git", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", + "clone_url": "https://github.com/octocat/Hello-World.git", + "mirror_url": "git:git.example.com/octocat/Hello-World", + "hooks_url": "https://api.github.com/repos/octocat/Hello-World/hooks", + "svn_url": "https://svn.github.com/octocat/Hello-World", + "homepage": "https://github.com", + "language": null, + "forks_count": 9, + "stargazers_count": 80, + "watchers_count": 80, + "size": 108, + "default_branch": "master", + "open_issues_count": 0, + "is_template": true, + "topics": [ + "octocat", + "atom", + "electron", + "api" + ], + "has_issues": true, + "has_projects": true, + "has_wiki": true, + "has_pages": false, + "has_downloads": true, + "archived": false, + "disabled": false, + "visibility": "public", + "pushed_at": "2011-01-26T19:06:43Z", + "created_at": "2011-01-26T19:01:12Z", + "updated_at": "2011-01-26T19:14:43Z", + "permissions": { + "admin": false, + "push": false, + "pull": true + }, + "allow_rebase_merge": true, + "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O", + "allow_squash_merge": true, + "allow_auto_merge": false, + "delete_branch_on_merge": true, + "allow_merge_commit": true, + "subscribers_count": 42, + "network_count": 0, + "license": { + "key": "mit", + "name": "MIT License", + "url": "https://api.github.com/licenses/mit", + "spdx_id": "MIT", + "node_id": "MDc6TGljZW5zZW1pdA==", + "html_url": "https://api.github.com/licenses/mit" + }, + "forks": 1, + "open_issues": 1, + "watchers": 1 + }, + "source": { + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/octocat/Hello-World", + "description": "This your first repo!", + "fork": false, + "url": "https://api.github.com/repos/octocat/Hello-World", + "archive_url": "https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", + "git_url": "git:github.com/octocat/Hello-World.git", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World/releases{/id}", + "ssh_url": "git@github.com:octocat/Hello-World.git", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", + "clone_url": "https://github.com/octocat/Hello-World.git", + "mirror_url": "git:git.example.com/octocat/Hello-World", + "hooks_url": "https://api.github.com/repos/octocat/Hello-World/hooks", + "svn_url": "https://svn.github.com/octocat/Hello-World", + "homepage": "https://github.com", + "language": null, + "forks_count": 9, + "stargazers_count": 80, + "watchers_count": 80, + "size": 108, + "default_branch": "master", + "open_issues_count": 0, + "is_template": true, + "topics": [ + "octocat", + "atom", + "electron", + "api" + ], + "has_issues": true, + "has_projects": true, + "has_wiki": true, + "has_pages": false, + "has_downloads": true, + "archived": false, + "disabled": false, + "visibility": "public", + "pushed_at": "2011-01-26T19:06:43Z", + "created_at": "2011-01-26T19:01:12Z", + "updated_at": "2011-01-26T19:14:43Z", + "permissions": { + "admin": false, + "push": false, + "pull": true + }, + "allow_rebase_merge": true, + "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O", + "allow_squash_merge": true, + "allow_auto_merge": false, + "delete_branch_on_merge": true, + "allow_merge_commit": true, + "subscribers_count": 42, + "network_count": 0, + "license": { + "key": "mit", + "name": "MIT License", + "url": "https://api.github.com/licenses/mit", + "spdx_id": "MIT", + "node_id": "MDc6TGljZW5zZW1pdA==", + "html_url": "https://api.github.com/licenses/mit" + }, + "forks": 1, + "open_issues": 1, + "watchers": 1 + } +} \ No newline at end of file diff --git a/tests/components/github/test_config_flow.py b/tests/components/github/test_config_flow.py new file mode 100644 index 00000000000000..dad974726209ee --- /dev/null +++ b/tests/components/github/test_config_flow.py @@ -0,0 +1,233 @@ +"""Test the GitHub config flow.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from aiogithubapi import GitHubException + +from homeassistant import config_entries +from homeassistant.components.github.config_flow import starred_repositories +from homeassistant.components.github.const import ( + CONF_ACCESS_TOKEN, + CONF_REPOSITORIES, + DEFAULT_REPOSITORIES, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_SHOW_PROGRESS, + RESULT_TYPE_SHOW_PROGRESS_DONE, +) + +from .common import MOCK_ACCESS_TOKEN + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, + mock_setup_entry: None, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the full manual user flow from start to finish.""" + aioclient_mock.post( + "https://github.com/login/device/code", + json={ + "device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5", + "user_code": "WDJB-MJHT", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5, + }, + headers={"Content-Type": "application/json"}, + ) + aioclient_mock.post( + "https://github.com/login/oauth/access_token", + json={ + CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN, + "token_type": "bearer", + "scope": "", + }, + headers={"Content-Type": "application/json"}, + ) + aioclient_mock.get( + "https://api.github.com/user/starred", + json=[{"full_name": "home-assistant/core"}, {"full_name": "esphome/esphome"}], + headers={"Content-Type": "application/json"}, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["step_id"] == "device" + assert result["type"] == RESULT_TYPE_SHOW_PROGRESS + assert "flow_id" in result + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_REPOSITORIES: DEFAULT_REPOSITORIES, + }, + ) + + assert result["title"] == "" + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_ACCESS_TOKEN] == MOCK_ACCESS_TOKEN + assert "options" in result + assert result["options"][CONF_REPOSITORIES] == DEFAULT_REPOSITORIES + + +async def test_flow_with_registration_failure( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test flow with registration failure of the device.""" + aioclient_mock.post( + "https://github.com/login/device/code", + exc=GitHubException("Registration failed"), + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result.get("reason") == "could_not_register" + + +async def test_flow_with_activation_failure( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test flow with activation failure of the device.""" + aioclient_mock.post( + "https://github.com/login/device/code", + json={ + "device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5", + "user_code": "WDJB-MJHT", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5, + }, + headers={"Content-Type": "application/json"}, + ) + aioclient_mock.post( + "https://github.com/login/oauth/access_token", + exc=GitHubException("Activation failed"), + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["step_id"] == "device" + assert result["type"] == RESULT_TYPE_SHOW_PROGRESS + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == RESULT_TYPE_SHOW_PROGRESS_DONE + assert result["step_id"] == "could_not_register" + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" + + +async def test_starred_pagination_with_paginated_result(hass: HomeAssistant) -> None: + """Test pagination of starred repositories with paginated result.""" + with patch( + "homeassistant.components.github.config_flow.GitHubAPI", + return_value=MagicMock( + user=MagicMock( + starred=AsyncMock( + return_value=MagicMock( + is_last_page=False, + next_page_number=2, + last_page_number=2, + data=[MagicMock(full_name="home-assistant/core")], + ) + ) + ) + ), + ): + repos = await starred_repositories(hass, MOCK_ACCESS_TOKEN) + + assert len(repos) == 2 + assert repos[-1] == DEFAULT_REPOSITORIES[0] + + +async def test_starred_pagination_with_no_starred(hass: HomeAssistant) -> None: + """Test pagination of starred repositories with no starred.""" + with patch( + "homeassistant.components.github.config_flow.GitHubAPI", + return_value=MagicMock( + user=MagicMock( + starred=AsyncMock( + return_value=MagicMock( + is_last_page=True, + data=[], + ) + ) + ) + ), + ): + repos = await starred_repositories(hass, MOCK_ACCESS_TOKEN) + + assert len(repos) == 2 + assert repos == DEFAULT_REPOSITORIES + + +async def test_starred_pagination_with_exception(hass: HomeAssistant) -> None: + """Test pagination of starred repositories with exception.""" + with patch( + "homeassistant.components.github.config_flow.GitHubAPI", + return_value=MagicMock( + user=MagicMock(starred=AsyncMock(side_effect=GitHubException("Error"))) + ), + ): + repos = await starred_repositories(hass, MOCK_ACCESS_TOKEN) + + assert len(repos) == 2 + assert repos == DEFAULT_REPOSITORIES + + +async def test_options_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: None, +) -> None: + """Test options flow.""" + mock_config_entry.options = { + CONF_REPOSITORIES: ["homeassistant/core", "homeassistant/architecture"] + } + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_REPOSITORIES: ["homeassistant/core"]}, + ) + + assert "homeassistant/architecture" not in result["data"][CONF_REPOSITORIES] diff --git a/tests/components/github/test_diagnostics.py b/tests/components/github/test_diagnostics.py new file mode 100644 index 00000000000000..6e5e6e13fa44d6 --- /dev/null +++ b/tests/components/github/test_diagnostics.py @@ -0,0 +1,68 @@ +"""Test GitHub diagnostics.""" + +from aiogithubapi import GitHubException +from aiohttp import ClientSession + +from homeassistant.components.github.const import CONF_REPOSITORIES +from homeassistant.core import HomeAssistant + +from .common import setup_github_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test config entry diagnostics.""" + mock_config_entry.options = {CONF_REPOSITORIES: ["home-assistant/core"]} + await setup_github_integration(hass, mock_config_entry, aioclient_mock) + aioclient_mock.get( + "https://api.github.com/rate_limit", + json={"resources": {"core": {"remaining": 100, "limit": 100}}}, + headers={"Content-Type": "application/json"}, + ) + + result = await get_diagnostics_for_config_entry( + hass, + hass_client, + mock_config_entry, + ) + + assert result["options"]["repositories"] == ["home-assistant/core"] + assert result["rate_limit"] == { + "resources": {"core": {"remaining": 100, "limit": 100}} + } + assert ( + result["repositories"]["home-assistant/core"]["full_name"] + == "home-assistant/core" + ) + + +async def test_entry_diagnostics_exception( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test config entry diagnostics with exception for ratelimit.""" + aioclient_mock.get( + "https://api.github.com/rate_limit", + exc=GitHubException("error"), + ) + + result = await get_diagnostics_for_config_entry( + hass, + hass_client, + init_integration, + ) + + assert ( + result["rate_limit"]["error"] + == "Unexpected exception for 'https://api.github.com/rate_limit' with - error" + ) diff --git a/tests/components/github/test_init.py b/tests/components/github/test_init.py new file mode 100644 index 00000000000000..8abb6ffda925c5 --- /dev/null +++ b/tests/components/github/test_init.py @@ -0,0 +1,46 @@ +"""Test the GitHub init file.""" +from pytest import LogCaptureFixture + +from homeassistant.components.github.const import CONF_REPOSITORIES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .common import setup_github_integration + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_device_registry_cleanup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + caplog: LogCaptureFixture, +) -> None: + """Test that we remove untracked repositories from the decvice registry.""" + mock_config_entry.options = {CONF_REPOSITORIES: ["home-assistant/core"]} + await setup_github_integration(hass, mock_config_entry, aioclient_mock) + + device_registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry( + registry=device_registry, + config_entry_id=mock_config_entry.entry_id, + ) + + assert len(devices) == 1 + + mock_config_entry.options = {CONF_REPOSITORIES: []} + assert await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + f"Unlinking device {devices[0].id} for untracked repository home-assistant/core from config entry {mock_config_entry.entry_id}" + in caplog.text + ) + + devices = dr.async_entries_for_config_entry( + registry=device_registry, + config_entry_id=mock_config_entry.entry_id, + ) + + assert len(devices) == 0 diff --git a/tests/components/github/test_sensor.py b/tests/components/github/test_sensor.py new file mode 100644 index 00000000000000..cea3edc6b47b88 --- /dev/null +++ b/tests/components/github/test_sensor.py @@ -0,0 +1,62 @@ +"""Test GitHub sensor.""" +from unittest.mock import MagicMock, patch + +from aiogithubapi import GitHubNotModifiedException +import pytest + +from homeassistant.components.github.const import DEFAULT_UPDATE_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from tests.common import MockConfigEntry, async_fire_time_changed + +TEST_SENSOR_ENTITY = "sensor.octocat_hello_world_latest_release" + + +async def test_sensor_updates_with_not_modified_content( + hass: HomeAssistant, + init_integration: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the sensor updates by default GitHub sensors.""" + state = hass.states.get(TEST_SENSOR_ENTITY) + assert state.state == "v1.0.0" + assert ( + "Content for octocat/Hello-World with RepositoryReleaseDataUpdateCoordinator not modified" + not in caplog.text + ) + + with patch( + "aiogithubapi.namespaces.releases.GitHubReleasesNamespace.list", + side_effect=GitHubNotModifiedException, + ): + + async_fire_time_changed(hass, dt.utcnow() + DEFAULT_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert ( + "Content for octocat/Hello-World with RepositoryReleaseDataUpdateCoordinator not modified" + in caplog.text + ) + new_state = hass.states.get(TEST_SENSOR_ENTITY) + assert state.state == new_state.state + + +async def test_sensor_updates_with_empty_release_array( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the sensor updates by default GitHub sensors.""" + state = hass.states.get(TEST_SENSOR_ENTITY) + assert state.state == "v1.0.0" + + with patch( + "aiogithubapi.namespaces.releases.GitHubReleasesNamespace.list", + return_value=MagicMock(data=[]), + ): + + async_fire_time_changed(hass, dt.utcnow() + DEFAULT_UPDATE_INTERVAL) + await hass.async_block_till_done() + + new_state = hass.states.get(TEST_SENSOR_ENTITY) + assert new_state.state == "unavailable" diff --git a/tests/components/goalzero/test_binary_sensor.py b/tests/components/goalzero/test_binary_sensor.py index 1c1300132830a8..4f2ffd751b5e90 100644 --- a/tests/components/goalzero/test_binary_sensor.py +++ b/tests/components/goalzero/test_binary_sensor.py @@ -1,16 +1,7 @@ """Binary sensor tests for the Goalzero integration.""" -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY_CHARGING, - DEVICE_CLASS_CONNECTIVITY, - DOMAIN, -) +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceClass from homeassistant.components.goalzero.const import DEFAULT_NAME -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - DEVICE_CLASS_POWER, - STATE_OFF, - STATE_ON, -) +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from . import async_setup_platform @@ -27,10 +18,15 @@ async def test_binary_sensors(hass: HomeAssistant, aioclient_mock: AiohttpClient assert state.attributes.get(ATTR_DEVICE_CLASS) is None state = hass.states.get(f"binary_sensor.{DEFAULT_NAME}_app_online") assert state.state == STATE_OFF - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CONNECTIVITY + assert ( + state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.CONNECTIVITY + ) state = hass.states.get(f"binary_sensor.{DEFAULT_NAME}_charging") assert state.state == STATE_OFF - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_BATTERY_CHARGING + assert ( + state.attributes.get(ATTR_DEVICE_CLASS) + == BinarySensorDeviceClass.BATTERY_CHARGING + ) state = hass.states.get(f"binary_sensor.{DEFAULT_NAME}_input_detected") assert state.state == STATE_OFF - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.POWER diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index 592c43b5d43147..67878d702db795 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -4,19 +4,12 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, @@ -42,42 +35,42 @@ async def test_sensors(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) state = hass.states.get(f"sensor.{DEFAULT_NAME}_watts_in") assert state.state == "0.0" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get(f"sensor.{DEFAULT_NAME}_amps_in") assert state.state == "0.0" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_AMPERE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get(f"sensor.{DEFAULT_NAME}_watts_out") assert state.state == "50.5" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get(f"sensor.{DEFAULT_NAME}_amps_out") assert state.state == "2.1" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_AMPERE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get(f"sensor.{DEFAULT_NAME}_wh_out") assert state.state == "5.23" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_WATT_HOUR - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING state = hass.states.get(f"sensor.{DEFAULT_NAME}_wh_stored") assert state.state == "1330" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_WATT_HOUR - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get(f"sensor.{DEFAULT_NAME}_volts") assert state.state == "12.0" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_VOLTAGE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_POTENTIAL_VOLT assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_state_of_charge_percent") assert state.state == "95" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_BATTERY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.BATTERY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_time_to_empty_full") @@ -87,12 +80,12 @@ async def test_sensors(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_temperature") assert state.state == "25" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_wifi_strength") assert state.state == "-62" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SIGNAL_STRENGTH + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SIGNAL_STRENGTH assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_STRENGTH_DECIBELS assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_total_run_time") diff --git a/tests/components/gogogate2/test_cover.py b/tests/components/gogogate2/test_cover.py index d3507283426e82..9f36dbc9008cf1 100644 --- a/tests/components/gogogate2/test_cover.py +++ b/tests/components/gogogate2/test_cover.py @@ -16,11 +16,10 @@ ) from homeassistant.components.cover import ( - DEVICE_CLASS_GARAGE, - DEVICE_CLASS_GATE, DOMAIN as COVER_DOMAIN, SUPPORT_CLOSE, SUPPORT_OPEN, + CoverDeviceClass, ) from homeassistant.components.gogogate2.const import ( DEVICE_TYPE_GOGOGATE2, @@ -282,11 +281,11 @@ async def test_availability(ismartgateapi_mock, hass: HomeAssistant) -> None: assert hass.states.get("cover.door1") assert ( hass.states.get("cover.door1").attributes[ATTR_DEVICE_CLASS] - == DEVICE_CLASS_GARAGE + == CoverDeviceClass.GARAGE ) assert ( hass.states.get("cover.door2").attributes[ATTR_DEVICE_CLASS] - == DEVICE_CLASS_GATE + == CoverDeviceClass.GATE ) api.async_info.side_effect = Exception("Error") diff --git a/tests/components/gogogate2/test_sensor.py b/tests/components/gogogate2/test_sensor.py index 129fcac504fa2e..8df88b2b4b78d2 100644 --- a/tests/components/gogogate2/test_sensor.py +++ b/tests/components/gogogate2/test_sensor.py @@ -17,6 +17,7 @@ ) from homeassistant.components.gogogate2.const import DEVICE_TYPE_ISMARTGATE, DOMAIN +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -25,8 +26,6 @@ CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_TEMPERATURE, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -296,11 +295,11 @@ async def test_availability(ismartgateapi_mock, hass: HomeAssistant) -> None: assert hass.states.get("sensor.door3_temperature") is None assert ( hass.states.get("sensor.door1_battery").attributes[ATTR_DEVICE_CLASS] - == DEVICE_CLASS_BATTERY + == SensorDeviceClass.BATTERY ) assert ( hass.states.get("sensor.door1_temperature").attributes[ATTR_DEVICE_CLASS] - == DEVICE_CLASS_TEMPERATURE + == SensorDeviceClass.TEMPERATURE ) assert ( hass.states.get("sensor.door1_temperature").attributes[ATTR_UNIT_OF_MEASUREMENT] diff --git a/tests/components/goodwe/__init__.py b/tests/components/goodwe/__init__.py new file mode 100644 index 00000000000000..21c1ce6f5438d3 --- /dev/null +++ b/tests/components/goodwe/__init__.py @@ -0,0 +1 @@ +"""Tests for the Goodwe integration.""" diff --git a/tests/components/goodwe/test_config_flow.py b/tests/components/goodwe/test_config_flow.py new file mode 100644 index 00000000000000..89dfd68a78316d --- /dev/null +++ b/tests/components/goodwe/test_config_flow.py @@ -0,0 +1,107 @@ +"""Test the Goodwe config flow.""" +from unittest.mock import AsyncMock, patch + +from goodwe import InverterError + +from homeassistant.components.goodwe.const import ( + CONF_MODEL_FAMILY, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + +TEST_HOST = "1.2.3.4" +TEST_SERIAL = "123456789" + + +def mock_inverter(): + """Get a mock object of the inverter.""" + goodwe_inverter = AsyncMock() + goodwe_inverter.serial_number = TEST_SERIAL + return goodwe_inverter + + +async def test_manual_setup(hass: HomeAssistant): + """Test manually setting up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert not result["errors"] + + with patch( + "homeassistant.components.goodwe.config_flow.connect", + return_value=mock_inverter(), + ), patch( + "homeassistant.components.goodwe.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_MODEL_FAMILY: "AsyncMock", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_manual_setup_already_exists(hass: HomeAssistant): + """Test manually setting up and the device already exists.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: TEST_HOST}, unique_id=TEST_SERIAL + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert not result["errors"] + + with patch( + "homeassistant.components.goodwe.config_flow.connect", + return_value=mock_inverter(), + ), patch("homeassistant.components.goodwe.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_manual_setup_device_offline(hass: HomeAssistant): + """Test manually setting up, device offline.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert not result["errors"] + + with patch( + "homeassistant.components.goodwe.config_flow.connect", + side_effect=InverterError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "connection_error"} diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index ad7b6b12001e56..01bd179e2ea111 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1,5 +1,11 @@ """The tests for the google calendar platform.""" + +from __future__ import annotations + +from collections.abc import Callable import copy +from http import HTTPStatus +from typing import Any from unittest.mock import Mock, patch import httplib2 @@ -11,10 +17,12 @@ CONF_CLIENT_SECRET, CONF_DEVICE_ID, CONF_ENTITIES, + CONF_IGNORE_AVAILABILITY, CONF_NAME, CONF_TRACK, DEVICE_SCHEMA, SERVICE_SCAN_CALENDARS, + GoogleCalendarService, do_setup, ) from homeassistant.const import STATE_OFF, STATE_ON @@ -23,6 +31,8 @@ from homeassistant.util import slugify import homeassistant.util.dt as dt_util +from .conftest import TEST_CALENDAR + from tests.common import async_mock_service GOOGLE_CONFIG = {CONF_CLIENT_ID: "client_id", CONF_CLIENT_SECRET: "client_secret"} @@ -69,6 +79,7 @@ def get_calendar_info(calendar): CONF_TRACK: calendar["track"], CONF_NAME: calendar["summary"], CONF_DEVICE_ID: slugify(calendar["summary"]), + CONF_IGNORE_AVAILABILITY: calendar.get("ignore_availability", True), } ], } @@ -95,12 +106,6 @@ def mock_google_setup(hass, test_calendar): yield -@pytest.fixture(autouse=True) -def mock_http(hass): - """Mock the http component.""" - hass.http = Mock() - - @pytest.fixture(autouse=True) def set_time_zone(): """Set the time zone for the tests.""" @@ -314,3 +319,160 @@ async def test_update_error(hass, google_service): state = hass.states.get(TEST_ENTITY) assert state.name == TEST_ENTITY_NAME assert state.state == "off" + + +async def test_calendars_api(hass, hass_client, google_service): + """Test the Rest API returns the calendar.""" + assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG}) + await hass.async_block_till_done() + + client = await hass_client() + response = await client.get("/api/calendars") + assert response.status == HTTPStatus.OK + data = await response.json() + assert data == [ + { + "entity_id": TEST_ENTITY, + "name": TEST_ENTITY_NAME, + } + ] + + +async def test_http_event_api_failure(hass, hass_client, google_service): + """Test the Rest API response during a calendar failure.""" + google_service.return_value.get = Mock( + side_effect=httplib2.ServerNotFoundError("unit test") + ) + + assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG}) + await hass.async_block_till_done() + + start = dt_util.now().isoformat() + end = (dt_util.now() + dt_util.dt.timedelta(minutes=60)).isoformat() + + client = await hass_client() + response = await client.get(f"/api/calendars/{TEST_ENTITY}?start={start}&end={end}") + assert response.status == HTTPStatus.OK + # A failure to talk to the server results in an empty list of events + events = await response.json() + assert events == [] + + +@pytest.fixture +def mock_events_list( + google_service: GoogleCalendarService, +) -> Callable[[dict[str, Any]], None]: + """Fixture to construct a fake event list API response.""" + + def _put_result(response: dict[str, Any]) -> None: + google_service.return_value.get.return_value.events.return_value.list.return_value.execute.return_value = ( + response + ) + return + + return _put_result + + +async def test_http_api_event(hass, hass_client, google_service, mock_events_list): + """Test querying the API and fetching events from the server.""" + now = dt_util.now() + + mock_events_list( + { + "items": [ + { + "summary": "Event title", + "start": {"dateTime": now.isoformat()}, + "end": { + "dateTime": (now + dt_util.dt.timedelta(minutes=5)).isoformat() + }, + } + ], + } + ) + assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG}) + await hass.async_block_till_done() + + start = (now - dt_util.dt.timedelta(minutes=60)).isoformat() + end = (now + dt_util.dt.timedelta(minutes=60)).isoformat() + + client = await hass_client() + response = await client.get(f"/api/calendars/{TEST_ENTITY}?start={start}&end={end}") + assert response.status == HTTPStatus.OK + events = await response.json() + assert len(events) == 1 + assert "summary" in events[0] + assert events[0]["summary"] == "Event title" + + +def create_ignore_avail_calendar() -> dict[str, Any]: + """Create a calendar with ignore_availability set.""" + calendar = TEST_CALENDAR.copy() + calendar["ignore_availability"] = False + return calendar + + +@pytest.mark.parametrize("test_calendar", [create_ignore_avail_calendar()]) +async def test_opaque_event(hass, hass_client, google_service, mock_events_list): + """Test querying the API and fetching events from the server.""" + now = dt_util.now() + + mock_events_list( + { + "items": [ + { + "summary": "Event title", + "transparency": "opaque", + "start": {"dateTime": now.isoformat()}, + "end": { + "dateTime": (now + dt_util.dt.timedelta(minutes=5)).isoformat() + }, + } + ], + } + ) + assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG}) + await hass.async_block_till_done() + + start = (now - dt_util.dt.timedelta(minutes=60)).isoformat() + end = (now + dt_util.dt.timedelta(minutes=60)).isoformat() + + client = await hass_client() + response = await client.get(f"/api/calendars/{TEST_ENTITY}?start={start}&end={end}") + assert response.status == HTTPStatus.OK + events = await response.json() + assert len(events) == 1 + assert "summary" in events[0] + assert events[0]["summary"] == "Event title" + + +@pytest.mark.parametrize("test_calendar", [create_ignore_avail_calendar()]) +async def test_transparent_event(hass, hass_client, google_service, mock_events_list): + """Test querying the API and fetching events from the server.""" + now = dt_util.now() + + mock_events_list( + { + "items": [ + { + "summary": "Event title", + "transparency": "transparent", + "start": {"dateTime": now.isoformat()}, + "end": { + "dateTime": (now + dt_util.dt.timedelta(minutes=5)).isoformat() + }, + } + ], + } + ) + assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG}) + await hass.async_block_till_done() + + start = (now - dt_util.dt.timedelta(minutes=60)).isoformat() + end = (now + dt_util.dt.timedelta(minutes=60)).isoformat() + + client = await hass_client() + response = await client.get(f"/api/calendars/{TEST_ENTITY}?start={start}&end={end}") + assert response.status == HTTPStatus.OK + events = await response.json() + assert events == [] diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 562bd4e16cdff0..2edd750a6e0b87 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -24,8 +24,6 @@ def __init__( enabled=True, entity_config=None, hass=None, - local_sdk_user_id=None, - local_sdk_webhook_id=None, secure_devices_pin=None, should_2fa=None, should_expose=None, @@ -35,8 +33,6 @@ def __init__( super().__init__(hass) self._enabled = enabled self._entity_config = entity_config or {} - self._local_sdk_user_id = local_sdk_user_id - self._local_sdk_webhook_id = local_sdk_webhook_id self._secure_devices_pin = secure_devices_pin self._should_2fa = should_2fa self._should_expose = should_expose @@ -58,16 +54,6 @@ def entity_config(self): """Return secure devices pin.""" return self._entity_config - @property - def local_sdk_webhook_id(self): - """Return local SDK webhook id.""" - return self._local_sdk_webhook_id - - @property - def local_sdk_user_id(self): - """Return local SDK webhook id.""" - return self._local_sdk_user_id - def get_agent_user_id(self, context): """Get agent user ID making request.""" return context.user_id diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index a260ef039487fd..ff441e44f25bc6 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -9,6 +9,9 @@ from homeassistant.components.google_assistant.const import ( EVENT_COMMAND_RECEIVED, NOT_EXPOSE_LOCAL, + SOURCE_CLOUD, + SOURCE_LOCAL, + STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) from homeassistant.config import async_process_ha_core_config from homeassistant.core import State @@ -27,7 +30,7 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass): """Test sync serialize attributes of a GoogleEntity.""" hass.states.async_set("light.ceiling_lights", "off") - hass.config.api = Mock(port=1234, use_ssl=True) + hass.config.api = Mock(port=1234, use_ssl=False) await async_process_ha_core_config( hass, {"external_url": "https://hostname:1234"}, @@ -36,8 +39,11 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass): hass.http = Mock(server_port=1234) config = MockConfig( hass=hass, - local_sdk_webhook_id="mock-webhook-id", - local_sdk_user_id="mock-user-id", + agent_user_ids={ + "mock-user-id": { + STORE_GOOGLE_LOCAL_WEBHOOK_ID: "mock-webhook-id", + }, + }, ) entity = helpers.GoogleEntity(hass, config, hass.states.get("light.ceiling_lights")) @@ -48,12 +54,12 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass): config.async_enable_local_sdk() with patch("homeassistant.helpers.instance_id.async_get", return_value="abcdef"): - serialized = await entity.sync_serialize(None) + serialized = await entity.sync_serialize("mock-user-id") assert serialized["otherDeviceIds"] == [{"deviceId": "light.ceiling_lights"}] assert serialized["customData"] == { "httpPort": 1234, - "httpSSL": True, - "proxyDeviceId": None, + "httpSSL": False, + "proxyDeviceId": "mock-user-id", "webhookId": "mock-webhook-id", "baseUrl": "https://hostname:1234", "uuid": "abcdef", @@ -79,8 +85,11 @@ async def test_config_local_sdk(hass, hass_client): config = MockConfig( hass=hass, - local_sdk_webhook_id="mock-webhook-id", - local_sdk_user_id="mock-user-id", + agent_user_ids={ + "mock-user-id": { + STORE_GOOGLE_LOCAL_WEBHOOK_ID: "mock-webhook-id", + }, + }, ) client = await hass_client() @@ -118,7 +127,7 @@ async def test_config_local_sdk(hass, hass_client): assert result["requestId"] == "mock-req-id" assert len(command_events) == 1 - assert command_events[0].context.user_id == config.local_sdk_user_id + assert command_events[0].context.user_id == "mock-user-id" assert len(turn_on_calls) == 1 assert turn_on_calls[0].context is command_events[0].context @@ -137,14 +146,19 @@ async def test_config_local_sdk_if_disabled(hass, hass_client): config = MockConfig( hass=hass, - local_sdk_webhook_id="mock-webhook-id", - local_sdk_user_id="mock-user-id", + agent_user_ids={ + "mock-user-id": { + STORE_GOOGLE_LOCAL_WEBHOOK_ID: "mock-webhook-id", + }, + }, enabled=False, ) + assert not config.is_local_sdk_active client = await hass_client() config.async_enable_local_sdk() + assert config.is_local_sdk_active resp = await client.post( "/api/webhook/mock-webhook-id", json={"requestId": "mock-req-id"} @@ -157,6 +171,7 @@ async def test_config_local_sdk_if_disabled(hass, hass_client): } config.async_disable_local_sdk() + assert not config.is_local_sdk_active # Webhook is no longer active resp = await client.post("/api/webhook/mock-webhook-id") @@ -164,6 +179,33 @@ async def test_config_local_sdk_if_disabled(hass, hass_client): assert await resp.read() == b"" +async def test_config_local_sdk_if_ssl_enabled(hass, hass_client): + """Test the local SDK is not enabled when SSL is enabled.""" + assert await async_setup_component(hass, "webhook", {}) + hass.config.api.use_ssl = True + + config = MockConfig( + hass=hass, + agent_user_ids={ + "mock-user-id": { + STORE_GOOGLE_LOCAL_WEBHOOK_ID: "mock-webhook-id", + }, + }, + enabled=False, + ) + assert not config.is_local_sdk_active + + client = await hass_client() + + config.async_enable_local_sdk() + assert not config.is_local_sdk_active + + # Webhook should not be activated + resp = await client.post("/api/webhook/mock-webhook-id") + assert resp.status == HTTPStatus.OK + assert await resp.read() == b"" + + async def test_agent_user_id_storage(hass, hass_storage): """Test a disconnect message.""" @@ -171,35 +213,61 @@ async def test_agent_user_id_storage(hass, hass_storage): "version": 1, "minor_version": 1, "key": "google_assistant", - "data": {"agent_user_ids": {"agent_1": {}}}, + "data": { + "agent_user_ids": { + "agent_1": { + "local_webhook_id": "test_webhook", + } + }, + }, } store = helpers.GoogleConfigStore(hass) - await store.async_load() + await store.async_initialize() assert hass_storage["google_assistant"] == { "version": 1, "minor_version": 1, "key": "google_assistant", - "data": {"agent_user_ids": {"agent_1": {}}}, + "data": { + "agent_user_ids": { + "agent_1": { + "local_webhook_id": "test_webhook", + } + }, + }, } async def _check_after_delay(data): async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=2)) await hass.async_block_till_done() - assert hass_storage["google_assistant"] == { - "version": 1, - "minor_version": 1, - "key": "google_assistant", - "data": data, - } + assert ( + list(hass_storage["google_assistant"]["data"]["agent_user_ids"].keys()) + == data + ) store.add_agent_user_id("agent_2") - await _check_after_delay({"agent_user_ids": {"agent_1": {}, "agent_2": {}}}) + await _check_after_delay(["agent_1", "agent_2"]) store.pop_agent_user_id("agent_1") - await _check_after_delay({"agent_user_ids": {"agent_2": {}}}) + await _check_after_delay(["agent_2"]) + + hass_storage["google_assistant"] = { + "version": 1, + "minor_version": 1, + "key": "google_assistant", + "data": { + "agent_user_ids": {"agent_1": {}}, + }, + } + store = helpers.GoogleConfigStore(hass) + await store.async_initialize() + + assert ( + STORE_GOOGLE_LOCAL_WEBHOOK_ID + in hass_storage["google_assistant"]["data"]["agent_user_ids"]["agent_1"] + ) async def test_agent_user_id_connect(): @@ -254,3 +322,17 @@ def test_supported_features_string(caplog): ) assert entity.is_supported() is False assert "Entity test.entity_id contains invalid supported_features value invalid" + + +def test_request_data(): + """Test request data properties.""" + config = MockConfig() + data = helpers.RequestData( + config, "test_user", SOURCE_LOCAL, "test_request_id", None + ) + assert data.is_local_request is True + + data = helpers.RequestData( + config, "test_user", SOURCE_CLOUD, "test_request_id", None + ) + assert data.is_local_request is False diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 1d62d034703635..520b736d7bb3a3 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -5,14 +5,23 @@ from homeassistant.components.google_assistant import GOOGLE_ASSISTANT_SCHEMA from homeassistant.components.google_assistant.const import ( + DOMAIN, + EVENT_COMMAND_RECEIVED, HOMEGRAPH_TOKEN_URL, REPORT_STATE_BASE_URL, + STORE_AGENT_USER_IDS, + STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) from homeassistant.components.google_assistant.http import ( GoogleConfig, _get_homegraph_jwt, _get_homegraph_token, ) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.core import State +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events, async_mock_service DUMMY_CONFIG = GOOGLE_ASSISTANT_SCHEMA( { @@ -97,7 +106,7 @@ async def test_update_access_token(hass): mock_get_token.assert_called_once() -async def test_call_homegraph_api(hass, aioclient_mock, hass_storage): +async def test_call_homegraph_api(hass, aioclient_mock, hass_storage, caplog): """Test the function to call the homegraph api.""" config = GoogleConfig(hass, DUMMY_CONFIG) await config.async_initialize() @@ -164,3 +173,237 @@ async def test_report_state(hass, aioclient_mock, hass_storage): REPORT_STATE_BASE_URL, {"requestId": ANY, "agentUserId": agent_user_id, "payload": message}, ) + + +async def test_google_config_local_fulfillment(hass, aioclient_mock, hass_storage): + """Test the google config for local fulfillment.""" + agent_user_id = "user" + local_webhook_id = "webhook" + + hass_storage["google_assistant"] = { + "version": 1, + "minor_version": 1, + "key": "google_assistant", + "data": { + "agent_user_ids": { + agent_user_id: { + "local_webhook_id": local_webhook_id, + } + }, + }, + } + + config = GoogleConfig(hass, DUMMY_CONFIG) + await config.async_initialize() + + with patch.object(config, "async_call_homegraph_api"): + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await hass.async_block_till_done() + + assert config.get_local_webhook_id(agent_user_id) == local_webhook_id + assert config.get_local_agent_user_id(local_webhook_id) == agent_user_id + assert config.get_local_agent_user_id("INCORRECT") is None + + +async def test_secure_device_pin_config(hass): + """Test the setting of the secure device pin configuration.""" + secure_pin = "TEST" + secure_config = GOOGLE_ASSISTANT_SCHEMA( + { + "project_id": "1234", + "service_account": { + "private_key": "-----BEGIN PRIVATE KEY-----\nMIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKYscIlwm7soDsHAz6L6YvUkCvkrX19rS6yeYOmovvhoK5WeYGWUsd8V72zmsyHB7XO94YgJVjvxfzn5K8bLePjFzwoSJjZvhBJ/ZQ05d8VmbvgyWUoPdG9oEa4fZ/lCYrXoaFdTot2xcJvrb/ZuiRl4s4eZpNeFYvVK/Am7UeFPAgMBAAECgYAUetOfzLYUudofvPCaKHu7tKZ5kQPfEa0w6BAPnBF1Mfl1JiDBRDMryFtKs6AOIAVwx00dY/Ex0BCbB3+Cr58H7t4NaPTJxCpmR09pK7o17B7xAdQv8+SynFNud9/5vQ5AEXMOLNwKiU7wpXT6Z7ZIibUBOR7ewsWgsHCDpN1iqQJBAOMODPTPSiQMwRAUHIc6GPleFSJnIz2PAoG3JOG9KFAL6RtIc19lob2ZXdbQdzKtjSkWo+O5W20WDNAl1k32h6MCQQC7W4ZCIY67mPbL6CxXfHjpSGF4Dr9VWJ7ZrKHr6XUoOIcEvsn/pHvWonjMdy93rQMSfOE8BKd/I1+GHRmNVgplAkAnSo4paxmsZVyfeKt7Jy2dMY+8tVZe17maUuQaAE7Sk00SgJYegwrbMYgQnWCTL39HBfj0dmYA2Zj8CCAuu6O7AkEAryFiYjaUAO9+4iNoL27+ZrFtypeeadyov7gKs0ZKaQpNyzW8A+Zwi7TbTeSqzic/E+z/bOa82q7p/6b7141xsQJBANCAcIwMcVb6KVCHlQbOtKspo5Eh4ZQi8bGl+IcwbQ6JSxeTx915IfAldgbuU047wOB04dYCFB2yLDiUGVXTifU=\n-----END PRIVATE KEY-----\n", + "client_email": "dummy@dummy.iam.gserviceaccount.com", + }, + "secure_devices_pin": secure_pin, + } + ) + config = GoogleConfig(hass, secure_config) + + assert config.secure_devices_pin == secure_pin + + +async def test_should_expose(hass): + """Test the google config should expose method.""" + config = GoogleConfig(hass, DUMMY_CONFIG) + await config.async_initialize() + + with patch.object(config, "async_call_homegraph_api"): + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await hass.async_block_till_done() + + assert ( + config.should_expose(State(DOMAIN + ".mock", "mock", {"view": "not None"})) + is False + ) + + with patch.object(config, "async_call_homegraph_api"): + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await hass.async_block_till_done() + + assert config.should_expose(State(CLOUD_NEVER_EXPOSED_ENTITIES[0], "mock")) is False + + +async def test_missing_service_account(hass): + """Test the google config _async_request_sync_devices.""" + incorrect_config = GOOGLE_ASSISTANT_SCHEMA( + { + "project_id": "1234", + } + ) + config = GoogleConfig(hass, incorrect_config) + await config.async_initialize() + + with patch.object(config, "async_call_homegraph_api"): + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await hass.async_block_till_done() + + assert ( + await config._async_request_sync_devices("mock") + is HTTPStatus.INTERNAL_SERVER_ERROR + ) + renew = config._access_token_renew + await config._async_update_token() + assert config._access_token_renew is renew + + +async def test_async_enable_local_sdk(hass, hass_client, hass_storage, caplog): + """Test the google config enable and disable local sdk.""" + command_events = async_capture_events(hass, EVENT_COMMAND_RECEIVED) + turn_on_calls = async_mock_service(hass, "light", "turn_on") + hass.states.async_set("light.ceiling_lights", "off") + + assert await async_setup_component(hass, "webhook", {}) + + hass_storage["google_assistant"] = { + "version": 1, + "minor_version": 1, + "key": "google_assistant", + "data": { + "agent_user_ids": { + "agent_1": { + "local_webhook_id": "mock_webhook_id", + }, + }, + }, + } + config = GoogleConfig(hass, DUMMY_CONFIG) + await config.async_initialize() + + with patch.object(config, "async_call_homegraph_api"): + # Wait for google_assistant.helpers.async_initialize.sync_google to be called + await hass.async_block_till_done() + + assert config.is_local_sdk_active is True + + client = await hass_client() + + resp = await client.post( + "/api/webhook/mock_webhook_id", + json={ + "inputs": [ + { + "context": {"locale_country": "US", "locale_language": "en"}, + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [ + { + "devices": [{"id": "light.ceiling_lights"}], + "execution": [ + { + "command": "action.devices.commands.OnOff", + "params": {"on": True}, + } + ], + } + ], + "structureData": {}, + }, + } + ], + "requestId": "mock_req_id", + }, + ) + assert resp.status == HTTPStatus.OK + result = await resp.json() + assert result["requestId"] == "mock_req_id" + + assert len(command_events) == 1 + assert command_events[0].context.user_id == "agent_1" + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].context is command_events[0].context + + config.async_disable_local_sdk() + assert config.is_local_sdk_active is False + + config._store._data = { + STORE_AGENT_USER_IDS: { + "agent_1": {STORE_GOOGLE_LOCAL_WEBHOOK_ID: "mock_webhook_id"}, + "agent_2": {STORE_GOOGLE_LOCAL_WEBHOOK_ID: "mock_webhook_id"}, + }, + } + config.async_enable_local_sdk() + assert config.is_local_sdk_active is False + + config._store._data = { + STORE_AGENT_USER_IDS: { + "agent_1": {STORE_GOOGLE_LOCAL_WEBHOOK_ID: None}, + }, + } + config.async_enable_local_sdk() + assert config.is_local_sdk_active is False + + config._store._data = { + STORE_AGENT_USER_IDS: { + "agent_2": {STORE_GOOGLE_LOCAL_WEBHOOK_ID: "mock_webhook_id"}, + "agent_1": {STORE_GOOGLE_LOCAL_WEBHOOK_ID: None}, + }, + } + config.async_enable_local_sdk() + assert config.is_local_sdk_active is False + + config.async_disable_local_sdk() + + config._store._data = { + STORE_AGENT_USER_IDS: { + "agent_1": {STORE_GOOGLE_LOCAL_WEBHOOK_ID: "mock_webhook_id"}, + }, + } + config.async_enable_local_sdk() + + config._store.pop_agent_user_id("agent_1") + + caplog.clear() + + resp = await client.post( + "/api/webhook/mock_webhook_id", + json={ + "inputs": [ + { + "context": {"locale_country": "US", "locale_language": "en"}, + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [ + { + "devices": [{"id": "light.ceiling_lights"}], + "execution": [ + { + "command": "action.devices.commands.OnOff", + "params": {"on": True}, + } + ], + } + ], + "structureData": {}, + }, + } + ], + "requestId": "mock_req_id", + }, + ) + assert resp.status == HTTPStatus.OK + assert ( + "Cannot process request for webhook mock_webhook_id as no linked agent user is found:" + in caplog.text + ) diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 911f66bb428e7b..3398fdca926d3e 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -53,6 +53,58 @@ def registries(hass): return ret +async def test_async_handle_message(hass): + """Test the async handle message method.""" + config = MockConfig( + should_expose=lambda state: state.entity_id != "light.not_expose", + entity_config={ + "light.demo_light": { + const.CONF_ROOM_HINT: "Living Room", + const.CONF_ALIASES: ["Hello", "World"], + } + }, + ) + + result = await sh.async_handle_message( + hass, + config, + "test-agent", + { + "requestId": REQ_ID, + "inputs": [ + {"intent": "action.devices.SYNC"}, + {"intent": "action.devices.SYNC"}, + ], + }, + const.SOURCE_CLOUD, + ) + assert result == { + "requestId": REQ_ID, + "payload": {"errorCode": const.ERR_PROTOCOL_ERROR}, + } + + await hass.async_block_till_done() + + result = await sh.async_handle_message( + hass, + config, + "test-agent", + { + "requestId": REQ_ID, + "inputs": [ + {"intent": "action.devices.DOES_NOT_EXIST"}, + ], + }, + const.SOURCE_CLOUD, + ) + assert result == { + "requestId": REQ_ID, + "payload": {"errorCode": const.ERR_PROTOCOL_ERROR}, + } + + await hass.async_block_till_done() + + async def test_sync_message(hass): """Test a sync message.""" light = DemoLight( @@ -1021,10 +1073,14 @@ async def test_device_class_binary_sensor(hass, device_class, google_type): ("non_existing_class", "action.devices.types.BLINDS"), ("door", "action.devices.types.DOOR"), ("garage", "action.devices.types.GARAGE"), + ("gate", "action.devices.types.GARAGE"), + ("awning", "action.devices.types.AWNING"), + ("shutter", "action.devices.types.SHUTTER"), + ("curtain", "action.devices.types.CURTAIN"), ], ) async def test_device_class_cover(hass, device_class, google_type): - """Test that a binary entity syncs to the correct device type.""" + """Test that a cover entity syncs to the correct device type.""" sensor = DemoCover(None, hass, "Demo Sensor", device_class=device_class) sensor.hass = hass sensor.entity_id = "cover.demo_sensor" @@ -1458,3 +1514,34 @@ async def test_query_recover(hass, caplog): } }, } + + +async def test_proxy_selected(hass, caplog): + """Test that we handle proxy selected.""" + + result = await sh.async_handle_message( + hass, + BASIC_CONFIG, + "test-agent", + { + "requestId": REQ_ID, + "inputs": [ + { + "intent": "action.devices.PROXY_SELECTED", + "payload": { + "device": { + "id": "abcdefg", + "customData": {}, + }, + "structureData": {}, + }, + } + ], + }, + const.SOURCE_LOCAL, + ) + + assert result == { + "requestId": REQ_ID, + "payload": {}, + } diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 278b6dc2ffeb49..a56a8f967e6fa8 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -13,6 +13,7 @@ fan, group, input_boolean, + input_button, input_select, light, lock, @@ -768,24 +769,30 @@ async def test_light_modes(hass): } -async def test_scene_button(hass): - """Test Scene trait support for the button domain.""" - assert helpers.get_google_type(button.DOMAIN, None) is not None - assert trait.SceneTrait.supported(button.DOMAIN, 0, None, None) +@pytest.mark.parametrize( + "component", + [button, input_button], +) +async def test_scene_button(hass, component): + """Test Scene trait support for the (input) button domain.""" + assert helpers.get_google_type(component.DOMAIN, None) is not None + assert trait.SceneTrait.supported(component.DOMAIN, 0, None, None) - trt = trait.SceneTrait(hass, State("button.bla", STATE_UNKNOWN), BASIC_CONFIG) + trt = trait.SceneTrait( + hass, State(f"{component.DOMAIN}.bla", STATE_UNKNOWN), BASIC_CONFIG + ) assert trt.sync_attributes() == {} assert trt.query_attributes() == {} assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) - calls = async_mock_service(hass, button.DOMAIN, button.SERVICE_PRESS) + calls = async_mock_service(hass, component.DOMAIN, component.SERVICE_PRESS) await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}, {}) # We don't wait till button press is done. await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "button.bla"} + assert calls[0].data == {ATTR_ENTITY_ID: f"{component.DOMAIN}.bla"} async def test_scene_scene(hass): @@ -793,7 +800,7 @@ async def test_scene_scene(hass): assert helpers.get_google_type(scene.DOMAIN, None) is not None assert trait.SceneTrait.supported(scene.DOMAIN, 0, None, None) - trt = trait.SceneTrait(hass, State("scene.bla", scene.STATE), BASIC_CONFIG) + trt = trait.SceneTrait(hass, State("scene.bla", STATE_UNKNOWN), BASIC_CONFIG) assert trt.sync_attributes() == {} assert trt.query_attributes() == {} assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) @@ -2444,7 +2451,11 @@ async def test_openclose_cover_no_position(hass): @pytest.mark.parametrize( "device_class", - (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE, cover.DEVICE_CLASS_GATE), + ( + cover.CoverDeviceClass.DOOR, + cover.CoverDeviceClass.GARAGE, + cover.CoverDeviceClass.GATE, + ), ) async def test_openclose_cover_secure(hass, device_class): """Test OpenClose trait support for cover domain.""" @@ -2507,11 +2518,11 @@ async def test_openclose_cover_secure(hass, device_class): @pytest.mark.parametrize( "device_class", ( - binary_sensor.DEVICE_CLASS_DOOR, - binary_sensor.DEVICE_CLASS_GARAGE_DOOR, - binary_sensor.DEVICE_CLASS_LOCK, - binary_sensor.DEVICE_CLASS_OPENING, - binary_sensor.DEVICE_CLASS_WINDOW, + binary_sensor.BinarySensorDeviceClass.DOOR, + binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, + binary_sensor.BinarySensorDeviceClass.LOCK, + binary_sensor.BinarySensorDeviceClass.OPENING, + binary_sensor.BinarySensorDeviceClass.WINDOW, ), ) async def test_openclose_binary_sensor(hass, device_class): @@ -2728,14 +2739,14 @@ async def test_media_player_mute(hass): async def test_temperature_control_sensor(hass): """Test TemperatureControl trait support for temperature sensor.""" assert ( - helpers.get_google_type(sensor.DOMAIN, sensor.DEVICE_CLASS_TEMPERATURE) + helpers.get_google_type(sensor.DOMAIN, sensor.SensorDeviceClass.TEMPERATURE) is not None ) assert not trait.TemperatureControlTrait.supported( - sensor.DOMAIN, 0, sensor.DEVICE_CLASS_HUMIDITY, None + sensor.DOMAIN, 0, sensor.SensorDeviceClass.HUMIDITY, None ) assert trait.TemperatureControlTrait.supported( - sensor.DOMAIN, 0, sensor.DEVICE_CLASS_TEMPERATURE, None + sensor.DOMAIN, 0, sensor.SensorDeviceClass.TEMPERATURE, None ) @@ -2755,7 +2766,9 @@ async def test_temperature_control_sensor_data(hass, unit_in, unit_out, state, a trt = trait.TemperatureControlTrait( hass, State( - "sensor.test", state, {ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TEMPERATURE} + "sensor.test", + state, + {ATTR_DEVICE_CLASS: sensor.SensorDeviceClass.TEMPERATURE}, ), BASIC_CONFIG, ) @@ -2779,13 +2792,14 @@ async def test_temperature_control_sensor_data(hass, unit_in, unit_out, state, a async def test_humidity_setting_sensor(hass): """Test HumiditySetting trait support for humidity sensor.""" assert ( - helpers.get_google_type(sensor.DOMAIN, sensor.DEVICE_CLASS_HUMIDITY) is not None + helpers.get_google_type(sensor.DOMAIN, sensor.SensorDeviceClass.HUMIDITY) + is not None ) assert not trait.HumiditySettingTrait.supported( - sensor.DOMAIN, 0, sensor.DEVICE_CLASS_TEMPERATURE, None + sensor.DOMAIN, 0, sensor.SensorDeviceClass.TEMPERATURE, None ) assert trait.HumiditySettingTrait.supported( - sensor.DOMAIN, 0, sensor.DEVICE_CLASS_HUMIDITY, None + sensor.DOMAIN, 0, sensor.SensorDeviceClass.HUMIDITY, None ) @@ -2796,7 +2810,9 @@ async def test_humidity_setting_sensor_data(hass, state, ambient): """Test HumiditySetting trait support for humidity sensor.""" trt = trait.HumiditySettingTrait( hass, - State("sensor.test", state, {ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_HUMIDITY}), + State( + "sensor.test", state, {ATTR_DEVICE_CLASS: sensor.SensorDeviceClass.HUMIDITY} + ), BASIC_CONFIG, ) @@ -2983,7 +2999,7 @@ async def test_channel(hass): assert trait.ChannelTrait.supported( media_player.DOMAIN, media_player.SUPPORT_PLAY_MEDIA, - media_player.DEVICE_CLASS_TV, + media_player.MediaPlayerDeviceClass.TV, None, ) assert ( @@ -3029,12 +3045,12 @@ async def test_channel(hass): async def test_sensorstate(hass): """Test SensorState trait support for sensor domain.""" sensor_types = { - sensor.DEVICE_CLASS_AQI: ("AirQuality", "AQI"), - sensor.DEVICE_CLASS_CO: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), - sensor.DEVICE_CLASS_CO2: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), - sensor.DEVICE_CLASS_PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), - sensor.DEVICE_CLASS_PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), - sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: ( + sensor.SensorDeviceClass.AQI: ("AirQuality", "AQI"), + sensor.SensorDeviceClass.CO: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + sensor.SensorDeviceClass.CO2: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), + sensor.SensorDeviceClass.PM25: ("PM2.5", "MICROGRAMS_PER_CUBIC_METER"), + sensor.SensorDeviceClass.PM10: ("PM10", "MICROGRAMS_PER_CUBIC_METER"), + sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: ( "VolatileOrganicCompounds", "PARTS_PER_MILLION", ), @@ -3073,7 +3089,7 @@ async def test_sensorstate(hass): assert helpers.get_google_type(sensor.DOMAIN, None) is not None assert ( trait.SensorStateTrait.supported( - sensor.DOMAIN, None, sensor.DEVICE_CLASS_MONETARY, None + sensor.DOMAIN, None, sensor.SensorDeviceClass.MONETARY, None ) is False ) diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 6118aac7cfaf73..9b615afbbe134b 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -7,14 +7,12 @@ CONF_DEPARTURE_TIME, CONF_DESTINATION, CONF_LANGUAGE, - CONF_OPTIONS, CONF_ORIGIN, CONF_TIME, CONF_TIME_TYPE, CONF_TRAFFIC_MODEL, CONF_TRANSIT_MODE, CONF_TRANSIT_ROUTING_PREFERENCE, - CONF_TRAVEL_MODE, CONF_UNITS, DEFAULT_NAME, DEPARTURE_TIME, @@ -25,7 +23,6 @@ CONF_MODE, CONF_NAME, CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, ) from tests.common import MockConfigEntry @@ -236,379 +233,3 @@ async def test_dupe(hass, validate_config_entry, bypass_setup): await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - -async def test_import_flow(hass, validate_config_entry, bypass_update): - """Test import_flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_MODE: "driving", - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "test_name" - assert result["data"] == { - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_MODE: "driving", - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - } - - entry = hass.config_entries.async_entries(DOMAIN)[0] - assert entry.data == { - CONF_NAME: "test_name", - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - } - assert entry.options == { - CONF_MODE: "driving", - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - } - - -async def test_dupe_import_no_options(hass, bypass_update): - """Test duplicate import with no options.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_dupe_import_default_options(hass, bypass_update): - """Test duplicate import with default options.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def _setup_dupe_import(hass, bypass_update): - """Set up dupe import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_MODE: "walking", - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - await hass.async_block_till_done() - - -async def test_dupe_import(hass, bypass_update): - """Test duplicate import.""" - await _setup_dupe_import(hass, bypass_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_MODE: "walking", - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_dupe_import_false_check_data_keys(hass, bypass_update): - """Test false duplicate import check when data keys differ.""" - await _setup_dupe_import(hass, bypass_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key2", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_MODE: "walking", - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - -async def test_dupe_import_false_check_no_units(hass, bypass_update): - """Test false duplicate import check when units aren't provided.""" - await _setup_dupe_import(hass, bypass_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_MODE: "walking", - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - -async def test_dupe_import_false_check_units(hass, bypass_update): - """Test false duplicate import check when units are provided but different.""" - await _setup_dupe_import(hass, bypass_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_MODE: "walking", - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_METRIC, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - -async def test_dupe_import_false_check_travel_mode(hass, bypass_update): - """Test false duplicate import check when travel mode differs.""" - await _setup_dupe_import(hass, bypass_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_TRAVEL_MODE: "driving", - CONF_OPTIONS: { - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - -async def test_dupe_import_false_check_mode(hass, bypass_update): - """Test false duplicate import check when mode diiffers.""" - await _setup_dupe_import(hass, bypass_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_MODE: "driving", - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - -async def test_dupe_import_false_check_no_mode(hass, bypass_update): - """Test false duplicate import check when no mode is provided.""" - await _setup_dupe_import(hass, bypass_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_LANGUAGE: "en", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - -async def test_dupe_import_false_check_options(hass, bypass_update): - """Test false duplicate import check when options differ.""" - await _setup_dupe_import(hass, bypass_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_NAME: "test_name", - CONF_OPTIONS: { - CONF_MODE: "walking", - CONF_AVOID: "tolls", - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_ARRIVAL_TIME: "test", - CONF_TRAFFIC_MODEL: "best_guess", - CONF_TRANSIT_MODE: "train", - CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", - }, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY diff --git a/tests/components/greeneye_monitor/common.py b/tests/components/greeneye_monitor/common.py index ac00ccbfc0b27f..0a19b79795f6b0 100644 --- a/tests/components/greeneye_monitor/common.py +++ b/tests/components/greeneye_monitor/common.py @@ -128,6 +128,35 @@ def make_single_monitor_config_with_sensors(sensors: dict[str, Any]) -> dict[str } ) +MULTI_MONITOR_CONFIG = { + DOMAIN: { + CONF_PORT: 7513, + CONF_MONITORS: [ + { + CONF_SERIAL_NUMBER: "00000001", + CONF_TEMPERATURE_SENSORS: { + CONF_TEMPERATURE_UNIT: "C", + CONF_SENSORS: [{CONF_NUMBER: 1, CONF_NAME: "unit_1_temp_1"}], + }, + }, + { + CONF_SERIAL_NUMBER: "00000002", + CONF_TEMPERATURE_SENSORS: { + CONF_TEMPERATURE_UNIT: "F", + CONF_SENSORS: [{CONF_NUMBER: 1, CONF_NAME: "unit_2_temp_1"}], + }, + }, + { + CONF_SERIAL_NUMBER: "00000003", + CONF_TEMPERATURE_SENSORS: { + CONF_TEMPERATURE_UNIT: "C", + CONF_SENSORS: [{CONF_NUMBER: 1, CONF_NAME: "unit_3_temp_1"}], + }, + }, + ], + } +} + async def setup_greeneye_monitor_component_with_config( hass: HomeAssistant, config: ConfigType @@ -185,6 +214,13 @@ def mock_temperature_sensor() -> MagicMock: return temperature_sensor +def mock_voltage_sensor() -> MagicMock: + """Create a mock GreenEye Monitor voltage sensor.""" + voltage_sensor = mock_with_listeners() + voltage_sensor.voltage = 120.0 + return voltage_sensor + + def mock_channel() -> MagicMock: """Create a mock GreenEye Monitor CT channel.""" channel = mock_with_listeners() @@ -198,7 +234,7 @@ def mock_monitor(serial_number: int) -> MagicMock: """Create a mock GreenEye Monitor.""" monitor = mock_with_listeners() monitor.serial_number = serial_number - monitor.voltage = 120.0 + monitor.voltage_sensor = mock_voltage_sensor() monitor.pulse_counters = [mock_pulse_counter() for i in range(0, 4)] monitor.temperature_sensors = [mock_temperature_sensor() for i in range(0, 8)] monitor.channels = [mock_channel() for i in range(0, 32)] diff --git a/tests/components/greeneye_monitor/conftest.py b/tests/components/greeneye_monitor/conftest.py index a68cc9e8b96690..00b534bb06d639 100644 --- a/tests/components/greeneye_monitor/conftest.py +++ b/tests/components/greeneye_monitor/conftest.py @@ -1,17 +1,12 @@ """Common fixtures for testing greeneye_monitor.""" -from typing import Any, Dict +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.greeneye_monitor import DOMAIN -from homeassistant.const import ( - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - ELECTRIC_POTENTIAL_VOLT, - POWER_WATT, -) +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ELECTRIC_POTENTIAL_VOLT, POWER_WATT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import ( RegistryEntry, @@ -25,7 +20,7 @@ def assert_sensor_state( hass: HomeAssistant, entity_id: str, expected_state: str, - attributes: Dict[str, Any] = {}, + attributes: dict[str, Any] = {}, ) -> None: """Assert that the given entity has the expected state and at least the provided attributes.""" state = hass.states.get(entity_id) @@ -45,7 +40,7 @@ def assert_temperature_sensor_registered( ): """Assert that a temperature sensor entity was registered properly.""" sensor = assert_sensor_registered(hass, serial_number, "temp", number, name) - assert sensor.original_device_class == DEVICE_CLASS_TEMPERATURE + assert sensor.original_device_class is SensorDeviceClass.TEMPERATURE def assert_pulse_counter_registered( @@ -67,7 +62,7 @@ def assert_power_sensor_registered( """Assert that a power sensor entity was registered properly.""" sensor = assert_sensor_registered(hass, serial_number, "current", number, name) assert sensor.unit_of_measurement == POWER_WATT - assert sensor.original_device_class == DEVICE_CLASS_POWER + assert sensor.original_device_class is SensorDeviceClass.POWER def assert_voltage_sensor_registered( @@ -76,7 +71,7 @@ def assert_voltage_sensor_registered( """Assert that a voltage sensor entity was registered properly.""" sensor = assert_sensor_registered(hass, serial_number, "volts", number, name) assert sensor.unit_of_measurement == ELECTRIC_POTENTIAL_VOLT - assert sensor.original_device_class == DEVICE_CLASS_VOLTAGE + assert sensor.original_device_class is SensorDeviceClass.VOLTAGE def assert_sensor_registered( diff --git a/tests/components/greeneye_monitor/test_init.py b/tests/components/greeneye_monitor/test_init.py index 143fb14f28ce67..c8e13714939587 100644 --- a/tests/components/greeneye_monitor/test_init.py +++ b/tests/components/greeneye_monitor/test_init.py @@ -6,23 +6,12 @@ import pytest -from homeassistant.components.greeneye_monitor import ( - CONF_MONITORS, - CONF_NUMBER, - CONF_SERIAL_NUMBER, - CONF_TEMPERATURE_SENSORS, - DOMAIN, -) -from homeassistant.const import ( - CONF_NAME, - CONF_PORT, - CONF_SENSORS, - CONF_TEMPERATURE_UNIT, -) +from homeassistant.components.greeneye_monitor import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .common import ( + MULTI_MONITOR_CONFIG, SINGLE_MONITOR_CONFIG_NO_SENSORS, SINGLE_MONITOR_CONFIG_POWER_SENSORS, SINGLE_MONITOR_CONFIG_PULSE_COUNTERS, @@ -155,45 +144,22 @@ async def test_multi_monitor_config(hass: HomeAssistant, monitors: AsyncMock) -> """Test that component setup registers entities from multiple monitors correctly.""" assert await setup_greeneye_monitor_component_with_config( hass, - { - DOMAIN: { - CONF_PORT: 7513, - CONF_MONITORS: [ - { - CONF_SERIAL_NUMBER: "00000001", - CONF_TEMPERATURE_SENSORS: { - CONF_TEMPERATURE_UNIT: "C", - CONF_SENSORS: [ - {CONF_NUMBER: 1, CONF_NAME: "unit_1_temp_1"} - ], - }, - }, - { - CONF_SERIAL_NUMBER: "00000002", - CONF_TEMPERATURE_SENSORS: { - CONF_TEMPERATURE_UNIT: "F", - CONF_SENSORS: [ - {CONF_NUMBER: 1, CONF_NAME: "unit_2_temp_1"} - ], - }, - }, - ], - } - }, + MULTI_MONITOR_CONFIG, ) assert_temperature_sensor_registered(hass, 1, 1, "unit_1_temp_1") assert_temperature_sensor_registered(hass, 2, 1, "unit_2_temp_1") + assert_temperature_sensor_registered(hass, 3, 1, "unit_3_temp_1") async def test_setup_and_shutdown(hass: HomeAssistant, monitors: AsyncMock) -> None: """Test that the component can set up and shut down cleanly, closing the underlying server on shutdown.""" - server = AsyncMock() - monitors.start_server = AsyncMock(return_value=server) + monitors.start_server = AsyncMock(return_value=None) + monitors.close = AsyncMock(return_value=None) assert await setup_greeneye_monitor_component_with_config( hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS ) await hass.async_stop() - assert server.close.called + assert monitors.close.called diff --git a/tests/components/greeneye_monitor/test_sensor.py b/tests/components/greeneye_monitor/test_sensor.py index 63ab8b64423b77..ac1fe92873a58b 100644 --- a/tests/components/greeneye_monitor/test_sensor.py +++ b/tests/components/greeneye_monitor/test_sensor.py @@ -7,9 +7,13 @@ ) from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get as get_entity_registry +from homeassistant.helpers.entity_registry import ( + RegistryEntryDisabler, + async_get as get_entity_registry, +) from .common import ( + MULTI_MONITOR_CONFIG, SINGLE_MONITOR_CONFIG_POWER_SENSORS, SINGLE_MONITOR_CONFIG_PULSE_COUNTERS, SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS, @@ -61,9 +65,9 @@ async def test_disable_sensor_after_monitor_connected( ) monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) - assert len(monitor.listeners) == 1 + assert len(monitor.voltage_sensor.listeners) == 1 await disable_entity(hass, "sensor.voltage_1") - assert len(monitor.listeners) == 0 + assert len(monitor.voltage_sensor.listeners) == 0 async def test_updates_state_when_sensor_pushes( @@ -77,8 +81,8 @@ async def test_updates_state_when_sensor_pushes( monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) assert_sensor_state(hass, "sensor.voltage_1", "120.0") - monitor.voltage = 119.8 - monitor.notify_all_listeners() + monitor.voltage_sensor.voltage = 119.8 + monitor.voltage_sensor.notify_all_listeners() assert_sensor_state(hass, "sensor.voltage_1", "119.8") @@ -151,6 +155,17 @@ async def test_voltage_sensor(hass: HomeAssistant, monitors: AsyncMock) -> None: assert_sensor_state(hass, "sensor.voltage_1", "120.0") +async def test_multi_monitor_sensors(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that sensors still work when multiple monitors are registered.""" + await setup_greeneye_monitor_component_with_config(hass, MULTI_MONITOR_CONFIG) + connect_monitor(monitors, 1) + connect_monitor(monitors, 2) + connect_monitor(monitors, 3) + assert_sensor_state(hass, "sensor.unit_1_temp_1", "32.0") + assert_sensor_state(hass, "sensor.unit_2_temp_1", "0.0") + assert_sensor_state(hass, "sensor.unit_3_temp_1", "32.0") + + def connect_monitor(monitors: AsyncMock, serial_number: int) -> MagicMock: """Simulate a monitor connecting to Home Assistant. Returns the mock monitor API object.""" monitor = mock_monitor(serial_number) @@ -161,5 +176,7 @@ def connect_monitor(monitors: AsyncMock, serial_number: int) -> MagicMock: async def disable_entity(hass: HomeAssistant, entity_id: str) -> None: """Disable the given entity.""" entity_registry = get_entity_registry(hass) - entity_registry.async_update_entity(entity_id, disabled_by="user") + entity_registry.async_update_entity( + entity_id, disabled_by=RegistryEntryDisabler.USER + ) await hass.async_block_till_done() diff --git a/tests/components/group/test_binary_sensor.py b/tests/components/group/test_binary_sensor.py index 9da54be2ab4856..0a85c793aaa334 100644 --- a/tests/components/group/test_binary_sensor.py +++ b/tests/components/group/test_binary_sensor.py @@ -1,7 +1,13 @@ """The tests for the Group Binary Sensor platform.""" from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.group import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -65,7 +71,7 @@ async def test_state_reporting_all(hass): hass.states.async_set("binary_sensor.test1", STATE_ON) hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN hass.states.async_set("binary_sensor.test1", STATE_ON) hass.states.async_set("binary_sensor.test2", STATE_OFF) @@ -114,7 +120,7 @@ async def test_state_reporting_any(hass): hass.states.async_set("binary_sensor.test1", STATE_ON) hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN hass.states.async_set("binary_sensor.test1", STATE_ON) hass.states.async_set("binary_sensor.test2", STATE_OFF) diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index fff1526b7119f3..4eb0e455422015 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -3,6 +3,8 @@ from collections import OrderedDict from unittest.mock import patch +import pytest + import homeassistant.components.group as group from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -713,7 +715,7 @@ async def test_group_persons_and_device_trackers(hass): async def test_group_mixed_domains_on(hass): """Test group of mixed domains that is on.""" - hass.states.async_set("lock.alexander_garage_exit_door", "locked") + hass.states.async_set("lock.alexander_garage_exit_door", "unlocked") hass.states.async_set("binary_sensor.alexander_garage_side_door_open", "on") hass.states.async_set("cover.small_garage_door", "open") @@ -738,7 +740,7 @@ async def test_group_mixed_domains_on(hass): async def test_group_mixed_domains_off(hass): """Test group of mixed domains that is off.""" - hass.states.async_set("lock.alexander_garage_exit_door", "unlocked") + hass.states.async_set("lock.alexander_garage_exit_door", "locked") hass.states.async_set("binary_sensor.alexander_garage_side_door_open", "off") hass.states.async_set("cover.small_garage_door", "closed") @@ -761,11 +763,18 @@ async def test_group_mixed_domains_off(hass): assert hass.states.get("group.group_zero").state == "off" -async def test_group_locks(hass): +@pytest.mark.parametrize( + "states,group_state", + [ + (("locked", "locked", "unlocked"), "unlocked"), + (("locked", "locked", "locked"), "locked"), + ], +) +async def test_group_locks(hass, states, group_state): """Test group of locks.""" - hass.states.async_set("lock.one", "locked") - hass.states.async_set("lock.two", "locked") - hass.states.async_set("lock.three", "unlocked") + hass.states.async_set("lock.one", states[0]) + hass.states.async_set("lock.two", states[1]) + hass.states.async_set("lock.three", states[2]) assert await async_setup_component(hass, "lock", {}) assert await async_setup_component( @@ -779,7 +788,7 @@ async def test_group_locks(hass): ) await hass.async_block_till_done() - assert hass.states.get("group.group_zero").state == "locked" + assert hass.states.get("group.group_zero").state == group_state async def test_group_sensors(hass): diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py index 1a83222c5758ef..492a486f76d252 100644 --- a/tests/components/guardian/conftest.py +++ b/tests/components/guardian/conftest.py @@ -1,18 +1,136 @@ """Define fixtures for Elexa Guardian tests.""" +import json from unittest.mock import patch import pytest +from homeassistant.components.guardian import CONF_UID, DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.setup import async_setup_component -@pytest.fixture() -def ping_client(): - """Define a patched client that returns a successful ping response.""" - with patch( - "homeassistant.components.guardian.async_setup_entry", return_value=True - ), patch("aioguardian.client.Client.connect"), patch( +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config, unique_id): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=unique_id, + data={CONF_UID: "3456", **config}, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_PORT: 7777, + } + + +@pytest.fixture(name="data_sensor_pair_dump", scope="session") +def data_sensor_pair_dump_fixture(): + """Define data from a successful sensor_pair_dump response.""" + return json.loads(load_fixture("sensor_pair_dump_data.json", "guardian")) + + +@pytest.fixture(name="data_sensor_pair_sensor", scope="session") +def data_sensor_pair_sensor_fixture(): + """Define data from a successful sensor_pair_sensor response.""" + return json.loads(load_fixture("sensor_pair_sensor_data.json", "guardian")) + + +@pytest.fixture(name="data_sensor_paired_sensor_status", scope="session") +def data_sensor_paired_sensor_status_fixture(): + """Define data from a successful sensor_paired_sensor_status response.""" + return json.loads(load_fixture("sensor_paired_sensor_status_data.json", "guardian")) + + +@pytest.fixture(name="data_system_diagnostics", scope="session") +def data_system_diagnostics_fixture(): + """Define data from a successful system_diagnostics response.""" + return json.loads(load_fixture("system_diagnostics_data.json", "guardian")) + + +@pytest.fixture(name="data_system_onboard_sensor_status", scope="session") +def data_system_onboard_sensor_status_fixture(): + """Define data from a successful system_onboard_sensor_status response.""" + return json.loads( + load_fixture("system_onboard_sensor_status_data.json", "guardian") + ) + + +@pytest.fixture(name="data_system_ping", scope="session") +def data_system_ping_fixture(): + """Define data from a successful system_ping response.""" + return json.loads(load_fixture("system_ping_data.json", "guardian")) + + +@pytest.fixture(name="data_valve_status", scope="session") +def data_valve_status_fixture(): + """Define data from a successful valve_status response.""" + return json.loads(load_fixture("valve_status_data.json", "guardian")) + + +@pytest.fixture(name="data_wifi_status", scope="session") +def data_wifi_status_fixture(): + """Define data from a successful wifi_status response.""" + return json.loads(load_fixture("wifi_status_data.json", "guardian")) + + +@pytest.fixture(name="setup_guardian") +async def setup_guardian_fixture( + hass, + config, + data_sensor_pair_dump, + data_sensor_pair_sensor, + data_sensor_paired_sensor_status, + data_system_diagnostics, + data_system_onboard_sensor_status, + data_system_ping, + data_valve_status, + data_wifi_status, +): + """Define a fixture to set up Guardian.""" + with patch("aioguardian.client.Client.connect"), patch( + "aioguardian.commands.sensor.SensorCommands.pair_dump", + return_value=data_sensor_pair_dump, + ), patch( + "aioguardian.commands.sensor.SensorCommands.pair_sensor", + return_value=data_sensor_pair_sensor, + ), patch( + "aioguardian.commands.sensor.SensorCommands.paired_sensor_status", + return_value=data_sensor_paired_sensor_status, + ), patch( + "aioguardian.commands.system.SystemCommands.diagnostics", + return_value=data_system_diagnostics, + ), patch( + "aioguardian.commands.system.SystemCommands.onboard_sensor_status", + return_value=data_system_onboard_sensor_status, + ), patch( "aioguardian.commands.system.SystemCommands.ping", - return_value={"command": 0, "status": "ok", "data": {"uid": "ABCDEF123456"}}, + return_value=data_system_ping, + ), patch( + "aioguardian.commands.valve.ValveCommands.status", + return_value=data_valve_status, + ), patch( + "aioguardian.commands.wifi.WiFiCommands.status", + return_value=data_wifi_status, ), patch( "aioguardian.client.Client.disconnect" + ), patch( + "homeassistant.components.guardian.PLATFORMS", [] ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() yield + + +@pytest.fixture(name="unique_id") +def unique_id_fixture(hass): + """Define a config entry unique ID fixture.""" + return "guardian_3456" diff --git a/tests/components/guardian/fixtures/sensor_pair_dump_data.json b/tests/components/guardian/fixtures/sensor_pair_dump_data.json new file mode 100644 index 00000000000000..2186b987cc93de --- /dev/null +++ b/tests/components/guardian/fixtures/sensor_pair_dump_data.json @@ -0,0 +1,10 @@ +{ + "command": 48, + "status": "ok", + "data": { + "pair_count": 1, + "paired_uids": [ + "6309FB799CDE" + ] + } +} diff --git a/tests/components/guardian/fixtures/sensor_pair_sensor_data.json b/tests/components/guardian/fixtures/sensor_pair_sensor_data.json new file mode 100644 index 00000000000000..c88c9c72283f1e --- /dev/null +++ b/tests/components/guardian/fixtures/sensor_pair_sensor_data.json @@ -0,0 +1,4 @@ +{ + "command": 49, + "status": "ok" +} diff --git a/tests/components/guardian/fixtures/sensor_paired_sensor_status_data.json b/tests/components/guardian/fixtures/sensor_paired_sensor_status_data.json new file mode 100644 index 00000000000000..47e0eeab6092de --- /dev/null +++ b/tests/components/guardian/fixtures/sensor_paired_sensor_status_data.json @@ -0,0 +1,13 @@ +{ + "command": 51, + "status": "ok", + "silent": true, + "data": { + "uid": "AABBCCDDEEFF", + "codename": "gld1", + "temperature": 68, + "wet": false, + "moved": true, + "battery_percentage": 79 + } +} diff --git a/tests/components/guardian/fixtures/system_diagnostics_data.json b/tests/components/guardian/fixtures/system_diagnostics_data.json new file mode 100644 index 00000000000000..839d77eed8f73d --- /dev/null +++ b/tests/components/guardian/fixtures/system_diagnostics_data.json @@ -0,0 +1,12 @@ +{ + "command": 1, + "status": "ok", + "data": { + "codename": "gvc1", + "uid": "ABCDEF123456", + "uptime": 41, + "firmware": "0.20.9-beta+official.ef3", + "rf_modem_firmware": "4.0.0", + "available_heap": 34456 + } +} diff --git a/tests/components/guardian/fixtures/system_onboard_sensor_status_data.json b/tests/components/guardian/fixtures/system_onboard_sensor_status_data.json new file mode 100644 index 00000000000000..7e21e34b633e92 --- /dev/null +++ b/tests/components/guardian/fixtures/system_onboard_sensor_status_data.json @@ -0,0 +1,8 @@ +{ + "command": 80, + "status": "ok", + "data": { + "temperature": 71, + "wet": false + } +} diff --git a/tests/components/guardian/fixtures/system_ping_data.json b/tests/components/guardian/fixtures/system_ping_data.json new file mode 100644 index 00000000000000..49af7305c6af3b --- /dev/null +++ b/tests/components/guardian/fixtures/system_ping_data.json @@ -0,0 +1,7 @@ +{ + "command": 0, + "status": "ok", + "data": { + "uid": "ABCDEF123456" + } +} diff --git a/tests/components/guardian/fixtures/valve_status_data.json b/tests/components/guardian/fixtures/valve_status_data.json new file mode 100644 index 00000000000000..2d0dc8890f3f65 --- /dev/null +++ b/tests/components/guardian/fixtures/valve_status_data.json @@ -0,0 +1,13 @@ +{ + "command": 16, + "status": "ok", + "data": { + "enabled": false, + "direction": true, + "state": 0, + "travel_count": 0, + "instantaneous_current": 0, + "instantaneous_current_ddt": 0, + "average_current": 34 + } +} diff --git a/tests/components/guardian/fixtures/wifi_status_data.json b/tests/components/guardian/fixtures/wifi_status_data.json new file mode 100644 index 00000000000000..30dc1a4c7607bc --- /dev/null +++ b/tests/components/guardian/fixtures/wifi_status_data.json @@ -0,0 +1,17 @@ +{ + "command": 32, + "status": "ok", + "data": { + "station_connected": true, + "ip_assigned": true, + "mqtt_connected": true, + "rssi": -63, + "channel": 1, + "lan_ipv4": "192.168.1.100", + "lan_ipv6": "AC10:BD0:FFFF:FFFF:AC10:BD0:FFFF:FFFF", + "ap_enabled": true, + "ap_clients": 0, + "bssid": "ABCDEF123456", + "ssid": "My_Network" + } +} diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index f3e0adeec0f799..fc3157289e9706 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -16,32 +16,23 @@ from tests.common import MockConfigEntry -async def test_duplicate_error(hass, ping_client): +async def test_duplicate_error(hass, config, config_entry, setup_guardian): """Test that errors are shown when duplicate entries are added.""" - conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PORT: 7777} - - MockConfigEntry(domain=DOMAIN, unique_id="guardian_3456", data=conf).add_to_hass( - hass - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -async def test_connect_error(hass): +async def test_connect_error(hass, config): """Test that the config entry errors out if the device cannot connect.""" - conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PORT: 7777} - with patch( "aioguardian.client.Client.connect", side_effect=GuardianError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {CONF_IP_ADDRESS: "cannot_connect"} @@ -59,10 +50,8 @@ async def test_get_pin_from_uid(): assert pin == "3456" -async def test_step_user(hass, ping_client): +async def test_step_user(hass, config, setup_guardian): """Test the user step.""" - conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PORT: 7777} - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -70,7 +59,7 @@ async def test_step_user(hass, ping_client): assert result["step_id"] == "user" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "ABCDEF123456" @@ -81,7 +70,7 @@ async def test_step_user(hass, ping_client): } -async def test_step_zeroconf(hass, ping_client): +async def test_step_zeroconf(hass, setup_guardian): """Test the zeroconf step.""" zeroconf_data = zeroconf.ZeroconfServiceInfo( host="192.168.1.100", @@ -134,7 +123,7 @@ async def test_step_zeroconf_already_in_progress(hass): assert result["reason"] == "already_in_progress" -async def test_step_dhcp(hass, ping_client): +async def test_step_dhcp(hass, setup_guardian): """Test the dhcp step.""" dhcp_data = dhcp.DhcpServiceInfo( ip="192.168.1.100", @@ -179,3 +168,45 @@ async def test_step_dhcp_already_in_progress(hass): ) assert result["type"] == "abort" assert result["reason"] == "already_in_progress" + + +async def test_step_dhcp_already_setup_match_mac(hass): + """Test we abort if the device is already setup with matching unique id and discovered via DHCP.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}, unique_id="guardian_ABCD" + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="192.168.1.100", + hostname="GVC1-ABCD.local.", + macaddress="aa:bb:cc:dd:ab:cd", + ), + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_step_dhcp_already_setup_match_ip(hass): + """Test we abort if the device is already setup with matching ip and discovered via DHCP.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "192.168.1.100"}, + unique_id="guardian_0000", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="192.168.1.100", + hostname="GVC1-ABCD.local.", + macaddress="aa:bb:cc:dd:ab:cd", + ), + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py new file mode 100644 index 00000000000000..f48c988c907fc1 --- /dev/null +++ b/tests/components/guardian/test_diagnostics.py @@ -0,0 +1,76 @@ +"""Test Guardian diagnostics.""" +from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.guardian import ( + DATA_PAIRED_SENSOR_MANAGER, + DOMAIN, + PairedSensorManager, +) + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_guardian): + """Test config entry diagnostics.""" + paired_sensor_manager: PairedSensorManager = hass.data[DOMAIN][ + config_entry.entry_id + ][DATA_PAIRED_SENSOR_MANAGER] + + # Simulate the pairing of a paired sensor: + await paired_sensor_manager.async_pair_sensor("AABBCCDDEEFF") + + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "title": "Mock Title", + "data": { + "ip_address": "192.168.1.100", + "port": 7777, + "uid": REDACTED, + }, + }, + "data": { + "valve_controller": { + "sensor_pair_dump": {"pair_count": 1, "paired_uids": REDACTED}, + "system_diagnostics": { + "codename": "gvc1", + "uid": REDACTED, + "uptime": 41, + "firmware": "0.20.9-beta+official.ef3", + "rf_modem_firmware": "4.0.0", + "available_heap": 34456, + }, + "system_onboard_sensor_status": {"temperature": 71, "wet": False}, + "valve_status": { + "enabled": False, + "direction": True, + "state": 0, + "travel_count": 0, + "instantaneous_current": 0, + "instantaneous_current_ddt": 0, + "average_current": 34, + }, + "wifi_status": { + "station_connected": True, + "ip_assigned": True, + "mqtt_connected": True, + "rssi": -63, + "channel": 1, + "lan_ipv4": "192.168.1.100", + "lan_ipv6": "AC10:BD0:FFFF:FFFF:AC10:BD0:FFFF:FFFF", + "ap_enabled": True, + "ap_clients": 0, + "bssid": REDACTED, + "ssid": REDACTED, + }, + }, + "paired_sensors": [ + { + "uid": REDACTED, + "codename": "gld1", + "temperature": 68, + "wet": False, + "moved": True, + "battery_percentage": 79, + } + ], + }, + } diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index efa983c344032d..89a8c6f5c5106c 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -6,6 +6,7 @@ from homeassistant.components.hassio.handler import HassIO, HassioAPIError from homeassistant.core import CoreState +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.setup import async_setup_component from . import HASSIO_TOKEN @@ -70,7 +71,7 @@ def hassio_handler(hass, aioclient_mock): """Create mock hassio handler.""" async def get_client_session(): - return hass.helpers.aiohttp_client.async_get_clientsession() + return async_get_clientsession(hass) websession = hass.loop.run_until_complete(get_client_session()) diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index a6b5c11dc9e430..d303df2619f320 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -1,7 +1,7 @@ """Configuration for HEOS tests.""" from __future__ import annotations -from typing import Sequence +from collections.abc import Sequence from unittest.mock import Mock, patch as patch from pyheos import ( diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py index 610bc371b25102..d7ab62a6847ad9 100644 --- a/tests/components/homeassistant/test_scene.py +++ b/tests/components/homeassistant/test_scene.py @@ -6,6 +6,7 @@ from homeassistant.components.homeassistant import scene as ha_scene from homeassistant.components.homeassistant.scene import EVENT_SCENE_RELOADED +from homeassistant.const import STATE_UNKNOWN from homeassistant.setup import async_setup_component from tests.common import async_capture_events, async_mock_service @@ -119,7 +120,7 @@ async def test_create_service(hass, caplog): assert scene is not None assert scene.domain == "scene" assert scene.name == "hallo" - assert scene.state == "scening" + assert scene.state == STATE_UNKNOWN assert scene.attributes.get("entity_id") == ["light.bed_light"] assert await hass.services.async_call( @@ -137,7 +138,7 @@ async def test_create_service(hass, caplog): assert scene is not None assert scene.domain == "scene" assert scene.name == "hallo" - assert scene.state == "scening" + assert scene.state == STATE_UNKNOWN assert scene.attributes.get("entity_id") == ["light.kitchen_light"] assert await hass.services.async_call( @@ -156,7 +157,7 @@ async def test_create_service(hass, caplog): assert scene is not None assert scene.domain == "scene" assert scene.name == "hallo_2" - assert scene.state == "scening" + assert scene.state == STATE_UNKNOWN assert scene.attributes.get("entity_id") == ["light.kitchen"] diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index b47ab223be8ade..103ee9ea2dadc7 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -19,6 +19,7 @@ BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CHAR_FIRMWARE_REVISION, + CHAR_HARDWARE_REVISION, CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, @@ -33,6 +34,7 @@ ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, + ATTR_HW_VERSION, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_SERVICE, @@ -215,6 +217,36 @@ async def test_accessory_with_missing_basic_service_info(hass, hk_driver): assert serv.get_characteristic(CHAR_MODEL).value == "Sensor" assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == entity_id assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == hass_version + assert isinstance(acc.to_HAP(), dict) + + +async def test_accessory_with_hardware_revision(hass, hk_driver): + """Test HomeAccessory class with hardware revision.""" + entity_id = "sensor.accessory" + hass.states.async_set(entity_id, "on") + acc = HomeAccessory( + hass, + hk_driver, + "Home Accessory", + entity_id, + 3, + { + ATTR_MODEL: None, + ATTR_MANUFACTURER: None, + ATTR_SW_VERSION: None, + ATTR_HW_VERSION: "1.2.3", + ATTR_INTEGRATION: None, + }, + ) + acc.driver = hk_driver + serv = acc.get_service(SERV_ACCESSORY_INFO) + assert serv.get_characteristic(CHAR_NAME).value == "Home Accessory" + assert serv.get_characteristic(CHAR_MANUFACTURER).value == "Home Assistant Sensor" + assert serv.get_characteristic(CHAR_MODEL).value == "Sensor" + assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == entity_id + assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == hass_version + assert serv.get_characteristic(CHAR_HARDWARE_REVISION).value == "1.2.3" + assert isinstance(acc.to_HAP(), dict) async def test_battery_service(hass, hk_driver, caplog): diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index ffd223d1d2ae3f..b4600c3190eb23 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -1,6 +1,9 @@ """Test the HomeKit config flow.""" from unittest.mock import patch +import pytest +import voluptuous + from homeassistant import config_entries, data_entry_flow from homeassistant.components.homekit.const import ( CONF_FILTER, @@ -9,6 +12,8 @@ ) from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.entityfilter import CONF_INCLUDE_DOMAINS from homeassistant.setup import async_setup_component @@ -296,15 +301,18 @@ async def test_options_flow_exclude_mode_advanced(hass, mock_get_source_ip): result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate", "humidifier"]}, + user_input={ + "domains": ["fan", "vacuum", "climate", "humidifier"], + "include_exclude_mode": "exclude", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "exclude" result2 = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"entities": ["climate.old"], "include_exclude_mode": "exclude"}, + user_input={"entities": ["climate.old"]}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "advanced" @@ -335,6 +343,8 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): config_entry.add_to_hass(hass) hass.states.async_set("climate.old", "off") + hass.states.async_set("climate.front_gate", "off") + await hass.async_block_till_done() result = await hass.config_entries.options.async_init( @@ -346,11 +356,16 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate"]}, + user_input={ + "domains": ["fan", "vacuum", "climate"], + "include_exclude_mode": "exclude", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "exclude" + entities = result["data_schema"]({})["entities"] + assert entities == ["climate.front_gate"] # Inject garbage to ensure the options data # is being deep copied and we cannot mutate it in flight @@ -358,7 +373,7 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): result2 = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"entities": ["climate.old"], "include_exclude_mode": "exclude"}, + user_input={"entities": ["climate.old"]}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { @@ -420,11 +435,14 @@ async def test_options_flow_devices( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate"]}, + user_input={ + "domains": ["fan", "vacuum", "climate"], + "include_exclude_mode": "exclude", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "exclude" entry = entity_reg.async_get("light.ceiling_lights") assert entry is not None @@ -434,7 +452,6 @@ async def test_options_flow_devices( result["flow_id"], user_input={ "entities": ["climate.old"], - "include_exclude_mode": "exclude", }, ) @@ -498,17 +515,19 @@ async def test_options_flow_devices_preserved_when_advanced_off( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate"]}, + user_input={ + "domains": ["fan", "vacuum", "climate"], + "include_exclude_mode": "exclude", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "exclude" result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ "entities": ["climate.old"], - "include_exclude_mode": "exclude", }, ) @@ -553,11 +572,14 @@ async def test_options_flow_include_mode_with_non_existant_entity( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate"]}, + user_input={ + "domains": ["fan", "vacuum", "climate"], + "include_exclude_mode": "include", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "include" entities = result["data_schema"]({})["entities"] assert "climate.not_exist" not in entities @@ -566,7 +588,6 @@ async def test_options_flow_include_mode_with_non_existant_entity( result["flow_id"], user_input={ "entities": ["climate.new", "climate.front_gate"], - "include_exclude_mode": "include", }, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -610,11 +631,14 @@ async def test_options_flow_exclude_mode_with_non_existant_entity( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["climate"]}, + user_input={ + "domains": ["climate"], + "include_exclude_mode": "exclude", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "exclude" entities = result["data_schema"]({})["entities"] assert "climate.not_exist" not in entities @@ -623,7 +647,6 @@ async def test_options_flow_exclude_mode_with_non_existant_entity( result["flow_id"], user_input={ "entities": ["climate.new", "climate.front_gate"], - "include_exclude_mode": "exclude", }, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -658,15 +681,18 @@ async def test_options_flow_include_mode_basic(hass, mock_get_source_ip): result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate"]}, + user_input={ + "domains": ["fan", "vacuum", "climate"], + "include_exclude_mode": "include", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "include" result2 = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"entities": ["climate.new"], "include_exclude_mode": "include"}, + user_input={"entities": ["climate.new"]}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == { @@ -702,17 +728,19 @@ async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip): result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate", "camera"]}, + user_input={ + "domains": ["fan", "vacuum", "climate", "camera"], + "include_exclude_mode": "exclude", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "exclude" result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ "entities": ["climate.old", "camera.excluded"], - "include_exclude_mode": "exclude", }, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -746,17 +774,19 @@ async def test_options_flow_exclude_mode_with_cameras(hass, mock_get_source_ip): result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate", "camera"]}, + user_input={ + "domains": ["fan", "vacuum", "climate", "camera"], + "include_exclude_mode": "exclude", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "exclude" result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ "entities": ["climate.old", "camera.excluded"], - "include_exclude_mode": "exclude", }, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -803,17 +833,19 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate", "camera"]}, + user_input={ + "domains": ["fan", "vacuum", "climate", "camera"], + "include_exclude_mode": "include", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "include" result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ "entities": ["camera.native_h264", "camera.transcode_h264"], - "include_exclude_mode": "include", }, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -847,6 +879,7 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): assert result["data_schema"]({}) == { "domains": ["fan", "vacuum", "climate", "camera"], "mode": "bridge", + "include_exclude_mode": "include", } schema = result["data_schema"].schema assert _get_schema_default(schema, "domains") == [ @@ -856,30 +889,31 @@ async def test_options_flow_include_mode_with_cameras(hass, mock_get_source_ip): "camera", ] assert _get_schema_default(schema, "mode") == "bridge" + assert _get_schema_default(schema, "include_exclude_mode") == "include" result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate", "camera"]}, + user_input={ + "domains": ["fan", "vacuum", "climate", "camera"], + "include_exclude_mode": "exclude", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "exclude" assert result["data_schema"]({}) == { "entities": ["camera.native_h264", "camera.transcode_h264"], - "include_exclude_mode": "include", } schema = result["data_schema"].schema assert _get_schema_default(schema, "entities") == [ "camera.native_h264", "camera.transcode_h264", ] - assert _get_schema_default(schema, "include_exclude_mode") == "include" result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ "entities": ["climate.old", "camera.excluded"], - "include_exclude_mode": "exclude", }, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -931,17 +965,19 @@ async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate", "camera"]}, + user_input={ + "domains": ["fan", "vacuum", "climate", "camera"], + "include_exclude_mode": "include", + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "include" result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ "entities": ["camera.audio", "camera.no_audio"], - "include_exclude_mode": "include", }, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -975,6 +1011,7 @@ async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): assert result["data_schema"]({}) == { "domains": ["fan", "vacuum", "climate", "camera"], "mode": "bridge", + "include_exclude_mode": "include", } schema = result["data_schema"].schema assert _get_schema_default(schema, "domains") == [ @@ -984,30 +1021,31 @@ async def test_options_flow_with_camera_audio(hass, mock_get_source_ip): "camera", ] assert _get_schema_default(schema, "mode") == "bridge" + assert _get_schema_default(schema, "include_exclude_mode") == "include" result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"domains": ["fan", "vacuum", "climate", "camera"]}, + user_input={ + "include_exclude_mode": "exclude", + "domains": ["fan", "vacuum", "climate", "camera"], + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "exclude" assert result["data_schema"]({}) == { "entities": ["camera.audio", "camera.no_audio"], - "include_exclude_mode": "include", } schema = result["data_schema"].schema assert _get_schema_default(schema, "entities") == [ "camera.audio", "camera.no_audio", ] - assert _get_schema_default(schema, "include_exclude_mode") == "include" result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ "entities": ["climate.old", "camera.excluded"], - "include_exclude_mode": "exclude", }, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -1105,6 +1143,7 @@ async def test_options_flow_include_mode_basic_accessory( "alarm_control_panel", ], "mode": "bridge", + "include_exclude_mode": "exclude", } result2 = await hass.config_entries.options.async_configure( @@ -1113,7 +1152,7 @@ async def test_options_flow_include_mode_basic_accessory( ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["step_id"] == "include_exclude" + assert result2["step_id"] == "accessory" assert _get_schema_default(result2["data_schema"].schema, "entities") is None result3 = await hass.config_entries.options.async_configure( @@ -1143,6 +1182,7 @@ async def test_options_flow_include_mode_basic_accessory( assert result["data_schema"]({}) == { "domains": ["media_player"], "mode": "accessory", + "include_exclude_mode": "include", } result2 = await hass.config_entries.options.async_configure( @@ -1151,7 +1191,7 @@ async def test_options_flow_include_mode_basic_accessory( ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["step_id"] == "include_exclude" + assert result2["step_id"] == "accessory" assert ( _get_schema_default(result2["data_schema"].schema, "entities") == "media_player.tv" @@ -1244,7 +1284,7 @@ async def test_converting_bridge_to_accessory_mode(hass, hk_driver, mock_get_sou ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "include_exclude" + assert result["step_id"] == "accessory" result2 = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1285,3 +1325,81 @@ def _get_schema_default(schema, key_name): if schema_key == key_name: return schema_key.default() raise KeyError(f"{key_name} not found in schema") + + +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +async def test_options_flow_exclude_mode_skips_category_entities( + port_mock, hass, mock_get_source_ip, hk_driver, mock_async_zeroconf, entity_reg +): + """Ensure exclude mode does not offer category entities.""" + config_entry = _mock_config_entry_with_options_populated() + await async_init_entry(hass, config_entry) + + hass.states.async_set("media_player.tv", "off") + hass.states.async_set("media_player.sonos", "off") + hass.states.async_set("switch.other", "off") + + sonos_config_switch: RegistryEntry = entity_reg.async_get_or_create( + "switch", + "sonos", + "config", + device_id="1234", + entity_category=EntityCategory.CONFIG, + ) + hass.states.async_set(sonos_config_switch.entity_id, "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": False} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + "domains": [ + "fan", + "humidifier", + "vacuum", + "media_player", + "climate", + "alarm_control_panel", + ], + "mode": "bridge", + "include_exclude_mode": "exclude", + } + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["media_player", "switch"], + "mode": "bridge", + "include_exclude_mode": "exclude", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "exclude" + assert _get_schema_default(result2["data_schema"].schema, "entities") == [] + + # sonos_config_switch.entity_id is a config category entity + # so it should not be selectable since it will always be excluded + with pytest.raises(voluptuous.error.MultipleInvalid): + await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"entities": [sonos_config_switch.entity_id]}, + ) + + result4 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"entities": ["media_player.tv", "switch.other"]}, + ) + assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": ["media_player.tv", "switch.other"], + "include_domains": ["media_player", "switch"], + "include_entities": [], + }, + } diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 8cc416adabdb40..31f7b0f3bccbdb 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -18,6 +18,7 @@ TYPE_VALVE, ) import homeassistant.components.media_player.const as media_player_c +from homeassistant.components.sensor import SensorDeviceClass import homeassistant.components.vacuum as vacuum from homeassistant.const import ( ATTR_CODE, @@ -26,8 +27,6 @@ ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, LIGHT_LUX, PERCENTAGE, STATE_UNKNOWN, @@ -219,14 +218,14 @@ def test_type_media_player(type_name, entity_id, state, attrs, config): "CarbonMonoxideSensor", "sensor.co", "2", - {ATTR_DEVICE_CLASS: DEVICE_CLASS_CO}, + {ATTR_DEVICE_CLASS: SensorDeviceClass.CO}, ), ("CarbonDioxideSensor", "sensor.airmeter_co2", "500", {}), ( "CarbonDioxideSensor", "sensor.co2", "500", - {ATTR_DEVICE_CLASS: DEVICE_CLASS_CO2}, + {ATTR_DEVICE_CLASS: SensorDeviceClass.CO2}, ), ( "HumiditySensor", @@ -273,6 +272,7 @@ def test_type_sensors(type_name, entity_id, state, attrs): ("Switch", "automation.test", "on", {}, {}), ("Switch", "button.test", STATE_UNKNOWN, {}, {}), ("Switch", "input_boolean.test", "on", {}, {}), + ("Switch", "input_button.test", STATE_UNKNOWN, {}, {}), ("Switch", "remote.test", "on", {}, {}), ("Switch", "scene.test", "on", {}, {}), ("Switch", "script.test", "on", {}, {}), diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index bd6375721911fc..5c79e764af1ab5 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -11,10 +11,7 @@ from homeassistant import config as hass_config from homeassistant.components import homekit as homekit_base, zeroconf -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_BATTERY_CHARGING, - DEVICE_CLASS_MOTION, -) +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homekit import ( MAX_DEVICES, STATUS_READY, @@ -37,6 +34,7 @@ ) from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -45,8 +43,6 @@ ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_PORT, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, EVENT_HOMEASSISTANT_STARTED, PERCENTAGE, SERVICE_RELOAD, @@ -433,6 +429,62 @@ async def test_homekit_entity_glob_filter(hass, mock_async_zeroconf): assert hass.states.get("light.included_test") in filtered_states +async def test_homekit_entity_glob_filter_with_config_entities( + hass, mock_async_zeroconf, entity_reg +): + """Test the entity filter with configuration entities.""" + entry = await async_init_integration(hass) + + from homeassistant.helpers.entity import EntityCategory + from homeassistant.helpers.entity_registry import RegistryEntry + + select_config_entity: RegistryEntry = entity_reg.async_get_or_create( + "select", + "any", + "any", + device_id="1234", + entity_category=EntityCategory.CONFIG, + ) + hass.states.async_set(select_config_entity.entity_id, "off") + + switch_config_entity: RegistryEntry = entity_reg.async_get_or_create( + "switch", + "any", + "any", + device_id="1234", + entity_category=EntityCategory.CONFIG, + ) + hass.states.async_set(switch_config_entity.entity_id, "off") + hass.states.async_set("select.keep", "open") + + hass.states.async_set("cover.excluded_test", "open") + hass.states.async_set("light.included_test", "on") + + entity_filter = generate_filter( + ["select"], + ["switch.test", switch_config_entity.entity_id], + [], + [], + ["*.included_*"], + ["*.excluded_*"], + ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, entity_filter) + + homekit.bridge = Mock() + homekit.bridge.accessories = {} + + filtered_states = await homekit.async_configure_accessories() + assert ( + hass.states.get(switch_config_entity.entity_id) in filtered_states + ) # explicitly included + assert ( + hass.states.get(select_config_entity.entity_id) not in filtered_states + ) # not explicted included and its a config entity + assert hass.states.get("cover.excluded_test") not in filtered_states + assert hass.states.get("light.included_test") in filtered_states + assert hass.states.get("select.keep") in filtered_states + + async def test_homekit_start(hass, hk_driver, mock_async_zeroconf, device_reg): """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1113,6 +1165,7 @@ async def test_homekit_finds_linked_batteries( device_entry = device_reg.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.0", + hw_version="2.34", model="Powerwall 2", manufacturer="Tesla", connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, @@ -1123,14 +1176,14 @@ async def test_homekit_finds_linked_batteries( "powerwall", "battery_charging", device_id=device_entry.id, - original_device_class=DEVICE_CLASS_BATTERY_CHARGING, + original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) battery_sensor = entity_reg.async_get_or_create( "sensor", "powerwall", "battery", device_id=device_entry.id, - original_device_class=DEVICE_CLASS_BATTERY, + original_device_class=SensorDeviceClass.BATTERY, ) light = entity_reg.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id @@ -1139,10 +1192,10 @@ async def test_homekit_finds_linked_batteries( hass.states.async_set( binary_charging_sensor.entity_id, STATE_ON, - {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING}, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING}, ) hass.states.async_set( - battery_sensor.entity_id, 30, {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY} + battery_sensor.entity_id, 30, {ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY} ) hass.states.async_set(light.entity_id, STATE_ON) @@ -1161,6 +1214,7 @@ async def test_homekit_finds_linked_batteries( "manufacturer": "Tesla", "model": "Powerwall 2", "sw_version": "0.16.0", + "hw_version": "2.34", "platform": "test", "linked_battery_charging_sensor": "binary_sensor.powerwall_battery_charging", "linked_battery_sensor": "sensor.powerwall_battery", @@ -1192,14 +1246,14 @@ async def test_homekit_async_get_integration_fails( "invalid_integration_does_not_exist", "battery_charging", device_id=device_entry.id, - original_device_class=DEVICE_CLASS_BATTERY_CHARGING, + original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) battery_sensor = entity_reg.async_get_or_create( "sensor", "invalid_integration_does_not_exist", "battery", device_id=device_entry.id, - original_device_class=DEVICE_CLASS_BATTERY, + original_device_class=SensorDeviceClass.BATTERY, ) light = entity_reg.async_get_or_create( "light", "invalid_integration_does_not_exist", "demo", device_id=device_entry.id @@ -1208,10 +1262,10 @@ async def test_homekit_async_get_integration_fails( hass.states.async_set( binary_charging_sensor.entity_id, STATE_ON, - {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING}, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.BATTERY_CHARGING}, ) hass.states.async_set( - battery_sensor.entity_id, 30, {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY} + battery_sensor.entity_id, 30, {ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY} ) hass.states.async_set(light.entity_id, STATE_ON) @@ -1308,8 +1362,7 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_async_zeroconf def _write_data(path: str, data: dict) -> None: """Write the data.""" - if not os.path.isdir(os.path.dirname(path)): - os.makedirs(os.path.dirname(path)) + os.makedirs(os.path.dirname(path), exist_ok=True) json_util.save_json(path, data) @@ -1339,14 +1392,14 @@ async def test_homekit_ignored_missing_devices( "powerwall", "battery_charging", device_id=device_entry.id, - original_device_class=DEVICE_CLASS_BATTERY_CHARGING, + original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) entity_reg.async_get_or_create( "sensor", "powerwall", "battery", device_id=device_entry.id, - original_device_class=DEVICE_CLASS_BATTERY, + original_device_class=SensorDeviceClass.BATTERY, ) light = entity_reg.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id @@ -1409,7 +1462,7 @@ async def test_homekit_finds_linked_motion_sensors( "camera", "motion_sensor", device_id=device_entry.id, - original_device_class=DEVICE_CLASS_MOTION, + original_device_class=BinarySensorDeviceClass.MOTION, ) camera = entity_reg.async_get_or_create( "camera", "camera", "demo", device_id=device_entry.id @@ -1418,7 +1471,7 @@ async def test_homekit_finds_linked_motion_sensors( hass.states.async_set( binary_motion_sensor.entity_id, STATE_ON, - {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION}, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION}, ) hass.states.async_set(camera.entity_id, STATE_ON) @@ -1471,7 +1524,7 @@ async def test_homekit_finds_linked_humidity_sensors( "humidifier", "humidity_sensor", device_id=device_entry.id, - original_device_class=DEVICE_CLASS_HUMIDITY, + original_device_class=SensorDeviceClass.HUMIDITY, ) humidifier = entity_reg.async_get_or_create( "humidifier", "humidifier", "demo", device_id=device_entry.id @@ -1481,7 +1534,7 @@ async def test_homekit_finds_linked_humidity_sensors( humidity_sensor.entity_id, "42", { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, ) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 05c809910cf718..9a8b284b97ee1a 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components import camera, ffmpeg +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.camera.img_util import TurboJPEGSingleton from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( @@ -20,8 +21,6 @@ CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OCCUPANCY, SERV_DOORBELL, SERV_MOTION_SENSOR, SERV_STATELESS_PROGRAMMABLE_SWITCH, @@ -608,7 +607,7 @@ async def test_camera_with_linked_motion_sensor(hass, run_driver, events): motion_entity_id = "binary_sensor.motion" hass.states.async_set( - motion_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION} + motion_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() entity_id = "camera.demo_camera" @@ -645,14 +644,14 @@ async def test_camera_with_linked_motion_sensor(hass, run_driver, events): assert char.value is True hass.states.async_set( - motion_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION} + motion_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() assert char.value is False char.set_value(True) hass.states.async_set( - motion_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION} + motion_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() assert char.value is True @@ -706,7 +705,9 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): doorbell_entity_id = "binary_sensor.doorbell" hass.states.async_set( - doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + doorbell_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY}, ) await hass.async_block_till_done() entity_id = "camera.demo_camera" @@ -750,7 +751,9 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): assert char2.value is None hass.states.async_set( - doorbell_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + doorbell_entity_id, + STATE_OFF, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY}, ) await hass.async_block_till_done() assert char.value is None @@ -759,7 +762,9 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): char.set_value(True) char2.set_value(True) hass.states.async_set( - doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + doorbell_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY}, ) await hass.async_block_till_done() assert char.value is None diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py index 1a301e340b3e8a..e5cbf978c83b0d 100644 --- a/tests/components/homekit/test_type_humidifiers.py +++ b/tests/components/homekit/test_type_humidifiers.py @@ -16,6 +16,7 @@ PROP_VALID_VALUES, ) from homeassistant.components.homekit.type_humidifiers import HumidifierDehumidifier +from homeassistant.components.humidifier import HumidifierDeviceClass from homeassistant.components.humidifier.const import ( ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, @@ -27,11 +28,11 @@ DOMAIN, SERVICE_SET_HUMIDITY, ) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_HUMIDITY, PERCENTAGE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -313,7 +314,7 @@ async def test_humidifier_with_linked_humidity_sensor(hass, hk_driver): humidity_sensor_entity_id, "42.0", { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, ) @@ -341,7 +342,7 @@ async def test_humidifier_with_linked_humidity_sensor(hass, hk_driver): humidity_sensor_entity_id, "43.0", { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, ) @@ -353,7 +354,7 @@ async def test_humidifier_with_linked_humidity_sensor(hass, hk_driver): humidity_sensor_entity_id, STATE_UNAVAILABLE, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, }, ) @@ -394,7 +395,9 @@ async def test_humidifier_as_dehumidifier(hass, hk_driver, events, caplog): """Test an invalid char_target_humidifier_dehumidifier from HomeKit.""" entity_id = "humidifier.test" - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set( + entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: HumidifierDeviceClass.HUMIDIFIER} + ) await hass.async_block_till_done() acc = HumidifierDehumidifier( hass, hk_driver, "HumidifierDehumidifier", entity_id, 1, None @@ -427,3 +430,44 @@ async def test_humidifier_as_dehumidifier(hass, hk_driver, events, caplog): await hass.async_block_till_done() assert "TargetHumidifierDehumidifierState is not supported" in caplog.text assert len(events) == 0 + + +async def test_dehumidifier_as_humidifier(hass, hk_driver, events, caplog): + """Test an invalid char_target_humidifier_dehumidifier from HomeKit.""" + entity_id = "humidifier.test" + + hass.states.async_set( + entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: HumidifierDeviceClass.DEHUMIDIFIER} + ) + await hass.async_block_till_done() + acc = HumidifierDehumidifier( + hass, hk_driver, "HumidifierDehumidifier", entity_id, 1, None + ) + hk_driver.add_accessory(acc) + + await acc.run() + await hass.async_block_till_done() + + assert acc.char_target_humidifier_dehumidifier.value == 2 + + # Set from HomeKit + char_target_humidifier_dehumidifier_iid = ( + acc.char_target_humidifier_dehumidifier.to_HAP()[HAP_REPR_IID] + ) + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_humidifier_dehumidifier_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + + await hass.async_block_till_done() + assert "TargetHumidifierDehumidifierState is not supported" in caplog.text + assert len(events) == 0 diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 9c0d45126fc4d6..8e7b60b0a47f16 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -13,11 +13,18 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, DOMAIN, ) from homeassistant.const import ( @@ -565,6 +572,244 @@ async def test_light_restore(hass, hk_driver, events): assert acc.char_on.value == 0 +@pytest.mark.parametrize( + "supported_color_modes, state_props, turn_on_props, turn_on_props_with_brightness", + [ + [ + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGBW], + { + ATTR_RGBW_COLOR: (128, 50, 0, 255), + ATTR_RGB_COLOR: (128, 50, 0), + ATTR_HS_COLOR: (23.438, 100.0), + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: COLOR_MODE_RGBW, + }, + {ATTR_RGBW_COLOR: (31, 127, 71, 0)}, + {ATTR_RGBW_COLOR: (15, 63, 35, 0)}, + ], + [ + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGBWW], + { + ATTR_RGBWW_COLOR: (128, 50, 0, 255, 255), + ATTR_RGB_COLOR: (128, 50, 0), + ATTR_HS_COLOR: (23.438, 100.0), + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: COLOR_MODE_RGBWW, + }, + {ATTR_RGBWW_COLOR: (31, 127, 71, 0, 0)}, + {ATTR_RGBWW_COLOR: (15, 63, 35, 0, 0)}, + ], + ], +) +async def test_light_rgb_with_white( + hass, + hk_driver, + events, + supported_color_modes, + state_props, + turn_on_props, + turn_on_props_with_brightness, +): + """Test lights with RGBW/RGBWW.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, **state_props}, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_hue.value == 23 + assert acc.char_saturation.value == 100 + + await acc.run() + await hass.async_block_till_done() + assert acc.char_hue.value == 23 + assert acc.char_saturation.value == 100 + assert acc.char_brightness.value == 50 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 145, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 75, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on + assert call_turn_on[-1].data[ATTR_ENTITY_ID] == entity_id + for k, v in turn_on_props.items(): + assert call_turn_on[-1].data[k] == v + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == "set color at (145, 75)" + assert acc.char_brightness.value == 50 + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 145, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 75, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 25, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on + assert call_turn_on[-1].data[ATTR_ENTITY_ID] == entity_id + for k, v in turn_on_props_with_brightness.items(): + assert call_turn_on[-1].data[k] == v + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == "brightness at 25%, set color at (145, 75)" + assert acc.char_brightness.value == 25 + + +@pytest.mark.parametrize( + "supported_color_modes, state_props, turn_on_props, turn_on_props_with_brightness", + [ + [ + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGBW], + { + ATTR_RGBW_COLOR: (128, 50, 0, 255), + ATTR_RGB_COLOR: (128, 50, 0), + ATTR_HS_COLOR: (23.438, 100.0), + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: COLOR_MODE_RGBW, + }, + {ATTR_RGBW_COLOR: (31, 127, 71, 0)}, + {ATTR_COLOR_TEMP: 2700}, + ], + [ + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGBWW], + { + ATTR_RGBWW_COLOR: (128, 50, 0, 255, 255), + ATTR_RGB_COLOR: (128, 50, 0), + ATTR_HS_COLOR: (23.438, 100.0), + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: COLOR_MODE_RGBWW, + }, + {ATTR_RGBWW_COLOR: (31, 127, 71, 0, 0)}, + {ATTR_COLOR_TEMP: 2700}, + ], + ], +) +async def test_light_rgb_with_white_switch_to_temp( + hass, + hk_driver, + events, + supported_color_modes, + state_props, + turn_on_props, + turn_on_props_with_brightness, +): + """Test lights with RGBW/RGBWW that preserves brightness when switching to color temp.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, **state_props}, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_hue.value == 23 + assert acc.char_saturation.value == 100 + + await acc.run() + await hass.async_block_till_done() + assert acc.char_hue.value == 23 + assert acc.char_saturation.value == 100 + assert acc.char_brightness.value == 50 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 145, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 75, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on + assert call_turn_on[-1].data[ATTR_ENTITY_ID] == entity_id + for k, v in turn_on_props.items(): + assert call_turn_on[-1].data[k] == v + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == "set color at (145, 75)" + assert acc.char_brightness.value == 50 + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temp_iid, + HAP_REPR_VALUE: 2700, + }, + ] + }, + "mock_addr", + ) + await _wait_for_light_coalesce(hass) + assert call_turn_on + assert call_turn_on[-1].data[ATTR_ENTITY_ID] == entity_id + for k, v in turn_on_props_with_brightness.items(): + assert call_turn_on[-1].data[k] == v + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == "color temperature at 2700" + assert acc.char_brightness.value == 50 + + async def test_light_set_brightness_and_color(hass, hk_driver, events): """Test light with all chars in one go.""" entity_id = "light.demo" diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 6b24c731fab5c3..7446b0d4c3ae08 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -18,7 +18,7 @@ MediaPlayer, TelevisionMediaPlayer, ) -from homeassistant.components.media_player import DEVICE_CLASS_TV +from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, @@ -181,7 +181,7 @@ async def test_media_player_television(hass, hk_driver, events, caplog): entity_id, None, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, ATTR_SUPPORTED_FEATURES: 3469, ATTR_MEDIA_VOLUME_MUTED: False, ATTR_INPUT_SOURCE_LIST: ["HDMI 1", "HDMI 2", "HDMI 3", "HDMI 4"], @@ -358,7 +358,7 @@ async def test_media_player_television_basic(hass, hk_driver, events, caplog): hass.states.async_set( entity_id, None, - {ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, ATTR_SUPPORTED_FEATURES: 384}, + {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, ATTR_SUPPORTED_FEATURES: 384}, ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) @@ -394,7 +394,7 @@ async def test_media_player_television_supports_source_select_no_sources( hass.states.async_set( entity_id, None, - {ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, ATTR_SUPPORTED_FEATURES: 3469}, + {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, ATTR_SUPPORTED_FEATURES: 3469}, ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) @@ -415,7 +415,7 @@ async def test_tv_restore(hass, hk_driver, events): "generic", "1234", suggested_object_id="simple", - original_device_class=DEVICE_CLASS_TV, + original_device_class=MediaPlayerDeviceClass.TV, ) registry.async_get_or_create( "media_player", @@ -426,7 +426,7 @@ async def test_tv_restore(hass, hk_driver, events): ATTR_INPUT_SOURCE_LIST: ["HDMI 1", "HDMI 2", "HDMI 3", "HDMI 4"], }, supported_features=3469, - original_device_class=DEVICE_CLASS_TV, + original_device_class=MediaPlayerDeviceClass.TV, ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) @@ -465,7 +465,7 @@ async def test_media_player_television_max_sources(hass, hk_driver, events, capl entity_id, None, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, ATTR_SUPPORTED_FEATURES: 3469, ATTR_MEDIA_VOLUME_MUTED: False, ATTR_INPUT_SOURCE: "HDMI 3", @@ -489,7 +489,7 @@ async def test_media_player_television_max_sources(hass, hk_driver, events, capl entity_id, None, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, ATTR_SUPPORTED_FEATURES: 3469, ATTR_MEDIA_VOLUME_MUTED: False, ATTR_INPUT_SOURCE: "HDMI 90", @@ -503,7 +503,7 @@ async def test_media_player_television_max_sources(hass, hk_driver, events, capl entity_id, None, { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, ATTR_SUPPORTED_FEATURES: 3469, ATTR_MEDIA_VOLUME_MUTED: False, ATTR_INPUT_SOURCE: "HDMI 91", diff --git a/tests/components/homekit/test_type_remote.py b/tests/components/homekit/test_type_remote.py index 5c5b5ee6cd92fd..e77dd5de54ca88 100644 --- a/tests/components/homekit/test_type_remote.py +++ b/tests/components/homekit/test_type_remote.py @@ -167,3 +167,41 @@ def listener(event): ) await hass.async_block_till_done() assert call_reset_accessory[0].data[ATTR_ENTITY_ID] == entity_id + + +async def test_activity_remote_bad_names(hass, hk_driver, events, caplog): + """Test if remote accessory with invalid names works as expected.""" + entity_id = "remote.harmony" + hass.states.async_set( + entity_id, + None, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_ACTIVITY, + ATTR_CURRENT_ACTIVITY: "Apple TV", + ATTR_ACTIVITY_LIST: ["TV", "Apple TV", "[[[--Special--]]]", "Super"], + }, + ) + await hass.async_block_till_done() + acc = ActivityRemote(hass, hk_driver, "ActivityRemote", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 31 # Television + + assert acc.char_active.value == 0 + assert acc.char_remote_key.value == 0 + assert acc.char_input_source.value == 1 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_ACTIVITY, + ATTR_CURRENT_ACTIVITY: "[[[--Special--]]]", + ATTR_ACTIVITY_LIST: ["TV", "Apple TV", "[[[--Special--]]]", "Super"], + }, + ) + await hass.async_block_till_done() + assert acc.char_active.value == 1 + assert acc.char_input_source.value == 2 diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index 7ad48bfbce625e..d864a90fe6123a 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -1,7 +1,8 @@ """Test different accessory types: Sensors.""" + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homekit import get_accessory from homeassistant.components.homekit.const import ( - DEVICE_CLASS_MOTION, PROP_CELSIUS, THRESHOLD_CO, THRESHOLD_CO2, @@ -60,6 +61,10 @@ async def test_temperature(hass, hk_driver): await hass.async_block_till_done() assert acc.char_temp.value == 20 + hass.states.async_set(entity_id, "0", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_temp.value == 0 + hass.states.async_set( entity_id, "75.2", {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT} ) @@ -90,6 +95,10 @@ async def test_humidity(hass, hk_driver): await hass.async_block_till_done() assert acc.char_humidity.value == 20 + hass.states.async_set(entity_id, "0") + await hass.async_block_till_done() + assert acc.char_humidity.value == 0 + async def test_air_quality(hass, hk_driver): """Test if accessory is updated after state change.""" @@ -226,6 +235,10 @@ async def test_light(hass, hk_driver): await hass.async_block_till_done() assert acc.char_light.value == 300 + hass.states.async_set(entity_id, "0") + await hass.async_block_till_done() + assert acc.char_light.value == 0.0001 + async def test_binary(hass, hk_driver): """Test if accessory is updated after state change.""" @@ -269,7 +282,7 @@ async def test_motion_uses_bool(hass, hk_driver): entity_id = "binary_sensor.motion" hass.states.async_set( - entity_id, STATE_UNKNOWN, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION} + entity_id, STATE_UNKNOWN, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() @@ -282,24 +295,26 @@ async def test_motion_uses_bool(hass, hk_driver): assert acc.char_detected.value is False - hass.states.async_set(entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION}) + hass.states.async_set( + entity_id, STATE_ON, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} + ) await hass.async_block_till_done() assert acc.char_detected.value is True hass.states.async_set( - entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION} + entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() assert acc.char_detected.value is False hass.states.async_set( - entity_id, STATE_HOME, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION} + entity_id, STATE_HOME, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() assert acc.char_detected.value is True hass.states.async_set( - entity_id, STATE_NOT_HOME, {ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION} + entity_id, STATE_NOT_HOME, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() assert acc.char_detected.value is False @@ -351,3 +366,37 @@ async def test_sensor_restore(hass, hk_driver, events): acc = get_accessory(hass, hk_driver, hass.states.get("sensor.humidity"), 2, {}) assert acc.category == 10 + + +async def test_bad_name(hass, hk_driver): + """Test an entity with a bad name.""" + entity_id = "sensor.humidity" + + hass.states.async_set(entity_id, "20") + await hass.async_block_till_done() + acc = HumiditySensor(hass, hk_driver, "[[Humid]]", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_humidity.value == 20 + assert acc.display_name == "--Humid--" + + +async def test_empty_name(hass, hk_driver): + """Test an entity with a empty name.""" + entity_id = "sensor.humidity" + + hass.states.async_set(entity_id, "20") + await hass.async_block_till_done() + acc = HumiditySensor(hass, hk_driver, None, entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_humidity.value == 20 + assert acc.display_name is None diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 54eae42ca1d9f2..c1340e1d34e015 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -451,10 +451,13 @@ async def test_input_select_switch(hass, hk_driver, events, domain): assert acc.select_chars["option3"].value is False -async def test_button_switch(hass, hk_driver, events): - """Test switch accessory from a button entity.""" - domain = "button" - entity_id = "button.test" +@pytest.mark.parametrize( + "domain", + ["button", "input_button"], +) +async def test_button_switch(hass, hk_driver, events, domain): + """Test switch accessory from a (input) button entity.""" + entity_id = f"{domain}.test" hass.states.async_set(entity_id, None) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index 4a265858cb387f..ad970b56ad4594 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.homekit.const import CHAR_PROGRAMMABLE_SWITCH_EVENT from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component @@ -26,7 +28,9 @@ async def test_programmable_switch_button_fires_on_trigger( assert entry is not None device_id = entry.device_id - device_triggers = await async_get_device_automations(hass, "trigger", device_id) + device_triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_id + ) acc = DeviceTriggerAccessory( hass, hk_driver, @@ -47,11 +51,16 @@ async def test_programmable_switch_button_fires_on_trigger( hk_driver.publish.reset_mock() hass.states.async_set("light.ceiling_lights", STATE_ON) await hass.async_block_till_done() - hk_driver.publish.assert_called_once() + assert len(hk_driver.publish.mock_calls) == 2 # one for on, one for toggle + for call in hk_driver.publish.mock_calls: + char = acc.get_characteristic(call.args[0]["aid"], call.args[0]["iid"]) + assert char.display_name == CHAR_PROGRAMMABLE_SWITCH_EVENT hk_driver.publish.reset_mock() hass.states.async_set("light.ceiling_lights", STATE_OFF) await hass.async_block_till_done() - hk_driver.publish.assert_called_once() - + assert len(hk_driver.publish.mock_calls) == 2 # one for on, one for toggle + for call in hk_driver.publish.mock_calls: + char = acc.get_characteristic(call.args[0]["aid"], call.args[0]["iid"]) + assert char.display_name == CHAR_PROGRAMMABLE_SWITCH_EVENT await acc.stop() diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 31efcc0b948369..0432fb27426a17 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -32,7 +32,7 @@ cleanup_name_for_homekit, convert_to_float, density_to_air_quality, - format_sw_version, + format_version, state_needs_accessory_mode, temperature_to_homekit, temperature_to_states, @@ -343,13 +343,17 @@ async def test_port_is_available_skips_existing_entries(hass): async_find_next_available_port(hass, 65530) -async def test_format_sw_version(): - """Test format_sw_version method.""" - assert format_sw_version("soho+3.6.8+soho-release-rt120+10") == "3.6.8" - assert format_sw_version("undefined-undefined-1.6.8") == "1.6.8" - assert format_sw_version("56.0-76060") == "56.0.76060" - assert format_sw_version(3.6) == "3.6" - assert format_sw_version("unknown") is None +async def test_format_version(): + """Test format_version method.""" + assert format_version("soho+3.6.8+soho-release-rt120+10") == "3.6.8" + assert format_version("undefined-undefined-1.6.8") == "1.6.8" + assert format_version("56.0-76060") == "56.0.76060" + assert format_version(3.6) == "3.6" + assert format_version("AK001-ZJ100") == "001.100" + assert format_version("HF-LPB100-") == "100" + assert format_version("AK001-ZJ2149") == "001.2149" + assert format_version("0.1") == "0.1" + assert format_version("unknown") is None async def test_accessory_friendly_name(): diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 49c63a761c23d9..ef77d5bc65246e 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -1,7 +1,12 @@ """Code to support homekit_controller tests.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import json +import logging import os +from typing import Any, Final from unittest import mock from aiohomekit.model import Accessories, Accessory @@ -10,16 +15,80 @@ from aiohomekit.testing import FakeController from homeassistant.components import zeroconf +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import ( CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, + IDENTIFIER_ACCESSORY_ID, + IDENTIFIER_SERIAL_NUMBER, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_get_device_automations, + load_fixture, +) + +logger = logging.getLogger(__name__) + + +# Root device in test harness always has an accessory id of this +HUB_TEST_ACCESSORY_ID: Final[str] = "00:00:00:00:00:00:aid:1" + + +@dataclass +class EntityTestInfo: + """Describes how we expected an entity to be created by homekit_controller.""" + + entity_id: str + unique_id: str + friendly_name: str + state: str + supported_features: int = 0 + capabilities: dict[str, Any] | None = None + entity_category: EntityCategory | None = None + unit_of_measurement: str | None = None + + +@dataclass +class DeviceTriggerInfo: + """ + Describe a automation trigger we expect to be created. + + We only use these for a stateless characteristic like a doorbell. + """ + + type: str + subtype: str + + +@dataclass +class DeviceTestInfo: + """Describes how we exepced a device to be created by homekit_controlller.""" + + name: str + manufacturer: str + model: str + sw_version: str + hw_version: str + + devices: list[DeviceTestInfo] + entities: list[EntityTestInfo] + + # At least one of these must be provided + unique_id: str | None = None + serial_number: str | None = None + + # A homekit device can have events but no entity (like a doorbell or remote) + stateless_triggers: list[DeviceTriggerInfo] | None = None class Helper: @@ -171,3 +240,102 @@ async def setup_test_component(hass, setup_accessory, capitalize=False, suffix=N config_entry, pairing = await setup_test_accessories(hass, [accessory]) entity = "testdevice" if suffix is None else f"testdevice_{suffix}" return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry) + + +async def assert_devices_and_entities_created( + hass: HomeAssistant, expected: DeviceTestInfo +): + """Check that all expected devices and entities are loaded and enumerated as expected.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + async def _do_assertions(expected: DeviceTestInfo) -> dr.DeviceEntry: + # Note: homekit_controller currently uses a 3-tuple for device identifiers + # The current standard is a 2-tuple (hkc was not migrated when this change was brought in) + + # There are currently really 3 cases here: + # - We can match exactly one device by serial number. This won't work for devices like the Ryse. + # These have nlank or broken serial numbers. + # - The device unique id is "00:00:00:00:00:00" - this is the pairing id. This is only set for + # the root (bridge) device. + # - The device unique id is "00:00:00:00:00:00-X", where X is a HAP aid. This is only set when + # we have detected broken serial numbers (and serial number is not used as an identifier). + + device = device_registry.async_get_device( + { + (IDENTIFIER_SERIAL_NUMBER, expected.serial_number), + (IDENTIFIER_ACCESSORY_ID, expected.unique_id), + } + ) + + logger.debug("Comparing device %r to %r", device, expected) + + assert device + assert device.name == expected.name + assert device.model == expected.model + assert device.manufacturer == expected.manufacturer + assert device.hw_version == expected.hw_version + assert device.sw_version == expected.sw_version + + # We might have matched the device by one identifier only + # Lets check that the other one is correct. Otherwise the test might silently be wrong. + serial_number_set = False + accessory_id_set = False + + for key, value in device.identifiers: + if key == IDENTIFIER_SERIAL_NUMBER: + assert value == expected.serial_number + serial_number_set = True + + elif key == IDENTIFIER_ACCESSORY_ID: + assert value == expected.unique_id + accessory_id_set = True + + # If unique_id or serial is provided it MUST actually appear in the device registry entry. + assert (not expected.unique_id) ^ accessory_id_set + assert (not expected.serial_number) ^ serial_number_set + + for entity_info in expected.entities: + entity = entity_registry.async_get(entity_info.entity_id) + logger.debug("Comparing entity %r to %r", entity, entity_info) + + assert entity + assert entity.device_id == device.id + assert entity.unique_id == entity_info.unique_id + assert entity.supported_features == entity_info.supported_features + assert entity.entity_category == entity_info.entity_category + assert entity.unit_of_measurement == entity_info.unit_of_measurement + assert entity.capabilities == entity_info.capabilities + + state = hass.states.get(entity_info.entity_id) + logger.debug("Comparing state %r to %r", state, entity_info) + + assert state is not None + assert state.state == entity_info.state + assert state.attributes["friendly_name"] == entity_info.friendly_name + + all_triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + stateless_triggers = [] + for trigger in all_triggers: + if trigger.get("entity_id"): + continue + stateless_triggers.append( + DeviceTriggerInfo( + type=trigger.get("type"), subtype=trigger.get("subtype") + ) + ) + assert stateless_triggers == (expected.stateless_triggers or []) + + for child in expected.devices: + child_device = await _do_assertions(child) + assert child_device.via_device_id == device.id + assert child_device.id != device.id + + return device + + root_device = await _do_assertions(expected) + + # Root device must not have a via, otherwise its not the device + assert root_device.via_device_id is None diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 174fc4f7b8d57a..46b8a5de3e70bc 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -10,6 +10,8 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 +pytest.register_assert_rewrite("tests.components.homekit_controller.common") + @pytest.fixture def utcnow(request): diff --git a/tests/components/homekit_controller/fixtures/aqara_e1.json b/tests/components/homekit_controller/fixtures/aqara_e1.json new file mode 100644 index 00000000000000..8c8ff326bd6d97 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/aqara_e1.json @@ -0,0 +1,646 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 65537, + "type": "00000014-0000-1000-8000-0026BB765291", + "format": "bool", + "perms": [ + "pw" + ] + }, + { + "iid": 65538, + "type": "00000020-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Aqara", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65539, + "type": "00000021-0000-1000-8000-0026BB765291", + "format": "string", + "value": "HE1-G01", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65540, + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Aqara-Hub-E1-00A0", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65541, + "type": "00000030-0000-1000-8000-0026BB765291", + "format": "string", + "value": "00aa00000a0", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65542, + "type": "00000052-0000-1000-8000-0026BB765291", + "format": "string", + "value": "3.3.0", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65543, + "type": "00000053-0000-1000-8000-0026BB765291", + "format": "string", + "value": "1.0", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65544, + "type": "34AB8811-AC7F-4340-BAC3-FD6A85F9943B", + "format": "string", + "value": "5.0;dfeceb3a", + "perms": [ + "pr", + "hd" + ], + "ev": false + }, + { + "iid": 65545, + "type": "220", + "format": "data", + "value": "xDsGO4QdTEA=", + "perms": [ + "pr" + ], + "ev": false, + "maxDataLen": 8 + } + ] + }, + { + "iid": 2, + "type": "000000A2-0000-1000-8000-0026BB765291", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 131074, + "type": "00000037-0000-1000-8000-0026BB765291", + "format": "string", + "value": "1.1.0", + "perms": [ + "pr" + ], + "ev": false + } + ] + }, + { + "iid": 4, + "type": "22A", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 262145, + "type": "22B", + "format": "bool", + "value": 1, + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 262146, + "type": "22C", + "format": "uint32", + "value": 9, + "perms": [ + "pr" + ], + "ev": false, + "minValue": 0, + "maxValue": 15, + "minStep": 1 + }, + { + "iid": 262147, + "type": "22D", + "format": "tlv8", + "value": "", + "perms": [ + "pr", + "pw", + "ev", + "tw", + "wr" + ], + "ev": false + } + ] + }, + { + "iid": 16, + "type": "0000007E-0000-1000-8000-0026BB765291", + "primary": true, + "hidden": false, + "characteristics": [ + { + "iid": 1048578, + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Security System", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 1048579, + "type": "00000066-0000-1000-8000-0026BB765291", + "format": "uint8", + "value": 3, + "perms": [ + "pr", + "ev" + ], + "ev": true, + "minValue": 0, + "maxValue": 4, + "minStep": 1, + "valid-values": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + { + "iid": 1048580, + "type": "00000067-0000-1000-8000-0026BB765291", + "format": "uint8", + "value": 3, + "perms": [ + "pr", + "pw", + "ev" + ], + "ev": true, + "minValue": 0, + "maxValue": 3, + "minStep": 1, + "valid-values": [ + 0, + 1, + 2, + 3 + ] + }, + { + "iid": 1048581, + "type": "60CDDE6C-42B6-4C72-9719-AB2740EABE2A", + "format": "tlv8", + "value": "AAA=", + "perms": [ + "pr", + "pw" + ], + "ev": false, + "description": "Stay Arm Trigger Devices" + }, + { + "iid": 1048582, + "type": "4AB2460A-41E4-4F05-97C3-CCFDAE1BE324", + "format": "tlv8", + "value": "AAA=", + "perms": [ + "pr", + "pw" + ], + "ev": false, + "description": "Alarm Trigger Devices" + }, + { + "iid": 1048583, + "type": "F8296386-5A30-4AA7-838C-ED0DA9D807DF", + "format": "tlv8", + "value": "AAA=", + "perms": [ + "pr", + "pw" + ], + "ev": false, + "description": "Night Arm Trigger Devices" + } + ] + }, + { + "iid": 17, + "type": "9715BF53-AB63-4449-8DC7-2785D617390A", + "primary": false, + "hidden": true, + "characteristics": [ + { + "iid": 1114114, + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Gateway", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 1114115, + "type": "4CB28907-66DF-4D9C-962C-9971ABF30EDC", + "format": "string", + "value": "1970-01-01 21:01:22+8", + "perms": [ + "pr", + "pw", + "hd" + ], + "ev": false, + "description": "Date and Time" + }, + { + "iid": 1114116, + "type": "EE56B186-B0D3-488E-8C79-C21FC9BCF437", + "format": "int", + "value": 40, + "perms": [ + "pr", + "pw", + "ev", + "hd" + ], + "ev": false, + "description": "Gateway Volume", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "iid": 1114117, + "type": "B1C09E4C-E202-4827-B863-B0F32F727CFF", + "format": "bool", + "value": 0, + "perms": [ + "pr", + "pw", + "ev", + "hd" + ], + "ev": false, + "description": "New Accessory Permission" + }, + { + "iid": 1114118, + "type": "2CB22739-1E4C-4798-A761-BC2FAF51AFC3", + "format": "string", + "value": "", + "perms": [ + "pr", + "ev", + "hd" + ], + "ev": false, + "description": "Accessory Joined" + }, + { + "iid": 1114119, + "type": "75D19FA9-218B-4943-997E-341E5D1C60CC", + "format": "string", + "perms": [ + "pw", + "hd" + ], + "description": "Remove Accessory" + }, + { + "iid": 1114120, + "type": "7D943F6A-E052-4E96-A176-D17BF00E32CB", + "format": "int", + "value": -1, + "perms": [ + "pr", + "ev", + "hd" + ], + "ev": false, + "description": "Firmware Update Status", + "minValue": -65535, + "maxValue": 65535, + "minStep": 1 + }, + { + "iid": 1114121, + "type": "A45EFD52-0DB5-4C1A-9727-513FBCD8185F", + "format": "string", + "perms": [ + "pw", + "hd" + ], + "description": "Firmware Update URL", + "maxLen": 256 + }, + { + "iid": 1114122, + "type": "40F0124A-579D-40E4-865E-0EF6740EA64B", + "format": "string", + "perms": [ + "pw", + "hd" + ], + "description": "Firmware Update Checksum" + }, + { + "iid": 1114123, + "type": "E1C20B22-E3A7-4B92-8BA3-C16E778648A7", + "format": "string", + "value": "", + "perms": [ + "pr", + "ev", + "hd" + ], + "ev": false, + "description": "Identify Accessory" + }, + { + "iid": 1114124, + "type": "4CF1436A-755C-4377-BDB8-30BE29EB8620", + "format": "string", + "value": "Chinese", + "perms": [ + "pr", + "pw", + "ev", + "hd" + ], + "ev": false, + "description": "Language" + }, + { + "iid": 1114125, + "type": "25D889CB-7135-4A29-B5B4-C1FFD6D2DD5C", + "format": "string", + "value": "", + "perms": [ + "pr", + "pw", + "hd" + ], + "ev": false, + "description": "Country Domain" + }, + { + "iid": 1114126, + "type": "C7EECAA7-91D9-40EB-AD0C-FFDDE3143CB9", + "format": "string", + "value": "lumi1.00aa00000a0", + "perms": [ + "pr", + "hd" + ], + "ev": false, + "description": "Lumi Did" + }, + { + "iid": 1114127, + "type": "80FA747E-CB45-45A4-B7BE-AA7D9964859E", + "format": "string", + "perms": [ + "pw", + "hd" + ], + "description": "Lumi Bindkey" + }, + { + "iid": 1114128, + "type": "C3B8A329-EF0C-4739-B773-E5B7AEA52C71", + "format": "bool", + "value": 0, + "perms": [ + "pr", + "hd" + ], + "ev": false, + "description": "Lumi Bindstate" + } + ] + } + ] + }, + { + "aid": 33, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 65537, + "type": "00000014-0000-1000-8000-0026BB765291", + "format": "bool", + "perms": [ + "pw" + ] + }, + { + "iid": 65538, + "type": "00000020-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Aqara", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65539, + "type": "00000021-0000-1000-8000-0026BB765291", + "format": "string", + "value": "AS006", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65540, + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Contact Sensor", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65541, + "type": "00000030-0000-1000-8000-0026BB765291", + "format": "string", + "value": "158d0007c59c6a", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65542, + "type": "00000052-0000-1000-8000-0026BB765291", + "format": "string", + "value": "0", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 65543, + "type": "00000053-0000-1000-8000-0026BB765291", + "format": "string", + "value": "1.0", + "perms": [ + "pr" + ], + "ev": false + } + ] + }, + { + "iid": 4, + "type": "00000080-0000-1000-8000-0026BB765291", + "primary": true, + "hidden": false, + "characteristics": [ + { + "iid": 262146, + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Contact Sensor", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 262147, + "type": "0000006A-0000-1000-8000-0026BB765291", + "format": "uint8", + "value": 0, + "perms": [ + "pr", + "ev" + ], + "ev": true, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "valid-values": [ + 0, + 1 + ] + } + ] + }, + { + "iid": 5, + "type": "00000096-0000-1000-8000-0026BB765291", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 327682, + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Battery Sensor", + "perms": [ + "pr" + ], + "ev": false + }, + { + "iid": 327683, + "type": "00000068-0000-1000-8000-0026BB765291", + "format": "uint8", + "value": 100, + "perms": [ + "pr", + "ev" + ], + "ev": true, + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "iid": 327685, + "type": "00000079-0000-1000-8000-0026BB765291", + "format": "uint8", + "value": 0, + "perms": [ + "pr", + "ev" + ], + "ev": true, + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "valid-values": [ + 0, + 1 + ] + }, + { + "iid": 327684, + "type": "0000008F-0000-1000-8000-0026BB765291", + "format": "uint8", + "value": 2, + "perms": [ + "pr", + "ev" + ], + "ev": true, + "minValue": 2, + "maxValue": 2, + "minStep": 1, + "valid-values": [ + 2 + ] + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/connectsense.json b/tests/components/homekit_controller/fixtures/connectsense.json new file mode 100644 index 00000000000000..a2ea1c17cb0d24 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/connectsense.json @@ -0,0 +1,476 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "iid": 2, + "type": "00000014-0000-1000-8000-0026BB765291", + "perms": [ + "pw" + ], + "ev": false, + "format": "bool" + }, + { + "iid": 3, + "value": "ConnectSense", + "type": "00000020-0000-1000-8000-0026BB765291", + "perms": [ + "pr" + ], + "ev": false, + "format": "string" + }, + { + "iid": 4, + "value": "CS-IWO", + "type": "00000021-0000-1000-8000-0026BB765291", + "perms": [ + "pr" + ], + "ev": false, + "format": "string" + }, + { + "iid": 5, + "value": "InWall Outlet-0394DE", + "type": "00000023-0000-1000-8000-0026BB765291", + "perms": [ + "pr" + ], + "ev": false, + "format": "string" + }, + { + "iid": 6, + "value": "1020301376", + "type": "00000030-0000-1000-8000-0026BB765291", + "perms": [ + "pr" + ], + "ev": false, + "format": "string" + }, + { + "iid": 7, + "value": "1.0.0", + "type": "00000052-0000-1000-8000-0026BB765291", + "perms": [ + "pr" + ], + "ev": false, + "format": "string" + } + ] + }, + { + "iid": 8, + "type": "000000A2-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "iid": 9, + "value": "1.1.0", + "type": "00000037-0000-1000-8000-0026BB765291", + "perms": [ + "pr" + ], + "ev": false, + "format": "string" + } + ] + }, + { + "iid": 10, + "type": "B3BD50B1-B30B-4974-A71F-5C68AA126698", + "hidden": true, + "characteristics": [ + { + "iid": 11, + "value": 100, + "type": "00000008-0000-1000-8000-0026BB765291", + "perms": [ + "pr", + "pw", + "ev" + ], + "ev": false, + "format": "int", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + "unit": "percentage" + }, + { + "iid": 12, + "value": 7250712, + "type": "00000005-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "uint32", + "description": "Epoch", + "unit": "seconds" + } + ] + }, + { + "iid": 13, + "type": "00000047-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "iid": 14, + "value": true, + "type": "00000025-0000-1000-8000-0026BB765291", + "perms": [ + "pr", + "pw", + "ev" + ], + "ev": true, + "format": "bool" + }, + { + "iid": 15, + "value": true, + "type": "00000026-0000-1000-8000-0026BB765291", + "perms": [ + "pr", + "ev" + ], + "ev": true, + "format": "bool" + }, + { + "iid": 16, + "value": "Outlet A", + "type": "00000023-0000-1000-8000-0026BB765291", + "perms": [ + "pr" + ], + "ev": false, + "format": "string" + }, + { + "iid": 17, + "value": 126.3, + "type": "00000008-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "float", + "minValue": 0.0, + "maxValue": 130.0, + "minStep": 0.1, + "description": "Volts" + }, + { + "iid": 18, + "value": 0.03, + "type": "00000009-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "float", + "minValue": 0.0, + "maxValue": 20.0, + "minStep": 0.1, + "description": "Amps" + }, + { + "iid": 19, + "value": 0.8, + "type": "0000000A-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "float", + "description": "Watts" + }, + { + "iid": 20, + "value": 379.69299, + "type": "0000000C-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "float", + "minValue": 0.0, + "maxValue": 34028234663852885981170418348, + "minStep": 0.1, + "description": "Kilowatt-hours" + }, + { + "iid": 21, + "value": 22, + "type": "0000000B-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "int", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + "description": "Power factor" + }, + { + "iid": 22, + "value": 390, + "type": "00000005-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "uint32", + "description": "State timer", + "unit": "seconds" + }, + { + "iid": 23, + "value": "Outlet A", + "type": "0000000E-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "string", + "description": "Assigned name" + }, + { + "iid": 24, + "value": 0, + "type": "0000000F-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "uint32", + "description": "Device type" + } + ] + }, + { + "iid": 25, + "type": "00000047-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "iid": 26, + "value": true, + "type": "00000025-0000-1000-8000-0026BB765291", + "perms": [ + "pr", + "pw", + "ev" + ], + "ev": true, + "format": "bool" + }, + { + "iid": 27, + "value": true, + "type": "00000026-0000-1000-8000-0026BB765291", + "perms": [ + "pr", + "ev" + ], + "ev": true, + "format": "bool" + }, + { + "iid": 28, + "value": "Outlet B", + "type": "00000023-0000-1000-8000-0026BB765291", + "perms": [ + "pr" + ], + "ev": false, + "format": "string" + }, + { + "iid": 29, + "value": 126.3, + "type": "00000008-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "float", + "minValue": 0.0, + "maxValue": 130.0, + "minStep": 0.1, + "description": "Volts" + }, + { + "iid": 30, + "value": 0.05, + "type": "00000009-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "float", + "minValue": 0.0, + "maxValue": 20.0, + "minStep": 0.1, + "description": "Amps" + }, + { + "iid": 31, + "value": 0.8, + "type": "0000000A-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "float", + "description": "Watts" + }, + { + "iid": 32, + "value": 175.85001, + "type": "0000000C-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "float", + "minValue": 0.0, + "maxValue": 34028234663852885981170418348, + "minStep": 0.1, + "description": "Kilowatt-hours" + }, + { + "iid": 33, + "value": 13, + "type": "0000000B-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "int", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + "description": "Power factor" + }, + { + "iid": 34, + "value": 390, + "type": "00000005-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "uint32", + "description": "State timer", + "unit": "seconds" + }, + { + "iid": 35, + "value": "Outlet B", + "type": "0000000E-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "string", + "description": "Assigned name" + }, + { + "iid": 36, + "value": 0, + "type": "0000000F-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "uint32", + "description": "Device type" + } + ] + }, + { + "iid": 37, + "type": "00000020-0000-1000-8000-001D4B474349", + "hidden": true, + "characteristics": [ + { + "iid": 38, + "value": 0, + "type": "00000021-0000-1000-8000-001D4B474349", + "perms": [ + "pr", + "ev" + ], + "ev": false, + "format": "uint8" + }, + { + "iid": 39, + "type": "00000022-0000-1000-8000-001D4B474349", + "perms": [ + "pw" + ], + "ev": false, + "format": "uint8" + }, + { + "iid": 40, + "type": "00000023-0000-1000-8000-001D4B474349", + "perms": [ + "pw" + ], + "ev": false, + "format": "string", + "maxLen": 256 + }, + { + "iid": 41, + "type": "00000024-0000-1000-8000-001D4B474349", + "perms": [ + "pw" + ], + "ev": false, + "format": "bool" + }, + { + "iid": 42, + "type": "00000300-0000-1000-8000-001D4B474349", + "perms": [ + "pw" + ], + "ev": false, + "format": "string", + "maxLen": 65 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/eve_energy.json b/tests/components/homekit_controller/fixtures/eve_energy.json new file mode 100644 index 00000000000000..f798476ec144be --- /dev/null +++ b/tests/components/homekit_controller/fixtures/eve_energy.json @@ -0,0 +1,299 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "format": "string", + "iid": 2, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Eve Energy 50FF" + }, + { + "format": "bool", + "iid": 3, + "perms": [ + "pw" + ], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "format": "string", + "iid": 4, + "perms": [ + "pr" + ], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Elgato" + }, + { + "format": "string", + "iid": 5, + "perms": [ + "pr" + ], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Eve Energy 20EAO8601" + }, + { + "format": "string", + "iid": 6, + "perms": [ + "pr" + ], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "AA00A0A00000" + }, + { + "format": "string", + "iid": 7, + "perms": [ + "pr" + ], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "1.2.9" + }, + { + "format": "string", + "iid": 8, + "perms": [ + "pr" + ], + "type": "00000053-0000-1000-8000-0026BB765291", + "value": "1.0.0" + } + ], + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 18, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Logging" + }, + { + "format": "data", + "iid": 19, + "perms": [ + "pr", + "pw" + ], + "type": "E863F11E-079E-48FF-8F27-9C2605A29F52", + "value": "HwABDigAuAQKAGLrnNSTogf+" + }, + { + "format": "uint32", + "iid": 20, + "maxValue": 4294967295, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw" + ], + "type": "E863F112-079E-48FF-8F27-9C2605A29F52", + "value": 0 + }, + { + "format": "data", + "iid": 21, + "perms": [ + "pw" + ], + "type": "E863F11C-079E-48FF-8F27-9C2605A29F52" + }, + { + "format": "data", + "iid": 22, + "perms": [ + "pw" + ], + "type": "E863F121-079E-48FF-8F27-9C2605A29F52" + }, + { + "format": "data", + "iid": 23, + "perms": [ + "pr" + ], + "type": "E863F116-079E-48FF-8F27-9C2605A29F52", + "value": "QFUeAAAAAAAAAAAABQsCDAINAgcCDgEkDQAQaCMBAAEAAAABAA==" + }, + { + "format": "data", + "iid": 24, + "perms": [ + "pr" + ], + "type": "E863F117-079E-48FF-8F27-9C2605A29F52", + "value": "EmojAQABAAAADwAAAAAAAAAA" + }, + { + "format": "tlv8", + "iid": 25, + "perms": [ + "pr" + ], + "type": "E863F131-079E-48FF-8F27-9C2605A29F52", + "value": "AAIoAAMCuAQEDEZWMzVHMUEwMjkyOAYCJA0HBGgjAQALAgAABQEAAgRoOQAAXwQAAAAAGQKWABQBAw8EAAAAAEUFBQAAAABGCQUAAAAOAABCBkQRBRQABQMAAAAAAAAAAAAAAABHEQVzG0Uc3xy4HXgAAAA8AAAASAYFAAAAAABKBgUAAAAAABoEAAAAAGABZNAEE7sdAJsEQFUeANIA" + }, + { + "format": "tlv8", + "iid": 26, + "perms": [ + "pw" + ], + "type": "E863F11D-079E-48FF-8F27-9C2605A29F52" + } + ], + "iid": 17, + "type": "E863F007-079E-48FF-8F27-9C2605A29F52", + "hidden": true + }, + { + "characteristics": [ + { + "format": "string", + "iid": 29, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Eve Energy" + }, + { + "format": "bool", + "iid": 30, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000025-0000-1000-8000-0026BB765291", + "value": false + }, + { + "format": "bool", + "iid": 31, + "perms": [ + "pr", + "ev" + ], + "type": "00000026-0000-1000-8000-0026BB765291", + "value": true + }, + { + "format": "float", + "iid": 32, + "maxValue": 380, + "minStep": 0.1, + "minValue": 0, + "perms": [ + "pr" + ], + "type": "E863F10A-079E-48FF-8F27-9C2605A29F52", + "value": 0.4000000059604645 + }, + { + "format": "float", + "iid": 33, + "maxValue": 16, + "minStep": 0.1, + "minValue": 0, + "perms": [ + "pr" + ], + "type": "E863F126-079E-48FF-8F27-9C2605A29F52", + "value": 0 + }, + { + "format": "float", + "iid": 34, + "maxValue": 5000, + "minStep": 0.1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "E863F10D-079E-48FF-8F27-9C2605A29F52", + "value": 0 + }, + { + "format": "float", + "iid": 35, + "minStep": 0.1, + "minValue": 0, + "perms": [ + "pr" + ], + "type": "E863F10C-079E-48FF-8F27-9C2605A29F52", + "value": 0.28999999165534973 + }, + { + "format": "uint8", + "iid": 36, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "000000A7-0000-1000-8000-0026BB765291", + "value": 0 + } + ], + "iid": 28, + "type": "00000047-0000-1000-8000-0026BB765291", + "primary": true + }, + { + "characteristics": [ + { + "format": "string", + "iid": 100001, + "perms": [ + "pr" + ], + "type": "E863F155-079E-48FF-8F27-9C2605A29F52", + "value": "03:29:72:EF:97:2F" + }, + { + "format": "uint16", + "iid": 100002, + "perms": [ + "pr" + ], + "type": "E863F156-079E-48FF-8F27-9C2605A29F52", + "value": 7 + }, + { + "format": "uint8", + "iid": 100003, + "perms": [ + "pr", + "ev" + ], + "type": "E863F157-079E-48FF-8F27-9C2605A29F52", + "value": 0 + } + ], + "hidden": true, + "iid": 100000, + "type": "E863F00B-079E-48FF-8F27-9C2605A29F52" + } + ] + } +] \ No newline at end of file diff --git a/tests/components/homekit_controller/fixtures/hue_bridge.json b/tests/components/homekit_controller/fixtures/hue_bridge.json index 7ed3882be09fcd..ed893cdad604f7 100644 --- a/tests/components/homekit_controller/fixtures/hue_bridge.json +++ b/tests/components/homekit_controller/fixtures/hue_bridge.json @@ -422,7 +422,7 @@ "pr" ], "type": "00000030-0000-1000-8000-0026BB765291", - "value": "1" + "value": "123456" }, { "format": "bool", diff --git a/tests/components/homekit_controller/fixtures/vocolinc_vp3.json b/tests/components/homekit_controller/fixtures/vocolinc_vp3.json new file mode 100644 index 00000000000000..bc58df1623e782 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/vocolinc_vp3.json @@ -0,0 +1,430 @@ +[ + { + "aid":1, + "services":[ + { + "iid":1, + "type":"0000003E-0000-1000-8000-0026BB765291", + "primary":false, + "hidden":false, + "linked":[ + + ], + "characteristics":[ + { + "iid":2, + "type":"00000014-0000-1000-8000-0026BB765291", + "format":"bool", + "perms":[ + "pw" + ] + }, + { + "iid":3, + "type":"00000020-0000-1000-8000-0026BB765291", + "format":"string", + "value":"VOCOlinc", + "perms":[ + "pr" + ], + "ev":false + }, + { + "iid":4, + "type":"00000021-0000-1000-8000-0026BB765291", + "format":"string", + "value":"VP3", + "perms":[ + "pr" + ], + "ev":false + }, + { + "iid":5, + "type":"00000023-0000-1000-8000-0026BB765291", + "format":"string", + "value":"VOCOlinc-VP3-123456", + "perms":[ + "pr" + ], + "ev":false + }, + { + "iid":6, + "type":"00000030-0000-1000-8000-0026BB765291", + "format":"string", + "value":"EU0121203xxxxx07", + "perms":[ + "pr" + ], + "ev":false + }, + { + "iid":7, + "type":"00000052-0000-1000-8000-0026BB765291", + "format":"string", + "value":"1.101.2", + "perms":[ + "pr" + ], + "ev":false + }, + { + "iid":8, + "type":"00000053-0000-1000-8000-0026BB765291", + "format":"string", + "value":"1.0.3", + "perms":[ + "pr" + ], + "ev":false + }, + { + "iid":9, + "type":"34AB8811-AC7F-4340-BAC3-FD6A85F9943B", + "format":"string", + "value":"3.0;17A126", + "perms":[ + "pr" + ], + "ev":false + }, + { + "iid":10, + "type":"220", + "format":"data", + "value":"wLrKXjM2g90=", + "perms":[ + "pr", + "hd" + ], + "ev":false, + "maxDataLen":8 + } + ] + }, + { + "iid":16, + "type":"000000A2-0000-1000-8000-0026BB765291", + "primary":false, + "hidden":false, + "linked":[ + + ], + "characteristics":[ + { + "iid":18, + "type":"00000037-0000-1000-8000-0026BB765291", + "format":"string", + "value":"1.1.0", + "perms":[ + "pr" + ], + "ev":false + } + ] + }, + { + "iid":48, + "type":"00000047-0000-1000-8000-0026BB765291", + "primary":true, + "hidden":false, + "linked":[ + + ], + "characteristics":[ + { + "iid":50, + "type":"00000023-0000-1000-8000-0026BB765291", + "format":"string", + "value":"Outlet", + "perms":[ + "pr" + ], + "ev":false + }, + { + "iid":51, + "type":"00000025-0000-1000-8000-0026BB765291", + "format":"bool", + "value":1, + "perms":[ + "pr", + "pw", + "ev" + ], + "ev":false + }, + { + "iid":83, + "type":"A30DFE96-271A-42A5-88BA-00E3FF5488AD", + "format":"data", + "value":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "perms":[ + "pr", + "pw", + "ev" + ], + "ev":false, + "maxDataLen":256 + }, + { + "iid":53, + "type":"E2E80928-E08A-472F-8AE9-70BA72E132F2", + "format":"int", + "value":1, + "perms":[ + "pr", + "pw", + "ev" + ], + "ev":false, + "minValue":1, + "maxValue":3600, + "minStep":1 + }, + { + "iid":54, + "type":"D4669376-C36E-4C43-ACA4-ED07686EAB19", + "format":"uint8", + "value":0, + "perms":[ + "pr", + "pw", + "ev" + ], + "ev":false, + "minValue":0, + "maxValue":2, + "minStep":0 + }, + { + "iid":97, + "type":"FC093458-18F0-4B1D-8360-BB68A3FCC9C5", + "format":"int", + "value":0, + "perms":[ + "pr", + "ev" + ], + "ev":false, + "minValue":0, + "maxValue":2147483647, + "minStep":1 + }, + { + "iid":98, + "type":"865AD00B-A016-416E-8918-CF8E7EC788C4", + "format":"int", + "value":2552, + "perms":[ + "pr" + ], + "ev":false, + "minValue":0, + "maxValue":2147483647, + "minStep":1 + }, + { + "iid":99, + "type":"2D5D1654-63EE-4314-9CF1-651F266D3BBE", + "format":"data", + "value":"AQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "perms":[ + "pr", + "ev" + ], + "ev":false, + "maxDataLen":128 + }, + { + "iid":100, + "type":"6E46AD30-6FC2-426F-9A86-C2A834DD8F29", + "format":"data", + "value":"AQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "perms":[ + "pr", + "ev" + ], + "ev":false, + "maxDataLen":128 + }, + { + "iid":101, + "type":"56F805A5-4B30-47D0-9908-E609B4CF18E3", + "format":"data", + "value":"AQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "perms":[ + "pr", + "ev" + ], + "ev":false, + "maxDataLen":128 + }, + { + "iid":102, + "type":"A121FC5E-67DB-41EC-BF4F-5A431F0DA9CB", + "format":"data", + "value":"AQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "perms":[ + "pr", + "ev" + ], + "ev":false, + "maxDataLen":64 + }, + { + "iid":103, + "type":"BC75E7A0-7DD8-4CBB-9DE8-93E70A04916D", + "format":"data", + "value":"AQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "perms":[ + "pr", + "ev" + ], + "ev":false, + "maxDataLen":64 + }, + { + "iid":89, + "type":"43CE176B-2933-4034-98A7-AD215BEEBF2F", + "format":"data", + "value":"AAAAAAAAAAAAAA==", + "perms":[ + "pr", + "pw", + "ev" + ], + "ev":false, + "description":"\"CountDown\"", + "maxDataLen":256 + } + ] + }, + { + "iid":64, + "type":"3138B537-E830-4F52-90A7-D6FDB000BF97", + "primary":false, + "hidden":true, + "linked":[ + + ], + "characteristics":[ + { + "iid":65, + "type":"00000023-0000-1000-8000-0026BB765291", + "format":"string", + "value":"FW Update", + "perms":[ + "pr" + ], + "ev":false + }, + { + "iid":66, + "type":"4C203E30-EB25-466D-9980-C6C2E14BF6AA", + "format":"string", + "perms":[ + "pw", + "hd" + ], + "description":"\"FW update\"", + "maxLen":128 + }, + { + "iid":67, + "type":"49DDDE07-C3FA-499E-8055-58E154E04F34", + "format":"int", + "value":null, + "perms":[ + "pr", + "ev" + ], + "ev":false, + "minValue":0, + "maxValue":3, + "minStep":1 + } + ] + }, + { + "iid":80, + "type":"C635EF5C-5BBC-4F96-B7DA-6669069A4B32", + "primary":false, + "hidden":true, + "linked":[ + + ], + "characteristics":[ + { + "iid":82, + "type":"8137182C-6904-4FB9-ADCC-61CECA85CE48", + "format":"uint8", + "value":27, + "perms":[ + "pr", + "ev" + ], + "ev":false + }, + { + "iid":81, + "type":"00000023-0000-1000-8000-0026BB765291", + "format":"string", + "value":"Rssi Report", + "perms":[ + "pr" + ], + "ev":false + } + ] + }, + { + "iid":84, + "type":"961BBB65-A1E3-4F34-BD31-86552706FE40", + "primary":false, + "hidden":false, + "linked":[ + + ], + "characteristics":[ + { + "iid":85, + "type":"38396B8E-161B-4A77-AF3F-C4DAC0BE9B74", + "format":"int", + "value":999, + "perms":[ + "pr", + "pw" + ], + "ev":false, + "minValue":-1200, + "maxValue":1400, + "minStep":1 + }, + { + "iid":86, + "type":"71216CD3-209E-40CC-BEA0-71A2A9458E13", + "format":"int", + "perms":[ + "pw" + ], + "minValue":0, + "maxValue":2147483647, + "minStep":1 + }, + { + "iid":87, + "type":"00000023-0000-1000-8000-0026BB765291", + "format":"string", + "value":"sync time", + "perms":[ + "pr" + ], + "ev":false + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py b/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py index 15ac6d2d3ab485..82348054df9c61 100644 --- a/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py +++ b/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py @@ -1,8 +1,10 @@ """Test against characteristics captured from a eufycam.""" -from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -11,44 +13,46 @@ async def test_eufycam_setup(hass): """Test that a eufycam can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "anker_eufycam.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - - # Check that the camera is correctly found and set up - camera_id = "camera.eufycam2_0000" - camera = entity_registry.async_get(camera_id) - assert camera.unique_id == "homekit-A0000A000000000D-aid:4" - - camera_helper = Helper( + await assert_devices_and_entities_created( hass, - "camera.eufycam2_0000", - pairing, - accessories[0], - config_entry, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="eufy HomeBase2-0AAA", + model="T8010", + manufacturer="Anker", + sw_version="2.1.6", + hw_version="2.0.0", + serial_number="A0000A000000000A", + devices=[ + DeviceTestInfo( + name="eufyCam2-0000", + model="T8113", + manufacturer="Anker", + sw_version="1.6.7", + hw_version="1.0.0", + serial_number="A0000A000000000D", + unique_id="00:00:00:00:00:00:aid:4", + devices=[], + entities=[ + EntityTestInfo( + entity_id="camera.eufycam2_0000", + friendly_name="eufyCam2-0000", + unique_id="homekit-A0000A000000000D-aid:4", + state="idle", + ), + ], + ), + ], + entities=[], + ), ) - camera_state = await camera_helper.poll_and_get_state() - assert camera_state.attributes["friendly_name"] == "eufyCam2-0000" - assert camera_state.state == "idle" - assert camera_state.attributes["supported_features"] == 0 - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(camera.device_id) - assert device.manufacturer == "Anker" - assert device.name == "eufyCam2-0000" - assert device.model == "T8113" - assert device.sw_version == "1.6.7" - - # These cameras are via a bridge, so via should be set - assert device.via_device_id is not None - + # There are multiple rtsp services, we only want to create 1 + # camera entity per accessory, not 1 camera per service. cameras_count = 0 for state in hass.states.async_all(): if state.entity_id.startswith("camera."): cameras_count += 1 - - # There are multiple rtsp services, we only want to create 1 - # camera entity per accessory, not 1 camera per service. assert cameras_count == 3 diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py index b4437a7a9b5c06..b75c8993904d16 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py @@ -3,12 +3,20 @@ https://github.com/home-assistant/core/issues/20957 """ - +from homeassistant.components.alarm_control_panel import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.components.number import NumberMode +from homeassistant.helpers.entity import EntityCategory from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -17,47 +25,108 @@ async def test_aqara_gateway_setup(hass): """Test that a Aqara Gateway can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "aqara_gateway.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) + await setup_test_accessories(hass, accessories) - # Check that the light is correctly found and set up - alarm_id = "alarm_control_panel.aqara_hub_1563" - alarm = entity_registry.async_get(alarm_id) - assert alarm.unique_id == "homekit-0000000123456789-66304" - - alarm_helper = Helper( + await assert_devices_and_entities_created( hass, - "alarm_control_panel.aqara_hub_1563", - pairing, - accessories[0], - config_entry, - ) - alarm_state = await alarm_helper.poll_and_get_state() - assert alarm_state.attributes["friendly_name"] == "Aqara Hub-1563" - - # Check that the light is correctly found and set up - light = entity_registry.async_get("light.aqara_hub_1563") - assert light.unique_id == "homekit-0000000123456789-65792" - - light_helper = Helper( - hass, "light.aqara_hub_1563", pairing, accessories[0], config_entry - ) - light_state = await light_helper.poll_and_get_state() - assert light_state.attributes["friendly_name"] == "Aqara Hub-1563" - assert light_state.attributes["supported_features"] == ( - SUPPORT_BRIGHTNESS | SUPPORT_COLOR + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Aqara Hub-1563", + model="ZHWA11LM", + manufacturer="Aqara", + sw_version="1.4.7", + hw_version="", + serial_number="0000000123456789", + devices=[], + entities=[ + EntityTestInfo( + "alarm_control_panel.aqara_hub_1563", + friendly_name="Aqara Hub-1563", + unique_id="homekit-0000000123456789-66304", + supported_features=SUPPORT_ALARM_ARM_NIGHT + | SUPPORT_ALARM_ARM_HOME + | SUPPORT_ALARM_ARM_AWAY, + state="disarmed", + ), + EntityTestInfo( + "light.aqara_hub_1563", + friendly_name="Aqara Hub-1563", + unique_id="homekit-0000000123456789-65792", + supported_features=SUPPORT_BRIGHTNESS | SUPPORT_COLOR, + capabilities={"supported_color_modes": ["hs"]}, + state="off", + ), + EntityTestInfo( + "number.aqara_hub_1563_volume", + friendly_name="Aqara Hub-1563 Volume", + unique_id="homekit-0000000123456789-aid:1-sid:65536-cid:65541", + capabilities={ + "max": 100, + "min": 0, + "mode": NumberMode.AUTO, + "step": 1, + }, + entity_category=EntityCategory.CONFIG, + state="40", + ), + EntityTestInfo( + "switch.aqara_hub_1563_pairing_mode", + friendly_name="Aqara Hub-1563 Pairing Mode", + unique_id="homekit-0000000123456789-aid:1-sid:65536-cid:65538", + entity_category=EntityCategory.CONFIG, + state="off", + ), + ], + ), ) - device_registry = dr.async_get(hass) - # All the entities are services of the same accessory - # So it looks at the protocol like a single physical device - assert alarm.device_id == light.device_id +async def test_aqara_gateway_e1_setup(hass): + """Test that an Aqara E1 Gateway can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "aqara_e1.json") + await setup_test_accessories(hass, accessories) - device = device_registry.async_get(light.device_id) - assert device.manufacturer == "Aqara" - assert device.name == "Aqara Hub-1563" - assert device.model == "ZHWA11LM" - assert device.sw_version == "1.4.7" - assert device.via_device_id is None + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Aqara-Hub-E1-00A0", + model="HE1-G01", + manufacturer="Aqara", + sw_version="3.3.0", + hw_version="1.0", + serial_number="00aa00000a0", + devices=[], + entities=[ + EntityTestInfo( + "alarm_control_panel.aqara_hub_e1_00a0", + friendly_name="Aqara-Hub-E1-00A0", + unique_id="homekit-00aa00000a0-16", + supported_features=SUPPORT_ALARM_ARM_NIGHT + | SUPPORT_ALARM_ARM_HOME + | SUPPORT_ALARM_ARM_AWAY, + state="disarmed", + ), + EntityTestInfo( + "number.aqara_hub_e1_00a0_volume", + friendly_name="Aqara-Hub-E1-00A0 Volume", + unique_id="homekit-00aa00000a0-aid:1-sid:17-cid:1114116", + capabilities={ + "max": 100, + "min": 0, + "mode": NumberMode.AUTO, + "step": 1, + }, + entity_category=EntityCategory.CONFIG, + state="40", + ), + EntityTestInfo( + "switch.aqara_hub_e1_00a0_pairing_mode", + friendly_name="Aqara-Hub-E1-00A0 Pairing Mode", + unique_id="homekit-00aa00000a0-aid:1-sid:17-cid:1114117", + entity_category=EntityCategory.CONFIG, + state="off", + ), + ], + ), + ) diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_switch.py b/tests/components/homekit_controller/specific_devices/test_aqara_switch.py index 6ed0d861193ee8..e6dce42a1f7d5a 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_switch.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_switch.py @@ -7,10 +7,14 @@ https://github.com/home-assistant/core/pull/39090 """ -from homeassistant.helpers import entity_registry as er +from homeassistant.const import PERCENTAGE -from tests.common import assert_lists_same, async_get_device_automations from tests.components.homekit_controller.common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + DeviceTriggerInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -19,36 +23,32 @@ async def test_aqara_switch_setup(hass): """Test that a Aqara Switch can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "aqara_switch.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - - battery_id = "sensor.programmable_switch_battery" - battery = entity_registry.async_get(battery_id) - assert battery.unique_id == "homekit-111a1111a1a111-5" - - # The fixture file has 1 button and a battery - - expected = [ - { - "device_id": battery.device_id, - "domain": "sensor", - "entity_id": "sensor.programmable_switch_battery", - "platform": "device", - "type": "battery_level", - } - ] - - for subtype in ("single_press", "double_press", "long_press"): - expected.append( - { - "device_id": battery.device_id, - "domain": "homekit_controller", - "platform": "device", - "type": "button1", - "subtype": subtype, - } - ) - - triggers = await async_get_device_automations(hass, "trigger", battery.device_id) - assert_lists_same(triggers, expected) + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Programmable Switch", + model="AR004", + manufacturer="Aqara", + sw_version="9", + hw_version="1.0", + serial_number="111a1111a1a111", + devices=[], + entities=[ + EntityTestInfo( + entity_id="sensor.programmable_switch_battery", + friendly_name="Programmable Switch Battery", + unique_id="homekit-111a1111a1a111-5", + unit_of_measurement=PERCENTAGE, + state="100", + ), + ], + stateless_triggers=[ + DeviceTriggerInfo(type="button1", subtype="single_press"), + DeviceTriggerInfo(type="button1", subtype="double_press"), + DeviceTriggerInfo(type="button1", subtype="long_press"), + ], + ), + ) diff --git a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py index 86fb9f65f11e7e..9f05baf2a60ed1 100644 --- a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py +++ b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py @@ -1,9 +1,14 @@ """Make sure that an Arlo Baby can be setup.""" -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR +from homeassistant.components.sensor import SensorStateClass +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,73 +17,68 @@ async def test_arlo_baby_setup(hass): """Test that an Arlo Baby can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "arlo_baby.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - - sensors = [ - ( - "camera.arlobabya0", - "homekit-00A0000000000-aid:1", - "ArloBabyA0", - ), - ( - "binary_sensor.arlobabya0", - "homekit-00A0000000000-500", - "ArloBabyA0", - ), - ( - "sensor.arlobabya0_battery", - "homekit-00A0000000000-700", - "ArloBabyA0 Battery", - ), - ( - "sensor.arlobabya0_humidity", - "homekit-00A0000000000-900", - "ArloBabyA0 Humidity", - ), - ( - "sensor.arlobabya0_temperature", - "homekit-00A0000000000-1000", - "ArloBabyA0 Temperature", - ), - ( - "sensor.arlobabya0_air_quality", - "homekit-00A0000000000-aid:1-sid:800-cid:802", - "ArloBabyA0 - Air Quality", - ), - ( - "light.arlobabya0", - "homekit-00A0000000000-1100", - "ArloBabyA0", + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="ArloBabyA0", + model="ABC1000", + manufacturer="Netgear, Inc", + sw_version="1.10.931", + hw_version="", + serial_number="00A0000000000", + devices=[], + entities=[ + EntityTestInfo( + entity_id="camera.arlobabya0", + unique_id="homekit-00A0000000000-aid:1", + friendly_name="ArloBabyA0", + state="idle", + ), + EntityTestInfo( + entity_id="binary_sensor.arlobabya0", + unique_id="homekit-00A0000000000-500", + friendly_name="ArloBabyA0", + state="off", + ), + EntityTestInfo( + entity_id="sensor.arlobabya0_battery", + unique_id="homekit-00A0000000000-700", + friendly_name="ArloBabyA0 Battery", + unit_of_measurement=PERCENTAGE, + state="82", + ), + EntityTestInfo( + entity_id="sensor.arlobabya0_humidity", + unique_id="homekit-00A0000000000-900", + friendly_name="ArloBabyA0 Humidity", + unit_of_measurement=PERCENTAGE, + state="60.099998", + ), + EntityTestInfo( + entity_id="sensor.arlobabya0_temperature", + unique_id="homekit-00A0000000000-1000", + friendly_name="ArloBabyA0 Temperature", + unit_of_measurement=TEMP_CELSIUS, + state="24.0", + ), + EntityTestInfo( + entity_id="sensor.arlobabya0_air_quality", + unique_id="homekit-00A0000000000-aid:1-sid:800-cid:802", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + friendly_name="ArloBabyA0 Air Quality", + state="1", + ), + EntityTestInfo( + entity_id="light.arlobabya0", + unique_id="homekit-00A0000000000-1100", + friendly_name="ArloBabyA0", + supported_features=SUPPORT_BRIGHTNESS | SUPPORT_COLOR, + capabilities={"supported_color_modes": ["hs"]}, + state="off", + ), + ], ), - ] - - device_ids = set() - - for (entity_id, unique_id, friendly_name) in sensors: - entry = entity_registry.async_get(entity_id) - assert entry.unique_id == unique_id - - helper = Helper( - hass, - entity_id, - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == friendly_name - - device = device_registry.async_get(entry.device_id) - assert device.manufacturer == "Netgear, Inc" - assert device.name == "ArloBabyA0" - assert device.model == "ABC1000" - assert device.sw_version == "1.10.931" - assert device.via_device_id is None - - device_ids.add(entry.device_id) - - # All entities should be part of same device - assert len(device_ids) == 1 + ) diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py new file mode 100644 index 00000000000000..2cbdf924319622 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -0,0 +1,99 @@ +"""Make sure that ConnectSense Smart Outlet2 / In-Wall Outlet is enumerated properly.""" + +from homeassistant.components.sensor import SensorStateClass +from homeassistant.const import ( + ELECTRIC_CURRENT_AMPERE, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) + +from tests.components.homekit_controller.common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_connectsense_setup(hass): + """Test that the accessory can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "connectsense.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="InWall Outlet-0394DE", + model="CS-IWO", + manufacturer="ConnectSense", + sw_version="1.0.0", + hw_version="", + serial_number="1020301376", + devices=[], + entities=[ + EntityTestInfo( + entity_id="sensor.inwall_outlet_0394de_current", + friendly_name="InWall Outlet-0394DE Current", + unique_id="homekit-1020301376-aid:1-sid:13-cid:18", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state="0.03", + ), + EntityTestInfo( + entity_id="sensor.inwall_outlet_0394de_power", + friendly_name="InWall Outlet-0394DE Power", + unique_id="homekit-1020301376-aid:1-sid:13-cid:19", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=POWER_WATT, + state="0.8", + ), + EntityTestInfo( + entity_id="sensor.inwall_outlet_0394de_energy_kwh", + friendly_name="InWall Outlet-0394DE Energy kWh", + unique_id="homekit-1020301376-aid:1-sid:13-cid:20", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state="379.69299", + ), + EntityTestInfo( + entity_id="switch.inwall_outlet_0394de", + friendly_name="InWall Outlet-0394DE", + unique_id="homekit-1020301376-13", + state="on", + ), + EntityTestInfo( + entity_id="sensor.inwall_outlet_0394de_current_2", + friendly_name="InWall Outlet-0394DE Current", + unique_id="homekit-1020301376-aid:1-sid:25-cid:30", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state="0.05", + ), + EntityTestInfo( + entity_id="sensor.inwall_outlet_0394de_power_2", + friendly_name="InWall Outlet-0394DE Power", + unique_id="homekit-1020301376-aid:1-sid:25-cid:31", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=POWER_WATT, + state="0.8", + ), + EntityTestInfo( + entity_id="sensor.inwall_outlet_0394de_energy_kwh_2", + friendly_name="InWall Outlet-0394DE Energy kWh", + unique_id="homekit-1020301376-aid:1-sid:25-cid:32", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state="175.85001", + ), + EntityTestInfo( + entity_id="switch.inwall_outlet_0394de_2", + friendly_name="InWall Outlet-0394DE", + unique_id="homekit-1020301376-25", + state="on", + ), + ], + ), + ) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 94f3aabc12a05a..2d540f31850c3d 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -14,11 +14,16 @@ SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) +from homeassistant.components.sensor import SensorStateClass from homeassistant.config_entries import ConfigEntryState -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers import entity_registry as er from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, device_config_changed, setup_accessories_from_file, setup_test_accessories, @@ -29,71 +34,104 @@ async def test_ecobee3_setup(hass): """Test that a Ecbobee 3 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "ecobee3.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - - climate = entity_registry.async_get("climate.homew") - assert climate.unique_id == "homekit-123456789012-16" - - climate_helper = Helper( - hass, "climate.homew", pairing, accessories[0], config_entry - ) - climate_state = await climate_helper.poll_and_get_state() - assert climate_state.attributes["friendly_name"] == "HomeW" - assert climate_state.attributes["supported_features"] == ( - SUPPORT_TARGET_TEMPERATURE - | SUPPORT_TARGET_TEMPERATURE_RANGE - | SUPPORT_TARGET_HUMIDITY - ) - - assert climate_state.attributes["hvac_modes"] == [ - "off", - "heat", - "cool", - "heat_cool", - ] - - assert climate_state.attributes["min_temp"] == 7.2 - assert climate_state.attributes["max_temp"] == 33.3 - assert climate_state.attributes["min_humidity"] == 20 - assert climate_state.attributes["max_humidity"] == 50 - - climate_sensor = entity_registry.async_get("sensor.homew_current_temperature") - assert climate_sensor.unique_id == "homekit-123456789012-aid:1-sid:16-cid:19" - - occ1 = entity_registry.async_get("binary_sensor.kitchen") - assert occ1.unique_id == "homekit-AB1C-56" + await setup_test_accessories(hass, accessories) - occ1_helper = Helper( - hass, "binary_sensor.kitchen", pairing, accessories[0], config_entry + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="HomeW", + model="ecobee3", + manufacturer="ecobee Inc.", + sw_version="4.2.394", + hw_version="", + serial_number="123456789012", + devices=[ + DeviceTestInfo( + name="Kitchen", + model="REMOTE SENSOR", + manufacturer="ecobee Inc.", + sw_version="1.0.0", + hw_version="", + serial_number="AB1C", + unique_id="00:00:00:00:00:00:aid:2", + devices=[], + entities=[ + EntityTestInfo( + entity_id="binary_sensor.kitchen", + friendly_name="Kitchen", + unique_id="homekit-AB1C-56", + state="off", + ), + ], + ), + DeviceTestInfo( + name="Porch", + model="REMOTE SENSOR", + manufacturer="ecobee Inc.", + sw_version="1.0.0", + hw_version="", + serial_number="AB2C", + unique_id="00:00:00:00:00:00:aid:3", + devices=[], + entities=[ + EntityTestInfo( + entity_id="binary_sensor.porch", + friendly_name="Porch", + unique_id="homekit-AB2C-56", + state="off", + ), + ], + ), + DeviceTestInfo( + name="Basement", + model="REMOTE SENSOR", + manufacturer="ecobee Inc.", + sw_version="1.0.0", + hw_version="", + serial_number="AB3C", + unique_id="00:00:00:00:00:00:aid:4", + devices=[], + entities=[ + EntityTestInfo( + entity_id="binary_sensor.basement", + friendly_name="Basement", + unique_id="homekit-AB3C-56", + state="off", + ), + ], + ), + ], + entities=[ + EntityTestInfo( + entity_id="climate.homew", + friendly_name="HomeW", + unique_id="homekit-123456789012-16", + supported_features=( + SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_TARGET_HUMIDITY + ), + capabilities={ + "hvac_modes": ["off", "heat", "cool", "heat_cool"], + "min_temp": 7.2, + "max_temp": 33.3, + "min_humidity": 20, + "max_humidity": 50, + }, + state="heat", + ), + EntityTestInfo( + entity_id="sensor.homew_current_temperature", + friendly_name="HomeW Current Temperature", + unique_id="homekit-123456789012-aid:1-sid:16-cid:19", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=TEMP_CELSIUS, + state="21.8", + ), + ], + ), ) - occ1_state = await occ1_helper.poll_and_get_state() - assert occ1_state.attributes["friendly_name"] == "Kitchen" - - occ2 = entity_registry.async_get("binary_sensor.porch") - assert occ2.unique_id == "homekit-AB2C-56" - - occ3 = entity_registry.async_get("binary_sensor.basement") - assert occ3.unique_id == "homekit-AB3C-56" - - device_registry = dr.async_get(hass) - - climate_device = device_registry.async_get(climate.device_id) - assert climate_device.manufacturer == "ecobee Inc." - assert climate_device.name == "HomeW" - assert climate_device.model == "ecobee3" - assert climate_device.sw_version == "4.2.394" - assert climate_device.via_device_id is None - - # Check that an attached sensor has its own device entity that - # is linked to the bridge - sensor_device = device_registry.async_get(occ1.device_id) - assert sensor_device.manufacturer == "ecobee Inc." - assert sensor_device.name == "Kitchen" - assert sensor_device.model == "REMOTE SENSOR" - assert sensor_device.sw_version == "1.0.0" - assert sensor_device.via_device_id == climate_device.id async def test_ecobee3_setup_from_cache(hass, hass_storage): @@ -104,7 +142,7 @@ async def test_ecobee3_setup_from_cache(hass, hass_storage): "version": 1, "data": { "pairings": { - "00:00:00:00:00:00": { + HUB_TEST_ACCESSORY_ID: { "config_num": 1, "accessories": [ a.to_accessory_and_service_list() for a in accessories diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py index 63f5d22e04b2f5..6d98467a4cb15c 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py @@ -4,10 +4,11 @@ https://github.com/home-assistant/core/issues/31827 """ -from homeassistant.helpers import device_registry as dr, entity_registry as er - from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -16,24 +17,26 @@ 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 = er.async_get(hass) - - 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 + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Master Fan", + model="ecobee Switch+", + manufacturer="ecobee Inc.", + sw_version="4.5.130201", + hw_version="", + serial_number="111111111111", + devices=[], + entities=[ + EntityTestInfo( + entity_id="binary_sensor.master_fan", + friendly_name="Master Fan", + unique_id="homekit-111111111111-56", + state="off", + ), + ], + ), ) - sensor_state = await sensor_helper.poll_and_get_state() - assert sensor_state.attributes["friendly_name"] == "Master Fan" - - device_registry = dr.async_get(hass) - - 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_eve_degree.py b/tests/components/homekit_controller/specific_devices/test_eve_degree.py index e419b140e94339..51880bc076a89c 100644 --- a/tests/components/homekit_controller/specific_devices/test_eve_degree.py +++ b/tests/components/homekit_controller/specific_devices/test_eve_degree.py @@ -1,9 +1,15 @@ """Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.components.number import NumberMode +from homeassistant.components.sensor import SensorStateClass +from homeassistant.const import PERCENTAGE, PRESSURE_HPA, TEMP_CELSIUS +from homeassistant.helpers.entity import EntityCategory from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,63 +18,62 @@ async def test_eve_degree_setup(hass): """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "eve_degree.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - - sensors = [ - ( - "sensor.eve_degree_aa11_temperature", - "homekit-AA00A0A00000-22", - "Eve Degree AA11 Temperature", - ), - ( - "sensor.eve_degree_aa11_humidity", - "homekit-AA00A0A00000-27", - "Eve Degree AA11 Humidity", - ), - ( - "sensor.eve_degree_aa11_air_pressure", - "homekit-AA00A0A00000-aid:1-sid:30-cid:32", - "Eve Degree AA11 - Air Pressure", + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Eve Degree AA11", + model="Eve Degree 00AAA0000", + manufacturer="Elgato", + sw_version="1.2.8", + hw_version="1.0.0", + serial_number="AA00A0A00000", + devices=[], + entities=[ + EntityTestInfo( + entity_id="sensor.eve_degree_aa11_temperature", + unique_id="homekit-AA00A0A00000-22", + friendly_name="Eve Degree AA11 Temperature", + unit_of_measurement=TEMP_CELSIUS, + state="22.7719116210938", + ), + EntityTestInfo( + entity_id="sensor.eve_degree_aa11_humidity", + unique_id="homekit-AA00A0A00000-27", + friendly_name="Eve Degree AA11 Humidity", + unit_of_measurement=PERCENTAGE, + state="59.4818115234375", + ), + EntityTestInfo( + entity_id="sensor.eve_degree_aa11_air_pressure", + unique_id="homekit-AA00A0A00000-aid:1-sid:30-cid:32", + friendly_name="Eve Degree AA11 Air Pressure", + unit_of_measurement=PRESSURE_HPA, + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + state="1005.70001220703", + ), + EntityTestInfo( + entity_id="sensor.eve_degree_aa11_battery", + unique_id="homekit-AA00A0A00000-17", + friendly_name="Eve Degree AA11 Battery", + unit_of_measurement=PERCENTAGE, + state="65", + ), + EntityTestInfo( + entity_id="number.eve_degree_aa11_elevation", + unique_id="homekit-AA00A0A00000-aid:1-sid:30-cid:33", + friendly_name="Eve Degree AA11 Elevation", + capabilities={ + "max": 9000, + "min": -450, + "mode": NumberMode.AUTO, + "step": 1, + }, + state="0", + entity_category=EntityCategory.CONFIG, + ), + ], ), - ( - "sensor.eve_degree_aa11_battery", - "homekit-AA00A0A00000-17", - "Eve Degree AA11 Battery", - ), - ( - "number.eve_degree_aa11", - "homekit-AA00A0A00000-aid:1-sid:30-cid:33", - "Eve Degree AA11", - ), - ] - - device_ids = set() - - for (entity_id, unique_id, friendly_name) in sensors: - entry = entity_registry.async_get(entity_id) - assert entry.unique_id == unique_id - - helper = Helper( - hass, - entity_id, - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == friendly_name - - device = device_registry.async_get(entry.device_id) - assert device.manufacturer == "Elgato" - assert device.name == "Eve Degree AA11" - assert device.model == "Eve Degree 00AAA0000" - assert device.sw_version == "1.2.8" - assert device.via_device_id is None - - device_ids.add(entry.device_id) - - # All entities should be part of same device - assert len(device_ids) == 1 + ) diff --git a/tests/components/homekit_controller/specific_devices/test_eve_energy.py b/tests/components/homekit_controller/specific_devices/test_eve_energy.py new file mode 100644 index 00000000000000..0ba9b0bee250f6 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_eve_energy.py @@ -0,0 +1,86 @@ +"""Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" + +from homeassistant.components.sensor import SensorStateClass +from homeassistant.const import ( + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) +from homeassistant.helpers.entity import EntityCategory + +from tests.components.homekit_controller.common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_eve_degree_setup(hass): + """Test that the accessory can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "eve_energy.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Eve Energy 50FF", + model="Eve Energy 20EAO8601", + manufacturer="Elgato", + sw_version="1.2.9", + hw_version="1.0.0", + serial_number="AA00A0A00000", + devices=[], + entities=[ + EntityTestInfo( + entity_id="switch.eve_energy_50ff", + unique_id="homekit-AA00A0A00000-28", + friendly_name="Eve Energy 50FF", + state="off", + ), + EntityTestInfo( + entity_id="sensor.eve_energy_50ff_amps", + unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:33", + friendly_name="Eve Energy 50FF Amps", + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + state="0", + ), + EntityTestInfo( + entity_id="sensor.eve_energy_50ff_volts", + unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:32", + friendly_name="Eve Energy 50FF Volts", + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + state="0.400000005960464", + ), + EntityTestInfo( + entity_id="sensor.eve_energy_50ff_power", + unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:34", + friendly_name="Eve Energy 50FF Power", + unit_of_measurement=POWER_WATT, + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + state="0", + ), + EntityTestInfo( + entity_id="sensor.eve_energy_50ff_energy_kwh", + unique_id="homekit-AA00A0A00000-aid:1-sid:28-cid:35", + friendly_name="Eve Energy 50FF Energy kWh", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state="0.28999999165535", + ), + EntityTestInfo( + entity_id="button.eve_energy_50ff_identify", + unique_id="homekit-AA00A0A00000-aid:1-sid:1-cid:3", + friendly_name="Eve Energy 50FF Identify", + entity_category=EntityCategory.DIAGNOSTIC, + state="unknown", + ), + ], + ), + ) diff --git a/tests/components/homekit_controller/specific_devices/test_haa_fan.py b/tests/components/homekit_controller/specific_devices/test_haa_fan.py index 0339c61168fe02..ae67bda20003bd 100644 --- a/tests/components/homekit_controller/specific_devices/test_haa_fan.py +++ b/tests/components/homekit_controller/specific_devices/test_haa_fan.py @@ -1,9 +1,13 @@ """Make sure that a H.A.A. fan can be setup.""" -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.components.fan import SUPPORT_SET_SPEED +from homeassistant.helpers.entity import EntityCategory from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,65 +16,66 @@ async def test_haa_fan_setup(hass): """Test that a H.A.A. fan can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "haa_fan.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) + # FIXME: assert round(state.attributes["percentage_step"], 2) == 33.33 - # Check that the switch entity is handled correctly - - entry = entity_registry.async_get("switch.haa_c718b3") - assert entry.unique_id == "homekit-C718B3-2-8" - - helper = Helper(hass, "switch.haa_c718b3", pairing, accessories[0], config_entry) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "HAA-C718B3" - - device = device_registry.async_get(entry.device_id) - assert device.manufacturer == "José A. Jiménez Campos" - assert device.name == "HAA-C718B3" - assert device.sw_version == "5.0.18" - assert device.via_device_id is not None - - # Assert the fan is detected - entry = entity_registry.async_get("fan.haa_c718b3") - assert entry.unique_id == "homekit-C718B3-1-8" - - helper = Helper( - hass, - "fan.haa_c718b3", - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "HAA-C718B3" - assert round(state.attributes["percentage_step"], 2) == 33.33 - - # Check that custom HAA Setup button is created - entry = entity_registry.async_get("button.haa_c718b3_setup") - assert entry.unique_id == "homekit-C718B3-1-aid:1-sid:1010-cid:1012" - - helper = Helper( - hass, - "button.haa_c718b3_setup", - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "HAA-C718B3 - Setup" - - # Check that custom HAA Update button is created - entry = entity_registry.async_get("button.haa_c718b3_update") - assert entry.unique_id == "homekit-C718B3-1-aid:1-sid:1010-cid:1011" - - helper = Helper( + await assert_devices_and_entities_created( hass, - "button.haa_c718b3_update", - pairing, - accessories[0], - config_entry, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="HAA-C718B3", + model="RavenSystem HAA", + manufacturer="José A. Jiménez Campos", + sw_version="5.0.18", + hw_version="", + serial_number="C718B3-1", + devices=[ + DeviceTestInfo( + name="HAA-C718B3", + model="RavenSystem HAA", + manufacturer="José A. Jiménez Campos", + sw_version="5.0.18", + hw_version="", + serial_number="C718B3-2", + unique_id="00:00:00:00:00:00:aid:2", + devices=[], + entities=[ + EntityTestInfo( + entity_id="switch.haa_c718b3", + friendly_name="HAA-C718B3", + unique_id="homekit-C718B3-2-8", + state="off", + ) + ], + ), + ], + entities=[ + EntityTestInfo( + entity_id="fan.haa_c718b3", + friendly_name="HAA-C718B3", + unique_id="homekit-C718B3-1-8", + state="off", + supported_features=SUPPORT_SET_SPEED, + capabilities={ + "preset_modes": None, + "speed_list": ["off", "low", "medium", "high"], + }, + ), + EntityTestInfo( + entity_id="button.haa_c718b3_setup", + friendly_name="HAA-C718B3 Setup", + unique_id="homekit-C718B3-1-aid:1-sid:1010-cid:1012", + entity_category=EntityCategory.CONFIG, + state="unknown", + ), + EntityTestInfo( + entity_id="button.haa_c718b3_update", + friendly_name="HAA-C718B3 Update", + unique_id="homekit-C718B3-1-aid:1-sid:1010-cid:1011", + entity_category=EntityCategory.CONFIG, + state="unknown", + ), + ], + ), ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "HAA-C718B3 - Update" diff --git a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py index 1cbfa23b64cfa0..33e53e9413cf01 100644 --- a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py @@ -5,10 +5,12 @@ SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, ) -from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -19,40 +21,47 @@ async def test_homeassistant_bridge_fan_setup(hass): accessories = await setup_accessories_from_file( hass, "home_assistant_bridge_fan.json" ) - config_entry, pairing = await setup_test_accessories(hass, accessories) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - - # Check that the fan is correctly found and set up - fan_id = "fan.living_room_fan" - fan = entity_registry.async_get(fan_id) - assert fan.unique_id == "homekit-fan.living_room_fan-8" - - fan_helper = Helper( + await assert_devices_and_entities_created( hass, - "fan.living_room_fan", - pairing, - accessories[0], - config_entry, - ) - - fan_state = await fan_helper.poll_and_get_state() - assert fan_state.attributes["friendly_name"] == "Living Room Fan" - assert fan_state.state == "off" - assert fan_state.attributes["supported_features"] == ( - SUPPORT_DIRECTION | SUPPORT_SET_SPEED | SUPPORT_OSCILLATE + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Home Assistant Bridge", + model="Bridge", + manufacturer="Home Assistant", + sw_version="0.104.0.dev0", + hw_version="", + serial_number="homekit.bridge", + devices=[ + DeviceTestInfo( + name="Living Room Fan", + model="Fan", + manufacturer="Home Assistant", + sw_version="0.104.0.dev0", + hw_version="", + serial_number="fan.living_room_fan", + unique_id="00:00:00:00:00:00:aid:1256851357", + devices=[], + entities=[ + EntityTestInfo( + entity_id="fan.living_room_fan", + friendly_name="Living Room Fan", + unique_id="homekit-fan.living_room_fan-8", + supported_features=( + SUPPORT_DIRECTION + | SUPPORT_SET_SPEED + | SUPPORT_OSCILLATE + ), + capabilities={ + "preset_modes": None, + "speed_list": ["off", "low", "medium", "high"], + }, + state="off", + ) + ], + ), + ], + entities=[], + ), ) - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(fan.device_id) - assert device.manufacturer == "Home Assistant" - assert device.name == "Living Room Fan" - assert device.model == "Fan" - assert device.sw_version == "0.104.0.dev0" - - bridge = device = device_registry.async_get(device.via_device_id) - assert bridge.manufacturer == "Home Assistant" - assert bridge.name == "Home Assistant Bridge" - assert bridge.model == "Bridge" - assert bridge.sw_version == "0.104.0.dev0" diff --git a/tests/components/homekit_controller/specific_devices/test_hue_bridge.py b/tests/components/homekit_controller/specific_devices/test_hue_bridge.py index 0452407bfb8ed2..76d09629064de2 100644 --- a/tests/components/homekit_controller/specific_devices/test_hue_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_hue_bridge.py @@ -1,10 +1,13 @@ """Tests for handling accessories on a Hue bridge via HomeKit.""" -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.const import PERCENTAGE -from tests.common import assert_lists_same, async_get_device_automations from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + DeviceTriggerInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -13,55 +16,45 @@ async def test_hue_bridge_setup(hass): """Test that a Hue hub can be correctly setup in HA via HomeKit.""" accessories = await setup_accessories_from_file(hass, "hue_bridge.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - - # Check that the battery is correctly found and set up - battery_id = "sensor.hue_dimmer_switch_battery" - battery = entity_registry.async_get(battery_id) - assert battery.unique_id == "homekit-6623462389072572-644245094400" - - battery_helper = Helper( - hass, "sensor.hue_dimmer_switch_battery", pairing, accessories[0], config_entry + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Philips hue - 482544", + model="BSB002", + manufacturer="Philips Lighting", + sw_version="1.32.1932126170", + hw_version="", + serial_number="123456", + devices=[ + DeviceTestInfo( + name="Hue dimmer switch", + model="RWL021", + manufacturer="Philips", + sw_version="45.1.17846", + hw_version="", + serial_number="6623462389072572", + unique_id="00:00:00:00:00:00:aid:6623462389072572", + devices=[], + entities=[ + EntityTestInfo( + entity_id="sensor.hue_dimmer_switch_battery", + friendly_name="Hue dimmer switch Battery", + unique_id="homekit-6623462389072572-644245094400", + unit_of_measurement=PERCENTAGE, + state="100", + ) + ], + stateless_triggers=[ + DeviceTriggerInfo(type="button1", subtype="single_press"), + DeviceTriggerInfo(type="button2", subtype="single_press"), + DeviceTriggerInfo(type="button3", subtype="single_press"), + DeviceTriggerInfo(type="button4", subtype="single_press"), + ], + ), + ], + entities=[], + ), ) - battery_state = await battery_helper.poll_and_get_state() - assert battery_state.attributes["friendly_name"] == "Hue dimmer switch Battery" - assert battery_state.attributes["icon"] == "mdi:battery" - assert battery_state.state == "100" - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(battery.device_id) - assert device.manufacturer == "Philips" - assert device.name == "Hue dimmer switch" - assert device.model == "RWL021" - assert device.sw_version == "45.1.17846" - - # The fixture file has 1 dimmer, which is a remote with 4 buttons - # It (incorrectly) claims to support single, double and long press events - # It also has a battery - - expected = [ - { - "device_id": device.id, - "domain": "sensor", - "entity_id": "sensor.hue_dimmer_switch_battery", - "platform": "device", - "type": "battery_level", - } - ] - - for button in ("button1", "button2", "button3", "button4"): - expected.append( - { - "device_id": device.id, - "domain": "homekit_controller", - "platform": "device", - "type": button, - "subtype": "single_press", - } - ) - - triggers = await async_get_device_automations(hass, "trigger", device.id) - assert_lists_same(triggers, expected) 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 505ff2aacc7f8a..9591eb27b6ffa7 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -8,12 +8,16 @@ import pytest from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed from tests.components.homekit_controller.common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, Helper, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -24,35 +28,38 @@ async def test_koogeek_ls1_setup(hass): """Test that a Koogeek LS1 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - - # Assert that the entity is correctly added to the entity registry - entry = entity_registry.async_get("light.koogeek_ls1_20833f") - assert entry.unique_id == "homekit-AAAA011111111111-7" - - helper = Helper( - hass, "light.koogeek_ls1_20833f", pairing, accessories[0], config_entry + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Koogeek-LS1-20833F", + model="LS1", + manufacturer="Koogeek", + sw_version="2.2.15", + hw_version="", + serial_number="AAAA011111111111", + devices=[], + entities=[ + EntityTestInfo( + entity_id="light.koogeek_ls1_20833f", + friendly_name="Koogeek-LS1-20833F", + unique_id="homekit-AAAA011111111111-7", + supported_features=SUPPORT_BRIGHTNESS | SUPPORT_COLOR, + capabilities={"supported_color_modes": ["hs"]}, + state="off", + ), + EntityTestInfo( + entity_id="button.koogeek_ls1_20833f_identify", + friendly_name="Koogeek-LS1-20833F Identify", + unique_id="homekit-AAAA011111111111-aid:1-sid:1-cid:6", + entity_category=EntityCategory.DIAGNOSTIC, + state="unknown", + ), + ], + ), ) - state = await helper.poll_and_get_state() - - # Assert that the friendly name is detected correctly - assert state.attributes["friendly_name"] == "Koogeek-LS1-20833F" - - # Assert that all optional features the LS1 supports are detected - assert state.attributes["supported_features"] == ( - SUPPORT_BRIGHTNESS | SUPPORT_COLOR - ) - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(entry.device_id) - assert device.manufacturer == "Koogeek" - assert device.name == "Koogeek-LS1-20833F" - assert device.model == "LS1" - assert device.sw_version == "2.2.15" - assert device.via_device_id is None @pytest.mark.parametrize("failure_cls", [AccessoryDisconnectedError, EncryptionError]) diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py index 1761edb3c8ca32..f93adc732ba5f4 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py @@ -1,10 +1,13 @@ """Make sure that existing Koogeek P1EU support isn't broken.""" +from homeassistant.components.sensor import SensorStateClass from homeassistant.const import POWER_WATT -from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -13,43 +16,34 @@ async def test_koogeek_p1eu_setup(hass): """Test that a Koogeek P1EU can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "koogeek_p1eu.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - - # Check that the switch entity is handled correctly - - entry = entity_registry.async_get("switch.koogeek_p1_a00aa0") - assert entry.unique_id == "homekit-EUCP03190xxxxx48-7" - - helper = Helper( - hass, "switch.koogeek_p1_a00aa0", pairing, accessories[0], config_entry - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "Koogeek-P1-A00AA0" - - device = device_registry.async_get(entry.device_id) - assert device.manufacturer == "Koogeek" - assert device.name == "Koogeek-P1-A00AA0" - assert device.model == "P1EU" - assert device.sw_version == "2.3.7" - assert device.via_device_id is None - - # Assert the power sensor is detected - entry = entity_registry.async_get("sensor.koogeek_p1_a00aa0_real_time_energy") - assert entry.unique_id == "homekit-EUCP03190xxxxx48-aid:1-sid:21-cid:22" - - helper = Helper( + await assert_devices_and_entities_created( hass, - "sensor.koogeek_p1_a00aa0_real_time_energy", - pairing, - accessories[0], - config_entry, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Koogeek-P1-A00AA0", + model="P1EU", + manufacturer="Koogeek", + sw_version="2.3.7", + hw_version="", + serial_number="EUCP03190xxxxx48", + devices=[], + entities=[ + EntityTestInfo( + entity_id="switch.koogeek_p1_a00aa0", + friendly_name="Koogeek-P1-A00AA0", + unique_id="homekit-EUCP03190xxxxx48-7", + state="off", + ), + EntityTestInfo( + entity_id="sensor.koogeek_p1_a00aa0_power", + friendly_name="Koogeek-P1-A00AA0 Power", + unique_id="homekit-EUCP03190xxxxx48-aid:1-sid:21-cid:22", + unit_of_measurement=POWER_WATT, + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + state="5", + ), + ], + ), ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "Koogeek-P1-A00AA0 - Real Time Energy" - assert state.attributes["unit_of_measurement"] == POWER_WATT - - # The sensor and switch should be part of the same device - assert entry.device_id == device.id diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py index 768959e03312b7..ed940cb637645f 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py @@ -6,61 +6,50 @@ It should have 2 entities - the actual switch and a sensor for power usage. """ +from homeassistant.components.sensor import SensorStateClass from homeassistant.const import POWER_WATT -from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) -async def test_koogeek_ls1_setup(hass): +async def test_koogeek_sw2_setup(hass): """Test that a Koogeek LS1 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "koogeek_sw2.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - - # Assert that the switch entity is correctly added to the entity registry - entry = entity_registry.async_get("switch.koogeek_sw2_187a91") - assert entry.unique_id == "homekit-CNNT061751001372-8" - - helper = Helper( - hass, "switch.koogeek_sw2_187a91", 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"] == "Koogeek-SW2-187A91" - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(entry.device_id) - assert device.manufacturer == "Koogeek" - assert device.name == "Koogeek-SW2-187A91" - assert device.model == "KH02CN" - assert device.sw_version == "1.0.3" - assert device.via_device_id is None - - # Assert that the power sensor entity is correctly added to the entity registry - entry = entity_registry.async_get("sensor.koogeek_sw2_187a91_real_time_energy") - assert entry.unique_id == "homekit-CNNT061751001372-aid:1-sid:14-cid:18" - - helper = Helper( + await assert_devices_and_entities_created( hass, - "sensor.koogeek_sw2_187a91_real_time_energy", - pairing, - accessories[0], - config_entry, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Koogeek-SW2-187A91", + model="KH02CN", + manufacturer="Koogeek", + sw_version="1.0.3", + hw_version="", + serial_number="CNNT061751001372", + devices=[], + entities=[ + EntityTestInfo( + entity_id="switch.koogeek_sw2_187a91", + friendly_name="Koogeek-SW2-187A91", + unique_id="homekit-CNNT061751001372-8", + state="off", + ), + EntityTestInfo( + entity_id="sensor.koogeek_sw2_187a91_power", + friendly_name="Koogeek-SW2-187A91 Power", + unique_id="homekit-CNNT061751001372-aid:1-sid:14-cid:18", + unit_of_measurement=POWER_WATT, + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + state="0", + ), + ], + ), ) - state = await helper.poll_and_get_state() - - # Assert that the friendly name is detected correctly - assert state.attributes["friendly_name"] == "Koogeek-SW2-187A91 - Real Time Energy" - assert state.attributes["unit_of_measurement"] == POWER_WATT - - device_registry = dr.async_get(hass) - - assert device.id == entry.device_id diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py index cdab08039e1c75..a53916cc0a9794 100644 --- a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py +++ b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py @@ -8,10 +8,12 @@ SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) -from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -20,30 +22,34 @@ async def test_lennox_e30_setup(hass): """Test that a Lennox E30 can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "lennox_e30.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - - climate = entity_registry.async_get("climate.lennox") - assert climate.unique_id == "homekit-XXXXXXXX-100" - - climate_helper = Helper( - hass, "climate.lennox", pairing, accessories[0], config_entry - ) - climate_state = await climate_helper.poll_and_get_state() - assert climate_state.attributes["friendly_name"] == "Lennox" - assert climate_state.attributes["supported_features"] == ( - SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Lennox", + model="E30 2B", + manufacturer="Lennox", + sw_version="3.40.XX", + hw_version="3.0.XX", + serial_number="XXXXXXXX", + devices=[], + entities=[ + EntityTestInfo( + entity_id="climate.lennox", + friendly_name="Lennox", + unique_id="homekit-XXXXXXXX-100", + supported_features=( + SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE + ), + capabilities={ + "hvac_modes": ["off", "heat", "cool", "heat_cool"], + "max_temp": 37, + "min_temp": 4.5, + }, + state="heat_cool", + ), + ], + ), ) - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(climate.device_id) - assert device.manufacturer == "Lennox" - assert device.name == "Lennox" - assert device.model == "E30 2B" - assert device.sw_version == "3.40.XX" - - # The fixture contains a single accessory - so its a single device - # and no bridge - assert device.via_device_id is None diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py index 7f7ada4ac1fb2c..1140ee2dabe2c8 100644 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -5,11 +5,12 @@ SUPPORT_PLAY, SUPPORT_SELECT_SOURCE, ) -from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import async_get_device_automations from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -18,53 +19,47 @@ 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) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - - # 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 + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="LG webOS TV AF80", + model="OLED55B9PUA", + manufacturer="LG Electronics", + sw_version="04.71.04", + hw_version="1", + serial_number="999AAAAAA999", + devices=[], + entities=[ + EntityTestInfo( + entity_id="media_player.lg_webos_tv_af80", + friendly_name="LG webOS TV AF80", + unique_id="homekit-999AAAAAA999-48", + supported_features=( + SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE + ), + capabilities={ + "source_list": [ + "AirPlay", + "Live TV", + "HDMI 1", + "Sony", + "Apple", + "AV", + "HDMI 4", + ] + }, + # 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. + state="ok", + ), + ], + ), ) - 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 = dr.async_get(hass) - - 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 - - # A TV has media player device triggers - triggers = await async_get_device_automations(hass, "trigger", device.id) - for trigger in triggers: - assert trigger["domain"] == "media_player" + """ diff --git a/tests/components/homekit_controller/specific_devices/test_mysa_living.py b/tests/components/homekit_controller/specific_devices/test_mysa_living.py index ea1c10840718ef..1d99e9358c8073 100644 --- a/tests/components/homekit_controller/specific_devices/test_mysa_living.py +++ b/tests/components/homekit_controller/specific_devices/test_mysa_living.py @@ -1,9 +1,15 @@ """Make sure that Mysa Living is enumerated properly.""" -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.components.climate import SUPPORT_TARGET_TEMPERATURE +from homeassistant.components.light import SUPPORT_BRIGHTNESS +from homeassistant.components.sensor import SensorStateClass +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,80 +18,56 @@ async def test_mysa_living_setup(hass): """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "mysa_living.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - - # Check that the switch entity is handled correctly - - entry = entity_registry.async_get("sensor.mysa_85dda9_current_humidity") - assert entry.unique_id == "homekit-AAAAAAA000-aid:1-sid:20-cid:27" - - helper = Helper( - hass, - "sensor.mysa_85dda9_current_humidity", - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "Mysa-85dda9 - Current Humidity" - - device = device_registry.async_get(entry.device_id) - assert device.manufacturer == "Empowered Homes Inc." - assert device.name == "Mysa-85dda9" - assert device.model == "v1" - assert device.sw_version == "2.8.1" - assert device.via_device_id is None - - # Assert the humidifier is detected - entry = entity_registry.async_get("sensor.mysa_85dda9_current_temperature") - assert entry.unique_id == "homekit-AAAAAAA000-aid:1-sid:20-cid:25" - - helper = Helper( + await assert_devices_and_entities_created( hass, - "sensor.mysa_85dda9_current_temperature", - pairing, - accessories[0], - config_entry, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Mysa-85dda9", + model="v1", + manufacturer="Empowered Homes Inc.", + sw_version="2.8.1", + hw_version="", + serial_number="AAAAAAA000", + devices=[], + entities=[ + EntityTestInfo( + entity_id="climate.mysa_85dda9", + friendly_name="Mysa-85dda9", + unique_id="homekit-AAAAAAA000-20", + supported_features=SUPPORT_TARGET_TEMPERATURE, + capabilities={ + "hvac_modes": ["off", "heat", "cool", "heat_cool"], + "max_temp": 35, + "min_temp": 7, + }, + state="off", + ), + EntityTestInfo( + entity_id="sensor.mysa_85dda9_current_humidity", + friendly_name="Mysa-85dda9 Current Humidity", + unique_id="homekit-AAAAAAA000-aid:1-sid:20-cid:27", + unit_of_measurement=PERCENTAGE, + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + state="40", + ), + EntityTestInfo( + entity_id="sensor.mysa_85dda9_current_temperature", + friendly_name="Mysa-85dda9 Current Temperature", + unique_id="homekit-AAAAAAA000-aid:1-sid:20-cid:25", + unit_of_measurement=TEMP_CELSIUS, + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + state="24.1", + ), + EntityTestInfo( + entity_id="light.mysa_85dda9", + friendly_name="Mysa-85dda9", + unique_id="homekit-AAAAAAA000-40", + supported_features=SUPPORT_BRIGHTNESS, + capabilities={"supported_color_modes": ["brightness"]}, + state="off", + ), + ], + ), ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "Mysa-85dda9 - Current Temperature" - - # The sensor should be part of the same device - assert entry.device_id == device.id - - # Assert the light is detected - entry = entity_registry.async_get("light.mysa_85dda9") - assert entry.unique_id == "homekit-AAAAAAA000-40" - - helper = Helper( - hass, - "light.mysa_85dda9", - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "Mysa-85dda9" - - # The light should be part of the same device - assert entry.device_id == device.id - - # Assert the climate entity is detected - entry = entity_registry.async_get("climate.mysa_85dda9") - assert entry.unique_id == "homekit-AAAAAAA000-20" - - helper = Helper( - hass, - "climate.mysa_85dda9", - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "Mysa-85dda9" - - # The light should be part of the same device - assert entry.device_id == device.id diff --git a/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py b/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py index 58fe9df077f81b..5d2b7fcbdeddac 100644 --- a/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py +++ b/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py @@ -4,11 +4,12 @@ https://github.com/home-assistant/core/issues/44596 """ -from homeassistant.helpers import device_registry as dr, entity_registry as er - -from tests.common import assert_lists_same, async_get_device_automations from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + DeviceTriggerInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -17,57 +18,31 @@ async def test_netamo_doorbell_setup(hass): """Test that a Netamo Doorbell can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "netamo_doorbell.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) + await setup_test_accessories(hass, accessories) - # Check that the camera is correctly found and set up - doorbell_id = "camera.netatmo_doorbell_g738658" - doorbell = entity_registry.async_get(doorbell_id) - assert doorbell.unique_id == "homekit-g738658-aid:1" - - camera_helper = Helper( + await assert_devices_and_entities_created( hass, - "camera.netatmo_doorbell_g738658", - pairing, - accessories[0], - config_entry, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Netatmo-Doorbell-g738658", + model="Netatmo Doorbell", + manufacturer="Netatmo", + sw_version="80.0.0", + hw_version="", + serial_number="g738658", + devices=[], + entities=[ + EntityTestInfo( + entity_id="camera.netatmo_doorbell_g738658", + friendly_name="Netatmo-Doorbell-g738658", + unique_id="homekit-g738658-aid:1", + state="idle", + ), + ], + stateless_triggers=[ + DeviceTriggerInfo(type="doorbell", subtype="single_press"), + DeviceTriggerInfo(type="doorbell", subtype="double_press"), + DeviceTriggerInfo(type="doorbell", subtype="long_press"), + ], + ), ) - camera_helper = await camera_helper.poll_and_get_state() - assert camera_helper.attributes["friendly_name"] == "Netatmo-Doorbell-g738658" - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(doorbell.device_id) - assert device.manufacturer == "Netatmo" - assert device.name == "Netatmo-Doorbell-g738658" - assert device.model == "Netatmo Doorbell" - assert device.sw_version == "80.0.0" - assert device.via_device_id is None - - # The fixture file has 1 button - expected = [] - for subtype in ("single_press", "double_press", "long_press"): - expected.append( - { - "device_id": doorbell.device_id, - "domain": "homekit_controller", - "platform": "device", - "type": "doorbell", - "subtype": subtype, - } - ) - - for type in ("no_motion", "motion"): - expected.append( - { - "device_id": doorbell.device_id, - "domain": "binary_sensor", - "entity_id": "binary_sensor.netatmo_doorbell_g738658", - "platform": "device", - "type": type, - } - ) - - triggers = await async_get_device_automations(hass, "trigger", doorbell.device_id) - assert_lists_same(triggers, expected) diff --git a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py index 81e31918c912d7..6da8f13cbdde7f 100644 --- a/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py +++ b/tests/components/homekit_controller/specific_devices/test_rainmachine_pro_8.py @@ -4,10 +4,11 @@ https://github.com/home-assistant/core/issues/31745 """ -from homeassistant.helpers import device_registry as dr, entity_registry as er - from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -16,52 +17,68 @@ async def test_rainmachine_pro_8_setup(hass): """Test that a RainMachine can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "rainmachine-pro-8.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - - # Assert that the entity is correctly added to the entity registry - entry = entity_registry.async_get("switch.rainmachine_00ce4a") - assert entry.unique_id == "homekit-00aa0000aa0a-512" - - helper = Helper( - hass, "switch.rainmachine_00ce4a", pairing, accessories[0], config_entry + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="RainMachine-00ce4a", + model="SPK5 Pro", + manufacturer="Green Electronics LLC", + sw_version="1.0.4", + hw_version="1", + serial_number="00aa0000aa0a", + devices=[], + entities=[ + EntityTestInfo( + entity_id="switch.rainmachine_00ce4a", + friendly_name="RainMachine-00ce4a", + unique_id="homekit-00aa0000aa0a-512", + state="off", + ), + EntityTestInfo( + entity_id="switch.rainmachine_00ce4a_2", + friendly_name="RainMachine-00ce4a", + unique_id="homekit-00aa0000aa0a-768", + state="off", + ), + EntityTestInfo( + entity_id="switch.rainmachine_00ce4a_3", + friendly_name="RainMachine-00ce4a", + unique_id="homekit-00aa0000aa0a-1024", + state="off", + ), + EntityTestInfo( + entity_id="switch.rainmachine_00ce4a_4", + friendly_name="RainMachine-00ce4a", + unique_id="homekit-00aa0000aa0a-1280", + state="off", + ), + EntityTestInfo( + entity_id="switch.rainmachine_00ce4a_5", + friendly_name="RainMachine-00ce4a", + unique_id="homekit-00aa0000aa0a-1536", + state="off", + ), + EntityTestInfo( + entity_id="switch.rainmachine_00ce4a_6", + friendly_name="RainMachine-00ce4a", + unique_id="homekit-00aa0000aa0a-1792", + state="off", + ), + EntityTestInfo( + entity_id="switch.rainmachine_00ce4a_7", + friendly_name="RainMachine-00ce4a", + unique_id="homekit-00aa0000aa0a-2048", + state="off", + ), + EntityTestInfo( + entity_id="switch.rainmachine_00ce4a_8", + friendly_name="RainMachine-00ce4a", + unique_id="homekit-00aa0000aa0a-2304", + state="off", + ), + ], + ), ) - state = await helper.poll_and_get_state() - - # Assert that the friendly name is detected correctly - assert state.attributes["friendly_name"] == "RainMachine-00ce4a" - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(entry.device_id) - assert device.manufacturer == "Green Electronics LLC" - assert device.name == "RainMachine-00ce4a" - assert device.model == "SPK5 Pro" - assert device.sw_version == "1.0.4" - assert device.via_device_id is None - - # The device is made up of multiple valves - make sure we have enumerated them all - entry = entity_registry.async_get("switch.rainmachine_00ce4a_2") - assert entry.unique_id == "homekit-00aa0000aa0a-768" - - entry = entity_registry.async_get("switch.rainmachine_00ce4a_3") - assert entry.unique_id == "homekit-00aa0000aa0a-1024" - - entry = entity_registry.async_get("switch.rainmachine_00ce4a_4") - assert entry.unique_id == "homekit-00aa0000aa0a-1280" - - entry = entity_registry.async_get("switch.rainmachine_00ce4a_5") - assert entry.unique_id == "homekit-00aa0000aa0a-1536" - - entry = entity_registry.async_get("switch.rainmachine_00ce4a_6") - assert entry.unique_id == "homekit-00aa0000aa0a-1792" - - entry = entity_registry.async_get("switch.rainmachine_00ce4a_7") - assert entry.unique_id == "homekit-00aa0000aa0a-2048" - - entry = entity_registry.async_get("switch.rainmachine_00ce4a_8") - assert entry.unique_id == "homekit-00aa0000aa0a-2304" - - entry = entity_registry.async_get("switch.rainmachine_00ce4a_9") - assert entry is None diff --git a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py index e10e0ccd62ae64..51ebbfdc345622 100644 --- a/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_ryse_smart_bridge.py @@ -1,141 +1,219 @@ """Test against characteristics captured from a ryse smart bridge platforms.""" -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.components.cover import ( + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, +) +from homeassistant.const import PERCENTAGE from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) +RYSE_SUPPORTED_FEATURES = SUPPORT_CLOSE | SUPPORT_SET_POSITION | SUPPORT_OPEN + async def test_ryse_smart_bridge_setup(hass): """Test that a Ryse smart bridge can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "ryse_smart_bridge.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - - # Check that the cover.master_bath_south is correctly found and set up - cover_id = "cover.master_bath_south" - cover = entity_registry.async_get(cover_id) - assert cover.unique_id == "homekit-00:00:00:00:00:00-2-48" - - cover_helper = Helper( - hass, - cover_id, - pairing, - accessories[0], - config_entry, - ) + await setup_test_accessories(hass, accessories) - cover_state = await cover_helper.poll_and_get_state() - assert cover_state.attributes["friendly_name"] == "Master Bath South" - assert cover_state.state == "closed" - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(cover.device_id) - assert device.manufacturer == "RYSE Inc." - assert device.name == "Master Bath South" - assert device.model == "RYSE Shade" - assert device.sw_version == "3.0.8" - - bridge = device_registry.async_get(device.via_device_id) - assert bridge.manufacturer == "RYSE Inc." - assert bridge.name == "RYSE SmartBridge" - assert bridge.model == "RYSE SmartBridge" - assert bridge.sw_version == "1.3.0" - - # Check that the cover.ryse_smartshade is correctly found and set up - cover_id = "cover.ryse_smartshade" - cover = entity_registry.async_get(cover_id) - assert cover.unique_id == "homekit-00:00:00:00:00:00-3-48" - - cover_helper = Helper( + await assert_devices_and_entities_created( hass, - cover_id, - pairing, - accessories[0], - config_entry, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="RYSE SmartBridge", + model="RYSE SmartBridge", + manufacturer="RYSE Inc.", + sw_version="1.3.0", + hw_version="0101.3521.0436", + devices=[ + DeviceTestInfo( + unique_id="00:00:00:00:00:00:aid:2", + name="Master Bath South", + model="RYSE Shade", + manufacturer="RYSE Inc.", + sw_version="3.0.8", + hw_version="1.0.0", + serial_number="", + devices=[], + entities=[ + EntityTestInfo( + entity_id="cover.master_bath_south", + friendly_name="Master Bath South", + unique_id="homekit-00:00:00:00:00:00-2-48", + supported_features=RYSE_SUPPORTED_FEATURES, + state="closed", + ), + EntityTestInfo( + entity_id="sensor.master_bath_south_battery", + friendly_name="Master Bath South Battery", + unique_id="homekit-00:00:00:00:00:00-2-64", + unit_of_measurement=PERCENTAGE, + state="100", + ), + ], + ), + DeviceTestInfo( + unique_id="00:00:00:00:00:00:aid:3", + name="RYSE SmartShade", + model="RYSE Shade", + manufacturer="RYSE Inc.", + sw_version="", + hw_version="", + serial_number="", + devices=[], + entities=[ + EntityTestInfo( + entity_id="cover.ryse_smartshade", + friendly_name="RYSE SmartShade", + unique_id="homekit-00:00:00:00:00:00-3-48", + supported_features=RYSE_SUPPORTED_FEATURES, + state="open", + ), + EntityTestInfo( + entity_id="sensor.ryse_smartshade_battery", + friendly_name="RYSE SmartShade Battery", + unique_id="homekit-00:00:00:00:00:00-3-64", + unit_of_measurement=PERCENTAGE, + state="100", + ), + ], + ), + ], + entities=[], + ), ) - cover_state = await cover_helper.poll_and_get_state() - assert cover_state.attributes["friendly_name"] == "RYSE SmartShade" - assert cover_state.state == "open" - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(cover.device_id) - assert device.manufacturer == "RYSE Inc." - assert device.name == "RYSE SmartShade" - assert device.model == "RYSE Shade" - assert device.sw_version == "" - async def test_ryse_smart_bridge_four_shades_setup(hass): """Test that a Ryse smart bridge with four shades can be correctly setup in HA.""" accessories = await setup_accessories_from_file( hass, "ryse_smart_bridge_four_shades.json" ) - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - - cover_id = "cover.lr_left" - cover = entity_registry.async_get(cover_id) - assert cover.unique_id == "homekit-00:00:00:00:00:00-2-48" - - cover_id = "cover.lr_right" - cover = entity_registry.async_get(cover_id) - assert cover.unique_id == "homekit-00:00:00:00:00:00-3-48" - - cover_id = "cover.br_left" - cover = entity_registry.async_get(cover_id) - assert cover.unique_id == "homekit-00:00:00:00:00:00-4-48" - - cover_id = "cover.rzss" - cover = entity_registry.async_get(cover_id) - assert cover.unique_id == "homekit-00:00:00:00:00:00-5-48" - - sensor_id = "sensor.lr_left_battery" - sensor = entity_registry.async_get(sensor_id) - assert sensor.unique_id == "homekit-00:00:00:00:00:00-2-64" + await setup_test_accessories(hass, accessories) - sensor_id = "sensor.lr_right_battery" - sensor = entity_registry.async_get(sensor_id) - assert sensor.unique_id == "homekit-00:00:00:00:00:00-3-64" - - sensor_id = "sensor.br_left_battery" - sensor = entity_registry.async_get(sensor_id) - assert sensor.unique_id == "homekit-00:00:00:00:00:00-4-64" - - sensor_id = "sensor.rzss_battery" - sensor = entity_registry.async_get(sensor_id) - assert sensor.unique_id == "homekit-00:00:00:00:00:00-5-64" - - cover_helper = Helper( + await assert_devices_and_entities_created( hass, - cover_id, - pairing, - accessories[0], - config_entry, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="RYSE SmartBridge", + model="RYSE SmartBridge", + manufacturer="RYSE Inc.", + sw_version="1.3.0", + hw_version="0401.3521.0679", + devices=[ + DeviceTestInfo( + unique_id="00:00:00:00:00:00:aid:2", + name="LR Left", + model="RYSE Shade", + manufacturer="RYSE Inc.", + sw_version="3.0.8", + hw_version="1.0.0", + serial_number="", + devices=[], + entities=[ + EntityTestInfo( + entity_id="cover.lr_left", + friendly_name="LR Left", + unique_id="homekit-00:00:00:00:00:00-2-48", + supported_features=RYSE_SUPPORTED_FEATURES, + state="closed", + ), + EntityTestInfo( + entity_id="sensor.lr_left_battery", + friendly_name="LR Left Battery", + unique_id="homekit-00:00:00:00:00:00-2-64", + unit_of_measurement=PERCENTAGE, + state="89", + ), + ], + ), + DeviceTestInfo( + unique_id="00:00:00:00:00:00:aid:3", + name="LR Right", + model="RYSE Shade", + manufacturer="RYSE Inc.", + sw_version="3.0.8", + hw_version="1.0.0", + serial_number="", + devices=[], + entities=[ + EntityTestInfo( + entity_id="cover.lr_right", + friendly_name="LR Right", + unique_id="homekit-00:00:00:00:00:00-3-48", + supported_features=RYSE_SUPPORTED_FEATURES, + state="closed", + ), + EntityTestInfo( + entity_id="sensor.lr_right_battery", + friendly_name="LR Right Battery", + unique_id="homekit-00:00:00:00:00:00-3-64", + unit_of_measurement=PERCENTAGE, + state="100", + ), + ], + ), + DeviceTestInfo( + unique_id="00:00:00:00:00:00:aid:4", + name="BR Left", + model="RYSE Shade", + manufacturer="RYSE Inc.", + sw_version="3.0.8", + hw_version="1.0.0", + serial_number="", + devices=[], + entities=[ + EntityTestInfo( + entity_id="cover.br_left", + friendly_name="BR Left", + unique_id="homekit-00:00:00:00:00:00-4-48", + supported_features=RYSE_SUPPORTED_FEATURES, + state="open", + ), + EntityTestInfo( + entity_id="sensor.br_left_battery", + friendly_name="BR Left Battery", + unique_id="homekit-00:00:00:00:00:00-4-64", + unit_of_measurement=PERCENTAGE, + state="100", + ), + ], + ), + DeviceTestInfo( + unique_id="00:00:00:00:00:00:aid:5", + name="RZSS", + model="RYSE Shade", + manufacturer="RYSE Inc.", + sw_version="3.0.8", + hw_version="1.0.0", + serial_number="", + devices=[], + entities=[ + EntityTestInfo( + entity_id="cover.rzss", + friendly_name="RZSS", + unique_id="homekit-00:00:00:00:00:00-5-48", + supported_features=RYSE_SUPPORTED_FEATURES, + state="open", + ), + EntityTestInfo( + entity_id="sensor.rzss_battery", + friendly_name="RZSS Battery", + unique_id="homekit-00:00:00:00:00:00-5-64", + unit_of_measurement=PERCENTAGE, + state="0", + ), + ], + ), + ], + entities=[], + ), ) - - cover_state = await cover_helper.poll_and_get_state() - assert cover_state.attributes["friendly_name"] == "RZSS" - assert cover_state.state == "open" - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(cover.device_id) - assert device.manufacturer == "RYSE Inc." - assert device.name == "RZSS" - assert device.model == "RYSE Shade" - assert device.sw_version == "3.0.8" - - bridge = device_registry.async_get(device.via_device_id) - assert bridge.manufacturer == "RYSE Inc." - assert bridge.name == "RYSE SmartBridge" - assert bridge.model == "RYSE SmartBridge" - assert bridge.sw_version == "1.3.0" diff --git a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py index a21953202938b7..ce21d643babd27 100644 --- a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py +++ b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py @@ -5,10 +5,12 @@ """ from homeassistant.components.fan import SUPPORT_DIRECTION, SUPPORT_SET_SPEED -from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -17,35 +19,31 @@ async def test_simpleconnect_fan_setup(hass): """Test that a SIMPLEconnect fan can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "simpleconnect_fan.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - - # Check that the fan is correctly found and set up - fan_id = "fan.simpleconnect_fan_06f674" - fan = entity_registry.async_get(fan_id) - assert fan.unique_id == "homekit-1234567890abcd-8" - - fan_helper = Helper( + await assert_devices_and_entities_created( hass, - "fan.simpleconnect_fan_06f674", - pairing, - accessories[0], - config_entry, - ) - - fan_state = await fan_helper.poll_and_get_state() - assert fan_state.attributes["friendly_name"] == "SIMPLEconnect Fan-06F674" - assert fan_state.state == "off" - assert fan_state.attributes["supported_features"] == ( - SUPPORT_DIRECTION | SUPPORT_SET_SPEED + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="SIMPLEconnect Fan-06F674", + model="SIMPLEconnect", + manufacturer="Hunter Fan", + sw_version="", + hw_version="", + serial_number="1234567890abcd", + devices=[], + entities=[ + EntityTestInfo( + entity_id="fan.simpleconnect_fan_06f674", + friendly_name="SIMPLEconnect Fan-06F674", + unique_id="homekit-1234567890abcd-8", + supported_features=SUPPORT_DIRECTION | SUPPORT_SET_SPEED, + capabilities={ + "preset_modes": None, + "speed_list": ["off", "low", "medium", "high"], + }, + state="off", + ), + ], + ), ) - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(fan.device_id) - assert device.manufacturer == "Hunter Fan" - assert device.name == "SIMPLEconnect Fan-06F674" - assert device.model == "SIMPLEconnect" - assert device.sw_version == "" - assert device.via_device_id is None diff --git a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py index b93afdfbfa4ecd..d8d73709c49728 100644 --- a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py @@ -9,72 +9,93 @@ SUPPORT_OPEN, SUPPORT_SET_POSITION, ) -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + TEMP_CELSIUS, +) from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) -async def test_simpleconnect_cover_setup(hass): +async def test_velux_cover_setup(hass): """Test that a velux gateway can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "velux_gateway.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - - # Check that the cover is correctly found and set up - cover_id = "cover.velux_window" - cover = entity_registry.async_get(cover_id) - assert cover.unique_id == "homekit-1111111a114a111a-8" - - cover_helper = Helper( - hass, - cover_id, - pairing, - accessories[0], - config_entry, - ) - - cover_state = await cover_helper.poll_and_get_state() - assert cover_state.attributes["friendly_name"] == "VELUX Window" - assert cover_state.state == "closed" - assert cover_state.attributes["supported_features"] == ( - SUPPORT_CLOSE | SUPPORT_SET_POSITION | SUPPORT_OPEN - ) - - # Check that one of the sensors is correctly found and set up - sensor_id = "sensor.velux_sensor_temperature" - sensor = entity_registry.async_get(sensor_id) - assert sensor.unique_id == "homekit-a11b111-8" + await setup_test_accessories(hass, accessories) - sensor_helper = Helper( + await assert_devices_and_entities_created( hass, - sensor_id, - pairing, - accessories[0], - config_entry, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="VELUX Gateway", + model="VELUX Gateway", + manufacturer="VELUX", + sw_version="70", + hw_version="", + serial_number="a1a11a1", + devices=[ + DeviceTestInfo( + name="VELUX Window", + model="VELUX Window", + manufacturer="VELUX", + sw_version="48", + hw_version="", + serial_number="1111111a114a111a", + unique_id="00:00:00:00:00:00:aid:3", + devices=[], + entities=[ + EntityTestInfo( + entity_id="cover.velux_window", + friendly_name="VELUX Window", + unique_id="homekit-1111111a114a111a-8", + supported_features=SUPPORT_CLOSE + | SUPPORT_SET_POSITION + | SUPPORT_OPEN, + state="closed", + ), + ], + ), + DeviceTestInfo( + name="VELUX Sensor", + model="VELUX Sensor", + manufacturer="VELUX", + sw_version="16", + hw_version="", + serial_number="a11b111", + unique_id="00:00:00:00:00:00:aid:2", + devices=[], + entities=[ + EntityTestInfo( + entity_id="sensor.velux_sensor_temperature", + friendly_name="VELUX Sensor Temperature", + unique_id="homekit-a11b111-8", + unit_of_measurement=TEMP_CELSIUS, + state="18.9", + ), + EntityTestInfo( + entity_id="sensor.velux_sensor_humidity", + friendly_name="VELUX Sensor Humidity", + unique_id="homekit-a11b111-11", + unit_of_measurement=PERCENTAGE, + state="58", + ), + EntityTestInfo( + entity_id="sensor.velux_sensor_co2", + friendly_name="VELUX Sensor CO2", + unique_id="homekit-a11b111-14", + unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state="400", + ), + ], + ), + ], + entities=[], + ), ) - - sensor_state = await sensor_helper.poll_and_get_state() - assert sensor_state.attributes["friendly_name"] == "VELUX Sensor Temperature" - assert sensor_state.state == "18.9" - - # The cover and sensor are different devices (accessories) attached to the same bridge - assert cover.device_id != sensor.device_id - - device_registry = dr.async_get(hass) - - device = device_registry.async_get(cover.device_id) - assert device.manufacturer == "VELUX" - assert device.name == "VELUX Window" - assert device.model == "VELUX Window" - assert device.sw_version == "48" - - bridge = device_registry.async_get(device.via_device_id) - assert bridge.manufacturer == "VELUX" - assert bridge.name == "VELUX Gateway" - assert bridge.model == "VELUX Gateway" - assert bridge.sw_version == "70" diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py index 6968c62257f9ca..16bd3830dfcbe4 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py @@ -1,9 +1,17 @@ """Make sure that Vocolinc Flowerbud is enumerated properly.""" -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.components.humidifier.const import SUPPORT_MODES +from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR +from homeassistant.components.number import NumberMode +from homeassistant.components.sensor import SensorStateClass +from homeassistant.const import PERCENTAGE +from homeassistant.helpers.entity import EntityCategory from tests.components.homekit_controller.common import ( - Helper, + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,81 +20,61 @@ async def test_vocolinc_flowerbud_setup(hass): """Test that a Vocolinc Flowerbud can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "vocolinc_flowerbud.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + await setup_test_accessories(hass, accessories) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - - # Check that the switch entity is handled correctly - - entry = entity_registry.async_get("number.vocolinc_flowerbud_0d324b") - assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:38" - - helper = Helper( - hass, "number.vocolinc_flowerbud_0d324b", pairing, accessories[0], config_entry - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "VOCOlinc-Flowerbud-0d324b" - - device = device_registry.async_get(entry.device_id) - assert device.manufacturer == "VOCOlinc" - assert device.name == "VOCOlinc-Flowerbud-0d324b" - assert device.model == "Flowerbud" - assert device.sw_version == "3.121.2" - assert device.via_device_id is None - - # Assert the humidifier is detected - entry = entity_registry.async_get("humidifier.vocolinc_flowerbud_0d324b") - assert entry.unique_id == "homekit-AM01121849000327-30" - - helper = Helper( - hass, - "humidifier.vocolinc_flowerbud_0d324b", - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "VOCOlinc-Flowerbud-0d324b" - - # The sensor and switch should be part of the same device - assert entry.device_id == device.id - - # Assert the light is detected - entry = entity_registry.async_get("light.vocolinc_flowerbud_0d324b") - assert entry.unique_id == "homekit-AM01121849000327-9" - - helper = Helper( - hass, - "light.vocolinc_flowerbud_0d324b", - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == "VOCOlinc-Flowerbud-0d324b" - - # The sensor and switch should be part of the same device - assert entry.device_id == device.id - - # Assert the humidity sensory is detected - entry = entity_registry.async_get( - "sensor.vocolinc_flowerbud_0d324b_current_humidity" - ) - assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:33" - - helper = Helper( + await assert_devices_and_entities_created( hass, - "sensor.vocolinc_flowerbud_0d324b_current_humidity", - pairing, - accessories[0], - config_entry, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="VOCOlinc-Flowerbud-0d324b", + model="Flowerbud", + manufacturer="VOCOlinc", + sw_version="3.121.2", + hw_version="0.1", + serial_number="AM01121849000327", + devices=[], + entities=[ + EntityTestInfo( + entity_id="humidifier.vocolinc_flowerbud_0d324b", + friendly_name="VOCOlinc-Flowerbud-0d324b", + unique_id="homekit-AM01121849000327-30", + supported_features=SUPPORT_MODES, + capabilities={ + "available_modes": ["normal", "auto"], + "max_humidity": 100.0, + "min_humidity": 0.0, + }, + state="off", + ), + EntityTestInfo( + entity_id="light.vocolinc_flowerbud_0d324b", + friendly_name="VOCOlinc-Flowerbud-0d324b", + unique_id="homekit-AM01121849000327-9", + supported_features=SUPPORT_BRIGHTNESS | SUPPORT_COLOR, + capabilities={"supported_color_modes": ["hs"]}, + state="on", + ), + EntityTestInfo( + entity_id="number.vocolinc_flowerbud_0d324b_spray_quantity", + friendly_name="VOCOlinc-Flowerbud-0d324b Spray Quantity", + unique_id="homekit-AM01121849000327-aid:1-sid:30-cid:38", + capabilities={ + "max": 5, + "min": 1, + "mode": NumberMode.AUTO, + "step": 1, + }, + state="5", + entity_category=EntityCategory.CONFIG, + ), + EntityTestInfo( + entity_id="sensor.vocolinc_flowerbud_0d324b_current_humidity", + friendly_name="VOCOlinc-Flowerbud-0d324b Current Humidity", + unique_id="homekit-AM01121849000327-aid:1-sid:30-cid:33", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=PERCENTAGE, + state="45.0", + ), + ], + ), ) - state = await helper.poll_and_get_state() - assert ( - state.attributes["friendly_name"] - == "VOCOlinc-Flowerbud-0d324b - Current Humidity" - ) - - # The sensor and humidifier should be part of the same device - assert entry.device_id == device.id diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py new file mode 100644 index 00000000000000..da69b7fe3093f4 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py @@ -0,0 +1,49 @@ +"""Make sure that existing VOCOlinc VP3 support isn't broken.""" + +from homeassistant.components.sensor import SensorStateClass +from homeassistant.const import POWER_WATT + +from tests.components.homekit_controller.common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_vocolinc_vp3_setup(hass): + """Test that a VOCOlinc VP3 can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "vocolinc_vp3.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="VOCOlinc-VP3-123456", + model="VP3", + manufacturer="VOCOlinc", + sw_version="1.101.2", + hw_version="1.0.3", + serial_number="EU0121203xxxxx07", + devices=[], + entities=[ + EntityTestInfo( + entity_id="switch.vocolinc_vp3_123456", + friendly_name="VOCOlinc-VP3-123456", + unique_id="homekit-EU0121203xxxxx07-48", + state="on", + ), + EntityTestInfo( + entity_id="sensor.vocolinc_vp3_123456_power", + friendly_name="VOCOlinc-VP3-123456 Power", + unique_id="homekit-EU0121203xxxxx07-aid:1-sid:48-cid:97", + unit_of_measurement=POWER_WATT, + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + state="0", + ), + ], + ), + ) diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index e9ba4420176b11..e0b23775c4d5ec 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -2,14 +2,7 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_GAS, - DEVICE_CLASS_MOISTURE, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_OCCUPANCY, - DEVICE_CLASS_OPENING, - DEVICE_CLASS_SMOKE, -) +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from tests.components.homekit_controller.common import setup_test_component @@ -41,7 +34,7 @@ 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 + assert state.attributes["device_class"] == BinarySensorDeviceClass.MOTION def create_contact_sensor_service(accessory): @@ -64,7 +57,7 @@ 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 + assert state.attributes["device_class"] == BinarySensorDeviceClass.OPENING def create_smoke_sensor_service(accessory): @@ -87,7 +80,7 @@ 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"] == DEVICE_CLASS_SMOKE + assert state.attributes["device_class"] == BinarySensorDeviceClass.SMOKE def create_carbon_monoxide_sensor_service(accessory): @@ -110,7 +103,7 @@ async def test_carbon_monoxide_sensor_read_state(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "on" - assert state.attributes["device_class"] == DEVICE_CLASS_GAS + assert state.attributes["device_class"] == BinarySensorDeviceClass.GAS def create_occupancy_sensor_service(accessory): @@ -133,7 +126,7 @@ async def test_occupancy_sensor_read_state(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "on" - assert state.attributes["device_class"] == DEVICE_CLASS_OCCUPANCY + assert state.attributes["device_class"] == BinarySensorDeviceClass.OCCUPANCY def create_leak_sensor_service(accessory): @@ -156,4 +149,4 @@ async def test_leak_sensor_read_state(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "on" - assert state.attributes["device_class"] == DEVICE_CLASS_MOISTURE + assert state.attributes["device_class"] == BinarySensorDeviceClass.MOISTURE diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index d077bb8eb4e67a..33b5b15698d409 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -206,7 +206,6 @@ async def test_discovery_works(hass, controller, upper_case_props, missing_cshar assert result["type"] == "form" assert result["step_id"] == "pair" assert get_flow_context(hass, result) == { - "hkid": "00:00:00:00:00:00", "source": config_entries.SOURCE_ZEROCONF, "title_placeholders": {"name": "TestDevice"}, "unique_id": "00:00:00:00:00:00", @@ -577,7 +576,6 @@ async def test_pair_form_errors_on_start(hass, controller, exception, expected): ) 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": config_entries.SOURCE_ZEROCONF, @@ -593,7 +591,6 @@ async def test_pair_form_errors_on_start(hass, controller, exception, expected): assert result["errors"]["pairing_code"] == expected 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": config_entries.SOURCE_ZEROCONF, @@ -627,7 +624,6 @@ async def test_pair_abort_errors_on_finish(hass, controller, exception, expected ) 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": config_entries.SOURCE_ZEROCONF, @@ -641,7 +637,6 @@ async def test_pair_abort_errors_on_finish(hass, controller, exception, expected 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": config_entries.SOURCE_ZEROCONF, @@ -669,7 +664,6 @@ async def test_pair_form_errors_on_finish(hass, controller, exception, expected) ) 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": config_entries.SOURCE_ZEROCONF, @@ -683,7 +677,6 @@ async def test_pair_form_errors_on_finish(hass, controller, exception, expected) 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": config_entries.SOURCE_ZEROCONF, @@ -697,7 +690,6 @@ async def test_pair_form_errors_on_finish(hass, controller, exception, expected) assert result["errors"]["pairing_code"] == expected 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": config_entries.SOURCE_ZEROCONF, @@ -820,7 +812,6 @@ async def test_unignore_works(hass, controller): assert result["type"] == "form" assert result["step_id"] == "pair" 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": config_entries.SOURCE_UNIGNORE, @@ -852,3 +843,44 @@ async def test_unignore_ignores_missing_devices(hass, controller): assert result["type"] == "abort" assert result["reason"] == "no_devices" + + +async def test_discovery_dismiss_existing_flow_on_paired(hass, controller): + """Test that existing flows get dismissed once paired to something else.""" + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Set device as already not paired + discovery_info.properties["sf"] = 0x01 + discovery_info.properties["c#"] = 99999 + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "AA:BB:CC:DD:EE:FF" + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] == "form" + assert result["step_id"] == "pair" + await hass.async_block_till_done() + assert ( + len(hass.config_entries.flow.async_progress_by_handler("homekit_controller")) + == 1 + ) + + # Set device as already paired + discovery_info.properties["sf"] = 0x00 + # Device is discovered again after pairing to someone else + result2 = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result2["type"] == "abort" + assert result2["reason"] == "already_paired" + await hass.async_block_till_done() + assert ( + len(hass.config_entries.flow.async_progress_by_handler("homekit_controller")) + == 0 + ) diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py new file mode 100644 index 00000000000000..0099900b5859ae --- /dev/null +++ b/tests/components/homekit_controller/test_connection.py @@ -0,0 +1,164 @@ +"""Tests for HKDevice.""" + +import dataclasses + +import pytest + +from homeassistant.components.homekit_controller.const import ( + DOMAIN, + IDENTIFIER_ACCESSORY_ID, + IDENTIFIER_LEGACY_ACCESSORY_ID, + IDENTIFIER_LEGACY_SERIAL_NUMBER, + IDENTIFIER_SERIAL_NUMBER, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry +from tests.components.homekit_controller.common import ( + setup_accessories_from_file, + setup_platform, + setup_test_accessories, +) + + +@dataclasses.dataclass +class DeviceMigrationTest: + """Holds the expected state before and after testing a device identifier migration.""" + + fixture: str + manufacturer: str + before: set[tuple[str, str, str]] + after: set[tuple[str, str]] + + +DEVICE_MIGRATION_TESTS = [ + # 0401.3521.0679 was incorrectly treated as a serial number, it should be stripped out during migration + DeviceMigrationTest( + fixture="ryse_smart_bridge_four_shades.json", + manufacturer="RYSE Inc.", + before={ + (DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, "00:00:00:00:00:00"), + (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, "0401.3521.0679"), + }, + after={(IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:1")}, + ), + # This shade has a serial of 1.0.0, which we should already ignore. Make sure it gets migrated to a 2-tuple + DeviceMigrationTest( + fixture="ryse_smart_bridge_four_shades.json", + manufacturer="RYSE Inc.", + before={ + (DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, "00:00:00:00:00:00_3"), + }, + after={(IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:3")}, + ), + # Test migrating a Hue bridge - it has a valid serial number and has an accessory id + DeviceMigrationTest( + fixture="hue_bridge.json", + manufacturer="Philips Lighting", + before={ + (DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, "00:00:00:00:00:00"), + (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, "123456"), + }, + after={ + (IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:1"), + (IDENTIFIER_SERIAL_NUMBER, "123456"), + }, + ), + # Test migrating a Hue remote - it has a valid serial number + # Originally as a non-hub non-broken device it wouldn't have had an accessory id + DeviceMigrationTest( + fixture="hue_bridge.json", + manufacturer="Philips", + before={ + (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, "6623462389072572"), + }, + after={ + (IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:6623462389072572"), + (IDENTIFIER_SERIAL_NUMBER, "6623462389072572"), + }, + ), + # Test migrating a Koogeek LS1. This is just for completeness (testing hub and hub-less devices) + DeviceMigrationTest( + fixture="koogeek_ls1.json", + manufacturer="Koogeek", + before={ + (DOMAIN, IDENTIFIER_LEGACY_ACCESSORY_ID, "00:00:00:00:00:00"), + (DOMAIN, IDENTIFIER_LEGACY_SERIAL_NUMBER, "AAAA011111111111"), + }, + after={ + (IDENTIFIER_ACCESSORY_ID, "00:00:00:00:00:00:aid:1"), + (IDENTIFIER_SERIAL_NUMBER, "AAAA011111111111"), + }, + ), +] + + +@pytest.mark.parametrize("variant", DEVICE_MIGRATION_TESTS) +async def test_migrate_device_id_no_serial_skip_if_other_owner( + hass: HomeAssistant, variant: DeviceMigrationTest +): + """ + Don't migrate unrelated devices. + + Create a device registry entry that needs migrate, but belongs to a different + config entry. It should be ignored. + """ + device_registry = dr.async_get(hass) + + bridge = device_registry.async_get_or_create( + config_entry_id="XX", + identifiers=variant.before, + manufacturer="RYSE Inc.", + model="RYSE SmartBridge", + name="Wiring Closet", + sw_version="1.3.0", + hw_version="0101.2136.0344", + ) + + accessories = await setup_accessories_from_file(hass, variant.fixture) + await setup_test_accessories(hass, accessories) + + bridge = device_registry.async_get(bridge.id) + + assert bridge.identifiers == variant.before + assert bridge.config_entries == {"XX"} + + +@pytest.mark.parametrize("variant", DEVICE_MIGRATION_TESTS) +async def test_migrate_device_id_no_serial( + hass: HomeAssistant, variant: DeviceMigrationTest +): + """Test that a Ryse smart bridge with four shades can be migrated correctly in HA.""" + device_registry = dr.async_get(hass) + + accessories = await setup_accessories_from_file(hass, variant.fixture) + + fake_controller = await setup_platform(hass) + await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00") + config_entry = MockConfigEntry( + version=1, + domain="homekit_controller", + entry_id="TestData", + data={"AccessoryPairingID": "00:00:00:00:00:00"}, + title="test", + ) + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers=variant.before, + manufacturer="Dummy Manufacturer", + model="Dummy Model", + name="Dummy Name", + sw_version="99999999991", + hw_version="99999999999", + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get(device.id) + + assert device.identifiers == variant.after + assert device.manufacturer == variant.manufacturer diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index 7c02c1a6456eb1..7a842470bd59fd 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.homekit_controller.const import DOMAIN from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -96,7 +97,14 @@ async def test_enumerate_remote(hass, utcnow): "entity_id": "sensor.testdevice_battery", "platform": "device", "type": "battery_level", - } + }, + { + "device_id": device.id, + "domain": "button", + "entity_id": "button.testdevice_identify", + "platform": "device", + "type": "pressed", + }, ] for button in ("button1", "button2", "button3", "button4"): @@ -111,7 +119,9 @@ async def test_enumerate_remote(hass, utcnow): } ) - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) assert_lists_same(triggers, expected) @@ -132,7 +142,14 @@ async def test_enumerate_button(hass, utcnow): "entity_id": "sensor.testdevice_battery", "platform": "device", "type": "battery_level", - } + }, + { + "device_id": device.id, + "domain": "button", + "entity_id": "button.testdevice_identify", + "platform": "device", + "type": "pressed", + }, ] for subtype in ("single_press", "double_press", "long_press"): @@ -146,7 +163,9 @@ async def test_enumerate_button(hass, utcnow): } ) - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) assert_lists_same(triggers, expected) @@ -167,7 +186,14 @@ async def test_enumerate_doorbell(hass, utcnow): "entity_id": "sensor.testdevice_battery", "platform": "device", "type": "battery_level", - } + }, + { + "device_id": device.id, + "domain": "button", + "entity_id": "button.testdevice_identify", + "platform": "device", + "type": "pressed", + }, ] for subtype in ("single_press", "double_press", "long_press"): @@ -181,7 +207,9 @@ async def test_enumerate_doorbell(hass, utcnow): } ) - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) assert_lists_same(triggers, expected) diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py new file mode 100644 index 00000000000000..bd9aa30f6aebec --- /dev/null +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -0,0 +1,549 @@ +"""Test homekit_controller diagnostics.""" +from aiohttp import ClientSession + +from homeassistant.components.homekit_controller.const import KNOWN_DEVICES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.components.homekit_controller.common import ( + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_config_entry(hass: HomeAssistant, hass_client: ClientSession, utcnow): + """Test generating diagnostics for a config entry.""" + accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") + config_entry, _ = await setup_test_accessories(hass, accessories) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert diag == { + "config-entry": { + "title": "test", + "version": 1, + "data": {"AccessoryPairingID": "00:00:00:00:00:00"}, + }, + "entity-map": [ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Koogeek-LS1-20833F", + "description": "Name", + "maxLen": 64, + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "Koogeek", + "description": "Manufacturer", + "maxLen": 64, + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "LS1", + "description": "Model", + "maxLen": 64, + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64, + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify", + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 23, + "perms": ["pr"], + "format": "string", + "value": "2.2.15", + "description": "Firmware Revision", + "maxLen": 64, + }, + ], + }, + { + "iid": 7, + "type": "00000043-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000025-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr", "pw", "ev"], + "format": "bool", + "value": False, + "description": "On", + }, + { + "type": "00000013-0000-1000-8000-0026BB765291", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 44, + "description": "Hue", + "unit": "arcdegrees", + "minValue": 0, + "maxValue": 359, + "minStep": 1, + }, + { + "type": "0000002F-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 0, + "description": "Saturation", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + }, + { + "type": "00000008-0000-1000-8000-0026BB765291", + "iid": 11, + "perms": ["pr", "pw", "ev"], + "format": "int", + "value": 100, + "description": "Brightness", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 12, + "perms": ["pr"], + "format": "string", + "value": "Light Strip", + "description": "Name", + "maxLen": 64, + }, + ], + }, + { + "iid": 13, + "type": "4aaaf940-0dec-11e5-b939-0800200c9a66", + "characteristics": [ + { + "type": "4AAAF942-0DEC-11E5-B939-0800200C9A66", + "iid": 14, + "perms": ["pr", "pw"], + "format": "tlv8", + "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "description": "TIMER_SETTINGS", + } + ], + }, + { + "iid": 15, + "type": "151909D0-3802-11E4-916C-0800200C9A66", + "characteristics": [ + { + "type": "151909D2-3802-11E4-916C-0800200C9A66", + "iid": 16, + "perms": ["pr", "hd"], + "format": "string", + "value": "url,data", + "description": "FW Upgrade supported types", + "maxLen": 64, + }, + { + "type": "151909D1-3802-11E4-916C-0800200C9A66", + "iid": 17, + "perms": ["pw", "hd"], + "format": "string", + "description": "FW Upgrade URL", + "maxLen": 64, + }, + { + "type": "151909D6-3802-11E4-916C-0800200C9A66", + "iid": 18, + "perms": ["pr", "ev", "hd"], + "format": "int", + "value": 0, + "description": "FW Upgrade Status", + }, + { + "type": "151909D7-3802-11E4-916C-0800200C9A66", + "iid": 19, + "perms": ["pw", "hd"], + "format": "data", + "description": "FW Upgrade Data", + }, + ], + }, + { + "iid": 20, + "type": "151909D3-3802-11E4-916C-0800200C9A66", + "characteristics": [ + { + "type": "151909D5-3802-11E4-916C-0800200C9A66", + "iid": 21, + "perms": ["pr", "pw"], + "format": "int", + "value": 0, + "description": "Timezone", + }, + { + "type": "151909D4-3802-11E4-916C-0800200C9A66", + "iid": 22, + "perms": ["pr", "pw"], + "format": "int", + "value": 1550348623, + "description": "Time value since Epoch", + }, + ], + }, + ], + } + ], + "devices": [ + { + "name": "Koogeek-LS1-20833F", + "model": "LS1", + "manfacturer": "Koogeek", + "sw_version": "2.2.15", + "hw_version": "", + "entities": [ + { + "original_name": "Koogeek-LS1-20833F", + "disabled": False, + "disabled_by": None, + "entity_category": None, + "device_class": None, + "original_device_class": None, + "icon": None, + "original_icon": None, + "unit_of_measurement": None, + "state": { + "entity_id": "light.koogeek_ls1_20833f", + "state": "off", + "attributes": { + "supported_color_modes": ["hs"], + "friendly_name": "Koogeek-LS1-20833F", + "supported_features": 17, + }, + "last_changed": "2023-01-01T00:00:00+00:00", + "last_updated": "2023-01-01T00:00:00+00:00", + }, + }, + { + "device_class": None, + "disabled": False, + "disabled_by": None, + "entity_category": "diagnostic", + "icon": None, + "original_device_class": None, + "original_icon": None, + "original_name": "Koogeek-LS1-20833F Identify", + "state": { + "attributes": { + "friendly_name": "Koogeek-LS1-20833F Identify" + }, + "entity_id": "button.koogeek_ls1_20833f_identify", + "last_changed": "2023-01-01T00:00:00+00:00", + "last_updated": "2023-01-01T00:00:00+00:00", + "state": "unknown", + }, + "unit_of_measurement": None, + }, + ], + } + ], + } + + +async def test_device(hass: HomeAssistant, hass_client: ClientSession, utcnow): + """Test generating diagnostics for a device entry.""" + accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") + config_entry, _ = await setup_test_accessories(hass, accessories) + + connection = hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] + device_registry = dr.async_get(hass) + device = device_registry.async_get(connection.devices[1]) + + diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device) + + assert diag == { + "config-entry": { + "title": "test", + "version": 1, + "data": {"AccessoryPairingID": "00:00:00:00:00:00"}, + }, + "entity-map": [ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Koogeek-LS1-20833F", + "description": "Name", + "maxLen": 64, + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "Koogeek", + "description": "Manufacturer", + "maxLen": 64, + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "LS1", + "description": "Model", + "maxLen": 64, + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64, + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify", + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 23, + "perms": ["pr"], + "format": "string", + "value": "2.2.15", + "description": "Firmware Revision", + "maxLen": 64, + }, + ], + }, + { + "iid": 7, + "type": "00000043-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000025-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr", "pw", "ev"], + "format": "bool", + "value": False, + "description": "On", + }, + { + "type": "00000013-0000-1000-8000-0026BB765291", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 44, + "description": "Hue", + "unit": "arcdegrees", + "minValue": 0, + "maxValue": 359, + "minStep": 1, + }, + { + "type": "0000002F-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 0, + "description": "Saturation", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + }, + { + "type": "00000008-0000-1000-8000-0026BB765291", + "iid": 11, + "perms": ["pr", "pw", "ev"], + "format": "int", + "value": 100, + "description": "Brightness", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 12, + "perms": ["pr"], + "format": "string", + "value": "Light Strip", + "description": "Name", + "maxLen": 64, + }, + ], + }, + { + "iid": 13, + "type": "4aaaf940-0dec-11e5-b939-0800200c9a66", + "characteristics": [ + { + "type": "4AAAF942-0DEC-11E5-B939-0800200C9A66", + "iid": 14, + "perms": ["pr", "pw"], + "format": "tlv8", + "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "description": "TIMER_SETTINGS", + } + ], + }, + { + "iid": 15, + "type": "151909D0-3802-11E4-916C-0800200C9A66", + "characteristics": [ + { + "type": "151909D2-3802-11E4-916C-0800200C9A66", + "iid": 16, + "perms": ["pr", "hd"], + "format": "string", + "value": "url,data", + "description": "FW Upgrade supported types", + "maxLen": 64, + }, + { + "type": "151909D1-3802-11E4-916C-0800200C9A66", + "iid": 17, + "perms": ["pw", "hd"], + "format": "string", + "description": "FW Upgrade URL", + "maxLen": 64, + }, + { + "type": "151909D6-3802-11E4-916C-0800200C9A66", + "iid": 18, + "perms": ["pr", "ev", "hd"], + "format": "int", + "value": 0, + "description": "FW Upgrade Status", + }, + { + "type": "151909D7-3802-11E4-916C-0800200C9A66", + "iid": 19, + "perms": ["pw", "hd"], + "format": "data", + "description": "FW Upgrade Data", + }, + ], + }, + { + "iid": 20, + "type": "151909D3-3802-11E4-916C-0800200C9A66", + "characteristics": [ + { + "type": "151909D5-3802-11E4-916C-0800200C9A66", + "iid": 21, + "perms": ["pr", "pw"], + "format": "int", + "value": 0, + "description": "Timezone", + }, + { + "type": "151909D4-3802-11E4-916C-0800200C9A66", + "iid": 22, + "perms": ["pr", "pw"], + "format": "int", + "value": 1550348623, + "description": "Time value since Epoch", + }, + ], + }, + ], + } + ], + "device": { + "name": "Koogeek-LS1-20833F", + "model": "LS1", + "manfacturer": "Koogeek", + "sw_version": "2.2.15", + "hw_version": "", + "entities": [ + { + "original_name": "Koogeek-LS1-20833F", + "disabled": False, + "disabled_by": None, + "entity_category": None, + "device_class": None, + "original_device_class": None, + "icon": None, + "original_icon": None, + "unit_of_measurement": None, + "state": { + "entity_id": "light.koogeek_ls1_20833f", + "state": "off", + "attributes": { + "supported_color_modes": ["hs"], + "friendly_name": "Koogeek-LS1-20833F", + "supported_features": 17, + }, + "last_changed": "2023-01-01T00:00:00+00:00", + "last_updated": "2023-01-01T00:00:00+00:00", + }, + }, + { + "device_class": None, + "disabled": False, + "disabled_by": None, + "entity_category": "diagnostic", + "icon": None, + "original_device_class": None, + "original_icon": None, + "original_name": "Koogeek-LS1-20833F Identify", + "state": { + "attributes": {"friendly_name": "Koogeek-LS1-20833F Identify"}, + "entity_id": "button.koogeek_ls1_20833f_identify", + "last_changed": "2023-01-01T00:00:00+00:00", + "last_updated": "2023-01-01T00:00:00+00:00", + "state": "unknown", + }, + "unit_of_measurement": None, + }, + ], + }, + } diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index cd5662d73c92d8..d1b133468d5a95 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -4,8 +4,11 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from aiohomekit.testing import FakeController +from homeassistant.components.homekit_controller.const import ENTITY_MAP from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant from tests.components.homekit_controller.common import setup_test_component @@ -27,3 +30,24 @@ async def test_unload_on_stop(hass, utcnow): await hass.async_block_till_done() assert async_unlock_mock.called + + +async def test_async_remove_entry(hass: HomeAssistant): + """Test unpairing a component.""" + helper = await setup_test_component(hass, create_motion_sensor_service) + + hkid = "00:00:00:00:00:00" + + with patch("aiohomekit.Controller") as controller_cls: + # Setup a fake controller with 1 pairing + controller = controller_cls.return_value = FakeController() + await controller.add_paired_device([helper.accessory], hkid) + assert len(controller.pairings) == 1 + + assert hkid in hass.data[ENTITY_MAP].storage_data + + # Remove it via config entry and number of pairings should go down + await helper.config_entry.async_remove(hass) + assert len(controller.pairings) == 0 + + assert hkid not in hass.data[ENTITY_MAP].storage_data diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py index 490b69b1a8053a..8eebcbda8f5d01 100644 --- a/tests/components/homekit_controller/test_number.py +++ b/tests/components/homekit_controller/test_number.py @@ -33,7 +33,7 @@ async def test_read_number(hass, utcnow): # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. energy_helper = Helper( hass, - "number.testdevice", + "number.testdevice_spray_quantity", helper.pairing, helper.accessory, helper.config_entry, @@ -61,7 +61,7 @@ async def test_write_number(hass, utcnow): # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. energy_helper = Helper( hass, - "number.testdevice", + "number.testdevice_spray_quantity", helper.pairing, helper.accessory, helper.config_entry, @@ -73,7 +73,7 @@ async def test_write_number(hass, utcnow): await hass.services.async_call( "number", "set_value", - {"entity_id": "number.testdevice", "value": 5}, + {"entity_id": "number.testdevice_spray_quantity", "value": 5}, blocking=True, ) assert spray_level.value == 5 @@ -81,7 +81,7 @@ async def test_write_number(hass, utcnow): await hass.services.async_call( "number", "set_value", - {"entity_id": "number.testdevice", "value": 3}, + {"entity_id": "number.testdevice_spray_quantity", "value": 3}, blocking=True, ) assert spray_level.value == 3 diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index f96569551d8597..4c57d94b2b8e3c 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -3,12 +3,7 @@ from aiohomekit.model.services import ServicesTypes from aiohomekit.protocol.statuscodes import HapStatusCode -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, -) +from homeassistant.components.sensor import SensorDeviceClass from tests.components.homekit_controller.common import Helper, setup_test_component @@ -84,7 +79,7 @@ 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 + assert state.attributes["device_class"] == SensorDeviceClass.TEMPERATURE async def test_temperature_sensor_not_added_twice(hass, utcnow): @@ -94,6 +89,8 @@ async def test_temperature_sensor_not_added_twice(hass, utcnow): ) for state in hass.states.async_all(): + if state.entity_id.startswith("button"): + continue assert state.entity_id == helper.entity_id @@ -111,7 +108,7 @@ 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 + assert state.attributes["device_class"] == SensorDeviceClass.HUMIDITY async def test_light_level_sensor_read_state(hass, utcnow): @@ -128,7 +125,7 @@ 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 + assert state.attributes["device_class"] == SensorDeviceClass.ILLUMINANCE async def test_carbon_dioxide_level_sensor_read_state(hass, utcnow): @@ -162,7 +159,7 @@ 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 + assert state.attributes["device_class"] == SensorDeviceClass.BATTERY async def test_battery_charging(hass, utcnow): @@ -221,7 +218,7 @@ async def test_switch_with_sensor(hass, utcnow): # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. energy_helper = Helper( hass, - "sensor.testdevice_real_time_energy", + "sensor.testdevice_power", helper.pairing, helper.accessory, helper.config_entry, @@ -251,7 +248,7 @@ async def test_sensor_unavailable(hass, utcnow): # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. energy_helper = Helper( hass, - "sensor.testdevice_real_time_energy", + "sensor.testdevice_power", helper.pairing, helper.accessory, helper.config_entry, diff --git a/tests/components/homekit_controller/test_storage.py b/tests/components/homekit_controller/test_storage.py index aa0a5e55057655..b4ed617f9015e5 100644 --- a/tests/components/homekit_controller/test_storage.py +++ b/tests/components/homekit_controller/test_storage.py @@ -2,8 +2,6 @@ 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 @@ -79,26 +77,3 @@ async def test_storage_is_updated_on_add(hass, hass_storage, utcnow): # Is saved out to store? await flush_store(entity_map.store) assert hkid in hass_storage[ENTITY_MAP]["data"]["pairings"] - - -async def test_storage_is_removed_on_config_entry_removal(hass, utcnow): - """Test entity map storage is cleaned up on config entry removal.""" - await setup_test_component(hass, create_lightbulb_service) - - hkid = "00:00:00:00:00:00" - - pairing_data = {"AccessoryPairingID": hkid} - - entry = config_entries.ConfigEntry( - 1, - "homekit_controller", - "TestData", - pairing_data, - "test", - ) - - assert hkid in hass.data[ENTITY_MAP].storage_data - - await async_remove_entry(hass, entry) - - assert hkid not in hass.data[ENTITY_MAP].storage_data diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py index c53d20891b162e..5c737e63edc94e 100644 --- a/tests/components/homekit_controller/test_switch.py +++ b/tests/components/homekit_controller/test_switch.py @@ -38,6 +38,14 @@ def create_valve_service(accessory): remaining.value = 99 +def create_char_switch_service(accessory): + """Define swtch characteristics.""" + service = accessory.add_service(ServicesTypes.OUTLET) + + on_char = service.add_char(CharacteristicsTypes.Vendor.AQARA_PAIRING_MODE) + on_char.value = False + + async def test_switch_change_outlet_state(hass, utcnow): """Test that we can turn a HomeKit outlet on and off again.""" helper = await setup_test_component(hass, create_switch_service) @@ -122,3 +130,51 @@ async def test_valve_read_state(hass, utcnow): helper.characteristics[("valve", "in-use")].value = InUseValues.NOT_IN_USE switch_1 = await helper.poll_and_get_state() assert switch_1.attributes["in_use"] is False + + +async def test_char_switch_change_state(hass, utcnow): + """Test that we can turn a characteristic on and off again.""" + helper = await setup_test_component( + hass, create_char_switch_service, suffix="pairing_mode" + ) + svc = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + pairing_mode = svc[CharacteristicsTypes.Vendor.AQARA_PAIRING_MODE] + + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.testdevice_pairing_mode"}, + blocking=True, + ) + assert pairing_mode.value is True + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.testdevice_pairing_mode"}, + blocking=True, + ) + assert pairing_mode.value is False + + +async def test_char_switch_read_state(hass, utcnow): + """Test that we can read the state of a HomeKit characteristic switch.""" + helper = await setup_test_component( + hass, create_char_switch_service, suffix="pairing_mode" + ) + svc = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + pairing_mode = svc[CharacteristicsTypes.Vendor.AQARA_PAIRING_MODE] + + # Initial state is that the switch is off + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == "off" + + # Simulate that someone switched on the device in the real world not via HA + pairing_mode.set_value(True) + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == "on" + + # Simulate that device switched off in the real world not via HA + pairing_mode.set_value(False) + switch_1 = await helper.poll_and_get_state() + assert switch_1.state == "off" diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index c39226666655ed..c74cda43209d85 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -22,7 +22,7 @@ ATTR_RSSI_DEVICE, ATTR_SABOTAGE, ) -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.setup import async_setup_component from .helper import async_manipulate_test_data, get_and_check_entity_basics @@ -152,7 +152,7 @@ async def test_hmip_contact_interface(hass, default_mock_hap_factory): await async_manipulate_test_data(hass, hmip_device, "windowState", None) ha_state = hass.states.get(entity_id) - assert ha_state.state == STATE_OFF + assert ha_state.state == STATE_UNKNOWN async def test_hmip_shutter_contact(hass, default_mock_hap_factory): @@ -185,7 +185,7 @@ async def test_hmip_shutter_contact(hass, default_mock_hap_factory): await async_manipulate_test_data(hass, hmip_device, "windowState", None) ha_state = hass.states.get(entity_id) - assert ha_state.state == STATE_OFF + assert ha_state.state == STATE_UNKNOWN # test common attributes assert ha_state.attributes[ATTR_RSSI_DEVICE] == -54 @@ -215,7 +215,7 @@ async def test_hmip_shutter_contact_optical(hass, default_mock_hap_factory): await async_manipulate_test_data(hass, hmip_device, "windowState", None) ha_state = hass.states.get(entity_id) - assert ha_state.state == STATE_OFF + assert ha_state.state == STATE_UNKNOWN # test common attributes assert ha_state.attributes[ATTR_RSSI_DEVICE] == -72 @@ -562,7 +562,7 @@ async def test_hmip_multi_contact_interface(hass, default_mock_hap_factory): await async_manipulate_test_data(hass, hmip_device, "windowState", None, channel=5) ha_state = hass.states.get(entity_id) - assert ha_state.state == STATE_OFF + assert ha_state.state == STATE_UNKNOWN ha_state, hmip_device = get_and_check_entity_basics( hass, @@ -572,4 +572,4 @@ async def test_hmip_multi_contact_interface(hass, default_mock_hap_factory): "HmIP-FCI6", ) - assert ha_state.state == STATE_OFF + assert ha_state.state == STATE_UNKNOWN diff --git a/tests/components/homewizard/__init__.py b/tests/components/homewizard/__init__.py new file mode 100644 index 00000000000000..bdd31419e12c77 --- /dev/null +++ b/tests/components/homewizard/__init__.py @@ -0,0 +1 @@ +"""Tests for the HomeWizard integration.""" diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py new file mode 100644 index 00000000000000..15993aa35ed49f --- /dev/null +++ b/tests/components/homewizard/conftest.py @@ -0,0 +1,30 @@ +"""Fixtures for HomeWizard integration tests.""" +import pytest + +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry_data(): + """Return the default mocked config entry data.""" + return { + "product_name": "Product Name", + "product_type": "product_type", + "serial": "aabbccddeeff", + "name": "Product Name", + CONF_IP_ADDRESS: "1.2.3.4", + } + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Product Name (aabbccddeeff)", + domain=DOMAIN, + data={}, + unique_id="aabbccddeeff", + ) diff --git a/tests/components/homewizard/generator.py b/tests/components/homewizard/generator.py new file mode 100644 index 00000000000000..74d33c9e6098e7 --- /dev/null +++ b/tests/components/homewizard/generator.py @@ -0,0 +1,27 @@ +"""Helper files for unit tests.""" + +from unittest.mock import AsyncMock + + +def get_mock_device( + serial="aabbccddeeff", + host="1.2.3.4", + product_name="P1 meter", + product_type="HWE-P1", +): + """Return a mock bridge.""" + mock_device = AsyncMock() + mock_device.host = host + + mock_device.device.product_name = product_name + mock_device.device.product_type = product_type + mock_device.device.serial = serial + mock_device.device.api_version = "v1" + mock_device.device.firmware_version = "1.00" + + mock_device.state = None + + mock_device.initialize = AsyncMock() + mock_device.close = AsyncMock() + + return mock_device diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py new file mode 100644 index 00000000000000..7364a0e632e4d0 --- /dev/null +++ b/tests/components/homewizard/test_config_flow.py @@ -0,0 +1,302 @@ +"""Test the homewizard config flow.""" +import logging +from unittest.mock import patch + +from aiohwenergy import DisabledError + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY + +from .generator import get_mock_device + +_LOGGER = logging.getLogger(__name__) + + +async def test_manual_flow_works(hass, aioclient_mock): + """Test config flow accepts user configuration.""" + + device = get_mock_device() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch("aiohwenergy.HomeWizardEnergy", return_value=device,), patch( + "homeassistant.components.homewizard.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == "create_entry" + assert result["title"] == f"{device.device.product_name} (aabbccddeeff)" + assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert len(device.initialize.mock_calls) == 1 + assert len(device.close.mock_calls) == 1 + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_discovery_flow_works(hass, aioclient_mock): + """Test discovery setup flow works.""" + + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ) + + with patch("aiohwenergy.HomeWizardEnergy", return_value=get_mock_device()): + flow = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + + with patch( + "homeassistant.components.homewizard.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], user_input={} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" + + assert result["result"] + assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + + +async def test_discovery_disabled_api(hass, aioclient_mock): + """Test discovery detecting disabled api.""" + + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "0", + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "api_not_enabled" + + +async def test_discovery_missing_data_in_service_info(hass, aioclient_mock): + """Test discovery detecting missing discovery info.""" + + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + # "api_enabled": "1", --> removed + "path": "/api/v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_discovery_parameters" + + +async def test_discovery_invalid_api(hass, aioclient_mock): + """Test discovery detecting invalid_api.""" + + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + port=80, + hostname="p1meter-ddeeff.local.", + type="", + name="", + properties={ + "api_enabled": "1", + "path": "/api/not_v1", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "aabbccddeeff", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unsupported_api_version" + + +async def test_check_disabled_api(hass, aioclient_mock): + """Test check detecting disabled api.""" + + def mock_initialize(): + raise DisabledError + + device = get_mock_device() + device.initialize.side_effect = mock_initialize + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "api_not_enabled" + + +async def test_check_error_handling_api(hass, aioclient_mock): + """Test check detecting error with api.""" + + def mock_initialize(): + raise Exception() + + device = get_mock_device() + device.initialize.side_effect = mock_initialize + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown_error" + + +async def test_check_detects_unexpected_api_response(hass, aioclient_mock): + """Test check detecting device endpoint failed fetching data.""" + + device = get_mock_device() + device.device = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown_error" + + +async def test_check_detects_invalid_api(hass, aioclient_mock): + """Test check detecting device endpoint failed fetching data.""" + + device = get_mock_device() + device.device.api_version = "not_v1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unsupported_api_version" + + +async def test_check_detects_unsuported_device(hass, aioclient_mock): + """Test check detecting device endpoint failed fetching data.""" + + device = get_mock_device(product_type="not_an_energy_device") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "device_not_supported" diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py new file mode 100644 index 00000000000000..f7aa4de7adecc2 --- /dev/null +++ b/tests/components/homewizard/test_init.py @@ -0,0 +1,173 @@ +"""Tests for the homewizard component.""" +from asyncio import TimeoutError +from unittest.mock import patch + +from aiohwenergy import AiohwenergyException, DisabledError + +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_IP_ADDRESS + +from .generator import get_mock_device + +from tests.common import MockConfigEntry + + +async def test_load_unload(aioclient_mock, hass): + """Test loading and unloading of integration.""" + + device = get_mock_device() + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_load_failed_host_unavailable(aioclient_mock, hass): + """Test setup handles unreachable host.""" + + def MockInitialize(): + raise TimeoutError() + + device = get_mock_device() + device.initialize.side_effect = MockInitialize + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_load_detect_api_disabled(aioclient_mock, hass): + """Test setup detects disabled API.""" + + def MockInitialize(): + raise DisabledError() + + device = get_mock_device() + device.initialize.side_effect = MockInitialize + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_load_handles_aiohwenergy_exception(aioclient_mock, hass): + """Test setup handles exception from API.""" + + def MockInitialize(): + raise AiohwenergyException() + + device = get_mock_device() + device.initialize.side_effect = MockInitialize + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR + + +async def test_load_handles_generic_exception(aioclient_mock, hass): + """Test setup handles global exception.""" + + def MockInitialize(): + raise Exception() + + device = get_mock_device() + device.initialize.side_effect = MockInitialize + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR + + +async def test_load_handles_initialization_error(aioclient_mock, hass): + """Test handles non-exception error.""" + + device = get_mock_device() + device.device = None + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.1.1.1"}, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY or ConfigEntryState.SETUP_ERROR diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py new file mode 100644 index 00000000000000..6f5396f8702853 --- /dev/null +++ b/tests/components/homewizard/test_sensor.py @@ -0,0 +1,747 @@ +"""Test the update coordinator for HomeWizard.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from aiohwenergy.errors import DisabledError + +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_GAS, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + VOLUME_CUBIC_METERS, +) +from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util + +from .generator import get_mock_device + +from tests.common import async_fire_time_changed + + +async def test_sensor_entity_smr_version( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads smr version.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "smr_version", + ] + api.data.smr_version = 50 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_dsmr_version") + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_dsmr_version") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_smr_version" + assert not entry.disabled + assert state.state == "50" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) DSMR Version" + ) + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes.get(ATTR_ICON) == "mdi:counter" + + +async def test_sensor_entity_meter_model( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads meter model.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "meter_model", + ] + api.data.meter_model = "Model X" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_smart_meter_model") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_smart_meter_model" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_meter_model" + assert not entry.disabled + assert state.state == "Model X" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Smart Meter Model" + ) + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes.get(ATTR_ICON) == "mdi:gauge" + + +async def test_sensor_entity_wifi_ssid(hass, mock_config_entry_data, mock_config_entry): + """Test entity loads wifi ssid.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "wifi_ssid", + ] + api.data.wifi_ssid = "My Wifi" + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_wifi_ssid") + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_wifi_ssid") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_wifi_ssid" + assert not entry.disabled + assert state.state == "My Wifi" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Wifi SSID" + ) + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes.get(ATTR_ICON) == "mdi:wifi" + + +async def test_sensor_entity_wifi_strength( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads wifi strength.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "wifi_strength", + ] + api.data.wifi_strength = 42 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_wifi_strength") + assert entry + assert entry.unique_id == "aabbccddeeff_wifi_strength" + assert entry.disabled + + +async def test_sensor_entity_total_power_import_t1_kwh( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads total power import t1.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_import_t1_kwh", + ] + api.data.total_power_import_t1_kwh = 1234.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_total_power_import_t1") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_import_t1" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_total_power_import_t1_kwh" + assert not entry.disabled + assert state.state == "1234.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Total Power Import T1" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_total_power_import_t2_kwh( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads total power import t2.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_import_t2_kwh", + ] + api.data.total_power_import_t2_kwh = 1234.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_total_power_import_t2") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_import_t2" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_total_power_import_t2_kwh" + assert not entry.disabled + assert state.state == "1234.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Total Power Import T2" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_total_power_export_t1_kwh( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads total power export t1.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_export_t1_kwh", + ] + api.data.total_power_export_t1_kwh = 1234.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_total_power_export_t1") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_export_t1" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_total_power_export_t1_kwh" + assert not entry.disabled + assert state.state == "1234.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Total Power Export T1" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_total_power_export_t2_kwh( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads total power export t2.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_export_t2_kwh", + ] + api.data.total_power_export_t2_kwh = 1234.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_total_power_export_t2") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_export_t2" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_total_power_export_t2_kwh" + assert not entry.disabled + assert state.state == "1234.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Total Power Export T2" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_active_power( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads active power.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "active_power_w", + ] + api.data.active_power_w = 123.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_active_power") + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_active_power") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_active_power_w" + assert not entry.disabled + assert state.state == "123.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Active Power" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_active_power_l1( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads active power l1.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "active_power_l1_w", + ] + api.data.active_power_l1_w = 123.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_l1") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_active_power_l1" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_active_power_l1_w" + assert not entry.disabled + assert state.state == "123.123" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Active Power L1" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_active_power_l2( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads active power l2.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "active_power_l2_w", + ] + api.data.active_power_l2_w = 456.456 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_l2") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_active_power_l2" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_active_power_l2_w" + assert not entry.disabled + assert state.state == "456.456" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Active Power L2" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_active_power_l3( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads active power l3.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "active_power_l3_w", + ] + api.data.active_power_l3_w = 789.789 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_active_power_l3") + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_active_power_l3" + ) + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_active_power_l3_w" + assert not entry.disabled + assert state.state == "789.789" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Active Power L3" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_total_gas(hass, mock_config_entry_data, mock_config_entry): + """Test entity loads total gas.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_gas_m3", + ] + api.data.total_gas_m3 = 50 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.product_name_aabbccddeeff_total_gas") + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_total_gas") + assert entry + assert state + assert entry.unique_id == "aabbccddeeff_total_gas_m3" + assert not entry.disabled + assert state.state == "50" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Total Gas" + ) + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert ATTR_ICON not in state.attributes + + +async def test_sensor_entity_disabled_when_null( + hass, mock_config_entry_data, mock_config_entry +): + """Test sensor disables data with null by default.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "active_power_l2_w", + "active_power_l3_w", + "total_gas_m3", + ] + api.data.active_power_l2_w = None + api.data.active_power_l3_w = None + api.data.total_gas_m3 = None + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_active_power_l2" + ) + assert entry is None + + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_active_power_l3" + ) + assert entry is None + + entry = entity_registry.async_get("sensor.product_name_aabbccddeeff_total_gas") + assert entry is None + + +async def test_sensor_entity_export_disabled_when_unused( + hass, mock_config_entry_data, mock_config_entry +): + """Test sensor disables export if value is 0.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_export_t1_kwh", + "total_power_export_t2_kwh", + ] + api.data.total_power_export_t1_kwh = 0 + api.data.total_power_export_t2_kwh = 0 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_export_t1" + ) + assert entry + assert entry.disabled + + entry = entity_registry.async_get( + "sensor.product_name_aabbccddeeff_total_power_export_t2" + ) + assert entry + assert entry.disabled + + +async def test_sensors_unreachable(hass, mock_config_entry_data, mock_config_entry): + """Test sensor handles api unreachable.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_import_t1_kwh", + ] + api.data.total_power_import_t1_kwh = 1234.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + api.update = AsyncMock(return_value=True) + + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + utcnow = dt_util.utcnow() + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "sensor.product_name_aabbccddeeff_total_power_import_t1" + ).state + == "1234.123" + ) + + api.update = AsyncMock(return_value=False) + async_fire_time_changed(hass, utcnow + timedelta(seconds=5)) + await hass.async_block_till_done() + assert ( + hass.states.get( + "sensor.product_name_aabbccddeeff_total_power_import_t1" + ).state + == "unavailable" + ) + + api.update = AsyncMock(return_value=True) + async_fire_time_changed(hass, utcnow + timedelta(seconds=10)) + await hass.async_block_till_done() + assert ( + hass.states.get( + "sensor.product_name_aabbccddeeff_total_power_import_t1" + ).state + == "1234.123" + ) + + +async def test_api_disabled(hass, mock_config_entry_data, mock_config_entry): + """Test sensor handles api unreachable.""" + + api = get_mock_device() + api.data.available_datapoints = [ + "total_power_import_t1_kwh", + ] + api.data.total_power_import_t1_kwh = 1234.123 + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + api.update = AsyncMock(return_value=True) + + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + utcnow = dt_util.utcnow() + + assert ( + hass.states.get( + "sensor.product_name_aabbccddeeff_total_power_import_t1" + ).state + == "1234.123" + ) + + api.update = AsyncMock(side_effect=DisabledError) + async_fire_time_changed(hass, utcnow + timedelta(seconds=5)) + await hass.async_block_till_done() + assert ( + hass.states.get( + "sensor.product_name_aabbccddeeff_total_power_import_t1" + ).state + == "unavailable" + ) + + api.update = AsyncMock(return_value=True) + async_fire_time_changed(hass, utcnow + timedelta(seconds=10)) + await hass.async_block_till_done() + assert ( + hass.states.get( + "sensor.product_name_aabbccddeeff_total_power_import_t1" + ).state + == "1234.123" + ) diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py new file mode 100644 index 00000000000000..f3792a9d75bc11 --- /dev/null +++ b/tests/components/homewizard/test_switch.py @@ -0,0 +1,293 @@ +"""Test the update coordinator for HomeWizard.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.components import switch +from homeassistant.components.switch import DEVICE_CLASS_OUTLET, DEVICE_CLASS_SWITCH +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.helpers import entity_registry as er + +from .generator import get_mock_device + + +async def test_switch_entity_not_loaded_when_not_available( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity loads smr version.""" + + api = get_mock_device() + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state_power_on = hass.states.get("sensor.product_name_aabbccddeeff_switch") + state_switch_lock = hass.states.get("sensor.product_name_aabbccddeeff_switch_lock") + + assert state_power_on is None + assert state_switch_lock is None + + +async def test_switch_loads_entities(hass, mock_config_entry_data, mock_config_entry): + """Test entity loads smr version.""" + + api = get_mock_device() + api.state = AsyncMock() + + api.state.power_on = False + api.state.switch_lock = False + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + state_power_on = hass.states.get("switch.product_name_aabbccddeeff_switch") + entry_power_on = entity_registry.async_get( + "switch.product_name_aabbccddeeff_switch" + ) + assert state_power_on + assert entry_power_on + assert entry_power_on.unique_id == "aabbccddeeff_power_on" + assert not entry_power_on.disabled + assert state_power_on.state == STATE_OFF + assert ( + state_power_on.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Switch" + ) + assert state_power_on.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_OUTLET + assert ATTR_ICON not in state_power_on.attributes + + state_switch_lock = hass.states.get("switch.product_name_aabbccddeeff_switch_lock") + entry_switch_lock = entity_registry.async_get( + "switch.product_name_aabbccddeeff_switch_lock" + ) + + assert state_switch_lock + assert entry_switch_lock + assert entry_switch_lock.unique_id == "aabbccddeeff_switch_lock" + assert not entry_switch_lock.disabled + assert state_switch_lock.state == STATE_OFF + assert ( + state_switch_lock.attributes.get(ATTR_FRIENDLY_NAME) + == "Product Name (aabbccddeeff) Switch Lock" + ) + assert state_switch_lock.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SWITCH + assert ATTR_ICON not in state_switch_lock.attributes + + +async def test_switch_power_on_off(hass, mock_config_entry_data, mock_config_entry): + """Test entity turns switch on and off.""" + + api = get_mock_device() + api.state = AsyncMock() + api.state.power_on = False + api.state.switch_lock = False + + def set_power_on(power_on): + api.state.power_on = power_on + + api.state.set = AsyncMock(side_effect=set_power_on) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state + == STATE_OFF + ) + + # Turn power_on on + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "switch.product_name_aabbccddeeff_switch"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(api.state.set.mock_calls) == 1 + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state == STATE_ON + ) + + # Turn power_on off + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "switch.product_name_aabbccddeeff_switch"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state + == STATE_OFF + ) + assert len(api.state.set.mock_calls) == 2 + + +async def test_switch_lock_power_on_off( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity turns switch on and off.""" + + api = get_mock_device() + api.state = AsyncMock() + api.state.power_on = False + api.state.switch_lock = False + + def set_switch_lock(switch_lock): + api.state.switch_lock = switch_lock + + api.state.set = AsyncMock(side_effect=set_switch_lock) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_OFF + ) + + # Turn power_on on + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(api.state.set.mock_calls) == 1 + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_ON + ) + + # Turn power_on off + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_OFF + ) + assert len(api.state.set.mock_calls) == 2 + + +async def test_switch_lock_sets_power_on_unavailable( + hass, mock_config_entry_data, mock_config_entry +): + """Test entity turns switch on and off.""" + + api = get_mock_device() + api.state = AsyncMock() + api.state.power_on = True + api.state.switch_lock = False + + def set_switch_lock(switch_lock): + api.state.switch_lock = switch_lock + + api.state.set = AsyncMock(side_effect=set_switch_lock) + + with patch( + "aiohwenergy.HomeWizardEnergy", + return_value=api, + ): + entry = mock_config_entry + entry.data = mock_config_entry_data + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state == STATE_ON + ) + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_OFF + ) + + # Turn power_on on + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(api.state.set.mock_calls) == 1 + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_ON + ) + + # Turn power_on off + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "switch.product_name_aabbccddeeff_switch_lock"}, + blocking=True, + ) + + await hass.async_block_till_done() + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch").state == STATE_ON + ) + assert ( + hass.states.get("switch.product_name_aabbccddeeff_switch_lock").state + == STATE_OFF + ) + assert len(api.state.set.mock_calls) == 2 diff --git a/tests/components/honeywell/test_config_flow.py b/tests/components/honeywell/test_config_flow.py index 65f47ddf35f69e..47897cf246dbda 100644 --- a/tests/components/honeywell/test_config_flow.py +++ b/tests/components/honeywell/test_config_flow.py @@ -5,7 +5,7 @@ from homeassistant import data_entry_flow from homeassistant.components.honeywell.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant FAKE_CONFIG = { @@ -49,15 +49,3 @@ async def test_create_entry(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == FAKE_CONFIG - - -async def test_async_step_import(hass: HomeAssistant) -> None: - """Test that the import step works.""" - with patch( - "somecomfort.SomeComfort", - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=FAKE_CONFIG - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"] == FAKE_CONFIG diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 1f1a3d32d2ca93..4a2e1e8aed39e5 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -6,16 +6,29 @@ from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized +import jwt import pytest +import yarl +from homeassistant.auth.const import GROUP_ID_READ_ONLY +from homeassistant.auth.models import User from homeassistant.auth.providers import trusted_networks +from homeassistant.components import websocket_api from homeassistant.components.http.auth import ( + CONTENT_USER_NAME, + DATA_SIGN_SECRET, + STORAGE_KEY, + async_setup_auth, async_sign_path, async_user_not_allowed_do_auth, - setup_auth, ) from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.forwarded import async_setup_forwarded +from homeassistant.components.http.request_context import ( + current_request, + setup_request_context, +) +from homeassistant.core import callback from homeassistant.setup import async_setup_component from . import HTTP_HEADER_HA_AUTH, mock_real_ip @@ -86,7 +99,7 @@ def trusted_networks_auth(hass): async def test_auth_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.setup_auth") as mock_setup: + with patch("homeassistant.components.http.async_setup_auth") as mock_setup: await async_setup_component(hass, "http", {"http": {}}) assert len(mock_setup.mock_calls) == 1 @@ -96,7 +109,7 @@ async def test_cant_access_with_password_in_header( app, aiohttp_client, legacy_auth, hass ): """Test access with password in header.""" - setup_auth(hass, app) + await async_setup_auth(hass, app) client = await aiohttp_client(app) req = await client.get("/", headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) @@ -110,7 +123,7 @@ async def test_cant_access_with_password_in_query( app, aiohttp_client, legacy_auth, hass ): """Test access with password in URL.""" - setup_auth(hass, app) + await async_setup_auth(hass, app) client = await aiohttp_client(app) resp = await client.get("/", params={"api_password": API_PASSWORD}) @@ -125,7 +138,7 @@ async def test_cant_access_with_password_in_query( async def test_basic_auth_does_not_work(app, aiohttp_client, hass, legacy_auth): """Test access with basic authentication.""" - setup_auth(hass, app) + await async_setup_auth(hass, app) client = await aiohttp_client(app) req = await client.get("/", auth=BasicAuth("homeassistant", API_PASSWORD)) @@ -145,7 +158,7 @@ async def test_cannot_access_with_trusted_ip( hass, app2, trusted_networks_auth, aiohttp_client, hass_owner_user ): """Test access with an untrusted ip address.""" - setup_auth(hass, app2) + await async_setup_auth(hass, app2) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -170,7 +183,7 @@ async def test_auth_active_access_with_access_token_in_header( ): """Test access with access token in header.""" token = hass_access_token - setup_auth(hass, app) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token(hass_access_token) @@ -202,7 +215,7 @@ async def test_auth_active_access_with_trusted_ip( hass, app2, trusted_networks_auth, aiohttp_client, hass_owner_user ): """Test access with an untrusted ip address.""" - setup_auth(hass, app2) + await async_setup_auth(hass, app2) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -226,7 +239,7 @@ async def test_auth_legacy_support_api_password_cannot_access( app, aiohttp_client, legacy_auth, hass ): """Test access using api_password if auth.support_legacy.""" - setup_auth(hass, app) + await async_setup_auth(hass, app) client = await aiohttp_client(app) req = await client.get("/", headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) @@ -239,16 +252,20 @@ async def test_auth_legacy_support_api_password_cannot_access( assert req.status == HTTPStatus.UNAUTHORIZED -async def test_auth_access_signed_path(hass, app, aiohttp_client, hass_access_token): +async def test_auth_access_signed_path_with_refresh_token( + hass, app, aiohttp_client, hass_access_token +): """Test access with signed url.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - setup_auth(hass, app) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token(hass_access_token) - signed_path = async_sign_path(hass, refresh_token.id, "/", timedelta(seconds=5)) + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) req = await client.get(signed_path) assert req.status == HTTPStatus.OK @@ -265,7 +282,7 @@ async def test_auth_access_signed_path(hass, app, aiohttp_client, hass_access_to # Never valid as expired in the past. expired_signed_path = async_sign_path( - hass, refresh_token.id, "/", timedelta(seconds=-5) + hass, "/", timedelta(seconds=-5), refresh_token_id=refresh_token.id ) req = await client.get(expired_signed_path) @@ -277,10 +294,94 @@ async def test_auth_access_signed_path(hass, app, aiohttp_client, hass_access_to assert req.status == HTTPStatus.UNAUTHORIZED +async def test_auth_access_signed_path_via_websocket( + hass, app, hass_ws_client, hass_read_only_access_token +): + """Test signed url via websockets uses connection user.""" + + @websocket_api.websocket_command({"type": "diagnostics/list"}) + @callback + def get_signed_path(hass, connection, msg): + connection.send_result( + msg["id"], {"path": async_sign_path(hass, "/", timedelta(seconds=5))} + ) + + websocket_api.async_register_command(hass, get_signed_path) + + # We use hass_read_only_access_token to make sure the connection WS is used. + client = await hass_ws_client(access_token=hass_read_only_access_token) + + await client.send_json({"id": 5, "type": "diagnostics/list"}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + + refresh_token = await hass.auth.async_validate_access_token( + hass_read_only_access_token + ) + signature = yarl.URL(msg["result"]["path"]).query["authSig"] + claims = jwt.decode( + signature, + hass.data[DATA_SIGN_SECRET], + algorithms=["HS256"], + options={"verify_signature": False}, + ) + assert claims["iss"] == refresh_token.id + + +async def test_auth_access_signed_path_with_http( + hass, app, aiohttp_client, hass_access_token +): + """Test signed url via HTTP uses HTTP user.""" + setup_request_context(app, current_request) + + async def mock_handler(request): + """Return signed path.""" + return web.json_response( + data={"path": async_sign_path(hass, "/", timedelta(seconds=-5))} + ) + + app.router.add_get("/hello", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + req = await client.get( + "/hello", headers={"Authorization": f"Bearer {hass_access_token}"} + ) + assert req.status == HTTPStatus.OK + data = await req.json() + signature = yarl.URL(data["path"]).query["authSig"] + claims = jwt.decode( + signature, + hass.data[DATA_SIGN_SECRET], + algorithms=["HS256"], + options={"verify_signature": False}, + ) + assert claims["iss"] == refresh_token.id + + +async def test_auth_access_signed_path_with_content_user(hass, app, aiohttp_client): + """Test access signed url uses content user.""" + await async_setup_auth(hass, app) + signed_path = async_sign_path(hass, "/", timedelta(seconds=5)) + signature = yarl.URL(signed_path).query["authSig"] + claims = jwt.decode( + signature, + hass.data[DATA_SIGN_SECRET], + algorithms=["HS256"], + options={"verify_signature": False}, + ) + assert claims["iss"] == hass.data[STORAGE_KEY] + + async def test_local_only_user_rejected(hass, app, aiohttp_client, hass_access_token): """Test access with access token in header.""" token = hass_access_token - setup_auth(hass, app) + await async_setup_auth(hass, app) set_mock_ip = mock_real_ip(app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token(hass_access_token) @@ -340,3 +441,25 @@ async def test_async_user_not_allowed_do_auth(hass, app): async_user_not_allowed_do_auth(hass, user, trusted_request) == "User is local only" ) + + +async def test_create_user_once(hass): + """Test that we reuse the user.""" + cur_users = len(await hass.auth.async_get_users()) + app = web.Application() + await async_setup_auth(hass, app) + users = await hass.auth.async_get_users() + assert len(users) == cur_users + 1 + + user: User = next((user for user in users if user.name == CONTENT_USER_NAME), None) + assert user is not None, users + + assert len(user.groups) == 1 + assert user.groups[0].id == GROUP_ID_READ_ONLY + assert len(user.refresh_tokens) == 1 + assert user.system_generated + + await async_setup_auth(hass, app) + + # test it did not create a user + assert len(await hass.auth.async_get_users()) == cur_users + 1 diff --git a/tests/components/hue/test_device_trigger_v1.py b/tests/components/hue/test_device_trigger_v1.py index fcb6ca5668ed33..bcb49fe9a16ec7 100644 --- a/tests/components/hue/test_device_trigger_v1.py +++ b/tests/components/hue/test_device_trigger_v1.py @@ -1,6 +1,7 @@ """The tests for Philips Hue device triggers for V1 bridge.""" from homeassistant.components import automation, hue +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.hue.v1 import device_trigger from homeassistant.setup import async_setup_component @@ -25,7 +26,9 @@ async def test_get_triggers(hass, mock_bridge_v1, device_reg): hue_tap_device = device_reg.async_get_device( {(hue.DOMAIN, "00:00:00:00:00:44:23:08")} ) - triggers = await async_get_device_automations(hass, "trigger", hue_tap_device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, hue_tap_device.id + ) expected_triggers = [ { @@ -43,7 +46,9 @@ async def test_get_triggers(hass, mock_bridge_v1, device_reg): hue_dimmer_device = device_reg.async_get_device( {(hue.DOMAIN, "00:17:88:01:10:3e:3a:dc")} ) - triggers = await async_get_device_automations(hass, "trigger", hue_dimmer_device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, hue_dimmer_device.id + ) trigger_batt = { "platform": "device", diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index 85fe7b66233a81..a5e60b965fdc55 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -2,6 +2,7 @@ from aiohue.v2.models.button import ButtonEvent from homeassistant.components import hue +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.hue.v2.device import async_setup_devices from homeassistant.components.hue.v2.hue_event import async_setup_hue_events @@ -52,7 +53,7 @@ async def test_get_triggers(hass, mock_bridge_v2, v2_resources_test_data, device {(hue.DOMAIN, "3ff06175-29e8-44a8-8fe7-af591b0025da")} ) triggers = await async_get_device_automations( - hass, "trigger", hue_wall_switch_device.id + hass, DeviceAutomationType.TRIGGER, hue_wall_switch_device.id ) trigger_batt = { diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 8b811ffe7c64f3..c7578df3a49127 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -291,7 +291,7 @@ async def test_grouped_lights(hass, mock_bridge_v2, v2_resources_test_data): assert entity_entry assert entity_entry.disabled - assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # entity should not have a device assigned assert entity_entry.device_id is None diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index 8982b70fbfbf87..7f30fd25681ba6 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -1,6 +1,7 @@ """Philips Hue scene platform tests for V2 bridge/api.""" +from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers import entity_registry as er from .conftest import setup_platform @@ -21,7 +22,7 @@ async def test_scene(hass, mock_bridge_v2, v2_resources_test_data): test_entity = hass.states.get("scene.test_zone_dynamic_test_scene") assert test_entity is not None assert test_entity.name == "Test Zone - Dynamic Test Scene" - assert test_entity.state == "scening" + assert test_entity.state == STATE_UNKNOWN assert test_entity.attributes["group_name"] == "Test Zone" assert test_entity.attributes["group_type"] == "zone" assert test_entity.attributes["name"] == "Dynamic Test Scene" @@ -33,7 +34,7 @@ async def test_scene(hass, mock_bridge_v2, v2_resources_test_data): test_entity = hass.states.get("scene.test_room_regular_test_scene") assert test_entity is not None assert test_entity.name == "Test Room - Regular Test Scene" - assert test_entity.state == "scening" + assert test_entity.state == STATE_UNKNOWN assert test_entity.attributes["group_name"] == "Test Room" assert test_entity.attributes["group_type"] == "room" assert test_entity.attributes["name"] == "Regular Test Scene" @@ -87,6 +88,43 @@ async def test_scene_turn_on_service(hass, mock_bridge_v2, v2_resources_test_dat } +async def test_scene_advanced_turn_on_service( + hass, mock_bridge_v2, v2_resources_test_data +): + """Test calling the advanced turn on service on a scene.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + + await setup_platform(hass, mock_bridge_v2, "scene") + + test_entity_id = "scene.test_room_regular_test_scene" + + # call the hue.activate_scene service + await hass.services.async_call( + "hue", + "activate_scene", + {"entity_id": test_entity_id}, + blocking=True, + ) + + # PUT request should have been sent to device with correct params + assert len(mock_bridge_v2.mock_requests) == 1 + assert mock_bridge_v2.mock_requests[0]["method"] == "put" + assert mock_bridge_v2.mock_requests[0]["json"]["recall"] == {"action": "active"} + + # test again with sending speed and dynamic + await hass.services.async_call( + "hue", + "activate_scene", + {"entity_id": test_entity_id, "speed": 80, "dynamic": True}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 3 + assert mock_bridge_v2.mock_requests[1]["json"]["speed"] == 0.8 + assert mock_bridge_v2.mock_requests[2]["json"]["recall"] == { + "action": "dynamic_palette", + } + + async def test_scene_updates(hass, mock_bridge_v2, v2_resources_test_data): """Test scene events from bridge.""" await mock_bridge_v2.api.load_test_data(v2_resources_test_data) @@ -105,7 +143,7 @@ async def test_scene_updates(hass, mock_bridge_v2, v2_resources_test_data): # the entity should now be available test_entity = hass.states.get(test_entity_id) assert test_entity is not None - assert test_entity.state == "scening" + assert test_entity.state == STATE_UNKNOWN assert test_entity.name == "Test Room - Mocked Scene" assert test_entity.attributes["brightness"] == 65.0 diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 256c323ccce59e..2b060308b714cb 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -56,7 +56,7 @@ async def test_sensors(hass, mock_bridge_v2, v2_resources_test_data): assert entity_entry assert entity_entry.disabled - assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_enable_sensor( @@ -76,7 +76,7 @@ async def test_enable_sensor( assert entity_entry assert entity_entry.disabled - assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # enable the entity updated_entry = ent_reg.async_update_entity( diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index f6e7d1c4609337..4418127a0d17d2 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -5,8 +5,8 @@ from homeassistant.components.huisbaasje.const import FLOW_CUBIC_METERS_PER_HOUR from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -15,9 +15,6 @@ CONF_ID, CONF_PASSWORD, CONF_USERNAME, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_GAS, - DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, POWER_WATT, VOLUME_CUBIC_METERS, @@ -62,17 +59,26 @@ async def test_setup_entry(hass: HomeAssistant): # Assert data is loaded current_power = hass.states.get("sensor.huisbaasje_current_power") assert current_power.state == "1012.0" - assert current_power.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ( + current_power.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER + ) assert current_power.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" - assert current_power.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + current_power.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.MEASUREMENT + ) assert current_power.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT current_power_in = hass.states.get("sensor.huisbaasje_current_power_in_peak") assert current_power_in.state == "1012.0" - assert current_power_in.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ( + current_power_in.attributes.get(ATTR_DEVICE_CLASS) + == SensorDeviceClass.POWER + ) assert current_power_in.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - current_power_in.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + current_power_in.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.MEASUREMENT ) assert current_power_in.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT @@ -81,12 +87,13 @@ async def test_setup_entry(hass: HomeAssistant): ) assert current_power_in_low.state == "unknown" assert ( - current_power_in_low.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + current_power_in_low.attributes.get(ATTR_DEVICE_CLASS) + == SensorDeviceClass.POWER ) assert current_power_in_low.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( current_power_in_low.attributes.get(ATTR_STATE_CLASS) - == STATE_CLASS_MEASUREMENT + is SensorStateClass.MEASUREMENT ) assert ( current_power_in_low.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT @@ -94,11 +101,14 @@ async def test_setup_entry(hass: HomeAssistant): current_power_out = hass.states.get("sensor.huisbaasje_current_power_out_peak") assert current_power_out.state == "unknown" - assert current_power_out.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert ( + current_power_out.attributes.get(ATTR_DEVICE_CLASS) + == SensorDeviceClass.POWER + ) assert current_power_out.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( current_power_out.attributes.get(ATTR_STATE_CLASS) - == STATE_CLASS_MEASUREMENT + is SensorStateClass.MEASUREMENT ) assert current_power_out.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT @@ -108,12 +118,12 @@ async def test_setup_entry(hass: HomeAssistant): assert current_power_out_low.state == "unknown" assert ( current_power_out_low.attributes.get(ATTR_DEVICE_CLASS) - == DEVICE_CLASS_POWER + == SensorDeviceClass.POWER ) assert current_power_out_low.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( current_power_out_low.attributes.get(ATTR_STATE_CLASS) - == STATE_CLASS_MEASUREMENT + is SensorStateClass.MEASUREMENT ) assert ( current_power_out_low.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT @@ -125,7 +135,7 @@ async def test_setup_entry(hass: HomeAssistant): assert energy_consumption_peak_today.state == "2.67" assert ( energy_consumption_peak_today.attributes.get(ATTR_DEVICE_CLASS) - == DEVICE_CLASS_ENERGY + == SensorDeviceClass.ENERGY ) assert ( energy_consumption_peak_today.attributes.get(ATTR_ICON) @@ -133,7 +143,7 @@ async def test_setup_entry(hass: HomeAssistant): ) assert ( energy_consumption_peak_today.attributes.get(ATTR_STATE_CLASS) - is STATE_CLASS_TOTAL_INCREASING + is SensorStateClass.TOTAL_INCREASING ) assert ( energy_consumption_peak_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -146,7 +156,7 @@ async def test_setup_entry(hass: HomeAssistant): assert energy_consumption_off_peak_today.state == "0.627" assert ( energy_consumption_off_peak_today.attributes.get(ATTR_DEVICE_CLASS) - == DEVICE_CLASS_ENERGY + == SensorDeviceClass.ENERGY ) assert ( energy_consumption_off_peak_today.attributes.get(ATTR_ICON) @@ -154,7 +164,7 @@ async def test_setup_entry(hass: HomeAssistant): ) assert ( energy_consumption_off_peak_today.attributes.get(ATTR_STATE_CLASS) - is STATE_CLASS_TOTAL_INCREASING + is SensorStateClass.TOTAL_INCREASING ) assert ( energy_consumption_off_peak_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -167,7 +177,7 @@ async def test_setup_entry(hass: HomeAssistant): assert energy_production_peak_today.state == "1.512" assert ( energy_production_peak_today.attributes.get(ATTR_DEVICE_CLASS) - == DEVICE_CLASS_ENERGY + == SensorDeviceClass.ENERGY ) assert ( energy_production_peak_today.attributes.get(ATTR_ICON) @@ -175,7 +185,7 @@ async def test_setup_entry(hass: HomeAssistant): ) assert ( energy_production_peak_today.attributes.get(ATTR_STATE_CLASS) - is STATE_CLASS_TOTAL_INCREASING + is SensorStateClass.TOTAL_INCREASING ) assert ( energy_production_peak_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -188,7 +198,7 @@ async def test_setup_entry(hass: HomeAssistant): assert energy_production_off_peak_today.state == "1.093" assert ( energy_production_off_peak_today.attributes.get(ATTR_DEVICE_CLASS) - == DEVICE_CLASS_ENERGY + == SensorDeviceClass.ENERGY ) assert ( energy_production_off_peak_today.attributes.get(ATTR_ICON) @@ -196,7 +206,7 @@ async def test_setup_entry(hass: HomeAssistant): ) assert ( energy_production_off_peak_today.attributes.get(ATTR_STATE_CLASS) - is STATE_CLASS_TOTAL_INCREASING + is SensorStateClass.TOTAL_INCREASING ) assert ( energy_production_off_peak_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -205,9 +215,14 @@ async def test_setup_entry(hass: HomeAssistant): energy_today = hass.states.get("sensor.huisbaasje_energy_today") assert energy_today.state == "3.3" - assert energy_today.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ( + energy_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + ) assert energy_today.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" - assert energy_today.attributes.get(ATTR_STATE_CLASS) is STATE_CLASS_MEASUREMENT + assert ( + energy_today.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.MEASUREMENT + ) assert ( energy_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR @@ -215,10 +230,14 @@ async def test_setup_entry(hass: HomeAssistant): energy_this_week = hass.states.get("sensor.huisbaasje_energy_this_week") assert energy_this_week.state == "17.5" - assert energy_this_week.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ( + energy_this_week.attributes.get(ATTR_DEVICE_CLASS) + == SensorDeviceClass.ENERGY + ) assert energy_this_week.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_week.attributes.get(ATTR_STATE_CLASS) is STATE_CLASS_MEASUREMENT + energy_this_week.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.MEASUREMENT ) assert ( energy_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -228,12 +247,13 @@ async def test_setup_entry(hass: HomeAssistant): energy_this_month = hass.states.get("sensor.huisbaasje_energy_this_month") assert energy_this_month.state == "103.3" assert ( - energy_this_month.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + energy_this_month.attributes.get(ATTR_DEVICE_CLASS) + == SensorDeviceClass.ENERGY ) assert energy_this_month.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( energy_this_month.attributes.get(ATTR_STATE_CLASS) - is STATE_CLASS_MEASUREMENT + is SensorStateClass.MEASUREMENT ) assert ( energy_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -242,10 +262,14 @@ async def test_setup_entry(hass: HomeAssistant): energy_this_year = hass.states.get("sensor.huisbaasje_energy_this_year") assert energy_this_year.state == "673.0" - assert energy_this_year.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert ( + energy_this_year.attributes.get(ATTR_DEVICE_CLASS) + == SensorDeviceClass.ENERGY + ) assert energy_this_year.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_year.attributes.get(ATTR_STATE_CLASS) is STATE_CLASS_MEASUREMENT + energy_this_year.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.MEASUREMENT ) assert ( energy_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -256,7 +280,9 @@ async def test_setup_entry(hass: HomeAssistant): assert current_gas.state == "0.0" assert current_gas.attributes.get(ATTR_DEVICE_CLASS) is None assert current_gas.attributes.get(ATTR_ICON) == "mdi:fire" - assert current_gas.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + current_gas.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + ) assert ( current_gas.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == FLOW_CUBIC_METERS_PER_HOUR @@ -264,20 +290,21 @@ async def test_setup_entry(hass: HomeAssistant): gas_today = hass.states.get("sensor.huisbaasje_gas_today") assert gas_today.state == "1.1" - assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_today.attributes.get(ATTR_ICON) == "mdi:counter" assert ( - gas_today.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + gas_today.attributes.get(ATTR_STATE_CLASS) + is SensorStateClass.TOTAL_INCREASING ) assert gas_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == VOLUME_CUBIC_METERS gas_this_week = hass.states.get("sensor.huisbaasje_gas_this_week") assert gas_this_week.state == "5.6" - assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_week.attributes.get(ATTR_ICON) == "mdi:counter" assert ( gas_this_week.attributes.get(ATTR_STATE_CLASS) - is STATE_CLASS_TOTAL_INCREASING + is SensorStateClass.TOTAL_INCREASING ) assert ( gas_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -286,11 +313,11 @@ async def test_setup_entry(hass: HomeAssistant): gas_this_month = hass.states.get("sensor.huisbaasje_gas_this_month") assert gas_this_month.state == "39.1" - assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_month.attributes.get(ATTR_ICON) == "mdi:counter" assert ( gas_this_month.attributes.get(ATTR_STATE_CLASS) - is STATE_CLASS_TOTAL_INCREASING + is SensorStateClass.TOTAL_INCREASING ) assert ( gas_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -299,11 +326,11 @@ async def test_setup_entry(hass: HomeAssistant): gas_this_year = hass.states.get("sensor.huisbaasje_gas_this_year") assert gas_this_year.state == "116.7" - assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_GAS + assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_year.attributes.get(ATTR_ICON) == "mdi:counter" assert ( gas_this_year.attributes.get(ATTR_STATE_CLASS) - is STATE_CLASS_TOTAL_INCREASING + is SensorStateClass.TOTAL_INCREASING ) assert ( gas_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 39767b569ac2a0..f8391e2509b6bf 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -3,6 +3,7 @@ import voluptuous_serialize import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.humidifier import DOMAIN, const, device_action from homeassistant.const import STATE_ON from homeassistant.helpers import config_validation as cv, device_registry @@ -87,7 +88,9 @@ async def test_get_actions( } for action in expected_action_types ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index 0d0f65d2c97bdf..aed1079b915e6e 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -3,6 +3,7 @@ import voluptuous_serialize import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.humidifier import DOMAIN, const, device_condition from homeassistant.const import ATTR_MODE, STATE_OFF, STATE_ON from homeassistant.helpers import config_validation as cv, device_registry @@ -95,7 +96,9 @@ async def test_get_conditions( } for condition in expected_condition_types ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert_lists_same(conditions, expected_conditions) diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 12918684df76c0..144bbf3cdafebe 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -5,6 +5,7 @@ import voluptuous_serialize import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.humidifier import DOMAIN, const, device_trigger from homeassistant.const import ATTR_MODE, ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON from homeassistant.helpers import config_validation as cv, device_registry @@ -83,8 +84,17 @@ async def test_get_triggers(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": f"{DOMAIN}.test_5678", }, + { + "platform": "device", + "domain": DOMAIN, + "type": "changed_states", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -197,6 +207,30 @@ async def test_if_fires_on_state_change(hass, calls): }, }, }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "changed_states", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on_or_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, ] }, ) @@ -222,18 +256,20 @@ async def test_if_fires_on_state_change(hass, calls): # Fake turn off hass.states.async_set("humidifier.entity", STATE_OFF, {const.ATTR_HUMIDITY: 37}) await hass.async_block_till_done() - assert len(calls) == 4 - assert ( - calls[3].data["some"] == "turn_off device - humidifier.entity - on - off - None" - ) + assert len(calls) == 5 + assert {calls[3].data["some"], calls[4].data["some"]} == { + "turn_off device - humidifier.entity - on - off - None", + "turn_on_or_off device - humidifier.entity - on - off - None", + } # Fake turn on hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 37}) await hass.async_block_till_done() - assert len(calls) == 5 - assert ( - calls[4].data["some"] == "turn_on device - humidifier.entity - off - on - None" - ) + assert len(calls) == 7 + assert {calls[5].data["some"], calls[6].data["some"]} == { + "turn_on device - humidifier.entity - off - on - None", + "turn_on_or_off device - humidifier.entity - off - on - None", + } async def test_invalid_config(hass, calls): diff --git a/tests/components/hyperion/test_camera.py b/tests/components/hyperion/test_camera.py index 2ab16fb1301fb2..71e1e42cb1aa83 100644 --- a/tests/components/hyperion/test_camera.py +++ b/tests/components/hyperion/test_camera.py @@ -3,8 +3,7 @@ import asyncio import base64 -from collections.abc import Awaitable -from typing import Callable +from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, Mock, patch from aiohttp import web diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 5d57d3be70d504..0cb85aeb5e2286 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -897,6 +897,7 @@ async def test_setup_entry_no_token_reauth(hass: HomeAssistant) -> None: CONF_SOURCE: SOURCE_REAUTH, "entry_id": config_entry.entry_id, "unique_id": config_entry.unique_id, + "title_placeholders": {"name": config_entry.title}, }, data=config_entry.data, ) @@ -925,6 +926,7 @@ async def test_setup_entry_bad_token_reauth(hass: HomeAssistant) -> None: CONF_SOURCE: SOURCE_REAUTH, "entry_id": config_entry.entry_id, "unique_id": config_entry.unique_id, + "title_placeholders": {"name": config_entry.title}, }, data=config_entry.data, ) @@ -1345,7 +1347,7 @@ async def test_lights_can_be_enabled(hass: HomeAssistant) -> None: entry = entity_registry.async_get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) assert not entity_state diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py index e88db516eff71e..7ec441716ce28b 100644 --- a/tests/components/hyperion/test_switch.py +++ b/tests/components/hyperion/test_switch.py @@ -199,7 +199,7 @@ async def test_switches_can_be_enabled(hass: HomeAssistant) -> None: entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION entity_state = hass.states.get(entity_id) assert not entity_state diff --git a/tests/components/iaqualink/conftest.py b/tests/components/iaqualink/conftest.py new file mode 100644 index 00000000000000..6a46e063501bab --- /dev/null +++ b/tests/components/iaqualink/conftest.py @@ -0,0 +1,79 @@ +"""Configuration for iAqualink tests.""" +import random +from unittest.mock import AsyncMock + +from iaqualink.client import AqualinkClient +from iaqualink.device import AqualinkDevice +from iaqualink.system import AqualinkSystem +import pytest + +from homeassistant.components.iaqualink import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +MOCK_USERNAME = "test@example.com" +MOCK_PASSWORD = "password" +MOCK_DATA = {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD} + + +def async_returns(x): + """Return value-returning async mock.""" + return AsyncMock(return_value=x) + + +def async_raises(x): + """Return exception-raising async mock.""" + return AsyncMock(side_effect=x) + + +@pytest.fixture(name="client") +def client_fixture(): + """Create client fixture.""" + return AqualinkClient(username=MOCK_USERNAME, password=MOCK_PASSWORD) + + +def get_aqualink_system(aqualink, cls=None, data=None): + """Create aqualink system.""" + if cls is None: + cls = AqualinkSystem + + if data is None: + data = {} + + num = random.randint(0, 99999) + data["serial_number"] = f"SN{num:05}" + + return cls(aqualink=aqualink, data=data) + + +def get_aqualink_device(system, cls=None, data=None): + """Create aqualink device.""" + if cls is None: + cls = AqualinkDevice + + if data is None: + data = {} + + return cls(system=system, data=data) + + +@pytest.fixture(name="config_data") +def config_data_fixture(): + """Create hass config fixture.""" + return MOCK_DATA + + +@pytest.fixture(name="config") +def config_fixture(): + """Create hass config fixture.""" + return {DOMAIN: MOCK_DATA} + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(): + """Create a mock HEOS config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA, + ) diff --git a/tests/components/iaqualink/test_config_flow.py b/tests/components/iaqualink/test_config_flow.py index 38dd2ec1a3aa64..2d00284775d4ce 100644 --- a/tests/components/iaqualink/test_config_flow.py +++ b/tests/components/iaqualink/test_config_flow.py @@ -1,20 +1,19 @@ """Tests for iAqualink config flow.""" from unittest.mock import patch -import iaqualink +from iaqualink.exception import ( + AqualinkServiceException, + AqualinkServiceUnauthorizedException, +) import pytest from homeassistant.components.iaqualink import config_flow -from tests.common import MockConfigEntry, mock_coro - -DATA = {"username": "test@example.com", "password": "pass"} - @pytest.mark.parametrize("step", ["import", "user"]) -async def test_already_configured(hass, step): +async def test_already_configured(hass, config_entry, config_data, step): """Test config flow when iaqualink component is already setup.""" - MockConfigEntry(domain="iaqualink", data=DATA).add_to_hass(hass) + config_entry.add_to_hass(hass) flow = config_flow.AqualinkFlowHandler() flow.hass = hass @@ -22,14 +21,14 @@ async def test_already_configured(hass, step): fname = f"async_step_{step}" func = getattr(flow, fname) - result = await func(DATA) + result = await func(config_data) assert result["type"] == "abort" @pytest.mark.parametrize("step", ["import", "user"]) async def test_without_config(hass, step): - """Test with no configuration.""" + """Test config flow with no configuration.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass flow.context = {} @@ -44,7 +43,7 @@ async def test_without_config(hass, step): @pytest.mark.parametrize("step", ["import", "user"]) -async def test_with_invalid_credentials(hass, step): +async def test_with_invalid_credentials(hass, config_data, step): """Test config flow with invalid username and/or password.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass @@ -52,9 +51,29 @@ async def test_with_invalid_credentials(hass, step): fname = f"async_step_{step}" func = getattr(flow, fname) with patch( - "iaqualink.AqualinkClient.login", side_effect=iaqualink.AqualinkLoginException + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + side_effect=AqualinkServiceUnauthorizedException, ): - result = await func(DATA) + result = await func(config_data) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.parametrize("step", ["import", "user"]) +async def test_service_exception(hass, config_data, step): + """Test config flow encountering service exception.""" + flow = config_flow.AqualinkFlowHandler() + flow.hass = hass + + fname = f"async_step_{step}" + func = getattr(flow, fname) + with patch( + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + side_effect=AqualinkServiceException, + ): + result = await func(config_data) assert result["type"] == "form" assert result["step_id"] == "user" @@ -62,17 +81,20 @@ async def test_with_invalid_credentials(hass, step): @pytest.mark.parametrize("step", ["import", "user"]) -async def test_with_existing_config(hass, step): - """Test with existing configuration.""" +async def test_with_existing_config(hass, config_data, step): + """Test config flow with existing configuration.""" flow = config_flow.AqualinkFlowHandler() flow.hass = hass flow.context = {} fname = f"async_step_{step}" func = getattr(flow, fname) - with patch("iaqualink.AqualinkClient.login", return_value=mock_coro(None)): - result = await func(DATA) + with patch( + "homeassistant.components.iaqualink.config_flow.AqualinkClient.login", + return_value=None, + ): + result = await func(config_data) assert result["type"] == "create_entry" - assert result["title"] == DATA["username"] - assert result["data"] == DATA + assert result["title"] == config_data["username"] + assert result["data"] == config_data diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py new file mode 100644 index 00000000000000..3a35804f447e65 --- /dev/null +++ b/tests/components/iaqualink/test_init.py @@ -0,0 +1,354 @@ +"""Tests for iAqualink integration.""" + +import asyncio +import logging +from unittest.mock import AsyncMock, patch + +from iaqualink.device import ( + AqualinkAuxToggle, + AqualinkBinarySensor, + AqualinkDevice, + AqualinkLightToggle, + AqualinkSensor, + AqualinkThermostat, +) +from iaqualink.exception import AqualinkServiceException + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.iaqualink.const import UPDATE_INTERVAL +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_ON, STATE_UNAVAILABLE +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed +from tests.components.iaqualink.conftest import get_aqualink_device, get_aqualink_system + + +async def _ffwd_next_update_interval(hass): + now = dt_util.utcnow() + async_fire_time_changed(hass, now + UPDATE_INTERVAL) + await hass.async_block_till_done() + + +async def test_setup_login_exception(hass, config_entry): + """Test setup encountering a login exception.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + side_effect=AqualinkServiceException, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_login_timeout(hass, config_entry): + """Test setup encountering a timeout while logging in.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + side_effect=asyncio.TimeoutError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_systems_exception(hass, config_entry): + """Test setup encountering an exception while retrieving systems.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + side_effect=AqualinkServiceException, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_no_systems_recognized(hass, config_entry): + """Test setup ending in no systems recognized.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value={}, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_devices_exception(hass, config_entry, client): + """Test setup encountering an exception while retrieving devices.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client) + systems = {system.serial: system} + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), patch.object( + system, "get_devices" + ) as mock_get_devices: + mock_get_devices.side_effect = AqualinkServiceException + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_all_good_no_recognized_devices(hass, config_entry, client): + """Test setup ending in no devices recognized.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client) + systems = {system.serial: system} + + device = get_aqualink_device(system, AqualinkDevice, data={"name": "dev_1"}) + devices = {device.name: device} + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ), patch.object( + system, "get_devices" + ) as mock_get_devices: + mock_get_devices.return_value = devices + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_all_good_all_device_types(hass, config_entry, client): + """Test setup ending in one device of each type recognized.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client) + systems = {system.serial: system} + + devices = [ + get_aqualink_device(system, AqualinkAuxToggle, data={"name": "aux_1"}), + get_aqualink_device( + system, AqualinkBinarySensor, data={"name": "freeze_protection"} + ), + get_aqualink_device(system, AqualinkLightToggle, data={"name": "aux_2"}), + get_aqualink_device(system, AqualinkSensor, data={"name": "ph"}), + get_aqualink_device( + system, AqualinkThermostat, data={"name": "pool_set_point"} + ), + ] + devices = {d.name: d for d in devices} + + system.get_devices = AsyncMock(return_value=devices) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_multiple_updates(hass, config_entry, caplog, client): + """Test all possible results of online status transition after update.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client) + systems = {system.serial: system} + + system.get_devices = AsyncMock(return_value={}) + + caplog.set_level(logging.WARNING) + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + def set_online_to_true(): + system.online = True + + def set_online_to_false(): + system.online = False + + system.update = AsyncMock() + + # True -> True + system.online = True + caplog.clear() + system.update.side_effect = set_online_to_true + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 0 + + # True -> False + system.online = True + caplog.clear() + system.update.side_effect = set_online_to_false + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 0 + + # True -> None / ServiceException + system.online = True + caplog.clear() + system.update.side_effect = AqualinkServiceException + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 1 + assert "Failed" in caplog.text + + # False -> False + system.online = False + caplog.clear() + system.update.side_effect = set_online_to_false + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 0 + + # False -> True + system.online = False + caplog.clear() + system.update.side_effect = set_online_to_true + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 1 + assert "Reconnected" in caplog.text + + # False -> None / ServiceException + system.online = False + caplog.clear() + system.update.side_effect = AqualinkServiceException + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 1 + assert "Failed" in caplog.text + + # None -> None / ServiceException + system.online = None + caplog.clear() + system.update.side_effect = AqualinkServiceException + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 0 + + # None -> True + system.online = None + caplog.clear() + system.update.side_effect = set_online_to_true + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 1 + assert "Reconnected" in caplog.text + + # None -> False + system.online = None + caplog.clear() + system.update.side_effect = set_online_to_false + await _ffwd_next_update_interval(hass) + assert len(caplog.records) == 0 + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_entity_assumed_and_available(hass, config_entry, client): + """Test assumed_state and_available properties for all values of online.""" + config_entry.add_to_hass(hass) + + system = get_aqualink_system(client) + systems = {system.serial: system} + + light = get_aqualink_device( + system, AqualinkLightToggle, data={"name": "aux_1", "state": "1"} + ) + devices = {d.name: d for d in [light]} + system.get_devices = AsyncMock(return_value=devices) + system.update = AsyncMock() + + with patch( + "homeassistant.components.iaqualink.AqualinkClient.login", + return_value=None, + ), patch( + "homeassistant.components.iaqualink.AqualinkClient.get_systems", + return_value=systems, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 + + name = f"{LIGHT_DOMAIN}.{light.name}" + + # None means maybe. + light.system.online = None + await _ffwd_next_update_interval(hass) + state = hass.states.get(name) + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + light.system.online = False + await _ffwd_next_update_interval(hass) + state = hass.states.get(name) + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + light.system.online = True + await _ffwd_next_update_interval(hass) + state = hass.states.get(name) + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) is None diff --git a/tests/components/iaqualink/test_utils.py b/tests/components/iaqualink/test_utils.py new file mode 100644 index 00000000000000..56b239c0d9f313 --- /dev/null +++ b/tests/components/iaqualink/test_utils.py @@ -0,0 +1,23 @@ +"""Tests for iAqualink integration utility functions.""" + +from iaqualink.exception import AqualinkServiceException +import pytest + +from homeassistant.components.iaqualink.utils import await_or_reraise +from homeassistant.exceptions import HomeAssistantError + +from tests.components.iaqualink.conftest import async_raises, async_returns + + +async def test_await_or_reraise(hass): + """Test await_or_reraise for all values of awaitable.""" + async_noop = async_returns(None) + await await_or_reraise(async_noop()) + + with pytest.raises(Exception): + async_ex = async_raises(Exception) + await await_or_reraise(async_ex()) + + with pytest.raises(HomeAssistantError): + async_ex = async_raises(AqualinkServiceException) + await await_or_reraise(async_ex()) diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index 24ef68dd5bdd2d..94071e849c2800 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -1608,7 +1608,7 @@ async def test_event_listener_attribute_name_conflict( BASE_V2_CONFIG, _get_write_api_mock_v2, influxdb.API_VERSION_2, - influxdb.ApiException(), + influxdb.ApiException(http_resp=MagicMock()), ), ], indirect=["mock_client", "get_mock_call"], @@ -1650,7 +1650,7 @@ async def test_connection_failure_on_startup( BASE_V2_CONFIG, _get_write_api_mock_v2, influxdb.API_VERSION_2, - influxdb.ApiException(status=HTTPStatus.BAD_REQUEST), + influxdb.ApiException(status=HTTPStatus.BAD_REQUEST, http_resp=MagicMock()), ), ], indirect=["mock_client", "get_mock_call"], diff --git a/tests/components/influxdb/test_sensor.py b/tests/components/influxdb/test_sensor.py index ac88c3ab967a0a..86a32877a792bc 100644 --- a/tests/components/influxdb/test_sensor.py +++ b/tests/components/influxdb/test_sensor.py @@ -417,14 +417,14 @@ async def test_state_for_no_results( BASE_V2_CONFIG, BASE_V2_QUERY, _set_query_mock_v2, - ApiException(), + ApiException(http_resp=MagicMock()), ), ( API_VERSION_2, BASE_V2_CONFIG, BASE_V2_QUERY, _set_query_mock_v2, - ApiException(status=HTTPStatus.BAD_REQUEST), + ApiException(status=HTTPStatus.BAD_REQUEST, http_resp=MagicMock()), ), ], indirect=["mock_client"], @@ -534,7 +534,7 @@ async def test_error_rendering_template( BASE_V2_CONFIG, BASE_V2_QUERY, _set_query_mock_v2, - ApiException(), + ApiException(http_resp=MagicMock()), _make_v2_resultset, ), ], diff --git a/tests/components/input_button/__init__.py b/tests/components/input_button/__init__.py new file mode 100644 index 00000000000000..f5fd0e4fa97676 --- /dev/null +++ b/tests/components/input_button/__init__.py @@ -0,0 +1 @@ +"""Tests for the input_test component.""" diff --git a/tests/components/input_button/test_init.py b/tests/components/input_button/test_init.py new file mode 100644 index 00000000000000..33342455147fe4 --- /dev/null +++ b/tests/components/input_button/test_init.py @@ -0,0 +1,357 @@ +"""The tests for the input_test component.""" +import logging +from unittest.mock import patch + +import pytest + +from homeassistant.components.input_button import DOMAIN, SERVICE_PRESS +from homeassistant.const import ( + ATTR_EDITABLE, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_NAME, + SERVICE_RELOAD, + STATE_UNKNOWN, +) +from homeassistant.core import Context, CoreState, State +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.event import async_track_state_change +from homeassistant.setup import async_setup_component + +from tests.common import mock_component, mock_restore_cache + +_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", "name": "from storage"}]}, + } + else: + hass_storage[DOMAIN] = 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}] + + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + + +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) + + _LOGGER.debug("ENTITIES @ start: %s", hass.states.async_entity_ids()) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "test_1": None, + "test_2": {"name": "Hello World", "icon": "mdi:work"}, + } + }, + ) + + _LOGGER.debug("ENTITIES: %s", hass.states.async_entity_ids()) + + assert count_start + 2 == len(hass.states.async_entity_ids()) + + state_1 = hass.states.get("input_button.test_1") + state_2 = hass.states.get("input_button.test_2") + + assert state_1 is not None + assert state_2 is not None + + assert state_1.state == STATE_UNKNOWN + assert ATTR_ICON not in state_1.attributes + assert ATTR_FRIENDLY_NAME not in state_1.attributes + + assert state_2.state == STATE_UNKNOWN + assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World" + assert state_2.attributes.get(ATTR_ICON) == "mdi:work" + + +async def test_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache( + hass, + (State("input_button.b1", "2021-01-01T23:59:59+00:00"),), + ) + + hass.state = CoreState.starting + mock_component(hass, "recorder") + + await async_setup_component(hass, DOMAIN, {DOMAIN: {"b1": None, "b2": None}}) + + state = hass.states.get("input_button.b1") + assert state + assert state.state == "2021-01-01T23:59:59+00:00" + + state = hass.states.get("input_button.b2") + assert state + assert state.state == STATE_UNKNOWN + + +async def test_input_button_context(hass, hass_admin_user): + """Test that input_button context works.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"update": {}}}) + + state = hass.states.get("input_button.update") + assert state is not None + + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state.entity_id}, + True, + Context(user_id=hass_admin_user.id), + ) + + state2 = hass.states.get("input_button.update") + assert state2 is not None + assert state.state != state2.state + assert state2.context.user_id == hass_admin_user.id + + +async def test_reload(hass, hass_admin_user): + """Test reload service.""" + count_start = len(hass.states.async_entity_ids()) + ent_reg = er.async_get(hass) + + _LOGGER.debug("ENTITIES @ start: %s", hass.states.async_entity_ids()) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "test_1": None, + "test_2": {"name": "Hello World", "icon": "mdi:work"}, + } + }, + ) + + _LOGGER.debug("ENTITIES: %s", hass.states.async_entity_ids()) + + assert count_start + 2 == len(hass.states.async_entity_ids()) + + state_1 = hass.states.get("input_button.test_1") + state_2 = hass.states.get("input_button.test_2") + state_3 = hass.states.get("input_button.test_3") + + assert state_1 is not None + assert state_2 is not None + assert state_3 is None + + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={ + DOMAIN: { + "test_2": { + "name": "Hello World reloaded", + "icon": "mdi:work_reloaded", + }, + "test_3": None, + } + }, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + blocking=True, + context=Context(user_id=hass_admin_user.id), + ) + + assert count_start + 2 == len(hass.states.async_entity_ids()) + + state_1 = hass.states.get("input_button.test_1") + state_2 = hass.states.get("input_button.test_2") + state_3 = hass.states.get("input_button.test_3") + + assert state_1 is None + assert state_2 is not None + assert state_3 is not None + + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None + + +async def test_reload_not_changing_state(hass, storage_setup): + """Test reload not changing state.""" + assert await storage_setup() + state_changes = [] + + def state_changed_listener(entity_id, from_s, to_s): + state_changes.append(to_s) + + state = hass.states.get(f"{DOMAIN}.from_storage") + assert state is not None + + async_track_state_change(hass, [f"{DOMAIN}.from_storage"], state_changed_listener) + + # Pressing button changes state + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state.entity_id}, + True, + ) + await hass.async_block_till_done() + assert len(state_changes) == 1 + + # Reloading does not + with patch( + "homeassistant.config.load_yaml_config_file", autospec=True, return_value={} + ): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.from_storage") + assert state is not None + assert len(state_changes) == 1 + + +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 state.state == STATE_UNKNOWN + 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": None}}) + + state = hass.states.get(f"{DOMAIN}.from_storage") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "from storage" + assert state.attributes.get(ATTR_EDITABLE) + + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_EDITABLE) + + +async def test_ws_list(hass, hass_ws_client, storage_setup): + """Test listing via WS.""" + assert await storage_setup(config={DOMAIN: {"from_yaml": None}}) + + 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_create_update(hass, hass_ws_client, storage_setup): + """Test creating and updating via WS.""" + assert await storage_setup(config={DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 7, "type": f"{DOMAIN}/create", "name": "new"}) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get(f"{DOMAIN}.new") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "new" + + ent_reg = er.async_get(hass) + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "new") is not None + + await client.send_json( + {"id": 8, "type": f"{DOMAIN}/update", f"{DOMAIN}_id": "new", "name": "newer"} + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get(f"{DOMAIN}.new") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "newer" + + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "new") is not None + + +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 = er.async_get(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_setup_no_config(hass, hass_admin_user): + """Test component setup with no config.""" + count_start = len(hass.states.async_entity_ids()) + assert await async_setup_component(hass, DOMAIN, {}) + + with patch( + "homeassistant.config.load_yaml_config_file", autospec=True, return_value={} + ): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + blocking=True, + context=Context(user_id=hass_admin_user.id), + ) + + assert count_start == len(hass.states.async_entity_ids()) diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index 9b628f4443af29..683e687ec85b1b 100644 --- a/tests/components/insteon/test_api_properties.py +++ b/tests/components/insteon/test_api_properties.py @@ -216,7 +216,6 @@ async def test_create_radio_button_group(hass, hass_ws_client, properties_data): # Make sure the baseline is correct assert len(rb_props) == 3 - print(rb_props) rb_props[0]["value"].append("1") diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 3fb1cb2d48b828..9ca54ea8d8f190 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -507,11 +507,6 @@ async def test_options_remove_x10_device(hass: HomeAssistant): config_entry.add_to_hass(hass) result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_X10) - for device in config_entry.options[CONF_X10]: - housecode = device[CONF_HOUSECODE].upper() - unitcode = device[CONF_UNITCODE] - print(f"Housecode: {housecode}, Unitcode: {unitcode}") - user_input = {CONF_DEVICE: "Housecode: C, Unitcode: 4"} result, _ = await _options_form(hass, result["flow_id"], user_input) @@ -547,11 +542,6 @@ async def test_options_remove_x10_device_with_override(hass: HomeAssistant): config_entry.add_to_hass(hass) result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_X10) - for device in config_entry.options[CONF_X10]: - housecode = device[CONF_HOUSECODE].upper() - unitcode = device[CONF_UNITCODE] - print(f"Housecode: {housecode}, Unitcode: {unitcode}") - user_input = {CONF_DEVICE: "Housecode: C, Unitcode: 4"} result, _ = await _options_form(hass, result["flow_id"], user_input) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 03a43fd2c66594..6317dd6f6b0cfd 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -2,12 +2,12 @@ from datetime import timedelta from unittest.mock import patch -from homeassistant.components.sensor import STATE_CLASS_TOTAL +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, POWER_WATT, + STATE_UNKNOWN, TIME_SECONDS, ) from homeassistant.core import HomeAssistant, State @@ -39,13 +39,13 @@ async def test_state(hass) -> None: state = hass.states.get("sensor.integration") assert state is not None - assert state.attributes.get("state_class") == STATE_CLASS_TOTAL + assert state.attributes.get("state_class") is SensorStateClass.TOTAL assert "device_class" not in state.attributes future_now = dt_util.utcnow() + timedelta(seconds=3600) with patch("homeassistant.util.dt.utcnow", return_value=future_now): hass.states.async_set( - entity_id, 1, {"device_class": DEVICE_CLASS_POWER}, force_update=True + entity_id, 1, {"device_class": SensorDeviceClass.POWER}, force_update=True ) await hass.async_block_till_done() @@ -56,8 +56,8 @@ async def test_state(hass) -> None: assert round(float(state.state), config["sensor"]["round"]) == 1.0 assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR - assert state.attributes.get("device_class") == DEVICE_CLASS_ENERGY - assert state.attributes.get("state_class") == STATE_CLASS_TOTAL + assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY + assert state.attributes.get("state_class") is SensorStateClass.TOTAL async def test_restore_state(hass: HomeAssistant) -> None: @@ -69,7 +69,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: "sensor.integration", "100.0", { - "device_class": DEVICE_CLASS_ENERGY, + "device_class": SensorDeviceClass.ENERGY, "unit_of_measurement": ENERGY_KILO_WATT_HOUR, }, ), @@ -92,7 +92,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state assert state.state == "100.00" assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR - assert state.attributes.get("device_class") == DEVICE_CLASS_ENERGY + assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY async def test_restore_state_failed(hass: HomeAssistant) -> None: @@ -125,7 +125,7 @@ async def test_restore_state_failed(hass: HomeAssistant) -> None: assert state assert state.state == "unknown" assert state.attributes.get("unit_of_measurement") is None - assert state.attributes.get("state_class") == STATE_CLASS_TOTAL + assert state.attributes.get("state_class") is SensorStateClass.TOTAL assert "device_class" not in state.attributes @@ -293,3 +293,116 @@ async def test_suffix(hass): # Testing a network speed sensor at 1000 bytes/s over 10s = 10kbytes assert round(float(state.state)) == 10 + + +async def test_units(hass): + """Test integration sensor units using a power source.""" + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + # This replicates the current sequence when HA starts up in a real runtime + # by updating the base sensor state before the base sensor's units + # or state have been correctly populated. Those interim updates + # include states of None and Unknown + hass.states.async_set(entity_id, 100, {"unit_of_measurement": None}) + await hass.async_block_till_done() + hass.states.async_set(entity_id, 200, {"unit_of_measurement": None}) + await hass.async_block_till_done() + hass.states.async_set(entity_id, 300, {"unit_of_measurement": POWER_WATT}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state is not None + + # Testing the sensor ignored the source sensor's units until + # they became valid + assert state.attributes.get("unit_of_measurement") == ENERGY_WATT_HOUR + + +async def test_device_class(hass): + """Test integration sensor units using a power source.""" + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + # This replicates the current sequence when HA starts up in a real runtime + # by updating the base sensor state before the base sensor's units + # or state have been correctly populated. Those interim updates + # include states of None and Unknown + hass.states.async_set(entity_id, STATE_UNKNOWN, {}) + await hass.async_block_till_done() + hass.states.async_set(entity_id, 100, {"device_class": None}) + await hass.async_block_till_done() + hass.states.async_set(entity_id, 200, {"device_class": None}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert "device_class" not in state.attributes + + hass.states.async_set( + entity_id, 300, {"device_class": SensorDeviceClass.POWER}, force_update=True + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state is not None + # Testing the sensor ignored the source sensor's device class until + # it became valid + assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY + + +async def test_calc_errors(hass): + """Test integration sensor units using a power source.""" + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + + hass.states.async_set(entity_id, None, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + # With the source sensor in a None state, the Reimann sensor should be + # unknown + assert state is not None + assert state.state == STATE_UNKNOWN + + # Moving from an unknown state to a value is a calc error and should + # not change the value of the Reimann sensor. + hass.states.async_set(entity_id, 0, {"device_class": None}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state is not None + assert state.state == STATE_UNKNOWN + + # With the source sensor updated successfully, the Reimann sensor + # should have a zero (known) value. + hass.states.async_set(entity_id, 1, {"device_class": None}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state is not None + assert round(float(state.state)) == 0 diff --git a/tests/components/intellifire/__init__.py b/tests/components/intellifire/__init__.py new file mode 100644 index 00000000000000..f655ccc2fa451e --- /dev/null +++ b/tests/components/intellifire/__init__.py @@ -0,0 +1 @@ +"""Tests for the IntelliFire integration.""" diff --git a/tests/components/intellifire/conftest.py b/tests/components/intellifire/conftest.py new file mode 100644 index 00000000000000..2bbb3318090865 --- /dev/null +++ b/tests/components/intellifire/conftest.py @@ -0,0 +1,29 @@ +"""Fixtures for IntelliFire integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.intellifire.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_intellifire_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked IntelliFire client.""" + data_mock = Mock() + data_mock.serial = "12345" + + with patch( + "homeassistant.components.intellifire.config_flow.IntellifireAsync", + autospec=True, + ) as intellifire_mock: + intellifire = intellifire_mock.return_value + intellifire.data = data_mock + yield intellifire diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py new file mode 100644 index 00000000000000..c9d08318d3c7e1 --- /dev/null +++ b/tests/components/intellifire/test_config_flow.py @@ -0,0 +1,55 @@ +"""Test the IntelliFire config flow.""" +from unittest.mock import AsyncMock, MagicMock + +from homeassistant import config_entries +from homeassistant.components.intellifire.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_intellifire_config_flow: MagicMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Fireplace" + assert result2["data"] == {"host": "1.1.1.1"} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_intellifire_config_flow: MagicMock +) -> None: + """Test we handle cannot connect error.""" + mock_intellifire_config_flow.poll.side_effect = ConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index 2397338c22c18d..b025ed13d73644 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -4,15 +4,13 @@ from homeassistant.components.iotawatt.const import ATTR_LAST_UPDATE from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DEVICE_CLASS_ENERGY, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_POWER, ENERGY_WATT_HOUR, POWER_WATT, ) @@ -47,10 +45,10 @@ async def test_sensor_type_input(hass, mock_iotawatt): state = hass.states.get("sensor.my_sensor") assert state is not None assert state.state == "23" - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT assert state.attributes[ATTR_FRIENDLY_NAME] == "My Sensor" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER assert state.attributes["channel"] == "1" assert state.attributes["type"] == "Input" @@ -74,10 +72,10 @@ async def test_sensor_type_output(hass, mock_iotawatt): state = hass.states.get("sensor.my_watthour_sensor") assert state is not None assert state.state == "243" - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL assert state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Sensor" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY assert state.attributes["type"] == "Output" mock_iotawatt.getSensors.return_value["sensors"].pop("my_watthour_sensor_key") @@ -102,7 +100,7 @@ async def test_sensor_type_accumulated_output(hass, mock_iotawatt): "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", "100.0", { - "device_class": DEVICE_CLASS_ENERGY, + "device_class": SensorDeviceClass.ENERGY, "unit_of_measurement": ENERGY_WATT_HOUR, "last_update": DUMMY_DATE, }, @@ -125,9 +123,9 @@ async def test_sensor_type_accumulated_output(hass, mock_iotawatt): state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Accumulated Output Sensor.wh Accumulated" ) - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY assert state.attributes["type"] == "Output" assert state.attributes[ATTR_LAST_UPDATE] is not None assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE @@ -166,9 +164,9 @@ async def test_sensor_type_accumulated_output_error_restore(hass, mock_iotawatt) state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Accumulated Output Sensor.wh Accumulated" ) - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY assert state.attributes["type"] == "Output" assert state.attributes[ATTR_LAST_UPDATE] is not None assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE @@ -192,7 +190,7 @@ async def test_sensor_type_multiple_accumulated_output(hass, mock_iotawatt): "sensor.my_watthour_accumulated_output_sensor_wh_accumulated", "100.0", { - "device_class": DEVICE_CLASS_ENERGY, + "device_class": SensorDeviceClass.ENERGY, "unit_of_measurement": ENERGY_WATT_HOUR, "last_update": DUMMY_DATE, }, @@ -201,7 +199,7 @@ async def test_sensor_type_multiple_accumulated_output(hass, mock_iotawatt): "sensor.my_watthour_accumulated_input_sensor_wh_accumulated", "50.0", { - "device_class": DEVICE_CLASS_ENERGY, + "device_class": SensorDeviceClass.ENERGY, "unit_of_measurement": ENERGY_WATT_HOUR, "last_update": DUMMY_DATE, }, @@ -224,9 +222,9 @@ async def test_sensor_type_multiple_accumulated_output(hass, mock_iotawatt): state.attributes[ATTR_FRIENDLY_NAME] == "My WattHour Accumulated Output Sensor.wh Accumulated" ) - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_WATT_HOUR - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY assert state.attributes["type"] == "Output" assert state.attributes[ATTR_LAST_UPDATE] is not None assert state.attributes[ATTR_LAST_UPDATE] != DUMMY_DATE diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py index 405d7309b2378f..5531259c597394 100644 --- a/tests/components/ipp/test_sensor.py +++ b/tests/components/ipp/test_sensor.py @@ -95,7 +95,7 @@ async def test_disabled_by_default_sensors( entry = registry.async_get("sensor.epson_xp_6000_series_uptime") assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_missing_entry_unique_id( diff --git a/tests/components/iqvia/conftest.py b/tests/components/iqvia/conftest.py new file mode 100644 index 00000000000000..5b6a76e7e5723c --- /dev/null +++ b/tests/components/iqvia/conftest.py @@ -0,0 +1,109 @@ +"""Define test fixtures for IQVIA.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components.iqvia.const import CONF_ZIP_CODE, DOMAIN +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config, unique_id): + """Define a config entry fixture.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_ZIP_CODE: "12345", + } + + +@pytest.fixture(name="data_allergy_forecast", scope="session") +def data_allergy_forecast_fixture(): + """Define allergy forecast data.""" + return json.loads(load_fixture("allergy_forecast_data.json", "iqvia")) + + +@pytest.fixture(name="data_allergy_index", scope="session") +def data_allergy_index_fixture(): + """Define allergy index data.""" + return json.loads(load_fixture("allergy_index_data.json", "iqvia")) + + +@pytest.fixture(name="data_allergy_outlook", scope="session") +def data_allergy_outlook_fixture(): + """Define allergy outlook data.""" + return json.loads(load_fixture("allergy_outlook_data.json", "iqvia")) + + +@pytest.fixture(name="data_asthma_forecast", scope="session") +def data_asthma_forecast_fixture(): + """Define asthma forecast data.""" + return json.loads(load_fixture("asthma_forecast_data.json", "iqvia")) + + +@pytest.fixture(name="data_asthma_index", scope="session") +def data_asthma_index_fixture(): + """Define asthma index data.""" + return json.loads(load_fixture("asthma_index_data.json", "iqvia")) + + +@pytest.fixture(name="data_disease_forecast", scope="session") +def data_disease_forecast_fixture(): + """Define disease forecast data.""" + return json.loads(load_fixture("disease_forecast_data.json", "iqvia")) + + +@pytest.fixture(name="data_disease_index", scope="session") +def data_disease_index_fixture(): + """Define disease index data.""" + return json.loads(load_fixture("disease_index_data.json", "iqvia")) + + +@pytest.fixture(name="setup_iqvia") +async def setup_iqvia_fixture( + hass, + config, + data_allergy_forecast, + data_allergy_index, + data_allergy_outlook, + data_asthma_forecast, + data_asthma_index, + data_disease_forecast, + data_disease_index, +): + """Define a fixture to set up IQVIA.""" + with patch( + "pyiqvia.allergens.Allergens.extended", return_value=data_allergy_forecast + ), patch( + "pyiqvia.allergens.Allergens.current", return_value=data_allergy_index + ), patch( + "pyiqvia.allergens.Allergens.outlook", return_value=data_allergy_outlook + ), patch( + "pyiqvia.asthma.Asthma.extended", return_value=data_asthma_forecast + ), patch( + "pyiqvia.asthma.Asthma.current", return_value=data_asthma_index + ), patch( + "pyiqvia.disease.Disease.extended", return_value=data_disease_forecast + ), patch( + "pyiqvia.disease.Disease.current", return_value=data_disease_index + ), patch( + "homeassistant.components.iqvia.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="unique_id") +def unique_id_fixture(hass): + """Define a config entry unique ID fixture.""" + return "12345" diff --git a/tests/components/iqvia/fixtures/allergy_forecast_data.json b/tests/components/iqvia/fixtures/allergy_forecast_data.json new file mode 100644 index 00000000000000..2888feaa5a6df8 --- /dev/null +++ b/tests/components/iqvia/fixtures/allergy_forecast_data.json @@ -0,0 +1,33 @@ +{ + "Type": "pollen", + "ForecastDate": "2018-06-12T00:00:00-04:00", + "Location": { + "ZIP": "12345", + "City": "SCHENECTADY", + "State": "NY", + "periods": [ + { + "Period": "2018-06-12T13:47:12.897", + "Index": 6.6 + }, + { + "Period": "2018-06-13T13:47:12.897", + "Index": 6.3 + }, + { + "Period": "2018-06-14T13:47:12.897", + "Index": 7.6 + }, + { + "Period": "2018-06-15T13:47:12.897", + "Index": 7.6 + }, + { + "Period": "2018-06-16T13:47:12.897", + "Index": 7.3 + } + ], + "DisplayLocation": "Schenectady, NY" + } +} + diff --git a/tests/components/iqvia/fixtures/allergy_index_data.json b/tests/components/iqvia/fixtures/allergy_index_data.json new file mode 100644 index 00000000000000..9954c780dbbbf4 --- /dev/null +++ b/tests/components/iqvia/fixtures/allergy_index_data.json @@ -0,0 +1,88 @@ +{ + "Type": "pollen", + "ForecastDate": "2018-06-12T00:00:00-04:00", + "Location": { + "ZIP": "12345", + "City": "SCHENECTADY", + "State": "NY", + "periods": [ + { + "Triggers": [ + { + "LGID": 272, + "Name": "Juniper", + "Genus": "Juniperus", + "PlantType": "Tree" + }, + { + "LGID": 346, + "Name": "Grasses", + "Genus": "Grasses", + "PlantType": "Grass" + }, + { + "LGID": 63, + "Name": "Chenopods", + "Genus": "Chenopods", + "PlantType": "Ragweed" + } + ], + "Period": "0001-01-01T00:00:00", + "Type": "Yesterday", + "Index": 7.2 + }, + { + "Triggers": [ + { + "LGID": 272, + "Name": "Juniper", + "Genus": "Juniperus", + "PlantType": "Tree" + }, + { + "LGID": 346, + "Name": "Grasses", + "Genus": "Grasses", + "PlantType": "Grass" + }, + { + "LGID": 63, + "Name": "Chenopods", + "Genus": "Chenopods", + "PlantType": "Ragweed" + } + ], + "Period": "0001-01-01T00:00:00", + "Type": "Today", + "Index": 6.6 + }, + { + "Triggers": [ + { + "LGID": 272, + "Name": "Juniper", + "Genus": "Juniperus", + "PlantType": "Tree" + }, + { + "LGID": 346, + "Name": "Grasses", + "Genus": "Grasses", + "PlantType": "Grass" + }, + { + "LGID": 63, + "Name": "Chenopods", + "Genus": "Chenopods", + "PlantType": "Ragweed" + } + ], + "Period": "0001-01-01T00:00:00", + "Type": "Tomorrow", + "Index": 6.3 + } + ], + "DisplayLocation": "Schenectady, NY" + } +} + diff --git a/tests/components/iqvia/fixtures/allergy_outlook_data.json b/tests/components/iqvia/fixtures/allergy_outlook_data.json new file mode 100644 index 00000000000000..8696b45173a94f --- /dev/null +++ b/tests/components/iqvia/fixtures/allergy_outlook_data.json @@ -0,0 +1,9 @@ +{ + "Market": "SCHENECTADY, CO", + "ZIP": "12345", + "TrendID": 4, + "Trend": "subsiding", + "Outlook": "The amount of pollen in the air for Wednesday...", + "Season": "Tree" +} + diff --git a/tests/components/iqvia/fixtures/asthma_forecast_data.json b/tests/components/iqvia/fixtures/asthma_forecast_data.json new file mode 100644 index 00000000000000..89b9d2279c0a6f --- /dev/null +++ b/tests/components/iqvia/fixtures/asthma_forecast_data.json @@ -0,0 +1,38 @@ +{ + "Type": "asthma", + "ForecastDate": "2018-10-28T00:00:00-04:00", + "Location": { + "ZIP": "12345", + "City": "SCHENECTADY", + "State": "NY", + "periods": [ + { + "Period": "2018-10-28T05:45:01.45", + "Index": 4.5, + "Idx": "4.5" + }, + { + "Period": "2018-10-29T05:45:01.45", + "Index": 4.7, + "Idx": "4.7" + }, + { + "Period": "2018-10-30T05:45:01.45", + "Index": 5, + "Idx": "5.0" + }, + { + "Period": "2018-10-31T05:45:01.45", + "Index": 5.2, + "Idx": "5.2" + }, + { + "Period": "2018-11-01T05:45:01.45", + "Index": 5.5, + "Idx": "5.5" + } + ], + "DisplayLocation": "Schenectady, NY" + } +} + diff --git a/tests/components/iqvia/fixtures/asthma_index_data.json b/tests/components/iqvia/fixtures/asthma_index_data.json new file mode 100644 index 00000000000000..3ddf54c150bddd --- /dev/null +++ b/tests/components/iqvia/fixtures/asthma_index_data.json @@ -0,0 +1,72 @@ +{ + "Type": "asthma", + "ForecastDate": "2018-10-29T00:00:00-04:00", + "Location": { + "ZIP": "12345", + "City": "SCHENECTADY", + "State": "NY", + "periods": [ + { + "Triggers": [ + { + "LGID": 1, + "Name": "OZONE", + "PPM": 42, + "Description": "Ozone (O3) is a odorless, colorless ...." + }, + { + "LGID": 1, + "Name": "PM2.5", + "PPM": 30, + "Description": "Fine particles (PM2.5) are 2.5 ..." + }, + { + "LGID": 1, + "Name": "PM10", + "PPM": 19, + "Description": "Coarse dust particles (PM10) are 2.5 ..." + } + ], + "Period": "0001-01-01T00:00:00", + "Type": "Yesterday", + "Index": 4.1, + "Idx": "4.1" + }, + { + "Triggers": [ + { + "LGID": 3, + "Name": "PM2.5", + "PPM": 105, + "Description": "Fine particles (PM2.5) are 2.5 ..." + }, + { + "LGID": 2, + "Name": "PM10", + "PPM": 65, + "Description": "Coarse dust particles (PM10) are 2.5 ..." + }, + { + "LGID": 1, + "Name": "OZONE", + "PPM": 42, + "Description": "Ozone (O3) is a odorless, colorless ..." + } + ], + "Period": "0001-01-01T00:00:00", + "Type": "Today", + "Index": 4.5, + "Idx": "4.5" + }, + { + "Triggers": [], + "Period": "0001-01-01T00:00:00", + "Type": "Tomorrow", + "Index": 4.6, + "Idx": "4.6" + } + ], + "DisplayLocation": "Schenectady, NY" + } +} + diff --git a/tests/components/iqvia/fixtures/disease_forecast_data.json b/tests/components/iqvia/fixtures/disease_forecast_data.json new file mode 100644 index 00000000000000..3760d60a5494ed --- /dev/null +++ b/tests/components/iqvia/fixtures/disease_forecast_data.json @@ -0,0 +1,29 @@ +{ + "Type": "cold", + "ForecastDate": "2018-06-12T00:00:00-04:00", + "Location": { + "ZIP": "12345", + "City": "SCHENECTADY", + "State": "NY", + "periods": [ + { + "Period": "2018-06-12T05:13:51.817", + "Index": 2.4 + }, + { + "Period": "2018-06-13T05:13:51.817", + "Index": 2.5 + }, + { + "Period": "2018-06-14T05:13:51.817", + "Index": 2.5 + }, + { + "Period": "2018-06-15T05:13:51.817", + "Index": 2.5 + } + ], + "DisplayLocation": "Schenectady, NY" + } +} + diff --git a/tests/components/iqvia/fixtures/disease_index_data.json b/tests/components/iqvia/fixtures/disease_index_data.json new file mode 100644 index 00000000000000..3f8241bf10c098 --- /dev/null +++ b/tests/components/iqvia/fixtures/disease_index_data.json @@ -0,0 +1,77 @@ +{ + "ForecastDate": "2019-04-07T00:00:00-04:00", + "Location": { + "City": "SCHENECTADY", + "DisplayLocation": "Schenectady, NY", + "State": "NY", + "ZIP": "12345", + "periods": [ + { + "Idx": "6.8", + "Index": 6.8, + "Period": "2019-04-06T00:00:00", + "Triggers": [ + { + "Description": "Influenza", + "Idx": "3.1", + "Index": 3.1, + "Name": "Flu" + }, + { + "Description": "High Fever", + "Idx": "6.2", + "Index": 6.2, + "Name": "Fever" + }, + { + "Description": "Strep & Sore throat", + "Idx": "5.2", + "Index": 5.2, + "Name": "Strep" + }, + { + "Description": "Cough", + "Idx": "7.8", + "Index": 7.8, + "Name": "Cough" + } + ], + "Type": "Yesterday" + }, + { + "Idx": "6.7", + "Index": 6.7, + "Period": "2019-04-07T03:52:58", + "Triggers": [ + { + "Description": "Influenza", + "Idx": "3.1", + "Index": 3.1, + "Name": "Flu" + }, + { + "Description": "High Fever", + "Idx": "5.9", + "Index": 5.9, + "Name": "Fever" + }, + { + "Description": "Strep & Sore throat", + "Idx": "5.1", + "Index": 5.1, + "Name": "Strep" + }, + { + "Description": "Cough", + "Idx": "7.7", + "Index": 7.7, + "Name": "Cough" + } + ], + "Type": "Today" + } + ] + }, + "Type": "cold" +} + diff --git a/tests/components/iqvia/test_config_flow.py b/tests/components/iqvia/test_config_flow.py index 5cc71302bc5209..315098a44d86c9 100644 --- a/tests/components/iqvia/test_config_flow.py +++ b/tests/components/iqvia/test_config_flow.py @@ -1,35 +1,23 @@ """Define tests for the IQVIA config flow.""" -from unittest.mock import patch - from homeassistant import data_entry_flow from homeassistant.components.iqvia import CONF_ZIP_CODE, DOMAIN from homeassistant.config_entries import SOURCE_USER -from tests.common import MockConfigEntry - -async def test_duplicate_error(hass): +async def test_duplicate_error(hass, config, config_entry): """Test that errors are shown when duplicates are added.""" - conf = {CONF_ZIP_CODE: "12345"} - - MockConfigEntry(domain=DOMAIN, unique_id="12345", data=conf).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" async def test_invalid_zip_code(hass): """Test that an invalid ZIP code key throws an error.""" - conf = {CONF_ZIP_CODE: "abcde"} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data={CONF_ZIP_CODE: "bad"} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {CONF_ZIP_CODE: "invalid_zip_code"} @@ -39,20 +27,15 @@ async def test_show_form(hass): 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_user(hass): +async def test_step_user(hass, config, setup_iqvia): """Test that the user step works (without MFA).""" - conf = {CONF_ZIP_CODE: "12345"} - - with patch("homeassistant.components.iqvia.async_setup_entry", return_value=True): - 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"] == "12345" - assert result["data"] == {CONF_ZIP_CODE: "12345"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=config + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "12345" + assert result["data"] == {CONF_ZIP_CODE: "12345"} diff --git a/tests/components/iqvia/test_diagnostics.py b/tests/components/iqvia/test_diagnostics.py new file mode 100644 index 00000000000000..4c5f4bcac75ef8 --- /dev/null +++ b/tests/components/iqvia/test_diagnostics.py @@ -0,0 +1,324 @@ +"""Test IQVIA diagnostics.""" +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_iqvia): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "title": "Mock Title", + "data": { + "zip_code": "12345", + }, + }, + "data": { + "allergy_average_forecasted": { + "Type": "pollen", + "ForecastDate": "2018-06-12T00:00:00-04:00", + "Location": { + "ZIP": "12345", + "City": "SCHENECTADY", + "State": "NY", + "periods": [ + {"Period": "2018-06-12T13:47:12.897", "Index": 6.6}, + {"Period": "2018-06-13T13:47:12.897", "Index": 6.3}, + {"Period": "2018-06-14T13:47:12.897", "Index": 7.6}, + {"Period": "2018-06-15T13:47:12.897", "Index": 7.6}, + {"Period": "2018-06-16T13:47:12.897", "Index": 7.3}, + ], + "DisplayLocation": "Schenectady, NY", + }, + }, + "allergy_index": { + "Type": "pollen", + "ForecastDate": "2018-06-12T00:00:00-04:00", + "Location": { + "ZIP": "12345", + "City": "SCHENECTADY", + "State": "NY", + "periods": [ + { + "Triggers": [ + { + "LGID": 272, + "Name": "Juniper", + "Genus": "Juniperus", + "PlantType": "Tree", + }, + { + "LGID": 346, + "Name": "Grasses", + "Genus": "Grasses", + "PlantType": "Grass", + }, + { + "LGID": 63, + "Name": "Chenopods", + "Genus": "Chenopods", + "PlantType": "Ragweed", + }, + ], + "Period": "0001-01-01T00:00:00", + "Type": "Yesterday", + "Index": 7.2, + }, + { + "Triggers": [ + { + "LGID": 272, + "Name": "Juniper", + "Genus": "Juniperus", + "PlantType": "Tree", + }, + { + "LGID": 346, + "Name": "Grasses", + "Genus": "Grasses", + "PlantType": "Grass", + }, + { + "LGID": 63, + "Name": "Chenopods", + "Genus": "Chenopods", + "PlantType": "Ragweed", + }, + ], + "Period": "0001-01-01T00:00:00", + "Type": "Today", + "Index": 6.6, + }, + { + "Triggers": [ + { + "LGID": 272, + "Name": "Juniper", + "Genus": "Juniperus", + "PlantType": "Tree", + }, + { + "LGID": 346, + "Name": "Grasses", + "Genus": "Grasses", + "PlantType": "Grass", + }, + { + "LGID": 63, + "Name": "Chenopods", + "Genus": "Chenopods", + "PlantType": "Ragweed", + }, + ], + "Period": "0001-01-01T00:00:00", + "Type": "Tomorrow", + "Index": 6.3, + }, + ], + "DisplayLocation": "Schenectady, NY", + }, + }, + "allergy_outlook": { + "Market": "SCHENECTADY, CO", + "ZIP": "12345", + "TrendID": 4, + "Trend": "subsiding", + "Outlook": "The amount of pollen in the air for Wednesday...", + "Season": "Tree", + }, + "asthma_average_forecasted": { + "Type": "asthma", + "ForecastDate": "2018-10-28T00:00:00-04:00", + "Location": { + "ZIP": "12345", + "City": "SCHENECTADY", + "State": "NY", + "periods": [ + { + "Period": "2018-10-28T05:45:01.45", + "Index": 4.5, + "Idx": "4.5", + }, + { + "Period": "2018-10-29T05:45:01.45", + "Index": 4.7, + "Idx": "4.7", + }, + {"Period": "2018-10-30T05:45:01.45", "Index": 5, "Idx": "5.0"}, + { + "Period": "2018-10-31T05:45:01.45", + "Index": 5.2, + "Idx": "5.2", + }, + { + "Period": "2018-11-01T05:45:01.45", + "Index": 5.5, + "Idx": "5.5", + }, + ], + "DisplayLocation": "Schenectady, NY", + }, + }, + "asthma_index": { + "Type": "asthma", + "ForecastDate": "2018-10-29T00:00:00-04:00", + "Location": { + "ZIP": "12345", + "City": "SCHENECTADY", + "State": "NY", + "periods": [ + { + "Triggers": [ + { + "LGID": 1, + "Name": "OZONE", + "PPM": 42, + "Description": "Ozone (O3) is a odorless, colorless ....", + }, + { + "LGID": 1, + "Name": "PM2.5", + "PPM": 30, + "Description": "Fine particles (PM2.5) are 2.5 ...", + }, + { + "LGID": 1, + "Name": "PM10", + "PPM": 19, + "Description": "Coarse dust particles (PM10) are 2.5 ...", + }, + ], + "Period": "0001-01-01T00:00:00", + "Type": "Yesterday", + "Index": 4.1, + "Idx": "4.1", + }, + { + "Triggers": [ + { + "LGID": 3, + "Name": "PM2.5", + "PPM": 105, + "Description": "Fine particles (PM2.5) are 2.5 ...", + }, + { + "LGID": 2, + "Name": "PM10", + "PPM": 65, + "Description": "Coarse dust particles (PM10) are 2.5 ...", + }, + { + "LGID": 1, + "Name": "OZONE", + "PPM": 42, + "Description": "Ozone (O3) is a odorless, colorless ...", + }, + ], + "Period": "0001-01-01T00:00:00", + "Type": "Today", + "Index": 4.5, + "Idx": "4.5", + }, + { + "Triggers": [], + "Period": "0001-01-01T00:00:00", + "Type": "Tomorrow", + "Index": 4.6, + "Idx": "4.6", + }, + ], + "DisplayLocation": "Schenectady, NY", + }, + }, + "disease_average_forecasted": { + "Type": "cold", + "ForecastDate": "2018-06-12T00:00:00-04:00", + "Location": { + "ZIP": "12345", + "City": "SCHENECTADY", + "State": "NY", + "periods": [ + {"Period": "2018-06-12T05:13:51.817", "Index": 2.4}, + {"Period": "2018-06-13T05:13:51.817", "Index": 2.5}, + {"Period": "2018-06-14T05:13:51.817", "Index": 2.5}, + {"Period": "2018-06-15T05:13:51.817", "Index": 2.5}, + ], + "DisplayLocation": "Schenectady, NY", + }, + }, + "disease_index": { + "ForecastDate": "2019-04-07T00:00:00-04:00", + "Location": { + "City": "SCHENECTADY", + "DisplayLocation": "Schenectady, NY", + "State": "NY", + "ZIP": "12345", + "periods": [ + { + "Idx": "6.8", + "Index": 6.8, + "Period": "2019-04-06T00:00:00", + "Triggers": [ + { + "Description": "Influenza", + "Idx": "3.1", + "Index": 3.1, + "Name": "Flu", + }, + { + "Description": "High Fever", + "Idx": "6.2", + "Index": 6.2, + "Name": "Fever", + }, + { + "Description": "Strep & Sore throat", + "Idx": "5.2", + "Index": 5.2, + "Name": "Strep", + }, + { + "Description": "Cough", + "Idx": "7.8", + "Index": 7.8, + "Name": "Cough", + }, + ], + "Type": "Yesterday", + }, + { + "Idx": "6.7", + "Index": 6.7, + "Period": "2019-04-07T03:52:58", + "Triggers": [ + { + "Description": "Influenza", + "Idx": "3.1", + "Index": 3.1, + "Name": "Flu", + }, + { + "Description": "High Fever", + "Idx": "5.9", + "Index": 5.9, + "Name": "Fever", + }, + { + "Description": "Strep & Sore throat", + "Idx": "5.1", + "Index": 5.1, + "Name": "Strep", + }, + { + "Description": "Cough", + "Idx": "7.7", + "Index": 7.7, + "Name": "Cough", + }, + ], + "Type": "Today", + }, + ], + }, + "Type": "cold", + }, + }, + } diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index e9a4c5dc4fb3c2..b16f5c0070d570 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -16,7 +16,12 @@ ISY_URL_POSTFIX, UDN_UUID_PREFIX, ) -from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IMPORT, SOURCE_SSDP +from homeassistant.config_entries import ( + SOURCE_DHCP, + SOURCE_IGNORE, + SOURCE_IMPORT, + SOURCE_SSDP, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -595,3 +600,27 @@ async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant): assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == f"http://1.2.3.4:1443{ISY_URL_POSTFIX}" assert entry.data[CONF_USERNAME] == "bob" + + +async def test_form_dhcp_existing_ignored_entry(hass: HomeAssistant): + """Test we handled an ignored entry from dhcp.""" + + entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MOCK_UUID, source=SOURCE_IGNORE + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="1.2.3.4", + hostname="isy994-ems", + macaddress=MOCK_MAC, + ), + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index e9471d5d144d73..879b5edb120e28 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -538,7 +538,6 @@ async def test_shabbat_times_sensor( for sensor_type, result_value in result.items(): if not sensor_type.startswith(language): - print(f"Not checking {sensor_type} for {language}") continue sensor_type = sensor_type.replace(f"{language}_", "") diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index 0b438487c49b46..18d5f3df1fb578 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -69,25 +69,6 @@ async def test_flow_works(hass: HomeAssistant, connect) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_works(hass: HomeAssistant, connect) -> None: - """Test config flow.""" - - with patch( - "homeassistant.components.keenetic_ndms2.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - keenetic.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_DATA, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == MOCK_NAME - assert result["data"] == MOCK_DATA - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_options(hass: HomeAssistant) -> None: """Test updating options.""" entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index a692fa978140ed..71a86f1e397fb0 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -60,13 +60,13 @@ def knx_ip_interface_mock(): def fish_xknx(*args, **kwargs): """Get the XKNX object from the constructor call.""" - self.xknx = args[0] + self.xknx = kwargs["xknx"] # disable rate limiter for tests (before StateUpdater starts) self.xknx.rate_limit = 0 return DEFAULT with patch( - "xknx.xknx.KNXIPInterface", + "xknx.xknx.knx_interface_factory", return_value=knx_ip_interface_mock(), side_effect=fish_xknx, ): @@ -109,7 +109,7 @@ async def assert_telegram_count(self, count: int) -> None: # APCI Service tests #################### - async def _assert_telegram( + async def assert_telegram( self, group_address: str, payload: int | tuple[int, ...] | None, @@ -141,19 +141,19 @@ async def _assert_telegram( async def assert_read(self, group_address: str) -> None: """Assert outgoing GroupValueRead telegram. One by one in timely order.""" - await self._assert_telegram(group_address, None, GroupValueRead) + await self.assert_telegram(group_address, None, GroupValueRead) async def assert_response( self, group_address: str, payload: int | tuple[int, ...] ) -> None: """Assert outgoing GroupValueResponse telegram. One by one in timely order.""" - await self._assert_telegram(group_address, payload, GroupValueResponse) + await self.assert_telegram(group_address, payload, GroupValueResponse) async def assert_write( self, group_address: str, payload: int | tuple[int, ...] ) -> None: """Assert outgoing GroupValueWrite telegram. One by one in timely order.""" - await self._assert_telegram(group_address, payload, GroupValueWrite) + await self.assert_telegram(group_address, payload, GroupValueWrite) #################### # Incoming telegrams diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index 5513cefcbb4a22..25a223e76c8330 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -4,14 +4,9 @@ from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE from homeassistant.components.knx.schema import BinarySensorSchema -from homeassistant.const import ( - CONF_ENTITY_CATEGORY, - CONF_NAME, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_OFF, - STATE_ON, -) +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_registry import ( async_get_registry as async_get_entity_registry, ) @@ -30,7 +25,7 @@ async def test_binary_sensor_entity_category(hass: HomeAssistant, knx: KNXTestKi { CONF_NAME: "test_normal", CONF_STATE_ADDRESS: "1/1/1", - CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC, + CONF_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, }, ] } @@ -42,7 +37,7 @@ async def test_binary_sensor_entity_category(hass: HomeAssistant, knx: KNXTestKi registry = await async_get_entity_registry(hass) entity = registry.async_get("binary_sensor.test_normal") - assert entity.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entity.entity_category is EntityCategory.DIAGNOSTIC async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit): diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index aec757a1086136..83b5a9988c77f0 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -11,7 +11,12 @@ from homeassistant.components.knx.config_flow import ( CONF_DEFAULT_LOCAL_IP, CONF_KNX_GATEWAY, + CONF_KNX_LABEL_TUNNELING_TCP, + CONF_KNX_LABEL_TUNNELING_UDP, + CONF_KNX_LABEL_TUNNELING_UDP_ROUTE_BACK, + CONF_KNX_TUNNELING_TYPE, DEFAULT_ENTRY_DATA, + get_knx_tunneling_type, ) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, @@ -19,6 +24,7 @@ CONF_KNX_INDIVIDUAL_ADDRESS, CONF_KNX_ROUTING, CONF_KNX_TUNNELING, + CONF_KNX_TUNNELING_TCP, DOMAIN, ) from homeassistant.const import CONF_HOST, CONF_PORT @@ -28,16 +34,19 @@ from tests.common import MockConfigEntry -def _gateway_descriptor(ip: str, port: int) -> GatewayDescriptor: +def _gateway_descriptor( + ip: str, port: int, supports_tunnelling_tcp: bool = False +) -> GatewayDescriptor: """Get mock gw descriptor.""" return GatewayDescriptor( - "Test", - ip, - port, - "eth0", - "127.0.0.1", + name="Test", + ip_addr=ip, + port=port, + local_interface="eth0", + local_ip="127.0.0.1", supports_routing=True, supports_tunnelling=True, + supports_tunnelling_tcp=supports_tunnelling_tcp, ) @@ -153,9 +162,64 @@ async def test_routing_setup_advanced(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_tunneling_setup(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "user_input,config_entry_data", + [ + ( + { + CONF_KNX_TUNNELING_TYPE: CONF_KNX_LABEL_TUNNELING_UDP, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + }, + { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: None, + }, + ), + ( + { + CONF_KNX_TUNNELING_TYPE: CONF_KNX_LABEL_TUNNELING_TCP, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + }, + { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: None, + }, + ), + ( + { + CONF_KNX_TUNNELING_TYPE: CONF_KNX_LABEL_TUNNELING_UDP_ROUTE_BACK, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + }, + { + **DEFAULT_ENTRY_DATA, + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_HOST: "192.168.0.1", + CONF_PORT: 3675, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + ConnectionSchema.CONF_KNX_LOCAL_IP: None, + }, + ), + ], +) +async def test_tunneling_setup( + hass: HomeAssistant, user_input, config_entry_data +) -> None: """Test tunneling if only one gateway is found.""" - gateway = _gateway_descriptor("192.168.0.1", 3675) + gateway = _gateway_descriptor("192.168.0.1", 3675, True) with patch("xknx.io.gateway_scanner.GatewayScanner.scan") as gateways: gateways.return_value = [gateway] result = await hass.config_entries.flow.async_init( @@ -181,23 +245,12 @@ async def test_tunneling_setup(hass: HomeAssistant) -> None: ) as mock_setup_entry: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - { - CONF_HOST: "192.168.0.1", - CONF_PORT: 3675, - }, + user_input, ) await hass.async_block_till_done() assert result3["type"] == RESULT_TYPE_CREATE_ENTRY assert result3["title"] == "Tunneling @ 192.168.0.1" - assert result3["data"] == { - **DEFAULT_ENTRY_DATA, - CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, - CONF_HOST: "192.168.0.1", - CONF_PORT: 3675, - CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", - ConnectionSchema.CONF_KNX_ROUTE_BACK: False, - ConnectionSchema.CONF_KNX_LOCAL_IP: None, - } + assert result3["data"] == config_entry_data assert len(mock_setup_entry.mock_calls) == 1 @@ -235,6 +288,7 @@ async def test_tunneling_setup_for_local_ip(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { + CONF_KNX_TUNNELING_TYPE: CONF_KNX_LABEL_TUNNELING_UDP, CONF_HOST: "192.168.0.2", CONF_PORT: 3675, ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", @@ -294,6 +348,7 @@ async def test_tunneling_setup_for_multiple_found_gateways(hass: HomeAssistant) manual_tunnel_flow = await hass.config_entries.flow.async_configure( manual_tunnel["flow_id"], { + CONF_KNX_TUNNELING_TYPE: CONF_KNX_LABEL_TUNNELING_UDP, CONF_HOST: "192.168.0.1", CONF_PORT: 3675, }, @@ -595,8 +650,73 @@ async def test_options_flow( } +@pytest.mark.parametrize( + "user_input,config_entry_data", + [ + ( + { + CONF_KNX_TUNNELING_TYPE: CONF_KNX_LABEL_TUNNELING_UDP_ROUTE_BACK, + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + }, + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_LOCAL_IP: None, + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + }, + ), + ( + { + CONF_KNX_TUNNELING_TYPE: CONF_KNX_LABEL_TUNNELING_UDP, + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + }, + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_LOCAL_IP: None, + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + }, + ), + ( + { + CONF_KNX_TUNNELING_TYPE: CONF_KNX_LABEL_TUNNELING_TCP, + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + }, + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, + ConnectionSchema.CONF_KNX_STATE_UPDATER: True, + ConnectionSchema.CONF_KNX_LOCAL_IP: None, + CONF_HOST: "192.168.1.1", + CONF_PORT: 3675, + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + }, + ), + ], +) async def test_tunneling_options_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + user_input, + config_entry_data, ) -> None: """Test options flow for tunneling.""" mock_config_entry.add_to_hass(hass) @@ -628,29 +748,14 @@ async def test_tunneling_options_flow( result3 = await hass.config_entries.options.async_configure( result2["flow_id"], - user_input={ - CONF_HOST: "192.168.1.1", - CONF_PORT: 3675, - ConnectionSchema.CONF_KNX_ROUTE_BACK: True, - }, + user_input=user_input, ) await hass.async_block_till_done() assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY assert not result3.get("data") - assert mock_config_entry.data == { - CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, - CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", - ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, - ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, - ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, - ConnectionSchema.CONF_KNX_STATE_UPDATER: True, - ConnectionSchema.CONF_KNX_LOCAL_IP: None, - CONF_HOST: "192.168.1.1", - CONF_PORT: 3675, - ConnectionSchema.CONF_KNX_ROUTE_BACK: True, - } + assert mock_config_entry.data == config_entry_data @pytest.mark.parametrize( @@ -730,3 +835,37 @@ async def test_advanced_options( assert not result2.get("data") assert mock_config_entry.data == config_entry_data + + +@pytest.mark.parametrize( + "config_entry_data,result", + [ + ( + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + }, + CONF_KNX_LABEL_TUNNELING_UDP, + ), + ( + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, + ConnectionSchema.CONF_KNX_ROUTE_BACK: True, + }, + CONF_KNX_LABEL_TUNNELING_UDP_ROUTE_BACK, + ), + ( + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, + ConnectionSchema.CONF_KNX_ROUTE_BACK: False, + }, + CONF_KNX_LABEL_TUNNELING_TCP, + ), + ], +) +async def test_get_knx_tunneling_type( + config_entry_data, + result, +) -> None: + """Test converting config entry data to tunneling type for config flow.""" + assert get_knx_tunneling_type(config_entry_data) == result diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py new file mode 100644 index 00000000000000..697ee45ac0777f --- /dev/null +++ b/tests/components/knx/test_diagnostic.py @@ -0,0 +1,67 @@ +"""Tests for the diagnostics data provided by the KNX integration.""" +from unittest.mock import patch + +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.components.knx.conftest import KNXTestKit + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + mock_config_entry: MockConfigEntry, + knx: KNXTestKit, +): + """Test diagnostics.""" + await knx.setup_integration({}) + + with patch("homeassistant.config.async_hass_config_yaml", return_value={}): + # Overwrite the version for this test since we don't want to change this with every library bump + knx.xknx.version = "1.0.0" + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == { + "config_entry_data": { + "connection_type": "automatic", + "individual_address": "15.15.250", + "multicast_group": "224.0.23.12", + "multicast_port": 3671, + }, + "configuration_error": None, + "configuration_yaml": None, + "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, + } + + +async def test_diagnostic_config_error( + hass: HomeAssistant, + hass_client: ClientSession, + mock_config_entry: MockConfigEntry, + knx: KNXTestKit, +): + """Test diagnostics.""" + await knx.setup_integration({}) + + with patch( + "homeassistant.config.async_hass_config_yaml", + return_value={"knx": {"wrong_key": {}}}, + ): + # Overwrite the version for this test since we don't want to change this with every library bump + knx.xknx.version = "1.0.0" + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == { + "config_entry_data": { + "connection_type": "automatic", + "individual_address": "15.15.250", + "multicast_group": "224.0.23.12", + "multicast_port": 3671, + }, + "configuration_error": "extra keys not allowed @ data['knx']['wrong_key']", + "configuration_yaml": {"wrong_key": {}}, + "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, + } diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index 25ec0f926049c7..e5030eef461751 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -1,5 +1,8 @@ """Test KNX expose.""" -from homeassistant.components.knx import CONF_KNX_EXPOSE, KNX_ADDRESS +import time +from unittest.mock import patch + +from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_TYPE from homeassistant.core import HomeAssistant @@ -123,3 +126,95 @@ async def test_expose_attribute_with_default(hass: HomeAssistant, knx: KNXTestKi # Change state to "off"; no attribute hass.states.async_set(entity_id, "off", {}) await knx.assert_write("1/1/8", (0,)) + + +async def test_expose_string(hass: HomeAssistant, knx: KNXTestKit): + """Test an expose to send string values of up to 14 bytes only.""" + + entity_id = "fake.entity" + attribute = "fake_attribute" + await knx.setup_integration( + { + CONF_KNX_EXPOSE: { + CONF_TYPE: "string", + KNX_ADDRESS: "1/1/8", + CONF_ENTITY_ID: entity_id, + CONF_ATTRIBUTE: attribute, + ExposeSchema.CONF_KNX_EXPOSE_DEFAULT: "Test", + } + }, + ) + assert not hass.states.async_all() + + # Before init default value shall be sent as response + await knx.receive_read("1/1/8") + await knx.assert_response( + "1/1/8", (84, 101, 115, 116, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + ) + + # Change attribute; keep state + hass.states.async_set( + entity_id, + "on", + {attribute: "This is a very long string that is larger than 14 bytes"}, + ) + await knx.assert_write( + "1/1/8", (84, 104, 105, 115, 32, 105, 115, 32, 97, 32, 118, 101, 114, 121) + ) + + +async def test_expose_conversion_exception(hass: HomeAssistant, knx: KNXTestKit): + """Test expose throws exception.""" + + entity_id = "fake.entity" + attribute = "fake_attribute" + await knx.setup_integration( + { + CONF_KNX_EXPOSE: { + CONF_TYPE: "percent", + KNX_ADDRESS: "1/1/8", + CONF_ENTITY_ID: entity_id, + CONF_ATTRIBUTE: attribute, + ExposeSchema.CONF_KNX_EXPOSE_DEFAULT: 1, + } + }, + ) + assert not hass.states.async_all() + + # Before init default value shall be sent as response + await knx.receive_read("1/1/8") + await knx.assert_response("1/1/8", (3,)) + + # Change attribute: Expect no exception + hass.states.async_set( + entity_id, + "on", + {attribute: 101}, + ) + + await knx.assert_no_telegram() + + +@patch("time.localtime") +async def test_expose_with_date(localtime, hass: HomeAssistant, knx: KNXTestKit): + """Test an expose with a date.""" + localtime.return_value = time.struct_time([2022, 1, 7, 9, 13, 14, 6, 0, 0]) + await knx.setup_integration( + { + CONF_KNX_EXPOSE: { + CONF_TYPE: "datetime", + KNX_ADDRESS: "1/1/8", + } + } + ) + assert not hass.states.async_all() + + await knx.assert_write("1/1/8", (0x7A, 0x1, 0x7, 0xE9, 0xD, 0xE, 0x20, 0x80)) + + await knx.receive_read("1/1/8") + await knx.assert_response("1/1/8", (0x7A, 0x1, 0x7, 0xE9, 0xD, 0xE, 0x20, 0x80)) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert await hass.config_entries.async_unload(entries[0].entry_id) diff --git a/tests/components/knx/test_scene.py b/tests/components/knx/test_scene.py index c2f15df6f6c3d4..37e4ac12728aea 100644 --- a/tests/components/knx/test_scene.py +++ b/tests/components/knx/test_scene.py @@ -2,12 +2,9 @@ from homeassistant.components.knx.const import KNX_ADDRESS from homeassistant.components.knx.schema import SceneSchema -from homeassistant.const import ( - CONF_ENTITY_CATEGORY, - CONF_NAME, - ENTITY_CATEGORY_DIAGNOSTIC, -) +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_registry import ( async_get_registry as async_get_entity_registry, ) @@ -24,7 +21,7 @@ async def test_activate_knx_scene(hass: HomeAssistant, knx: KNXTestKit): CONF_NAME: "test", SceneSchema.CONF_SCENE_NUMBER: 24, KNX_ADDRESS: "1/1/1", - CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC, + CONF_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, }, ] } @@ -33,7 +30,7 @@ async def test_activate_knx_scene(hass: HomeAssistant, knx: KNXTestKit): registry = await async_get_entity_registry(hass) entity = registry.async_get("scene.test") - assert entity.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == "1/1/1_24" await hass.services.async_call( diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py index c61dc542586308..039dd5986ca3fa 100644 --- a/tests/components/knx/test_services.py +++ b/tests/components/knx/test_services.py @@ -1,4 +1,7 @@ """Test KNX services.""" +import pytest +from xknx.telegram.apci import GroupValueResponse, GroupValueWrite + from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -7,51 +10,114 @@ from tests.common import async_capture_events -async def test_send(hass: HomeAssistant, knx: KNXTestKit): +@pytest.mark.parametrize( + "service_payload,expected_telegrams,expected_apci", + [ + # send DPT 1 telegram + ( + {"address": "1/2/3", "payload": True, "response": True}, + [("1/2/3", True)], + GroupValueResponse, + ), + ( + {"address": "1/2/3", "payload": True, "response": False}, + [("1/2/3", True)], + GroupValueWrite, + ), + # send DPT 5 telegram + ( + {"address": "1/2/3", "payload": [99], "response": True}, + [("1/2/3", (99,))], + GroupValueResponse, + ), + ( + {"address": "1/2/3", "payload": [99], "response": False}, + [("1/2/3", (99,))], + GroupValueWrite, + ), + # send DPT 5 percent telegram + ( + {"address": "1/2/3", "payload": 99, "type": "percent", "response": True}, + [("1/2/3", (0xFC,))], + GroupValueResponse, + ), + ( + {"address": "1/2/3", "payload": 99, "type": "percent", "response": False}, + [("1/2/3", (0xFC,))], + GroupValueWrite, + ), + # send temperature DPT 9 telegram + ( + { + "address": "1/2/3", + "payload": 21.0, + "type": "temperature", + "response": True, + }, + [("1/2/3", (0x0C, 0x1A))], + GroupValueResponse, + ), + ( + { + "address": "1/2/3", + "payload": 21.0, + "type": "temperature", + "response": False, + }, + [("1/2/3", (0x0C, 0x1A))], + GroupValueWrite, + ), + # send multiple telegrams + ( + { + "address": ["1/2/3", "2/2/2", "3/3/3"], + "payload": 99, + "type": "percent", + "response": True, + }, + [ + ("1/2/3", (0xFC,)), + ("2/2/2", (0xFC,)), + ("3/3/3", (0xFC,)), + ], + GroupValueResponse, + ), + ( + { + "address": ["1/2/3", "2/2/2", "3/3/3"], + "payload": 99, + "type": "percent", + "response": False, + }, + [ + ("1/2/3", (0xFC,)), + ("2/2/2", (0xFC,)), + ("3/3/3", (0xFC,)), + ], + GroupValueWrite, + ), + ], +) +async def test_send( + hass: HomeAssistant, + knx: KNXTestKit, + service_payload, + expected_telegrams, + expected_apci, +): """Test `knx.send` service.""" - test_address = "1/2/3" await knx.setup_integration({}) - # send DPT 1 telegram - await hass.services.async_call( - "knx", "send", {"address": test_address, "payload": True}, blocking=True - ) - await knx.assert_write(test_address, True) - - # send raw DPT 5 telegram - await hass.services.async_call( - "knx", "send", {"address": test_address, "payload": [99]}, blocking=True - ) - await knx.assert_write(test_address, (99,)) - - # send "percent" DPT 5 telegram await hass.services.async_call( "knx", "send", - {"address": test_address, "payload": 99, "type": "percent"}, + service_payload, blocking=True, ) - await knx.assert_write(test_address, (0xFC,)) - # send "temperature" DPT 9 telegram - await hass.services.async_call( - "knx", - "send", - {"address": test_address, "payload": 21.0, "type": "temperature"}, - blocking=True, - ) - await knx.assert_write(test_address, (0x0C, 0x1A)) - - # send multiple telegrams - await hass.services.async_call( - "knx", - "send", - {"address": [test_address, "2/2/2", "3/3/3"], "payload": 99, "type": "percent"}, - blocking=True, - ) - await knx.assert_write(test_address, (0xFC,)) - await knx.assert_write("2/2/2", (0xFC,)) - await knx.assert_write("3/3/3", (0xFC,)) + for expected_response in expected_telegrams: + group_address, payload = expected_response + await knx.assert_telegram(group_address, payload, expected_apci) async def test_read(hass: HomeAssistant, knx: KNXTestKit): diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index 462d2381cec2f6..2bb5dbcd578c00 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.kodi import DOMAIN from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN from homeassistant.setup import async_setup_component @@ -70,7 +71,9 @@ async def test_get_triggers(hass, device_reg, entity_reg): ] # Test triggers are either kodi specific triggers or media_player entity triggers - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) for expected_trigger in expected_triggers: assert expected_trigger in triggers for trigger in triggers: diff --git a/tests/components/launch_library/__init__.py b/tests/components/launch_library/__init__.py new file mode 100644 index 00000000000000..f6264de191403e --- /dev/null +++ b/tests/components/launch_library/__init__.py @@ -0,0 +1 @@ +"""Tests for the launch_library component.""" diff --git a/tests/components/launch_library/test_config_flow.py b/tests/components/launch_library/test_config_flow.py new file mode 100644 index 00000000000000..1b8f2bde45332e --- /dev/null +++ b/tests/components/launch_library/test_config_flow.py @@ -0,0 +1,65 @@ +"""Test launch_library config flow.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components.launch_library.const import DOMAIN +from homeassistant.components.launch_library.sensor import DEFAULT_NEXT_LAUNCH_NAME +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME + +from tests.common import MockConfigEntry + + +async def test_import(hass): + """Test entry will be imported.""" + + imported_config = {CONF_NAME: DEFAULT_NEXT_LAUNCH_NAME} + + with patch( + "homeassistant.components.launch_library.async_setup_entry", return_value=True + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=imported_config + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result.get("result").data == imported_config + + +async def test_create_entry(hass): + """Test we can finish a config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + + with patch( + "homeassistant.components.launch_library.async_setup_entry", return_value=True + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result.get("type") == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result.get("result").data == {} + + +async def test_integration_already_exists(hass): + """Test we only allow a single config flow.""" + + MockConfigEntry( + domain=DOMAIN, + data={}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={} + ) + + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "single_instance_allowed" diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index 81c2fdc68e457d..cf52263e69d6b8 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -9,10 +9,12 @@ import pytest from homeassistant.components.lcn.const import DOMAIN +from homeassistant.components.lcn.helpers import generate_unique_id from homeassistant.const import CONF_HOST +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, async_mock_service, load_fixture class MockModuleConnection(ModuleConnection): @@ -50,9 +52,9 @@ async def async_close(self): @patch.object(pypck.connection, "ModuleConnection", MockModuleConnection) @patch.object(pypck.connection, "GroupConnection", MockGroupConnection) - def get_address_conn(self, addr): + def get_address_conn(self, addr, request_serials=False): """Get LCN address connection.""" - return super().get_address_conn(addr, request_serials=False) + return super().get_address_conn(addr, request_serials) send_command = AsyncMock() @@ -75,6 +77,12 @@ def create_config_entry(name): return entry +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + @pytest.fixture(name="entry") def create_config_entry_pchk(): """Return one specific config entry.""" @@ -87,11 +95,24 @@ def create_config_entry_myhome(): return create_config_entry("myhome") +@pytest.fixture(name="lcn_connection") async def init_integration(hass, entry): """Set up the LCN integration in Home Assistant.""" + lcn_connection = None + + def lcn_connection_factory(*args, **kwargs): + nonlocal lcn_connection + lcn_connection = MockPchkConnectionManager(*args, **kwargs) + return lcn_connection + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch( + "pypck.connection.PchkConnectionManager", + side_effect=lcn_connection_factory, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + yield lcn_connection async def setup_component(hass): @@ -101,3 +122,12 @@ async def setup_component(hass): await async_setup_component(hass, DOMAIN, config_data) await hass.async_block_till_done() + + +def get_device(hass, entry, address): + """Get LCN device for specified address.""" + device_registry = dr.async_get(hass) + identifiers = {(DOMAIN, generate_unique_id(entry.entry_id, address))} + device = device_registry.async_get_device(identifiers) + assert device + return device diff --git a/tests/components/lcn/fixtures/config.json b/tests/components/lcn/fixtures/config.json index 3cbb66b4e31a7d..e9278d8e2cd6b9 100644 --- a/tests/components/lcn/fixtures/config.json +++ b/tests/components/lcn/fixtures/config.json @@ -20,12 +20,58 @@ "dim_mode": "steps200" } ], + "lights": [ + { + "name": "Light_Output1", + "address": "pchk.s0.m7", + "output": "output1", + "dimmable": true, + "transition": 5 + }, + { + "name": "Light_Output2", + "address": "pchk.s0.m7", + "output": "output2", + "dimmable": false, + "transition": 0 + }, + { + "name": "Light_Relay1", + "address": "s0.m7", + "output": "relay1" + }, + { + "name": "Light_Relay3", + "address": "myhome.s0.m7", + "output": "relay3" + }, + { + "name": "Light_Relay4", + "address": "myhome.s0.m7", + "output": "relay4" + } + ], "switches": [ { "name": "Switch_Output1", "address": "s0.m7", "output": "output1" }, + { + "name": "Switch_Output2", + "address": "s0.m7", + "output": "output2" + }, + { + "name": "Switch_Relay1", + "address": "s0.m7", + "output": "relay1" + }, + { + "name": "Switch_Relay2", + "address": "s0.m7", + "output": "relay2" + }, { "name": "Switch_Group5", "address": "s0.g5", diff --git a/tests/components/lcn/fixtures/config_entry_myhome.json b/tests/components/lcn/fixtures/config_entry_myhome.json index 8ab59d0087d998..a0f8e7d3e10c1e 100644 --- a/tests/components/lcn/fixtures/config_entry_myhome.json +++ b/tests/components/lcn/fixtures/config_entry_myhome.json @@ -7,5 +7,28 @@ "sk_num_tries": 0, "dim_mode": "STEPS200", "devices": [], - "entities": [] + "entities": [ + { + "address": [0, 7, false], + "name": "Light_Relay3", + "resource": "relay3", + "domain": "light", + "domain_data": { + "output": "RELAY3", + "dimmable": false, + "transition": 0.0 + } + }, + { + "address": [0, 7, false], + "name": "Light_Relay4", + "resource": "relay4", + "domain": "light", + "domain_data": { + "output": "RELAY4", + "dimmable": false, + "transition": 0.0 + } + } + ] } diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index a4f78c16b41618..9d34add37ff2e4 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -23,6 +23,39 @@ } ], "entities": [ + { + "address": [0, 7, false], + "name": "Light_Output1", + "resource": "output1", + "domain": "light", + "domain_data": { + "output": "OUTPUT1", + "dimmable": true, + "transition": 5000.0 + } + }, + { + "address": [0, 7, false], + "name": "Light_Output2", + "resource": "output2", + "domain": "light", + "domain_data": { + "output": "OUTPUT2", + "dimmable": false, + "transition": 0 + } + }, + { + "address": [0, 7, false], + "name": "Light_Relay1", + "resource": "relay1", + "domain": "light", + "domain_data": { + "output": "RELAY1", + "dimmable": false, + "transition": 0.0 + } + }, { "address": [0, 7, false], "name": "Switch_Output1", @@ -32,6 +65,33 @@ "output": "OUTPUT1" } }, + { + "address": [0, 7, false], + "name": "Switch_Output2", + "resource": "output2", + "domain": "switch", + "domain_data": { + "output": "OUTPUT2" + } + }, + { + "address": [0, 7, false], + "name": "Switch_Relay1", + "resource": "relay1", + "domain": "switch", + "domain_data": { + "output": "RELAY1" + } + }, + { + "address": [0, 7, false], + "name": "Switch_Relay2", + "resource": "relay2", + "domain": "switch", + "domain_data": { + "output": "RELAY2" + } + }, { "address": [0, 5, true], "name": "Switch_Group5", diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py new file mode 100644 index 00000000000000..aced80642e6e60 --- /dev/null +++ b/tests/components/lcn/test_device_trigger.py @@ -0,0 +1,390 @@ +"""Tests for LCN device triggers.""" +from pypck.inputs import ModSendKeysHost, ModStatusAccessControl +from pypck.lcn_addr import LcnAddr +from pypck.lcn_defs import AccessControlPeriphery, KeyAction, SendKeyCommand +import voluptuous_serialize + +from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.lcn import device_trigger +from homeassistant.components.lcn.const import DOMAIN, KEY_ACTIONS, SENDKEYS +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.setup import async_setup_component + +from .conftest import get_device + +from tests.common import assert_lists_same, async_get_device_automations + + +async def test_get_triggers_module_device(hass, entry, lcn_connection): + """Test we get the expected triggers from a LCN module device.""" + device = get_device(hass, entry, (0, 7, False)) + + expected_triggers = [ + { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "transmitter", + CONF_DEVICE_ID: device.id, + }, + { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "transponder", + CONF_DEVICE_ID: device.id, + }, + { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "fingerprint", + CONF_DEVICE_ID: device.id, + }, + { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "send_keys", + CONF_DEVICE_ID: device.id, + }, + ] + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert_lists_same(triggers, expected_triggers) + + +async def test_get_triggers_non_module_device(hass, entry, lcn_connection): + """Test we get the expected triggers from a LCN non-module device.""" + not_included_types = ("transmitter", "transponder", "fingerprint", "send_keys") + + device_registry = dr.async_get(hass) + host_device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + group_device = get_device(hass, entry, (0, 5, True)) + resource_device = device_registry.async_get_device( + {(DOMAIN, f"{entry.entry_id}-m000007-output1")} + ) + + for device in (host_device, group_device, resource_device): + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + for trigger in triggers: + assert trigger[CONF_TYPE] not in not_included_types + + +async def test_if_fires_on_transponder_event(hass, calls, entry, lcn_connection): + """Test for transponder event triggers firing.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "transponder", + }, + "action": { + "service": "test.automation", + "data_template": { + "test": "test_trigger_transponder", + "code": "{{ trigger.event.data.code }}", + }, + }, + }, + ] + }, + ) + + inp = ModStatusAccessControl( + LcnAddr(*address), + periphery=AccessControlPeriphery.TRANSPONDER, + code="aabbcc", + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == { + "test": "test_trigger_transponder", + "code": "aabbcc", + } + + +async def test_if_fires_on_fingerprint_event(hass, calls, entry, lcn_connection): + """Test for fingerprint event triggers firing.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "fingerprint", + }, + "action": { + "service": "test.automation", + "data_template": { + "test": "test_trigger_fingerprint", + "code": "{{ trigger.event.data.code }}", + }, + }, + }, + ] + }, + ) + + inp = ModStatusAccessControl( + LcnAddr(*address), + periphery=AccessControlPeriphery.FINGERPRINT, + code="aabbcc", + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == { + "test": "test_trigger_fingerprint", + "code": "aabbcc", + } + + +async def test_if_fires_on_transmitter_event(hass, calls, entry, lcn_connection): + """Test for transmitter event triggers firing.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "transmitter", + }, + "action": { + "service": "test.automation", + "data_template": { + "test": "test_trigger_transmitter", + "code": "{{ trigger.event.data.code }}", + "level": "{{ trigger.event.data.level }}", + "key": "{{ trigger.event.data.key }}", + "action": "{{ trigger.event.data.action }}", + }, + }, + }, + ] + }, + ) + + inp = ModStatusAccessControl( + LcnAddr(*address), + periphery=AccessControlPeriphery.TRANSMITTER, + code="aabbcc", + level=0, + key=0, + action=KeyAction.HIT, + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == { + "test": "test_trigger_transmitter", + "code": "aabbcc", + "level": 0, + "key": 0, + "action": "hit", + } + + +async def test_if_fires_on_send_keys_event(hass, calls, entry, lcn_connection): + """Test for send_keys event triggers firing.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "send_keys", + }, + "action": { + "service": "test.automation", + "data_template": { + "test": "test_trigger_send_keys", + "key": "{{ trigger.event.data.key }}", + "action": "{{ trigger.event.data.action }}", + }, + }, + }, + ] + }, + ) + + inp = ModSendKeysHost( + LcnAddr(*address), + actions=[SendKeyCommand.HIT, SendKeyCommand.DONTSEND, SendKeyCommand.DONTSEND], + keys=[True, False, False, False, False, False, False, False], + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == { + "test": "test_trigger_send_keys", + "key": "a1", + "action": "hit", + } + + +async def test_get_transponder_trigger_capabilities(hass, entry, lcn_connection): + """Test we get the expected capabilities from a transponder device trigger.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "transponder", + CONF_DEVICE_ID: device.id, + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"name": "code", "optional": True, "type": "string", "lower": True}] + + +async def test_get_fingerprint_trigger_capabilities(hass, entry, lcn_connection): + """Test we get the expected capabilities from a fingerprint device trigger.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "fingerprint", + CONF_DEVICE_ID: device.id, + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"name": "code", "optional": True, "type": "string", "lower": True}] + + +async def test_get_transmitter_trigger_capabilities(hass, entry, lcn_connection): + """Test we get the expected capabilities from a transmitter device trigger.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "transmitter", + CONF_DEVICE_ID: device.id, + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + {"name": "code", "type": "string", "optional": True, "lower": True}, + {"name": "level", "type": "integer", "optional": True, "valueMin": 0}, + {"name": "key", "type": "integer", "optional": True, "valueMin": 0}, + { + "name": "action", + "type": "select", + "optional": True, + "options": [("hit", "hit"), ("make", "make"), ("break", "break")], + }, + ] + + +async def test_get_send_keys_trigger_capabilities(hass, entry, lcn_connection): + """Test we get the expected capabilities from a send_keys device trigger.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "send_keys", + CONF_DEVICE_ID: device.id, + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "key", + "type": "select", + "optional": True, + "options": [(send_key.lower(), send_key.lower()) for send_key in SENDKEYS], + }, + { + "name": "action", + "type": "select", + "options": [ + (key_action.lower(), key_action.lower()) for key_action in KEY_ACTIONS + ], + "optional": True, + }, + ] + + +async def test_unknown_trigger_capabilities(hass, entry, lcn_connection): + """Test we get empty capabilities if trigger is unknown.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "dummy", + CONF_DEVICE_ID: device.id, + }, + ) + assert capabilities == {} diff --git a/tests/components/lcn/test_events.py b/tests/components/lcn/test_events.py new file mode 100644 index 00000000000000..38a685ad663742 --- /dev/null +++ b/tests/components/lcn/test_events.py @@ -0,0 +1,124 @@ +"""Tests for LCN events.""" +from pypck.inputs import Input, ModSendKeysHost, ModStatusAccessControl +from pypck.lcn_addr import LcnAddr +from pypck.lcn_defs import AccessControlPeriphery, KeyAction, SendKeyCommand + +from tests.common import async_capture_events + + +async def test_fire_transponder_event(hass, lcn_connection): + """Test the transponder event is fired.""" + events = async_capture_events(hass, "lcn_transponder") + + inp = ModStatusAccessControl( + LcnAddr(0, 7, False), + periphery=AccessControlPeriphery.TRANSPONDER, + code="aabbcc", + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].event_type == "lcn_transponder" + assert events[0].data["code"] == "aabbcc" + + +async def test_fire_fingerprint_event(hass, lcn_connection): + """Test the fingerprint event is fired.""" + events = async_capture_events(hass, "lcn_fingerprint") + + inp = ModStatusAccessControl( + LcnAddr(0, 7, False), + periphery=AccessControlPeriphery.FINGERPRINT, + code="aabbcc", + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].event_type == "lcn_fingerprint" + assert events[0].data["code"] == "aabbcc" + + +async def test_fire_transmitter_event(hass, lcn_connection): + """Test the transmitter event is fired.""" + events = async_capture_events(hass, "lcn_transmitter") + + inp = ModStatusAccessControl( + LcnAddr(0, 7, False), + periphery=AccessControlPeriphery.TRANSMITTER, + code="aabbcc", + level=0, + key=0, + action=KeyAction.HIT, + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].event_type == "lcn_transmitter" + assert events[0].data["code"] == "aabbcc" + assert events[0].data["level"] == 0 + assert events[0].data["key"] == 0 + assert events[0].data["action"] == "hit" + + +async def test_fire_sendkeys_event(hass, lcn_connection): + """Test the send_keys event is fired.""" + events = async_capture_events(hass, "lcn_send_keys") + + inp = ModSendKeysHost( + LcnAddr(0, 7, False), + actions=[SendKeyCommand.HIT, SendKeyCommand.MAKE, SendKeyCommand.DONTSEND], + keys=[True, True, False, False, False, False, False, False], + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(events) == 4 + assert events[0].event_type == "lcn_send_keys" + assert events[0].data["key"] == "a1" + assert events[0].data["action"] == "hit" + assert events[1].event_type == "lcn_send_keys" + assert events[1].data["key"] == "a2" + assert events[1].data["action"] == "hit" + assert events[2].event_type == "lcn_send_keys" + assert events[2].data["key"] == "b1" + assert events[2].data["action"] == "make" + assert events[3].event_type == "lcn_send_keys" + assert events[3].data["key"] == "b2" + assert events[3].data["action"] == "make" + + +async def test_dont_fire_on_non_module_input(hass, lcn_connection): + """Test for no event is fired if a non-module input is received.""" + inp = Input() + + for event_name in ( + "lcn_transponder", + "lcn_fingerprint", + "lcn_transmitter", + "lcn_send_keys", + ): + events = async_capture_events(hass, event_name) + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + assert len(events) == 0 + + +async def test_dont_fire_on_unknown_module(hass, lcn_connection): + """Test for no event is fired if an input from an unknown module is received.""" + inp = ModStatusAccessControl( + LcnAddr(0, 10, False), # unknown module + periphery=AccessControlPeriphery.FINGERPRINT, + code="aabbcc", + ) + + events = async_capture_events(hass, "lcn_fingerprint") + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + assert len(events) == 0 diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index e4fb5beef0de80..844bf5fa9b8497 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -12,13 +12,11 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import MockPchkConnectionManager, init_integration, setup_component +from .conftest import MockPchkConnectionManager, setup_component -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) -async def test_async_setup_entry(hass, entry): +async def test_async_setup_entry(hass, entry, lcn_connection): """Test a successful setup entry and unload of entry.""" - await init_integration(hass, entry) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state == ConfigEntryState.LOADED @@ -29,13 +27,14 @@ async def test_async_setup_entry(hass, entry): assert not hass.data.get(DOMAIN) -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) async def test_async_setup_multiple_entries(hass, entry, entry2): """Test a successful setup and unload of multiple entries.""" - for config_entry in (entry, entry2): - await init_integration(hass, config_entry) - assert config_entry.state == ConfigEntryState.LOADED - await hass.async_block_till_done() + with patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager): + for config_entry in (entry, entry2): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 2 @@ -48,7 +47,6 @@ async def test_async_setup_multiple_entries(hass, entry, entry2): assert not hass.data.get(DOMAIN) -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) async def test_async_setup_entry_update(hass, entry): """Test a successful setup entry if entry with same id already exists.""" # setup first entry @@ -73,9 +71,10 @@ async def test_async_setup_entry_update(hass, entry): assert dummy_device in device_registry.devices.values() # setup new entry with same data via import step (should cleanup dummy device) - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=entry.data - ) + with patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager): + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=entry.data + ) assert dummy_device not in device_registry.devices.values() assert dummy_entity not in entity_registry.entities.values() @@ -86,7 +85,10 @@ async def test_async_setup_entry_raises_authentication_error(hass, entry): with patch.object( PchkConnectionManager, "async_connect", side_effect=PchkAuthenticationError ): - await init_integration(hass, entry) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_ERROR @@ -95,22 +97,28 @@ async def test_async_setup_entry_raises_license_error(hass, entry): with patch.object( PchkConnectionManager, "async_connect", side_effect=PchkLicenseError ): - await init_integration(hass, entry) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_ERROR async def test_async_setup_entry_raises_timeout_error(hass, entry): """Test that an authentication error is handled properly.""" with patch.object(PchkConnectionManager, "async_connect", side_effect=TimeoutError): - await init_integration(hass, entry) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_ERROR -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) async def test_async_setup_from_configuration_yaml(hass): """Test a successful setup using data from configuration.yaml.""" - - with patch("homeassistant.components.lcn.async_setup_entry") as async_setup_entry: + with patch( + "pypck.connection.PchkConnectionManager", MockPchkConnectionManager + ), patch("homeassistant.components.lcn.async_setup_entry") as async_setup_entry: await setup_component(hass) assert async_setup_entry.await_count == 2 diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py new file mode 100644 index 00000000000000..4fd58aab743a9a --- /dev/null +++ b/tests/components/lcn/test_light.py @@ -0,0 +1,335 @@ +"""Test for the LCN light platform.""" +from unittest.mock import patch + +from pypck.inputs import ModStatusOutput, ModStatusRelays +from pypck.lcn_addr import LcnAddr +from pypck.lcn_defs import RelayStateModifier + +from homeassistant.components.lcn.helpers import get_device_connection +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_TRANSITION, + DOMAIN as DOMAIN_LIGHT, + SUPPORT_BRIGHTNESS, + SUPPORT_TRANSITION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) + +from .conftest import MockModuleConnection + + +async def test_setup_lcn_light(hass, lcn_connection): + """Test the setup of light.""" + for entity_id in ( + "light.light_output1", + "light.light_output2", + "light.light_relay1", + ): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + +async def test_entity_state(hass, lcn_connection): + """Test state of entity.""" + state = hass.states.get("light.light_output1") + assert state + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION + ) + + state = hass.states.get("light.light_output2") + assert state + assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_TRANSITION + + +async def test_entity_attributes(hass, entry, lcn_connection): + """Test the attributes of an entity.""" + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entity_output = entity_registry.async_get("light.light_output1") + + assert entity_output + assert entity_output.unique_id == f"{entry.entry_id}-m000007-output1" + assert entity_output.original_name == "Light_Output1" + + entity_relay = entity_registry.async_get("light.light_relay1") + + assert entity_relay + assert entity_relay.unique_id == f"{entry.entry_id}-m000007-relay1" + assert entity_relay.original_name == "Light_Relay1" + + +@patch.object(MockModuleConnection, "dim_output") +async def test_output_turn_on(dim_output, hass, lcn_connection): + """Test the output light turns on.""" + # command failed + dim_output.return_value = False + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.light_output1"}, + blocking=True, + ) + await hass.async_block_till_done() + dim_output.assert_awaited_with(0, 100, 9) + + state = hass.states.get("light.light_output1") + assert state is not None + assert state.state != STATE_ON + + # command success + dim_output.reset_mock(return_value=True) + dim_output.return_value = True + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.light_output1"}, + blocking=True, + ) + await hass.async_block_till_done() + dim_output.assert_awaited_with(0, 100, 9) + + state = hass.states.get("light.light_output1") + assert state is not None + assert state.state == STATE_ON + + +@patch.object(MockModuleConnection, "dim_output") +async def test_output_turn_on_with_attributes(dim_output, hass, lcn_connection): + """Test the output light turns on.""" + dim_output.return_value = True + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.light_output1", + ATTR_BRIGHTNESS: 50, + ATTR_TRANSITION: 2, + }, + blocking=True, + ) + await hass.async_block_till_done() + dim_output.assert_awaited_with(0, 19, 6) + + state = hass.states.get("light.light_output1") + assert state is not None + assert state.state == STATE_ON + + +@patch.object(MockModuleConnection, "dim_output") +async def test_output_turn_off(dim_output, hass, lcn_connection): + """Test the output light turns off.""" + state = hass.states.get("light.light_output1") + state.state = STATE_ON + + # command failed + dim_output.return_value = False + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.light_output1"}, + blocking=True, + ) + await hass.async_block_till_done() + dim_output.assert_awaited_with(0, 0, 9) + + state = hass.states.get("light.light_output1") + assert state is not None + assert state.state != STATE_OFF + + # command success + dim_output.reset_mock(return_value=True) + dim_output.return_value = True + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.light_output1"}, + blocking=True, + ) + await hass.async_block_till_done() + dim_output.assert_awaited_with(0, 0, 9) + + state = hass.states.get("light.light_output1") + assert state is not None + assert state.state == STATE_OFF + + +@patch.object(MockModuleConnection, "dim_output") +async def test_output_turn_off_with_attributes(dim_output, hass, lcn_connection): + """Test the output light turns off.""" + dim_output.return_value = True + + state = hass.states.get("light.light_output1") + state.state = STATE_ON + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "light.light_output1", + ATTR_TRANSITION: 2, + }, + blocking=True, + ) + await hass.async_block_till_done() + dim_output.assert_awaited_with(0, 0, 6) + + state = hass.states.get("light.light_output1") + assert state is not None + assert state.state == STATE_OFF + + +@patch.object(MockModuleConnection, "control_relays") +async def test_relay_turn_on(control_relays, hass, lcn_connection): + """Test the relay light turns on.""" + states = [RelayStateModifier.NOCHANGE] * 8 + states[0] = RelayStateModifier.ON + + # command failed + control_relays.return_value = False + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.light_relay1"}, + blocking=True, + ) + await hass.async_block_till_done() + control_relays.assert_awaited_with(states) + + state = hass.states.get("light.light_relay1") + assert state is not None + assert state.state != STATE_ON + + # command success + control_relays.reset_mock(return_value=True) + control_relays.return_value = True + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.light_relay1"}, + blocking=True, + ) + await hass.async_block_till_done() + control_relays.assert_awaited_with(states) + + state = hass.states.get("light.light_relay1") + assert state is not None + assert state.state == STATE_ON + + +@patch.object(MockModuleConnection, "control_relays") +async def test_relay_turn_off(control_relays, hass, lcn_connection): + """Test the relay light turns off.""" + states = [RelayStateModifier.NOCHANGE] * 8 + states[0] = RelayStateModifier.OFF + + state = hass.states.get("light.light_relay1") + state.state = STATE_ON + + # command failed + control_relays.return_value = False + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.light_relay1"}, + blocking=True, + ) + await hass.async_block_till_done() + control_relays.assert_awaited_with(states) + + state = hass.states.get("light.light_relay1") + assert state is not None + assert state.state != STATE_OFF + + # command success + control_relays.reset_mock(return_value=True) + control_relays.return_value = True + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.light_relay1"}, + blocking=True, + ) + await hass.async_block_till_done() + control_relays.assert_awaited_with(states) + + state = hass.states.get("light.light_relay1") + assert state is not None + assert state.state == STATE_OFF + + +async def test_pushed_output_status_change(hass, entry, lcn_connection): + """Test the output light changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + # push status "on" + inp = ModStatusOutput(address, 0, 50) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("light.light_output1") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] == 127 + + # push status "off" + inp = ModStatusOutput(address, 0, 0) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("light.light_output1") + assert state is not None + assert state.state == STATE_OFF + + +async def test_pushed_relay_status_change(hass, entry, lcn_connection): + """Test the relay light changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + states = [False] * 8 + + # push status "on" + states[0] = True + inp = ModStatusRelays(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("light.light_relay1") + assert state is not None + assert state.state == STATE_ON + + # push status "off" + states[0] = False + inp = ModStatusRelays(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("light.light_relay1") + assert state is not None + assert state.state == STATE_OFF + + +async def test_unload_config_entry(hass, entry, lcn_connection): + """Test the light is removed when the config entry is unloaded.""" + await hass.config_entries.async_unload(entry.entry_id) + assert hass.states.get("light.light_output1").state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_switch.py b/tests/components/lcn/test_switch.py new file mode 100644 index 00000000000000..aee063f310db2a --- /dev/null +++ b/tests/components/lcn/test_switch.py @@ -0,0 +1,254 @@ +"""Test for the LCN switch platform.""" +from unittest.mock import patch + +from pypck.inputs import ModStatusOutput, ModStatusRelays +from pypck.lcn_addr import LcnAddr +from pypck.lcn_defs import RelayStateModifier + +from homeassistant.components.lcn.helpers import get_device_connection +from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) + +from .conftest import MockModuleConnection + + +async def test_setup_lcn_switch(hass, lcn_connection): + """Test the setup of switch.""" + for entity_id in ( + "switch.switch_output1", + "switch.switch_output2", + "switch.switch_relay1", + "switch.switch_relay2", + ): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + +async def test_entity_attributes(hass, entry, lcn_connection): + """Test the attributes of an entity.""" + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entity_output = entity_registry.async_get("switch.switch_output1") + + assert entity_output + assert entity_output.unique_id == f"{entry.entry_id}-m000007-output1" + assert entity_output.original_name == "Switch_Output1" + + entity_relay = entity_registry.async_get("switch.switch_relay1") + + assert entity_relay + assert entity_relay.unique_id == f"{entry.entry_id}-m000007-relay1" + assert entity_relay.original_name == "Switch_Relay1" + + +@patch.object(MockModuleConnection, "dim_output") +async def test_output_turn_on(dim_output, hass, lcn_connection): + """Test the output switch turns on.""" + # command failed + dim_output.return_value = False + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.switch_output1"}, + blocking=True, + ) + await hass.async_block_till_done() + dim_output.assert_awaited_with(0, 100, 0) + + state = hass.states.get("switch.switch_output1") + assert state.state == STATE_OFF + + # command success + dim_output.reset_mock(return_value=True) + dim_output.return_value = True + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.switch_output1"}, + blocking=True, + ) + await hass.async_block_till_done() + dim_output.assert_awaited_with(0, 100, 0) + + state = hass.states.get("switch.switch_output1") + assert state.state == STATE_ON + + +@patch.object(MockModuleConnection, "dim_output") +async def test_output_turn_off(dim_output, hass, lcn_connection): + """Test the output switch turns off.""" + state = hass.states.get("switch.switch_output1") + state.state = STATE_ON + + # command failed + dim_output.return_value = False + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.switch_output1"}, + blocking=True, + ) + await hass.async_block_till_done() + dim_output.assert_awaited_with(0, 0, 0) + + state = hass.states.get("switch.switch_output1") + assert state.state == STATE_ON + + # command success + dim_output.reset_mock(return_value=True) + dim_output.return_value = True + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.switch_output1"}, + blocking=True, + ) + await hass.async_block_till_done() + dim_output.assert_awaited_with(0, 0, 0) + + state = hass.states.get("switch.switch_output1") + assert state.state == STATE_OFF + + +@patch.object(MockModuleConnection, "control_relays") +async def test_relay_turn_on(control_relays, hass, lcn_connection): + """Test the relay switch turns on.""" + states = [RelayStateModifier.NOCHANGE] * 8 + states[0] = RelayStateModifier.ON + + # command failed + control_relays.return_value = False + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.switch_relay1"}, + blocking=True, + ) + await hass.async_block_till_done() + control_relays.assert_awaited_with(states) + + state = hass.states.get("switch.switch_relay1") + assert state.state == STATE_OFF + + # command success + control_relays.reset_mock(return_value=True) + control_relays.return_value = True + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.switch_relay1"}, + blocking=True, + ) + await hass.async_block_till_done() + control_relays.assert_awaited_with(states) + + state = hass.states.get("switch.switch_relay1") + assert state.state == STATE_ON + + +@patch.object(MockModuleConnection, "control_relays") +async def test_relay_turn_off(control_relays, hass, lcn_connection): + """Test the relay switch turns off.""" + states = [RelayStateModifier.NOCHANGE] * 8 + states[0] = RelayStateModifier.OFF + + state = hass.states.get("switch.switch_relay1") + state.state = STATE_ON + + # command failed + control_relays.return_value = False + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.switch_relay1"}, + blocking=True, + ) + await hass.async_block_till_done() + control_relays.assert_awaited_with(states) + + state = hass.states.get("switch.switch_relay1") + assert state.state == STATE_ON + + # command success + control_relays.reset_mock(return_value=True) + control_relays.return_value = True + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.switch_relay1"}, + blocking=True, + ) + await hass.async_block_till_done() + control_relays.assert_awaited_with(states) + + state = hass.states.get("switch.switch_relay1") + assert state.state == STATE_OFF + + +async def test_pushed_output_status_change(hass, entry, lcn_connection): + """Test the output switch changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + # push status "on" + inp = ModStatusOutput(address, 0, 100) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("switch.switch_output1") + assert state.state == STATE_ON + + # push status "off" + inp = ModStatusOutput(address, 0, 0) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("switch.switch_output1") + assert state.state == STATE_OFF + + +async def test_pushed_relay_status_change(hass, entry, lcn_connection): + """Test the relay switch changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + states = [False] * 8 + + # push status "on" + states[0] = True + inp = ModStatusRelays(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("switch.switch_relay1") + assert state.state == STATE_ON + + # push status "off" + states[0] = False + inp = ModStatusRelays(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("switch.switch_relay1") + assert state.state == STATE_OFF + + +async def test_unload_config_entry(hass, entry, lcn_connection): + """Test the switch is removed when the config entry is unloaded.""" + await hass.config_entries.async_unload(entry.entry_id) + assert hass.states.get("switch.switch_output1").state == STATE_UNAVAILABLE diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 58743b1ae05ff9..ec47710a6f64dd 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.light import ( ATTR_SUPPORTED_COLOR_MODES, COLOR_MODE_BRIGHTNESS, @@ -97,7 +98,9 @@ async def test_get_actions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert actions == expected_actions @@ -116,13 +119,15 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): "5678", device_id=device_entry.id, ).entity_id - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert len(actions) == 3 action_types = {action["type"] for action in actions} assert action_types == {"turn_on", "toggle", "turn_off"} for action in actions: capabilities = await async_get_device_automation_capabilities( - hass, "action", action + hass, DeviceAutomationType.ACTION, action ) assert capabilities == {"extra_fields": []} @@ -130,7 +135,7 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): entity_reg.async_remove(entity_id) for action in actions: capabilities = await async_get_device_automation_capabilities( - hass, "action", action + hass, DeviceAutomationType.ACTION, action ) assert capabilities == {"extra_fields": []} @@ -260,13 +265,15 @@ async def test_get_action_capabilities_features( {"supported_features": supported_features_state, **attributes_state}, ) - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert len(actions) == len(expected_actions) action_types = {action["type"] for action in actions} assert action_types == expected_actions for action in actions: capabilities = await async_get_device_automation_capabilities( - hass, "action", action + hass, DeviceAutomationType.ACTION, action ) expected = {"extra_fields": expected_capabilities.get(action["type"], [])} assert capabilities == expected diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index b174a312cd9629..ba718b385fbd41 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -5,6 +5,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.light import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -65,7 +66,9 @@ async def test_get_conditions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert conditions == expected_conditions @@ -83,10 +86,12 @@ async def test_get_condition_capabilities(hass, device_reg, entity_reg): {"name": "for", "optional": True, "type": "positive_time_period_dict"} ] } - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) for condition in conditions: capabilities = await async_get_device_automation_capabilities( - hass, "condition", condition + hass, DeviceAutomationType.CONDITION, condition ) assert capabilities == expected_capabilities diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 3217eb461b08ec..0e9b334c6d17f7 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.light import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -50,6 +51,13 @@ async def test_get_triggers(hass, device_reg, entity_reg): ) entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "changed_states", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, { "platform": "device", "domain": DOMAIN, @@ -65,7 +73,9 @@ async def test_get_triggers(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert triggers == expected_triggers @@ -83,10 +93,12 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): {"name": "for", "optional": True, "type": "positive_time_period_dict"} ] } - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == expected_capabilities @@ -156,6 +168,30 @@ async def test_if_fires_on_state_change(hass, calls, enable_custom_integrations) }, }, }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "changed_states", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on_or_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, ] }, ) @@ -165,17 +201,19 @@ async def test_if_fires_on_state_change(hass, calls, enable_custom_integrations) hass.states.async_set(ent1.entity_id, STATE_OFF) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "turn_off device - {} - on - off - None".format( - ent1.entity_id - ) + assert len(calls) == 2 + assert {calls[0].data["some"], calls[1].data["some"]} == { + f"turn_off device - {ent1.entity_id} - on - off - None", + f"turn_on_or_off device - {ent1.entity_id} - on - off - None", + } hass.states.async_set(ent1.entity_id, STATE_ON) await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "turn_on device - {} - off - on - None".format( - ent1.entity_id - ) + assert len(calls) == 4 + assert {calls[2].data["some"], calls[3].data["some"]} == { + f"turn_on device - {ent1.entity_id} - off - on - None", + f"turn_on_or_off device - {ent1.entity_id} - off - on - None", + } async def test_if_fires_on_state_change_with_for( diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index d51a5b64861aa1..a8a6ebc901e12e 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -898,16 +898,6 @@ async def test_light_brightness_pct_conversion(hass, enable_custom_integrations) assert data["brightness"] == 255 -def test_deprecated_base_class(caplog): - """Test deprecated base class.""" - - class CustomLight(light.Light): - pass - - CustomLight() - assert "Light is deprecated, modify CustomLight" in caplog.text - - async def test_profiles(hass): """Test profiles loading.""" profiles = orig_Profiles(hass) @@ -1862,6 +1852,74 @@ async def test_light_service_call_color_temp_emulation( assert data == {"brightness": 255, "hs_color": (27.001, 19.243)} +async def test_light_service_call_color_temp_conversion( + hass, enable_custom_integrations +): + """Test color temp conversion in service calls.""" + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + platform.ENTITIES.append(platform.MockLight("Test_rgbww_ct", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("Test_rgbww", STATE_ON)) + + entity0 = platform.ENTITIES[0] + entity0.supported_color_modes = { + light.COLOR_MODE_COLOR_TEMP, + light.COLOR_MODE_RGBWW, + } + + entity1 = platform.ENTITIES[1] + entity1.supported_color_modes = {light.COLOR_MODE_RGBWW} + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.attributes["supported_color_modes"] == [ + light.COLOR_MODE_COLOR_TEMP, + light.COLOR_MODE_RGBWW, + ] + + state = hass.states.get(entity1.entity_id) + assert state.attributes["supported_color_modes"] == [light.COLOR_MODE_RGBWW] + + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [ + entity0.entity_id, + entity1.entity_id, + ], + "brightness_pct": 100, + "color_temp": 153, + }, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"brightness": 255, "color_temp": 153} + _, data = entity1.last_call("turn_on") + assert data == {"brightness": 255, "rgbww_color": (0, 0, 0, 0, 255)} + + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": [ + entity0.entity_id, + entity1.entity_id, + ], + "brightness_pct": 50, + "color_temp": 500, + }, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"brightness": 128, "color_temp": 500} + _, data = entity1.last_call("turn_on") + assert data == {"brightness": 128, "rgbww_color": (0, 0, 0, 128, 0)} + + async def test_light_service_call_white_mode(hass, enable_custom_integrations): """Test color_mode white in service calls.""" platform = getattr(hass.components, "test.light") diff --git a/tests/components/litejet/test_scene.py b/tests/components/litejet/test_scene.py index 077793279d8cbc..9784d96d2ef24b 100644 --- a/tests/components/litejet/test_scene.py +++ b/tests/components/litejet/test_scene.py @@ -23,7 +23,7 @@ async def test_disabled_by_default(hass, mock_litejet): entry = registry.async_get(ENTITY_SCENE) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_activate(hass, mock_litejet): diff --git a/tests/components/litterrobot/test_button.py b/tests/components/litterrobot/test_button.py index 0ca74da5d02205..3f802d0e6b282a 100644 --- a/tests/components/litterrobot/test_button.py +++ b/tests/components/litterrobot/test_button.py @@ -4,14 +4,10 @@ from freezegun import freeze_time from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_ICON, - ENTITY_CATEGORY_CONFIG, - STATE_UNKNOWN, -) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory from .conftest import setup_integration @@ -31,7 +27,7 @@ async def test_button(hass: HomeAssistant, mock_account: MagicMock) -> None: entry = entity_registry.async_get(BUTTON_ENTITY) assert entry - assert entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entry.entity_category is EntityCategory.CONFIG await hass.services.async_call( BUTTON_DOMAIN, diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index dfb1b0e639e025..e3e9782423e4cc 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -10,9 +10,10 @@ DOMAIN as PLATFORM_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_CATEGORY_CONFIG +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity import EntityCategory from homeassistant.util.dt import utcnow from .conftest import setup_integration @@ -32,7 +33,7 @@ async def test_wait_time_select(hass: HomeAssistant, mock_account): ent_reg = entity_registry.async_get(hass) entity_entry = ent_reg.async_get(SELECT_ENTITY_ID) assert entity_entry - assert entity_entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entity_entry.entity_category is EntityCategory.CONFIG data = {ATTR_ENTITY_ID: SELECT_ENTITY_ID} diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index dbc8c39790c8b5..e3e62d5f5e4401 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -2,8 +2,8 @@ from unittest.mock import Mock from homeassistant.components.litterrobot.sensor import LitterRobotSleepTimeSensor -from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN -from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE +from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN, SensorDeviceClass +from homeassistant.const import PERCENTAGE from .conftest import create_mock_robot, setup_integration @@ -30,7 +30,7 @@ async def test_sleep_time_sensor_with_none_state(hass): assert sensor assert sensor.state is None - assert sensor.device_class == DEVICE_CLASS_TIMESTAMP + assert sensor.device_class is SensorDeviceClass.TIMESTAMP async def test_gauge_icon(): diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py index 99c34e4273f94f..540a1c928109cc 100644 --- a/tests/components/litterrobot/test_switch.py +++ b/tests/components/litterrobot/test_switch.py @@ -10,14 +10,10 @@ SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ENTITY_CATEGORY_CONFIG, - STATE_OFF, - STATE_ON, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity import EntityCategory from homeassistant.util.dt import utcnow from .conftest import setup_integration @@ -39,7 +35,7 @@ async def test_switch(hass: HomeAssistant, mock_account: MagicMock): ent_reg = entity_registry.async_get(hass) entity_entry = ent_reg.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) assert entity_entry - assert entity_entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entity_entry.entity_category is EntityCategory.CONFIG @pytest.mark.parametrize( diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index b9b2b19832e38c..8432420244e331 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -14,6 +14,9 @@ async def test_loading_file(hass, hass_client): with mock.patch("os.path.isfile", mock.Mock(return_value=True)), mock.patch( "os.access", mock.Mock(return_value=True) + ), mock.patch( + "homeassistant.components.local_file.camera.mimetypes.guess_type", + mock.Mock(return_value=(None, None)), ): await async_setup_component( hass, @@ -138,6 +141,9 @@ async def test_update_file_path(hass): with mock.patch("os.path.isfile", mock.Mock(return_value=True)), mock.patch( "os.access", mock.Mock(return_value=True) + ), mock.patch( + "homeassistant.components.local_file.camera.mimetypes.guess_type", + mock.Mock(return_value=(None, None)), ): camera_1 = {"platform": "local_file", "file_path": "mock/path.jpg"} diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index c5a9b19d949bbe..4ee03bcbb0fac8 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.lock import DOMAIN, SUPPORT_OPEN from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -85,7 +86,9 @@ async def test_get_actions( } for action in expected_action_types ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index aeb304cb1c8c1c..08cc8c9792527d 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.lock import DOMAIN from homeassistant.const import ( STATE_JAMMED, @@ -88,7 +89,9 @@ async def test_get_conditions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert_lists_same(conditions, expected_conditions) diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index c3539288f94338..cf4287a02be4dd 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.lock import DOMAIN from homeassistant.const import ( STATE_JAMMED, @@ -93,7 +94,9 @@ async def test_get_triggers(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -107,11 +110,13 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): ) entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 5 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == { "extra_fields": [ diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py deleted file mode 100644 index a788b9fa9173fe..00000000000000 --- a/tests/components/lock/test_init.py +++ /dev/null @@ -1,12 +0,0 @@ -"""The tests for Lock.""" -from homeassistant.components import lock - - -def test_deprecated_base_class(caplog): - """Test deprecated base class.""" - - class CustomLock(lock.LockDevice): - pass - - CustomLock() - assert "LockDevice is deprecated, modify CustomLock" in caplog.text diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 39277ef7aa7444..fab1121542f2f6 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -35,37 +35,32 @@ import homeassistant.core as ha from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.json import JSONEncoder -from homeassistant.setup import async_setup_component, setup_component +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant, init_recorder_component, mock_platform +from tests.common import ( + async_capture_events, + async_init_recorder_component, + mock_platform, +) from tests.components.recorder.common import trigger_db_commit EMPTY_CONFIG = logbook.CONFIG_SCHEMA({logbook.DOMAIN: {}}) @pytest.fixture -def hass_(): +async def hass_(hass): """Set up things to be run when tests are started.""" - hass = get_test_home_assistant() - init_recorder_component(hass) # Force an in memory DB - with patch("homeassistant.components.http.start_http_server_and_save_config"): - assert setup_component(hass, logbook.DOMAIN, EMPTY_CONFIG) - yield hass - hass.stop() + await async_init_recorder_component(hass) # Force an in memory DB + assert await async_setup_component(hass, logbook.DOMAIN, EMPTY_CONFIG) + return hass -def test_service_call_create_logbook_entry(hass_): +async def test_service_call_create_logbook_entry(hass_): """Test if service call create log book entry.""" - calls = [] - - @ha.callback - def event_listener(event): - """Append on event.""" - calls.append(event) + calls = async_capture_events(hass_, logbook.EVENT_LOGBOOK_ENTRY) - hass_.bus.listen(logbook.EVENT_LOGBOOK_ENTRY, event_listener) - hass_.services.call( + await hass_.services.async_call( logbook.DOMAIN, "log", { @@ -76,7 +71,7 @@ def event_listener(event): }, True, ) - hass_.services.call( + await hass_.services.async_call( logbook.DOMAIN, "log", { @@ -88,9 +83,11 @@ def event_listener(event): # Logbook entry service call results in firing an event. # Our service call will unblock when the event listeners have been # scheduled. This means that they may not have been processed yet. - trigger_db_commit(hass_) - hass_.block_till_done() - hass_.data[recorder.DATA_INSTANCE].block_till_done() + await hass_.async_add_executor_job(trigger_db_commit, hass_) + await hass_.async_block_till_done() + await hass_.async_add_executor_job( + hass_.data[recorder.DATA_INSTANCE].block_till_done + ) events = list( logbook._get_events( @@ -116,24 +113,17 @@ def event_listener(event): assert last_call.data.get(logbook.ATTR_DOMAIN) == "logbook" -def test_service_call_create_log_book_entry_no_message(hass_): +async def test_service_call_create_log_book_entry_no_message(hass_): """Test if service call create log book entry without message.""" - calls = [] - - @ha.callback - def event_listener(event): - """Append on event.""" - calls.append(event) - - hass_.bus.listen(logbook.EVENT_LOGBOOK_ENTRY, event_listener) + calls = async_capture_events(hass_, logbook.EVENT_LOGBOOK_ENTRY) with pytest.raises(vol.Invalid): - hass_.services.call(logbook.DOMAIN, "log", {}, True) + await hass_.services.async_call(logbook.DOMAIN, "log", {}, True) # Logbook entry service call results in firing an event. # Our service call will unblock when the event listeners have been # scheduled. This means that they may not have been processed yet. - hass_.block_till_done() + await hass_.async_block_till_done() assert len(calls) == 0 @@ -310,7 +300,7 @@ def create_state_changed_event_from_old_new( async def test_logbook_view(hass, hass_client): """Test the logbook view.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) client = await hass_client() @@ -320,7 +310,7 @@ async def test_logbook_view(hass, hass_client): async def test_logbook_view_period_entity(hass, hass_client): """Test the logbook view with period and entity.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -404,7 +394,7 @@ async def test_logbook_view_period_entity(hass, hass_client): 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) + await async_init_recorder_component(hass) def _describe(event): """Describe an event.""" @@ -471,7 +461,7 @@ def async_describe_events(hass, async_describe_event): Mock(async_describe_events=async_describe_events), ) - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) assert await async_setup_component( hass, logbook.DOMAIN, @@ -515,7 +505,7 @@ def async_describe_events(hass, async_describe_event): async def test_logbook_view_end_time_entity(hass, hass_client): """Test the logbook view with end_time and entity.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -573,7 +563,7 @@ async def test_logbook_view_end_time_entity(hass, hass_client): async def test_logbook_entity_filter_with_automations(hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await async_setup_component(hass, "automation", {}) await async_setup_component(hass, "script", {}) @@ -648,7 +638,7 @@ async def test_logbook_entity_filter_with_automations(hass, hass_client): async def test_filter_continuous_sensor_values(hass, hass_client): """Test remove continuous sensor events from logbook.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -684,7 +674,7 @@ async def test_filter_continuous_sensor_values(hass, hass_client): async def test_exclude_new_entities(hass, hass_client): """Test if events are excluded on first update.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -719,7 +709,7 @@ async def test_exclude_new_entities(hass, hass_client): async def test_exclude_removed_entities(hass, hass_client): """Test if events are excluded on last update.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -761,7 +751,7 @@ async def test_exclude_removed_entities(hass, hass_client): async def test_exclude_attribute_changes(hass, hass_client): """Test if events of attribute changes are filtered.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -799,7 +789,7 @@ async def test_exclude_attribute_changes(hass, hass_client): async def test_logbook_entity_context_id(hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await async_setup_component(hass, "automation", {}) await async_setup_component(hass, "script", {}) @@ -951,7 +941,7 @@ async def test_logbook_entity_context_id(hass, hass_client): async def test_logbook_entity_context_parent_id(hass, hass_client): """Test the logbook view links events via context parent_id.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await async_setup_component(hass, "automation", {}) await async_setup_component(hass, "script", {}) @@ -1132,7 +1122,7 @@ async def test_logbook_entity_context_parent_id(hass, hass_client): async def test_logbook_context_from_template(hass, hass_client): """Test the logbook view with end_time and entity with automations and scripts.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) assert await async_setup_component( hass, @@ -1218,7 +1208,7 @@ async def test_logbook_context_from_template(hass, hass_client): async def test_logbook_entity_matches_only(hass, hass_client): """Test the logbook view with a single entity and entity_matches_only.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) assert await async_setup_component( hass, @@ -1292,7 +1282,7 @@ async def test_logbook_entity_matches_only(hass, hass_client): async def test_custom_log_entry_discoverable_via_entity_matches_only(hass, hass_client): """Test if a custom log entry is later discoverable via entity_matches_only.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1331,7 +1321,7 @@ async def test_custom_log_entry_discoverable_via_entity_matches_only(hass, hass_ async def test_logbook_entity_matches_only_multiple(hass, hass_client): """Test the logbook view with a multiple entities and entity_matches_only.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) assert await async_setup_component( hass, @@ -1415,7 +1405,7 @@ async def test_logbook_entity_matches_only_multiple(hass, hass_client): async def test_logbook_invalid_entity(hass, hass_client): """Test the logbook view with requesting an invalid entity.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await hass.async_block_till_done() client = await hass_client() @@ -1434,7 +1424,7 @@ async def test_logbook_invalid_entity(hass, hass_client): async def test_icon_and_state(hass, hass_client): """Test to ensure state and custom icons are returned.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1481,7 +1471,7 @@ async def test_exclude_events_domain(hass, hass_client): logbook.DOMAIN: {CONF_EXCLUDE: {CONF_DOMAINS: ["switch", "alexa"]}}, } ) - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", config) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1521,7 +1511,7 @@ async def test_exclude_events_domain_glob(hass, hass_client): }, } ) - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", config) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1561,7 +1551,7 @@ async def test_include_events_entity(hass, hass_client): }, } ) - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", config) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1594,7 +1584,7 @@ async def test_exclude_events_entity(hass, hass_client): logbook.DOMAIN: {CONF_EXCLUDE: {CONF_ENTITIES: [entity_id]}}, } ) - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", config) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1628,7 +1618,7 @@ async def test_include_events_domain(hass, hass_client): }, } ) - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", config) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1672,7 +1662,7 @@ async def test_include_events_domain_glob(hass, hass_client): }, } ) - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", config) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1724,7 +1714,7 @@ async def test_include_exclude_events(hass, hass_client): }, } ) - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", config) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1778,7 +1768,7 @@ async def test_include_exclude_events_with_glob_filters(hass, hass_client): }, } ) - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", config) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1821,7 +1811,7 @@ async def test_empty_config(hass, hass_client): logbook.DOMAIN: {}, } ) - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", config) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1843,7 +1833,7 @@ async def test_empty_config(hass, hass_client): async def test_context_filter(hass, hass_client): """Test we can filter by context.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) assert await async_setup_component(hass, "logbook", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) diff --git a/tests/components/luftdaten/conftest.py b/tests/components/luftdaten/conftest.py new file mode 100644 index 00000000000000..248e1344f1bff4 --- /dev/null +++ b/tests/components/luftdaten/conftest.py @@ -0,0 +1,82 @@ +"""Fixtures for Luftdaten tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.luftdaten.const import CONF_SENSOR_ID, DOMAIN +from homeassistant.const import CONF_SHOW_ON_MAP +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="12345", + domain=DOMAIN, + data={CONF_SENSOR_ID: 12345, CONF_SHOW_ON_MAP: True}, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.luftdaten.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_luftdaten_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked Luftdaten client.""" + with patch( + "homeassistant.components.luftdaten.config_flow.Luftdaten", autospec=True + ) as luftdaten_mock: + luftdaten = luftdaten_mock.return_value + luftdaten.validate_sensor.return_value = True + yield luftdaten + + +@pytest.fixture +def mock_luftdaten() -> Generator[None, MagicMock, None]: + """Return a mocked Luftdaten client.""" + with patch( + "homeassistant.components.luftdaten.Luftdaten", autospec=True + ) as luftdaten_mock: + luftdaten = luftdaten_mock.return_value + luftdaten.sensor_id = 12345 + luftdaten.meta = { + "altitude": 123.456, + "latitude": 56.789, + "longitude": 12.345, + "sensor_id": 12345, + } + luftdaten.values = { + "humidity": 34.70, + "P1": 8.5, + "P2": 4.07, + "pressure_at_sealevel": 103102.13, + "pressure": 98545.00, + "temperature": 22.30, + } + yield luftdaten + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_luftdaten: MagicMock +) -> MockConfigEntry: + """Set up the Luftdaten integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/luftdaten/test_config_flow.py b/tests/components/luftdaten/test_config_flow.py index 4ea55e26aeed2d..595d4397200bc6 100644 --- a/tests/components/luftdaten/test_config_flow.py +++ b/tests/components/luftdaten/test_config_flow.py @@ -1,105 +1,144 @@ """Define tests for the Luftdaten config flow.""" -from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock -from homeassistant import data_entry_flow -from homeassistant.components.luftdaten import DOMAIN, config_flow +from luftdaten.exceptions import LuftdatenConnectionError + +from homeassistant.components.luftdaten import DOMAIN from homeassistant.components.luftdaten.const import CONF_SENSOR_ID -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_SHOW_ON_MAP +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) from tests.common import MockConfigEntry -async def test_duplicate_error(hass): +async def test_duplicate_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test that errors are shown when duplicates are added.""" - conf = {CONF_SENSOR_ID: "12345abcde"} + mock_config_entry.add_to_hass(hass) - MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) - flow = config_flow.LuftDatenFlowHandler() - flow.hass = hass + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {CONF_SENSOR_ID: "already_configured"} + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_SENSOR_ID: 12345}, + ) -async def test_communication_error(hass): - """Test that no sensor is added while unable to communicate with API.""" - conf = {CONF_SENSOR_ID: "12345abcde"} + assert result2.get("type") == RESULT_TYPE_ABORT + assert result2.get("reason") == "already_configured" - flow = config_flow.LuftDatenFlowHandler() - flow.hass = hass - with patch("luftdaten.Luftdaten.get_data", return_value=None): - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {CONF_SENSOR_ID: "invalid_sensor"} +async def test_communication_error( + hass: HomeAssistant, mock_luftdaten_config_flow: MagicMock +) -> None: + """Test that no sensor is added while unable to communicate with API.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_luftdaten_config_flow.get_data.side_effect = LuftdatenConnectionError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_SENSOR_ID: 12345}, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {CONF_SENSOR_ID: "cannot_connect"} + assert "flow_id" in result2 + + mock_luftdaten_config_flow.get_data.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_SENSOR_ID: 12345}, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "12345" + assert result3.get("data") == { + CONF_SENSOR_ID: 12345, + CONF_SHOW_ON_MAP: False, + } -async def test_invalid_sensor(hass): +async def test_invalid_sensor( + hass: HomeAssistant, mock_luftdaten_config_flow: MagicMock +) -> None: """Test that an invalid sensor throws an error.""" - conf = {CONF_SENSOR_ID: "12345abcde"} - - flow = config_flow.LuftDatenFlowHandler() - flow.hass = hass - - with patch("luftdaten.Luftdaten.get_data", return_value=False), patch( - "luftdaten.Luftdaten.validate_sensor", return_value=False - ): - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {CONF_SENSOR_ID: "invalid_sensor"} - - -async def test_show_form(hass): - """Test that the form is served with no input.""" - flow = config_flow.LuftDatenFlowHandler() - flow.hass = hass - - result = await flow.async_step_user(user_input=None) - - 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_SENSOR_ID: "12345abcde", CONF_SHOW_ON_MAP: False} - - flow = config_flow.LuftDatenFlowHandler() - flow.hass = hass - - with patch("luftdaten.Luftdaten.get_data", return_value=True), patch( - "luftdaten.Luftdaten.validate_sensor", return_value=True - ): - result = await flow.async_step_import(import_config=conf) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345abcde" - assert result["data"] == { - CONF_SENSOR_ID: "12345abcde", - CONF_SHOW_ON_MAP: False, - CONF_SCAN_INTERVAL: 600, - } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_luftdaten_config_flow.validate_sensor.return_value = False + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_SENSOR_ID: 11111}, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {CONF_SENSOR_ID: "invalid_sensor"} + assert "flow_id" in result2 + + mock_luftdaten_config_flow.validate_sensor.return_value = True + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_SENSOR_ID: 12345}, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "12345" + assert result3.get("data") == { + CONF_SENSOR_ID: 12345, + CONF_SHOW_ON_MAP: False, + } -async def test_step_user(hass): +async def test_step_user( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_luftdaten_config_flow: MagicMock, +) -> None: """Test that the user step works.""" - conf = { - CONF_SENSOR_ID: "12345abcde", - CONF_SHOW_ON_MAP: False, - CONF_SCAN_INTERVAL: timedelta(minutes=5), + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_SENSOR_ID: 12345, + CONF_SHOW_ON_MAP: True, + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "12345" + assert result2.get("data") == { + CONF_SENSOR_ID: 12345, + CONF_SHOW_ON_MAP: True, } - - flow = config_flow.LuftDatenFlowHandler() - flow.hass = hass - - with patch("luftdaten.Luftdaten.get_data", return_value=True), patch( - "luftdaten.Luftdaten.validate_sensor", return_value=True - ): - result = await flow.async_step_user(user_input=conf) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345abcde" - assert result["data"] == { - CONF_SENSOR_ID: "12345abcde", - CONF_SHOW_ON_MAP: False, - CONF_SCAN_INTERVAL: 300, - } diff --git a/tests/components/luftdaten/test_diagnostics.py b/tests/components/luftdaten/test_diagnostics.py new file mode 100644 index 00000000000000..59db2e036bf955 --- /dev/null +++ b/tests/components/luftdaten/test_diagnostics.py @@ -0,0 +1,30 @@ +"""Tests for the diagnostics data provided by the Sensor.Community integration.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "P1": 8.5, + "P2": 4.07, + "altitude": 123.456, + "humidity": 34.7, + "latitude": REDACTED, + "longitude": REDACTED, + "pressure": 98545.0, + "pressure_at_sealevel": 103102.13, + "sensor_id": REDACTED, + "temperature": 22.3, + } diff --git a/tests/components/luftdaten/test_init.py b/tests/components/luftdaten/test_init.py index ebe5f73669eae6..83902b19cd7ce5 100644 --- a/tests/components/luftdaten/test_init.py +++ b/tests/components/luftdaten/test_init.py @@ -1,39 +1,75 @@ -"""Test the Luftdaten component setup.""" -from unittest.mock import patch +"""Tests for the Luftdaten integration.""" +from unittest.mock import AsyncMock, MagicMock, patch -from homeassistant.components import luftdaten -from homeassistant.components.luftdaten.const import CONF_SENSOR_ID, DOMAIN -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP -from homeassistant.setup import async_setup_component +from luftdaten.exceptions import LuftdatenError +from homeassistant.components.luftdaten.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant -async def test_config_with_sensor_passed_to_config_entry(hass): - """Test that configured options for a sensor are loaded.""" - conf = { - CONF_SENSOR_ID: "12345abcde", - CONF_SHOW_ON_MAP: False, - CONF_SCAN_INTERVAL: 600, - } +from tests.common import MockConfigEntry - with patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_entries, patch.object( - luftdaten, "configured_sensors", return_value=[] - ): - assert await async_setup_component(hass, DOMAIN, conf) is True - assert len(mock_config_entries.flow.mock_calls) == 0 +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luftdaten: AsyncMock, +) -> None: + """Test the Luftdaten configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED -async def test_config_already_registered_not_passed_to_config_entry(hass): - """Test that an already registered sensor does not initiate an import.""" - conf = {CONF_SENSOR_ID: "12345abcde"} + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() - with patch.object( - hass.config_entries.flow, "async_init" - ) as mock_config_entries, patch.object( - luftdaten, "configured_sensors", return_value=["12345abcde"] - ): - assert await async_setup_component(hass, DOMAIN, conf) is True + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - assert len(mock_config_entries.flow.mock_calls) == 0 + +@patch( + "homeassistant.components.luftdaten.Luftdaten.get_data", + side_effect=LuftdatenError, +) +async def test_config_entry_not_ready( + mock_get_data: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Luftdaten configuration entry not ready.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_get_data.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_not_ready_no_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_luftdaten: MagicMock, +) -> None: + """Test the Luftdaten configuration entry not ready.""" + mock_luftdaten.values = {} + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_luftdaten.get_data.assert_called() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setting_unique_id( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_luftdaten: MagicMock +) -> None: + """Test we set unique ID if not set yet.""" + mock_config_entry.unique_id = None + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.unique_id == "12345" diff --git a/tests/components/luftdaten/test_sensor.py b/tests/components/luftdaten/test_sensor.py new file mode 100644 index 00000000000000..3cf4426d500b68 --- /dev/null +++ b/tests/components/luftdaten/test_sensor.py @@ -0,0 +1,131 @@ +"""Tests for the sensors provided by the Luftdaten integration.""" +from homeassistant.components.luftdaten.const import DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + PERCENTAGE, + PRESSURE_PA, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_luftdaten_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Luftdaten sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + entry = entity_registry.async_get("sensor.temperature") + assert entry + assert entry.device_id + assert entry.unique_id == "12345_temperature" + + state = hass.states.get("sensor.temperature") + assert state + assert state.state == "22.3" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Temperature" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert ATTR_ICON not in state.attributes + + entry = entity_registry.async_get("sensor.humidity") + assert entry + assert entry.device_id + assert entry.unique_id == "12345_humidity" + + state = hass.states.get("sensor.humidity") + assert state + assert state.state == "34.7" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Humidity" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert ATTR_ICON not in state.attributes + + entry = entity_registry.async_get("sensor.pressure") + assert entry + assert entry.device_id + assert entry.unique_id == "12345_pressure" + + state = hass.states.get("sensor.pressure") + assert state + assert state.state == "98545.0" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Pressure" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PA + assert ATTR_ICON not in state.attributes + + entry = entity_registry.async_get("sensor.pressure_at_sealevel") + assert entry + assert entry.device_id + assert entry.unique_id == "12345_pressure_at_sealevel" + + state = hass.states.get("sensor.pressure_at_sealevel") + assert state + assert state.state == "103102.13" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Pressure at sealevel" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PA + assert ATTR_ICON not in state.attributes + + entry = entity_registry.async_get("sensor.pm10") + assert entry + assert entry.device_id + assert entry.unique_id == "12345_P1" + + state = hass.states.get("sensor.pm10") + assert state + assert state.state == "8.5" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "PM10" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert ATTR_ICON not in state.attributes + + entry = entity_registry.async_get("sensor.pm2_5") + assert entry + assert entry.device_id + assert entry.unique_id == "12345_P2" + + state = hass.states.get("sensor.pm2_5") + assert state + assert state.state == "4.07" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "PM2.5" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert ATTR_ICON not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "12345")} + assert device_entry.manufacturer == "Sensor.Community" + assert device_entry.name == "Sensor 12345" + assert ( + device_entry.configuration_url + == "https://devices.sensor.community/sensors/12345/settings" + ) diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index 23faa929574921..97c31e980d0dda 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -2,6 +2,7 @@ import pytest from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) @@ -166,7 +167,9 @@ async def test_get_triggers(hass, device_reg): }, ] - triggers = await async_get_device_automations(hass, "trigger", device_id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_id + ) assert_lists_same(triggers, expected_triggers) @@ -180,7 +183,9 @@ async def test_get_triggers_for_invalid_device_id(hass, device_reg): ) with pytest.raises(InvalidDeviceAutomationConfig): - await async_get_device_automations(hass, "trigger", invalid_device.id) + await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, invalid_device.id + ) async def test_if_fires_on_button_event(hass, calls, device_reg): diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 6a09ff405c8f55..29b2bc29f4a706 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -1,143 +1,124 @@ """The tests for the MaryTTS speech platform.""" -import asyncio import os import shutil from unittest.mock import patch +import pytest + from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) import homeassistant.components.tts as tts -from homeassistant.config import async_process_ha_core_config -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component, async_mock_service + + +@pytest.fixture(autouse=True) +def cleanup_cache(hass): + """Prevent TTS writing.""" + yield + default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) + if os.path.isdir(default_tts): + shutil.rmtree(default_tts) + + +async def test_setup_component(hass): + """Test setup component.""" + config = {tts.DOMAIN: {"platform": "marytts"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + +async def test_service_say(hass): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = {tts.DOMAIN: {"platform": "marytts"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.marytts.tts.MaryTTS.speak", + return_value=b"audio", + ) as mock_speak: + await hass.services.async_call( + tts.DOMAIN, + "marytts_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "HomeAssistant", + }, + blocking=True, + ) + + mock_speak.assert_called_once() + mock_speak.assert_called_with("HomeAssistant", {}) + + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".wav") != -1 + + +async def test_service_say_with_effect(hass): + """Test service call say with effects.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = {tts.DOMAIN: {"platform": "marytts", "effect": {"Volume": "amount:2.0;"}}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.marytts.tts.MaryTTS.speak", + return_value=b"audio", + ) as mock_speak: + await hass.services.async_call( + tts.DOMAIN, + "marytts_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "HomeAssistant", + }, + blocking=True, + ) + + mock_speak.assert_called_once() + mock_speak.assert_called_with("HomeAssistant", {"Volume": "amount:2.0;"}) + + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".wav") != -1 -from tests.common import assert_setup_component, get_test_home_assistant, mock_service +async def test_service_say_http_error(hass): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) -class TestTTSMaryTTSPlatform: - """Test the speech component.""" + config = {tts.DOMAIN: {"platform": "marytts"}} - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() - asyncio.run_coroutine_threadsafe( - async_process_ha_core_config( - self.hass, {"internal_url": "http://example.local:8123"} - ), - self.hass.loop, + with patch( + "homeassistant.components.marytts.tts.MaryTTS.speak", + side_effect=Exception(), + ) as mock_speak: + await hass.services.async_call( + tts.DOMAIN, + "marytts_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "HomeAssistant", + }, ) + await hass.async_block_till_done() - self.host = "localhost" - self.port = 59125 - self.params = { - "INPUT_TEXT": "HomeAssistant", - "INPUT_TYPE": "TEXT", - "OUTPUT_TYPE": "AUDIO", - "LOCALE": "en_US", - "AUDIO": "WAVE_FILE", - "VOICE": "cmu-slt-hsmm", - } - - def teardown_method(self): - """Stop everything that was started.""" - default_tts = self.hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) - - self.hass.stop() - - def test_setup_component(self): - """Test setup component.""" - config = {tts.DOMAIN: {"platform": "marytts"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - def test_service_say(self): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - config = {tts.DOMAIN: {"platform": "marytts"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - with patch( - "homeassistant.components.marytts.tts.MaryTTS.speak", - return_value=b"audio", - ) as mock_speak: - self.hass.services.call( - tts.DOMAIN, - "marytts_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "HomeAssistant", - }, - ) - self.hass.block_till_done() - - mock_speak.assert_called_once() - mock_speak.assert_called_with("HomeAssistant", {}) - - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".wav") != -1 - - def test_service_say_with_effect(self): - """Test service call say with effects.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - config = { - tts.DOMAIN: {"platform": "marytts", "effect": {"Volume": "amount:2.0;"}} - } - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - with patch( - "homeassistant.components.marytts.tts.MaryTTS.speak", - return_value=b"audio", - ) as mock_speak: - self.hass.services.call( - tts.DOMAIN, - "marytts_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "HomeAssistant", - }, - ) - self.hass.block_till_done() - - mock_speak.assert_called_once() - mock_speak.assert_called_with("HomeAssistant", {"Volume": "amount:2.0;"}) - - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".wav") != -1 - - def test_service_say_http_error(self): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - config = {tts.DOMAIN: {"platform": "marytts"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - with patch( - "homeassistant.components.marytts.tts.MaryTTS.speak", - side_effect=Exception(), - ) as mock_speak: - self.hass.services.call( - tts.DOMAIN, - "marytts_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "HomeAssistant", - }, - ) - self.hass.block_till_done() - - mock_speak.assert_called_once() - assert len(calls) == 0 + mock_speak.assert_called_once() + assert len(calls) == 0 diff --git a/tests/components/maxcube/conftest.py b/tests/components/maxcube/conftest.py index b36072190c4b75..f0dd12eb6c664d 100644 --- a/tests/components/maxcube/conftest.py +++ b/tests/components/maxcube/conftest.py @@ -41,6 +41,7 @@ def thermostat(): t.max_temperature = None t.min_temperature = None t.valve_position = 25 # 25% + t.battery = 1 return t @@ -62,6 +63,7 @@ def wallthermostat(): t.actual_temperature = 19.0 t.max_temperature = 29.0 t.min_temperature = 4.5 + t.battery = 1 return t @@ -77,6 +79,7 @@ def windowshutter(): shutter.is_thermostat.return_value = False shutter.is_wallthermostat.return_value = False shutter.is_windowshutter.return_value = True + shutter.battery = 1 return shutter diff --git a/tests/components/maxcube/test_maxcube_binary_sensor.py b/tests/components/maxcube/test_maxcube_binary_sensor.py index 48d34a0df4ebb8..39e1fb547406f9 100644 --- a/tests/components/maxcube/test_maxcube_binary_sensor.py +++ b/tests/components/maxcube/test_maxcube_binary_sensor.py @@ -4,7 +4,7 @@ from maxcube.cube import MaxCube from maxcube.windowshutter import MaxWindowShutter -from homeassistant.components.binary_sensor import DEVICE_CLASS_WINDOW +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -12,11 +12,13 @@ STATE_ON, ) from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory from homeassistant.util import utcnow from tests.common import async_fire_time_changed ENTITY_ID = "binary_sensor.testroom_testshutter" +BATTERY_ENTITY_ID = f"{ENTITY_ID}_battery" async def test_window_shuttler(hass, cube: MaxCube, windowshutter: MaxWindowShutter): @@ -25,12 +27,13 @@ async def test_window_shuttler(hass, cube: MaxCube, windowshutter: MaxWindowShut assert entity_registry.async_is_registered(ENTITY_ID) entity = entity_registry.async_get(ENTITY_ID) assert entity.unique_id == "AABBCCDD03" + assert entity.entity_category == EntityCategory.DIAGNOSTIC state = hass.states.get(ENTITY_ID) assert state is not None assert state.state == STATE_ON assert state.attributes.get(ATTR_FRIENDLY_NAME) == "TestRoom TestShutter" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_WINDOW + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.WINDOW windowshutter.is_open = False async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) @@ -38,3 +41,31 @@ async def test_window_shuttler(hass, cube: MaxCube, windowshutter: MaxWindowShut state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF + + +async def test_window_shuttler_battery( + hass, cube: MaxCube, windowshutter: MaxWindowShutter +): + """Test battery binary_state with a shuttler device.""" + entity_registry = er.async_get(hass) + assert entity_registry.async_is_registered(BATTERY_ENTITY_ID) + entity = entity_registry.async_get(BATTERY_ENTITY_ID) + assert entity.unique_id == "AABBCCDD03_battery" + assert entity.entity_category == EntityCategory.DIAGNOSTIC + + state = hass.states.get(BATTERY_ENTITY_ID) + assert state is not None + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.BATTERY + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "TestRoom TestShutter battery" + + windowshutter.battery = 1 # maxcube-api MAX_DEVICE_BATTERY_LOW + async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + state = hass.states.get(BATTERY_ENTITY_ID) + assert state.state == STATE_ON # on means low + + windowshutter.battery = 0 # maxcube-api MAX_DEVICE_BATTERY_OK + async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + state = hass.states.get(BATTERY_ENTITY_ID) + assert state.state == STATE_OFF # off means normal diff --git a/tests/components/mazda/__init__.py b/tests/components/mazda/__init__.py index 7a81a9224d703b..9d20f78bc00055 100644 --- a/tests/components/mazda/__init__.py +++ b/tests/components/mazda/__init__.py @@ -19,15 +19,22 @@ } -async def init_integration(hass: HomeAssistant, use_nickname=True) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, use_nickname=True, electric_vehicle=False +) -> MockConfigEntry: """Set up the Mazda Connected Services integration in Home Assistant.""" get_vehicles_fixture = json.loads(load_fixture("mazda/get_vehicles.json")) if not use_nickname: get_vehicles_fixture[0].pop("nickname") + if electric_vehicle: + get_vehicles_fixture[0]["isElectric"] = True get_vehicle_status_fixture = json.loads( load_fixture("mazda/get_vehicle_status.json") ) + get_ev_vehicle_status_fixture = json.loads( + load_fixture("mazda/get_ev_vehicle_status.json") + ) config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) config_entry.add_to_hass(hass) @@ -42,6 +49,9 @@ async def init_integration(hass: HomeAssistant, use_nickname=True) -> MockConfig ) client_mock.get_vehicles = AsyncMock(return_value=get_vehicles_fixture) client_mock.get_vehicle_status = AsyncMock(return_value=get_vehicle_status_fixture) + client_mock.get_ev_vehicle_status = AsyncMock( + return_value=get_ev_vehicle_status_fixture + ) client_mock.lock_doors = AsyncMock() client_mock.unlock_doors = AsyncMock() client_mock.send_poi = AsyncMock() diff --git a/tests/components/mazda/fixtures/diagnostics_config_entry.json b/tests/components/mazda/fixtures/diagnostics_config_entry.json new file mode 100644 index 00000000000000..445ae141cb2c4c --- /dev/null +++ b/tests/components/mazda/fixtures/diagnostics_config_entry.json @@ -0,0 +1,62 @@ +{ + "info": { + "email": "**REDACTED**", + "password": "**REDACTED**", + "region": "MNAO" + }, + "data": [ + { + "vin": "**REDACTED**", + "id": "**REDACTED**", + "nickname": "My Mazda3", + "carlineCode": "M3S", + "carlineName": "MAZDA3 2.5 S SE AWD", + "modelYear": "2021", + "modelCode": "M3S SE XA", + "modelName": "W/ SELECT PKG AWD SDN", + "automaticTransmission": true, + "interiorColorCode": "BY3", + "interiorColorName": "BLACK", + "exteriorColorCode": "42M", + "exteriorColorName": "DEEP CRYSTAL BLUE MICA", + "isElectric": false, + "status": { + "lastUpdatedTimestamp": "20210123143809", + "latitude": "**REDACTED**", + "longitude": "**REDACTED**", + "positionTimestamp": "20210123143808", + "fuelRemainingPercent": 87.0, + "fuelDistanceRemainingKm": 380.8, + "odometerKm": 2795.8, + "doors": { + "driverDoorOpen": false, + "passengerDoorOpen": false, + "rearLeftDoorOpen": false, + "rearRightDoorOpen": false, + "trunkOpen": false, + "hoodOpen": false, + "fuelLidOpen": false + }, + "doorLocks": { + "driverDoorUnlocked": false, + "passengerDoorUnlocked": false, + "rearLeftDoorUnlocked": false, + "rearRightDoorUnlocked": false + }, + "windows": { + "driverWindowOpen": false, + "passengerWindowOpen": false, + "rearLeftWindowOpen": false, + "rearRightWindowOpen": false + }, + "hazardLightsOn": false, + "tirePressure": { + "frontLeftTirePressurePsi": 35.0, + "frontRightTirePressurePsi": 35.0, + "rearLeftTirePressurePsi": 33.0, + "rearRightTirePressurePsi": 33.0 + } + } + } + ] +} diff --git a/tests/components/mazda/fixtures/diagnostics_device.json b/tests/components/mazda/fixtures/diagnostics_device.json new file mode 100644 index 00000000000000..0b2fa8550ac315 --- /dev/null +++ b/tests/components/mazda/fixtures/diagnostics_device.json @@ -0,0 +1,60 @@ +{ + "info": { + "email": "**REDACTED**", + "password": "**REDACTED**", + "region": "MNAO" + }, + "data": { + "vin": "**REDACTED**", + "id": "**REDACTED**", + "nickname": "My Mazda3", + "carlineCode": "M3S", + "carlineName": "MAZDA3 2.5 S SE AWD", + "modelYear": "2021", + "modelCode": "M3S SE XA", + "modelName": "W/ SELECT PKG AWD SDN", + "automaticTransmission": true, + "interiorColorCode": "BY3", + "interiorColorName": "BLACK", + "exteriorColorCode": "42M", + "exteriorColorName": "DEEP CRYSTAL BLUE MICA", + "isElectric": false, + "status": { + "lastUpdatedTimestamp": "20210123143809", + "latitude": "**REDACTED**", + "longitude": "**REDACTED**", + "positionTimestamp": "20210123143808", + "fuelRemainingPercent": 87.0, + "fuelDistanceRemainingKm": 380.8, + "odometerKm": 2795.8, + "doors": { + "driverDoorOpen": false, + "passengerDoorOpen": false, + "rearLeftDoorOpen": false, + "rearRightDoorOpen": false, + "trunkOpen": false, + "hoodOpen": false, + "fuelLidOpen": false + }, + "doorLocks": { + "driverDoorUnlocked": false, + "passengerDoorUnlocked": false, + "rearLeftDoorUnlocked": false, + "rearRightDoorUnlocked": false + }, + "windows": { + "driverWindowOpen": false, + "passengerWindowOpen": false, + "rearLeftWindowOpen": false, + "rearRightWindowOpen": false + }, + "hazardLightsOn": false, + "tirePressure": { + "frontLeftTirePressurePsi": 35.0, + "frontRightTirePressurePsi": 35.0, + "rearLeftTirePressurePsi": 33.0, + "rearRightTirePressurePsi": 33.0 + } + } + } +} diff --git a/tests/components/mazda/fixtures/get_ev_vehicle_status.json b/tests/components/mazda/fixtures/get_ev_vehicle_status.json new file mode 100644 index 00000000000000..6aeaa1ebda00f6 --- /dev/null +++ b/tests/components/mazda/fixtures/get_ev_vehicle_status.json @@ -0,0 +1,19 @@ +{ + "chargeInfo": { + "lastUpdatedTimestamp": "20210807083956", + "batteryLevelPercentage": 80, + "drivingRangeKm": 218, + "pluggedIn": true, + "charging": true, + "basicChargeTimeMinutes": 30, + "quickChargeTimeMinutes": 15, + "batteryHeaterAuto": true, + "batteryHeaterOn": true + }, + "hvacInfo": { + "hvacOn": true, + "frontDefroster": false, + "rearDefroster": false, + "interiorTemperatureCelsius": 15.1 + } +} diff --git a/tests/components/mazda/fixtures/get_vehicle_status.json b/tests/components/mazda/fixtures/get_vehicle_status.json index f170b222b318fe..1e74d7202ca115 100644 --- a/tests/components/mazda/fixtures/get_vehicle_status.json +++ b/tests/components/mazda/fixtures/get_vehicle_status.json @@ -34,4 +34,4 @@ "rearLeftTirePressurePsi": 33.0, "rearRightTirePressurePsi": 33.0 } -} \ No newline at end of file +} diff --git a/tests/components/mazda/fixtures/get_vehicles.json b/tests/components/mazda/fixtures/get_vehicles.json index 871eeb9d2ecd06..887ae1194c5ba9 100644 --- a/tests/components/mazda/fixtures/get_vehicles.json +++ b/tests/components/mazda/fixtures/get_vehicles.json @@ -12,6 +12,7 @@ "interiorColorCode": "BY3", "interiorColorName": "BLACK", "exteriorColorCode": "42M", - "exteriorColorName": "DEEP CRYSTAL BLUE MICA" + "exteriorColorName": "DEEP CRYSTAL BLUE MICA", + "isElectric": false } -] \ No newline at end of file +] diff --git a/tests/components/mazda/test_diagnostics.py b/tests/components/mazda/test_diagnostics.py new file mode 100644 index 00000000000000..a75601cdea2108 --- /dev/null +++ b/tests/components/mazda/test_diagnostics.py @@ -0,0 +1,75 @@ +"""Test Mazda diagnostics.""" + +import json + +import pytest + +from homeassistant.components.mazda.const import DATA_COORDINATOR, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import init_integration + +from tests.common import load_fixture +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) + + +async def test_config_entry_diagnostics(hass: HomeAssistant, hass_client): + """Test config entry diagnostics.""" + await init_integration(hass) + assert hass.data[DOMAIN] + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + diagnostics_fixture = json.loads( + load_fixture("mazda/diagnostics_config_entry.json") + ) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == diagnostics_fixture + ) + + +async def test_device_diagnostics(hass: HomeAssistant, hass_client): + """Test device diagnostics.""" + await init_integration(hass) + assert hass.data[DOMAIN] + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "JM000000000000000")}, + ) + assert reg_device is not None + + diagnostics_fixture = json.loads(load_fixture("mazda/diagnostics_device.json")) + + assert ( + await get_diagnostics_for_device(hass, hass_client, config_entry, reg_device) + == diagnostics_fixture + ) + + +async def test_device_diagnostics_vehicle_not_found(hass: HomeAssistant, hass_client): + """Test device diagnostics when the vehicle cannot be found.""" + await init_integration(hass) + assert hass.data[DOMAIN] + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "JM000000000000000")}, + ) + assert reg_device is not None + + # Remove vehicle info from hass.data so that vehicle will not be found + hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR].data = [] + + with pytest.raises(AssertionError): + await get_diagnostics_for_device(hass, hass_client, config_entry, reg_device) diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py index 8b135f15e80791..e2d4661d36fc51 100644 --- a/tests/components/mazda/test_init.py +++ b/tests/components/mazda/test_init.py @@ -158,6 +158,19 @@ async def test_unload_config_entry(hass: HomeAssistant) -> None: assert entries[0].state is ConfigEntryState.NOT_LOADED +async def test_init_electric_vehicle(hass): + """Test initialization of the integration with an electric vehicle.""" + client_mock = await init_integration(hass, electric_vehicle=True) + + client_mock.get_vehicles.assert_called_once() + client_mock.get_vehicle_status.assert_called_once() + client_mock.get_ev_vehicle_status.assert_called_once() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + async def test_device_nickname(hass): """Test creation of the device when vehicle has a nickname.""" await init_integration(hass, use_nickname=True) diff --git a/tests/components/mazda/test_sensor.py b/tests/components/mazda/test_sensor.py index 179ad96d533c69..8d4085930dd226 100644 --- a/tests/components/mazda/test_sensor.py +++ b/tests/components/mazda/test_sensor.py @@ -1,6 +1,12 @@ """The sensor tests for the Mazda Connected Services integration.""" +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, @@ -30,6 +36,7 @@ async def test_sensors(hass): ) assert state.attributes.get(ATTR_ICON) == "mdi:gas-station" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.state == "87.0" entry = entity_registry.async_get("sensor.my_mazda3_fuel_remaining_percentage") assert entry @@ -43,6 +50,7 @@ async def test_sensors(hass): ) assert state.attributes.get(ATTR_ICON) == "mdi:gas-station" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_KILOMETERS + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.state == "381" entry = entity_registry.async_get("sensor.my_mazda3_fuel_distance_remaining") assert entry @@ -54,6 +62,7 @@ async def test_sensors(hass): assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Odometer" assert state.attributes.get(ATTR_ICON) == "mdi:speedometer" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_KILOMETERS + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING assert state.state == "2796" entry = entity_registry.async_get("sensor.my_mazda3_odometer") assert entry @@ -67,6 +76,7 @@ async def test_sensors(hass): ) assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.state == "35" entry = entity_registry.async_get("sensor.my_mazda3_front_left_tire_pressure") assert entry @@ -81,6 +91,7 @@ async def test_sensors(hass): ) assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.state == "35" entry = entity_registry.async_get("sensor.my_mazda3_front_right_tire_pressure") assert entry @@ -94,6 +105,7 @@ async def test_sensors(hass): ) assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.state == "33" entry = entity_registry.async_get("sensor.my_mazda3_rear_left_tire_pressure") assert entry @@ -107,6 +119,7 @@ async def test_sensors(hass): ) assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_PSI + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.state == "33" entry = entity_registry.async_get("sensor.my_mazda3_rear_right_tire_pressure") assert entry @@ -130,3 +143,43 @@ async def test_sensors_imperial_units(hass): assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_MILES assert state.state == "1737" + + +async def test_electric_vehicle_sensors(hass): + """Test sensors which are specific to electric vehicles.""" + + await init_integration(hass, electric_vehicle=True) + + entity_registry = er.async_get(hass) + + # Fuel Remaining Percentage should not exist for an electric vehicle + entry = entity_registry.async_get("sensor.my_mazda3_fuel_remaining_percentage") + assert entry is None + + # Fuel Distance Remaining should not exist for an electric vehicle + entry = entity_registry.async_get("sensor.my_mazda3_fuel_distance_remaining") + assert entry is None + + # Charge Level + state = hass.states.get("sensor.my_mazda3_charge_level") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Charge Level" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.BATTERY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.state == "80" + entry = entity_registry.async_get("sensor.my_mazda3_charge_level") + assert entry + assert entry.unique_id == "JM000000000000000_ev_charge_level" + + # Remaining Range + state = hass.states.get("sensor.my_mazda3_remaining_range") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Remaining Range" + assert state.attributes.get(ATTR_ICON) == "mdi:ev-station" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_KILOMETERS + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.state == "218" + entry = entity_registry.async_get("sensor.my_mazda3_remaining_range") + assert entry + assert entry.unique_id == "JM000000000000000_ev_remaining_range" diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py index 63cdb1c55a7d2a..60e1ae7d19b7c0 100644 --- a/tests/components/media_player/test_device_condition.py +++ b/tests/components/media_player/test_device_condition.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.media_player import DOMAIN from homeassistant.const import ( STATE_IDLE, @@ -88,7 +89,9 @@ async def test_get_conditions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert_lists_same(conditions, expected_conditions) diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 2e3641242f9ab6..0e58395319e6ce 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.media_player import DOMAIN from homeassistant.const import ( STATE_IDLE, @@ -57,7 +58,14 @@ async def test_get_triggers(hass, device_reg, entity_reg): ) entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) - trigger_types = {"turned_on", "turned_off", "idle", "paused", "playing"} + trigger_types = { + "turned_on", + "turned_off", + "idle", + "paused", + "playing", + "changed_states", + } expected_triggers = [ { "platform": "device", @@ -68,7 +76,9 @@ async def test_get_triggers(hass, device_reg, entity_reg): } for trigger in trigger_types ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -82,11 +92,13 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): ) entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert len(triggers) == 5 + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert len(triggers) == 6 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == { "extra_fields": [ @@ -104,7 +116,14 @@ async def test_if_fires_on_state_change(hass, calls): "{{{{ trigger.entity_id}}}} - {{{{ trigger.from_state.state}}}} - " "{{{{ trigger.to_state.state}}}} - {{{{ trigger.for }}}}" ) - trigger_types = {"turned_on", "turned_off", "idle", "paused", "playing"} + trigger_types = { + "turned_on", + "turned_off", + "idle", + "paused", + "playing", + "changed_states", + } assert await async_setup_component( hass, @@ -132,47 +151,47 @@ async def test_if_fires_on_state_change(hass, calls): # Fake that the entity is turning on. hass.states.async_set("media_player.entity", STATE_ON) await hass.async_block_till_done() - assert len(calls) == 1 - assert ( - calls[0].data["some"] - == "turned_on - device - media_player.entity - off - on - None" - ) + assert len(calls) == 2 + assert {calls[0].data["some"], calls[1].data["some"]} == { + "turned_on - device - media_player.entity - off - on - None", + "changed_states - device - media_player.entity - off - on - None", + } # Fake that the entity is turning off. hass.states.async_set("media_player.entity", STATE_OFF) await hass.async_block_till_done() - assert len(calls) == 2 - assert ( - calls[1].data["some"] - == "turned_off - device - media_player.entity - on - off - None" - ) + assert len(calls) == 4 + assert {calls[2].data["some"], calls[3].data["some"]} == { + "turned_off - device - media_player.entity - on - off - None", + "changed_states - device - media_player.entity - on - off - None", + } # Fake that the entity becomes idle. hass.states.async_set("media_player.entity", STATE_IDLE) await hass.async_block_till_done() - assert len(calls) == 3 - assert ( - calls[2].data["some"] - == "idle - device - media_player.entity - off - idle - None" - ) + assert len(calls) == 6 + assert {calls[4].data["some"], calls[5].data["some"]} == { + "idle - device - media_player.entity - off - idle - None", + "changed_states - device - media_player.entity - off - idle - None", + } # Fake that the entity starts playing. hass.states.async_set("media_player.entity", STATE_PLAYING) await hass.async_block_till_done() - assert len(calls) == 4 - assert ( - calls[3].data["some"] - == "playing - device - media_player.entity - idle - playing - None" - ) + assert len(calls) == 8 + assert {calls[6].data["some"], calls[7].data["some"]} == { + "playing - device - media_player.entity - idle - playing - None", + "changed_states - device - media_player.entity - idle - playing - None", + } # Fake that the entity is paused. hass.states.async_set("media_player.entity", STATE_PAUSED) await hass.async_block_till_done() - assert len(calls) == 5 - assert ( - calls[4].data["some"] - == "paused - device - media_player.entity - playing - paused - None" - ) + assert len(calls) == 10 + assert {calls[8].data["some"], calls[9].data["some"]} == { + "paused - device - media_player.entity - playing - paused - None", + "changed_states - device - media_player.entity - playing - paused - None", + } async def test_if_fires_on_state_change_with_for(hass, calls): diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 7bdf3722d5d2ea..3f0efcd45aa3bf 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -1,5 +1,7 @@ """Test the base functions of the media player.""" +import asyncio import base64 +from http import HTTPStatus from unittest.mock import patch from homeassistant.components import media_player @@ -92,6 +94,37 @@ async def test_get_image_http_remote(hass, hass_client_no_auth): assert content == b"image" +async def test_get_image_http_log_credentials_redacted( + hass, hass_client_no_auth, aioclient_mock, caplog +): + """Test credentials are redacted when logging url when fetching image.""" + url = "http://vi:pass@example.com/default.jpg" + with patch( + "homeassistant.components.demo.media_player.DemoYoutubePlayer.media_image_url", + url, + ): + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + state = hass.states.get("media_player.bedroom") + assert "entity_picture_local" not in state.attributes + + aioclient_mock.get(url, exc=asyncio.TimeoutError()) + + client = await hass_client_no_auth() + + resp = await client.get(state.attributes["entity_picture"]) + + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert f"Error retrieving proxied image from {url}" not in caplog.text + assert ( + "Error retrieving proxied image from " + f"{url.replace('pass', 'xxxxxxxx').replace('vi', 'xxxx')}" + ) in caplog.text + + async def test_get_async_get_browse_image(hass, hass_client_no_auth, hass_ws_client): """Test get browse image.""" await async_setup_component( @@ -119,16 +152,6 @@ async def test_get_async_get_browse_image(hass, hass_client_no_auth, hass_ws_cli assert content == b"image" -def test_deprecated_base_class(caplog): - """Test deprecated base class.""" - - class CustomMediaPlayer(media_player.MediaPlayerDevice): - pass - - CustomMediaPlayer() - assert "MediaPlayerDevice is deprecated, modify CustomMediaPlayer" in caplog.text - - async def test_media_browse(hass, hass_ws_client): """Test browsing media.""" await async_setup_component( diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index d8ee73ebc2f117..5b25e878e5ae72 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -1,22 +1,22 @@ """Test Media Source initialization.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from urllib.parse import quote import pytest from homeassistant.components import media_source -from homeassistant.components.media_player.const import MEDIA_CLASS_DIRECTORY -from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_player import MEDIA_CLASS_DIRECTORY, BrowseError from homeassistant.components.media_source import const -from homeassistant.components.media_source.error import Unresolvable from homeassistant.setup import async_setup_component async def test_is_media_source_id(): """Test media source validation.""" - assert media_source.is_media_source_id(const.URI_SCHEME) - assert media_source.is_media_source_id(f"{const.URI_SCHEME}domain") - assert media_source.is_media_source_id(f"{const.URI_SCHEME}domain/identifier") + assert media_source.is_media_source_id(media_source.URI_SCHEME) + assert media_source.is_media_source_id(f"{media_source.URI_SCHEME}domain") + assert media_source.is_media_source_id( + f"{media_source.URI_SCHEME}domain/identifier" + ) assert not media_source.is_media_source_id("test") @@ -39,15 +39,26 @@ async def test_generate_media_source_id(): async def test_async_browse_media(hass): """Test browse media.""" - assert await async_setup_component(hass, const.DOMAIN, {}) + assert await async_setup_component(hass, media_source.DOMAIN, {}) await hass.async_block_till_done() # Test non-media ignored (/media has test.mp3 and not_media.txt) media = await media_source.async_browse_media(hass, "") assert isinstance(media, media_source.models.BrowseMediaSource) - assert media.title == "media/" + assert media.title == "media" assert len(media.children) == 2 + # Test content filter + media = await media_source.async_browse_media( + hass, + "", + content_filter=lambda item: item.media_content_type.startswith("video/"), + ) + assert isinstance(media, media_source.models.BrowseMediaSource) + assert media.title == "media" + assert len(media.children) == 1, media.children + media.children[0].title = "Epic Sax Guy 10 Hours" + # Test invalid media content with pytest.raises(ValueError): await media_source.async_browse_media(hass, "invalid") @@ -61,35 +72,35 @@ async def test_async_browse_media(hass): async def test_async_resolve_media(hass): """Test browse media.""" - assert await async_setup_component(hass, const.DOMAIN, {}) + assert await async_setup_component(hass, media_source.DOMAIN, {}) await hass.async_block_till_done() media = await media_source.async_resolve_media( hass, - media_source.generate_media_source_id(const.DOMAIN, "local/test.mp3"), + media_source.generate_media_source_id(media_source.DOMAIN, "local/test.mp3"), ) assert isinstance(media, media_source.models.PlayMedia) async def test_async_unresolve_media(hass): """Test browse media.""" - assert await async_setup_component(hass, const.DOMAIN, {}) + assert await async_setup_component(hass, media_source.DOMAIN, {}) await hass.async_block_till_done() # Test no media content - with pytest.raises(Unresolvable): + with pytest.raises(media_source.Unresolvable): await media_source.async_resolve_media(hass, "") async def test_websocket_browse_media(hass, hass_ws_client): """Test browse media websocket.""" - assert await async_setup_component(hass, const.DOMAIN, {}) + assert await async_setup_component(hass, media_source.DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) media = media_source.models.BrowseMediaSource( - domain=const.DOMAIN, + domain=media_source.DOMAIN, identifier="/media", title="Local Media", media_class=MEDIA_CLASS_DIRECTORY, @@ -137,7 +148,7 @@ async def test_websocket_browse_media(hass, hass_ws_client): @pytest.mark.parametrize("filename", ["test.mp3", "Epic Sax Guy 10 Hours.mp4"]) async def test_websocket_resolve_media(hass, hass_ws_client, filename): """Test browse media websocket.""" - assert await async_setup_component(hass, const.DOMAIN, {}) + assert await async_setup_component(hass, media_source.DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) @@ -152,7 +163,7 @@ async def test_websocket_resolve_media(hass, hass_ws_client, filename): { "id": 1, "type": "media_source/resolve_media", - "media_content_id": f"{const.URI_SCHEME}{const.DOMAIN}/local/{filename}", + "media_content_id": f"{const.URI_SCHEME}{media_source.DOMAIN}/local/{filename}", } ) @@ -180,3 +191,12 @@ async def test_websocket_resolve_media(hass, hass_ws_client, filename): assert not msg["success"] assert msg["error"]["code"] == "resolve_media_failed" assert msg["error"]["message"] == "test" + + +async def test_browse_resolve_without_setup(): + """Test browse and resolve work without being setup.""" + with pytest.raises(BrowseError): + await media_source.async_browse_media(Mock(data={}), None) + + with pytest.raises(media_source.Unresolvable): + await media_source.async_resolve_media(Mock(data={}), None) diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py index 8ffa4076b8a513..3025356d8fb926 100644 --- a/tests/components/met/test_weather.py +++ b/tests/components/met/test_weather.py @@ -22,7 +22,7 @@ async def test_tracking_home(hass, mock_weather): entry = registry.async_get("weather.test_home_hourly") assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test we track config await hass.config.async_update(latitude=10, longitude=20) diff --git a/tests/components/mfi/test_sensor.py b/tests/components/mfi/test_sensor.py index 4032e29b743868..ea67ab6f42703a 100644 --- a/tests/components/mfi/test_sensor.py +++ b/tests/components/mfi/test_sensor.py @@ -1,4 +1,5 @@ """The tests for the mFi sensor platform.""" +from copy import deepcopy import unittest.mock as mock from mficlient.client import FailedToLogin @@ -7,7 +8,8 @@ import homeassistant.components.mfi.sensor as mfi import homeassistant.components.sensor as sensor_component -from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import TEMP_CELSIUS from homeassistant.setup import async_setup_component PLATFORM = mfi @@ -38,20 +40,20 @@ async def test_setup_failed_login(hass): """Test setup with login failure.""" with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client: mock_client.side_effect = FailedToLogin - assert not PLATFORM.setup_platform(hass, dict(GOOD_CONFIG), None) + assert not PLATFORM.setup_platform(hass, GOOD_CONFIG, None) async def test_setup_failed_connect(hass): """Test setup with connection failure.""" with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client: mock_client.side_effect = requests.exceptions.ConnectionError - assert not PLATFORM.setup_platform(hass, dict(GOOD_CONFIG), None) + assert not PLATFORM.setup_platform(hass, GOOD_CONFIG, None) async def test_setup_minimum(hass): """Test setup with minimum configuration.""" with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client: - config = dict(GOOD_CONFIG) + config = deepcopy(GOOD_CONFIG) del config[THING]["port"] assert await async_setup_component(hass, COMPONENT.DOMAIN, config) await hass.async_block_till_done() @@ -64,9 +66,7 @@ async def test_setup_minimum(hass): async def test_setup_with_port(hass): """Test setup with port.""" with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client: - config = dict(GOOD_CONFIG) - config[THING]["port"] = 6123 - assert await async_setup_component(hass, COMPONENT.DOMAIN, config) + assert await async_setup_component(hass, COMPONENT.DOMAIN, GOOD_CONFIG) await hass.async_block_till_done() assert mock_client.call_count == 1 assert mock_client.call_args == mock.call( @@ -77,7 +77,7 @@ async def test_setup_with_port(hass): async def test_setup_with_tls_disabled(hass): """Test setup without TLS.""" with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client: - config = dict(GOOD_CONFIG) + config = deepcopy(GOOD_CONFIG) del config[THING]["port"] config[THING]["ssl"] = False config[THING]["verify_ssl"] = False @@ -135,7 +135,7 @@ async def test_uom_temp(port, sensor): """Test the UOM temperature.""" port.tag = "temperature" assert sensor.unit_of_measurement == TEMP_CELSIUS - assert sensor.device_class == DEVICE_CLASS_TEMPERATURE + assert sensor.device_class is SensorDeviceClass.TEMPERATURE async def test_uom_power(port, sensor): diff --git a/tests/components/mfi/test_switch.py b/tests/components/mfi/test_switch.py index 1e9f56853c3dd9..8eed41f8a32577 100644 --- a/tests/components/mfi/test_switch.py +++ b/tests/components/mfi/test_switch.py @@ -37,7 +37,6 @@ async def test_setup_adds_proper_devices(hass): for i, model in enumerate(mfi.SWITCH_MODELS) } ports["bad"] = mock.MagicMock(model="notaswitch") - print(ports["bad"].model) mock_client.return_value.get_devices.return_value = [ mock.MagicMock(ports=ports) ] diff --git a/tests/components/microsoft_face/test_init.py b/tests/components/microsoft_face/test_init.py index 191c59e556fd4d..30f6f88bd297ee 100644 --- a/tests/components/microsoft_face/test_init.py +++ b/tests/components/microsoft_face/test_init.py @@ -2,6 +2,8 @@ import asyncio from unittest.mock import patch +import pytest + from homeassistant.components import camera, microsoft_face as mf from homeassistant.components.microsoft_face import ( ATTR_CAMERA_ENTITY, @@ -16,9 +18,9 @@ SERVICE_TRAIN_GROUP, ) from homeassistant.const import ATTR_NAME -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component, get_test_home_assistant, load_fixture +from tests.common import assert_setup_component, load_fixture def create_group(hass, name): @@ -27,7 +29,7 @@ def create_group(hass, name): This is a legacy helper method. Do not use it for new tests. """ data = {ATTR_NAME: name} - hass.services.call(DOMAIN, SERVICE_CREATE_GROUP, data) + hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_CREATE_GROUP, data)) def delete_group(hass, name): @@ -36,7 +38,7 @@ def delete_group(hass, name): This is a legacy helper method. Do not use it for new tests. """ data = {ATTR_NAME: name} - hass.services.call(DOMAIN, SERVICE_DELETE_GROUP, data) + hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_DELETE_GROUP, data)) def train_group(hass, group): @@ -45,7 +47,7 @@ def train_group(hass, group): This is a legacy helper method. Do not use it for new tests. """ data = {ATTR_GROUP: group} - hass.services.call(DOMAIN, SERVICE_TRAIN_GROUP, data) + hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_TRAIN_GROUP, data)) def create_person(hass, group, name): @@ -54,7 +56,9 @@ def create_person(hass, group, name): This is a legacy helper method. Do not use it for new tests. """ data = {ATTR_GROUP: group, ATTR_NAME: name} - hass.services.call(DOMAIN, SERVICE_CREATE_PERSON, data) + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_CREATE_PERSON, data) + ) def delete_person(hass, group, name): @@ -63,7 +67,9 @@ def delete_person(hass, group, name): This is a legacy helper method. Do not use it for new tests. """ data = {ATTR_GROUP: group, ATTR_NAME: name} - hass.services.call(DOMAIN, SERVICE_DELETE_PERSON, data) + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_DELETE_PERSON, data) + ) def face_person(hass, group, person, camera_entity): @@ -72,285 +78,254 @@ def face_person(hass, group, person, camera_entity): This is a legacy helper method. Do not use it for new tests. """ data = {ATTR_GROUP: group, ATTR_PERSON: person, ATTR_CAMERA_ENTITY: camera_entity} - hass.services.call(DOMAIN, SERVICE_FACE_PERSON, data) + hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_FACE_PERSON, data)) -class TestMicrosoftFaceSetup: - """Test the microsoft face component.""" +CONFIG = {mf.DOMAIN: {"api_key": "12345678abcdef"}} +ENDPOINT_URL = f"https://westus.{mf.FACE_API_URL}" - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.config = {mf.DOMAIN: {"api_key": "12345678abcdef"}} +@pytest.fixture +def mock_update(): + """Mock update store.""" + with patch( + "homeassistant.components.microsoft_face.MicrosoftFace.update_store", + return_value=None, + ) as mock_update_store: + yield mock_update_store - self.endpoint_url = f"https://westus.{mf.FACE_API_URL}" - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() +async def test_setup_component(hass, mock_update): + """Set up component.""" + with assert_setup_component(3, mf.DOMAIN): + await async_setup_component(hass, mf.DOMAIN, CONFIG) - @patch( - "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=None, + +async def test_setup_component_wrong_api_key(hass, mock_update): + """Set up component without api key.""" + with assert_setup_component(0, mf.DOMAIN): + await async_setup_component(hass, mf.DOMAIN, {mf.DOMAIN: {}}) + + +async def test_setup_component_test_service(hass, mock_update): + """Set up component.""" + with assert_setup_component(3, mf.DOMAIN): + await async_setup_component(hass, mf.DOMAIN, CONFIG) + + assert hass.services.has_service(mf.DOMAIN, "create_group") + assert hass.services.has_service(mf.DOMAIN, "delete_group") + assert hass.services.has_service(mf.DOMAIN, "train_group") + assert hass.services.has_service(mf.DOMAIN, "create_person") + assert hass.services.has_service(mf.DOMAIN, "delete_person") + assert hass.services.has_service(mf.DOMAIN, "face_person") + + +async def test_setup_component_test_entities(hass, aioclient_mock): + """Set up component.""" + aioclient_mock.get( + ENDPOINT_URL.format("persongroups"), + text=load_fixture("microsoft_face_persongroups.json"), + ) + aioclient_mock.get( + ENDPOINT_URL.format("persongroups/test_group1/persons"), + text=load_fixture("microsoft_face_persons.json"), + ) + aioclient_mock.get( + ENDPOINT_URL.format("persongroups/test_group2/persons"), + text=load_fixture("microsoft_face_persons.json"), ) - def test_setup_component(self, mock_update): - """Set up component.""" - with assert_setup_component(3, mf.DOMAIN): - setup_component(self.hass, mf.DOMAIN, self.config) - @patch( - "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=None, + with assert_setup_component(3, mf.DOMAIN): + await async_setup_component(hass, mf.DOMAIN, CONFIG) + + assert len(aioclient_mock.mock_calls) == 3 + + entity_group1 = hass.states.get("microsoft_face.test_group1") + entity_group2 = hass.states.get("microsoft_face.test_group2") + + assert entity_group1 is not None + assert entity_group2 is not None + + assert entity_group1.attributes["Ryan"] == "25985303-c537-4467-b41d-bdb45cd95ca1" + assert entity_group1.attributes["David"] == "2ae4935b-9659-44c3-977f-61fac20d0538" + + assert entity_group2.attributes["Ryan"] == "25985303-c537-4467-b41d-bdb45cd95ca1" + assert entity_group2.attributes["David"] == "2ae4935b-9659-44c3-977f-61fac20d0538" + + +async def test_service_groups(hass, mock_update, aioclient_mock): + """Set up component, test groups services.""" + aioclient_mock.put( + ENDPOINT_URL.format("persongroups/service_group"), + status=200, + text="{}", + ) + aioclient_mock.delete( + ENDPOINT_URL.format("persongroups/service_group"), + status=200, + text="{}", ) - def test_setup_component_wrong_api_key(self, mock_update): - """Set up component without api key.""" - with assert_setup_component(0, mf.DOMAIN): - setup_component(self.hass, mf.DOMAIN, {mf.DOMAIN: {}}) - @patch( - "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=None, + with assert_setup_component(3, mf.DOMAIN): + await async_setup_component(hass, mf.DOMAIN, CONFIG) + + create_group(hass, "Service Group") + await hass.async_block_till_done() + + entity = hass.states.get("microsoft_face.service_group") + assert entity is not None + assert len(aioclient_mock.mock_calls) == 1 + + delete_group(hass, "Service Group") + await hass.async_block_till_done() + + entity = hass.states.get("microsoft_face.service_group") + assert entity is None + assert len(aioclient_mock.mock_calls) == 2 + + +async def test_service_person(hass, aioclient_mock): + """Set up component, test person services.""" + aioclient_mock.get( + ENDPOINT_URL.format("persongroups"), + text=load_fixture("microsoft_face_persongroups.json"), ) - def test_setup_component_test_service(self, mock_update): - """Set up component.""" - with assert_setup_component(3, mf.DOMAIN): - setup_component(self.hass, mf.DOMAIN, self.config) - - assert self.hass.services.has_service(mf.DOMAIN, "create_group") - assert self.hass.services.has_service(mf.DOMAIN, "delete_group") - assert self.hass.services.has_service(mf.DOMAIN, "train_group") - assert self.hass.services.has_service(mf.DOMAIN, "create_person") - assert self.hass.services.has_service(mf.DOMAIN, "delete_person") - assert self.hass.services.has_service(mf.DOMAIN, "face_person") - - def test_setup_component_test_entities(self, aioclient_mock): - """Set up component.""" - aioclient_mock.get( - self.endpoint_url.format("persongroups"), - text=load_fixture("microsoft_face_persongroups.json"), - ) - aioclient_mock.get( - self.endpoint_url.format("persongroups/test_group1/persons"), - text=load_fixture("microsoft_face_persons.json"), - ) - aioclient_mock.get( - self.endpoint_url.format("persongroups/test_group2/persons"), - text=load_fixture("microsoft_face_persons.json"), - ) - - with assert_setup_component(3, mf.DOMAIN): - setup_component(self.hass, mf.DOMAIN, self.config) - - assert len(aioclient_mock.mock_calls) == 3 - - entity_group1 = self.hass.states.get("microsoft_face.test_group1") - entity_group2 = self.hass.states.get("microsoft_face.test_group2") - - assert entity_group1 is not None - assert entity_group2 is not None - - assert ( - entity_group1.attributes["Ryan"] == "25985303-c537-4467-b41d-bdb45cd95ca1" - ) - assert ( - entity_group1.attributes["David"] == "2ae4935b-9659-44c3-977f-61fac20d0538" - ) - - assert ( - entity_group2.attributes["Ryan"] == "25985303-c537-4467-b41d-bdb45cd95ca1" - ) - assert ( - entity_group2.attributes["David"] == "2ae4935b-9659-44c3-977f-61fac20d0538" - ) - - @patch( - "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=None, + aioclient_mock.get( + ENDPOINT_URL.format("persongroups/test_group1/persons"), + text=load_fixture("microsoft_face_persons.json"), ) - def test_service_groups(self, mock_update, aioclient_mock): - """Set up component, test groups services.""" - aioclient_mock.put( - self.endpoint_url.format("persongroups/service_group"), - status=200, - text="{}", - ) - aioclient_mock.delete( - self.endpoint_url.format("persongroups/service_group"), - status=200, - text="{}", - ) - - with assert_setup_component(3, mf.DOMAIN): - setup_component(self.hass, mf.DOMAIN, self.config) - - create_group(self.hass, "Service Group") - self.hass.block_till_done() - - entity = self.hass.states.get("microsoft_face.service_group") - assert entity is not None - assert len(aioclient_mock.mock_calls) == 1 - - delete_group(self.hass, "Service Group") - self.hass.block_till_done() - - entity = self.hass.states.get("microsoft_face.service_group") - assert entity is None - assert len(aioclient_mock.mock_calls) == 2 - - def test_service_person(self, aioclient_mock): - """Set up component, test person services.""" - aioclient_mock.get( - self.endpoint_url.format("persongroups"), - text=load_fixture("microsoft_face_persongroups.json"), - ) - aioclient_mock.get( - self.endpoint_url.format("persongroups/test_group1/persons"), - text=load_fixture("microsoft_face_persons.json"), - ) - aioclient_mock.get( - self.endpoint_url.format("persongroups/test_group2/persons"), - text=load_fixture("microsoft_face_persons.json"), - ) - - with assert_setup_component(3, mf.DOMAIN): - setup_component(self.hass, mf.DOMAIN, self.config) - - assert len(aioclient_mock.mock_calls) == 3 - - aioclient_mock.post( - self.endpoint_url.format("persongroups/test_group1/persons"), - text=load_fixture("microsoft_face_create_person.json"), - ) - aioclient_mock.delete( - self.endpoint_url.format( - "persongroups/test_group1/persons/" - "25985303-c537-4467-b41d-bdb45cd95ca1" - ), - status=200, - text="{}", - ) - - create_person(self.hass, "test group1", "Hans") - self.hass.block_till_done() - - entity_group1 = self.hass.states.get("microsoft_face.test_group1") - - assert len(aioclient_mock.mock_calls) == 4 - assert entity_group1 is not None - assert ( - entity_group1.attributes["Hans"] == "25985303-c537-4467-b41d-bdb45cd95ca1" - ) - - delete_person(self.hass, "test group1", "Hans") - self.hass.block_till_done() - - entity_group1 = self.hass.states.get("microsoft_face.test_group1") - - assert len(aioclient_mock.mock_calls) == 5 - assert entity_group1 is not None - assert "Hans" not in entity_group1.attributes - - @patch( - "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=None, + aioclient_mock.get( + ENDPOINT_URL.format("persongroups/test_group2/persons"), + text=load_fixture("microsoft_face_persons.json"), + ) + + with assert_setup_component(3, mf.DOMAIN): + await async_setup_component(hass, mf.DOMAIN, CONFIG) + + assert len(aioclient_mock.mock_calls) == 3 + + aioclient_mock.post( + ENDPOINT_URL.format("persongroups/test_group1/persons"), + text=load_fixture("microsoft_face_create_person.json"), + ) + aioclient_mock.delete( + ENDPOINT_URL.format( + "persongroups/test_group1/persons/" "25985303-c537-4467-b41d-bdb45cd95ca1" + ), + status=200, + text="{}", + ) + + create_person(hass, "test group1", "Hans") + await hass.async_block_till_done() + + entity_group1 = hass.states.get("microsoft_face.test_group1") + + assert len(aioclient_mock.mock_calls) == 4 + assert entity_group1 is not None + assert entity_group1.attributes["Hans"] == "25985303-c537-4467-b41d-bdb45cd95ca1" + + delete_person(hass, "test group1", "Hans") + await hass.async_block_till_done() + + entity_group1 = hass.states.get("microsoft_face.test_group1") + + assert len(aioclient_mock.mock_calls) == 5 + assert entity_group1 is not None + assert "Hans" not in entity_group1.attributes + + +async def test_service_train(hass, mock_update, aioclient_mock): + """Set up component, test train groups services.""" + with assert_setup_component(3, mf.DOMAIN): + await async_setup_component(hass, mf.DOMAIN, CONFIG) + + aioclient_mock.post( + ENDPOINT_URL.format("persongroups/service_group/train"), + status=200, + text="{}", + ) + + train_group(hass, "Service Group") + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_service_face(hass, aioclient_mock): + """Set up component, test person face services.""" + aioclient_mock.get( + ENDPOINT_URL.format("persongroups"), + text=load_fixture("microsoft_face_persongroups.json"), + ) + aioclient_mock.get( + ENDPOINT_URL.format("persongroups/test_group1/persons"), + text=load_fixture("microsoft_face_persons.json"), + ) + aioclient_mock.get( + ENDPOINT_URL.format("persongroups/test_group2/persons"), + text=load_fixture("microsoft_face_persons.json"), ) - def test_service_train(self, mock_update, aioclient_mock): - """Set up component, test train groups services.""" - with assert_setup_component(3, mf.DOMAIN): - setup_component(self.hass, mf.DOMAIN, self.config) - aioclient_mock.post( - self.endpoint_url.format("persongroups/service_group/train"), - status=200, - text="{}", - ) + CONFIG["camera"] = {"platform": "demo"} + with assert_setup_component(3, mf.DOMAIN): + await async_setup_component(hass, mf.DOMAIN, CONFIG) - train_group(self.hass, "Service Group") - self.hass.block_till_done() + assert len(aioclient_mock.mock_calls) == 3 - assert len(aioclient_mock.mock_calls) == 1 + aioclient_mock.post( + ENDPOINT_URL.format( + "persongroups/test_group2/persons/" + "2ae4935b-9659-44c3-977f-61fac20d0538/persistedFaces" + ), + status=200, + text="{}", + ) - @patch( + with patch( "homeassistant.components.camera.async_get_image", return_value=camera.Image("image/jpeg", b"Test"), + ): + face_person(hass, "test_group2", "David", "camera.demo_camera") + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 4 + assert aioclient_mock.mock_calls[3][2] == b"Test" + + +async def test_service_status_400(hass, mock_update, aioclient_mock): + """Set up component, test groups services with error.""" + aioclient_mock.put( + ENDPOINT_URL.format("persongroups/service_group"), + status=400, + text="{'error': {'message': 'Error'}}", ) - def test_service_face(self, camera_mock, aioclient_mock): - """Set up component, test person face services.""" - aioclient_mock.get( - self.endpoint_url.format("persongroups"), - text=load_fixture("microsoft_face_persongroups.json"), - ) - aioclient_mock.get( - self.endpoint_url.format("persongroups/test_group1/persons"), - text=load_fixture("microsoft_face_persons.json"), - ) - aioclient_mock.get( - self.endpoint_url.format("persongroups/test_group2/persons"), - text=load_fixture("microsoft_face_persons.json"), - ) - - self.config["camera"] = {"platform": "demo"} - with assert_setup_component(3, mf.DOMAIN): - setup_component(self.hass, mf.DOMAIN, self.config) - - assert len(aioclient_mock.mock_calls) == 3 - - aioclient_mock.post( - self.endpoint_url.format( - "persongroups/test_group2/persons/" - "2ae4935b-9659-44c3-977f-61fac20d0538/persistedFaces" - ), - status=200, - text="{}", - ) - - face_person(self.hass, "test_group2", "David", "camera.demo_camera") - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 4 - assert aioclient_mock.mock_calls[3][2] == b"Test" - - @patch( - "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=None, - ) - def test_service_status_400(self, mock_update, aioclient_mock): - """Set up component, test groups services with error.""" - aioclient_mock.put( - self.endpoint_url.format("persongroups/service_group"), - status=400, - text="{'error': {'message': 'Error'}}", - ) - with assert_setup_component(3, mf.DOMAIN): - setup_component(self.hass, mf.DOMAIN, self.config) + with assert_setup_component(3, mf.DOMAIN): + await async_setup_component(hass, mf.DOMAIN, CONFIG) - create_group(self.hass, "Service Group") - self.hass.block_till_done() + create_group(hass, "Service Group") + await hass.async_block_till_done() - entity = self.hass.states.get("microsoft_face.service_group") - assert entity is None - assert len(aioclient_mock.mock_calls) == 1 + entity = hass.states.get("microsoft_face.service_group") + assert entity is None + assert len(aioclient_mock.mock_calls) == 1 - @patch( - "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=None, + +async def test_service_status_timeout(hass, mock_update, aioclient_mock): + """Set up component, test groups services with timeout.""" + aioclient_mock.put( + ENDPOINT_URL.format("persongroups/service_group"), + status=400, + exc=asyncio.TimeoutError(), ) - def test_service_status_timeout(self, mock_update, aioclient_mock): - """Set up component, test groups services with timeout.""" - aioclient_mock.put( - self.endpoint_url.format("persongroups/service_group"), - status=400, - exc=asyncio.TimeoutError(), - ) - - with assert_setup_component(3, mf.DOMAIN): - setup_component(self.hass, mf.DOMAIN, self.config) - - create_group(self.hass, "Service Group") - self.hass.block_till_done() - - entity = self.hass.states.get("microsoft_face.service_group") - assert entity is None - assert len(aioclient_mock.mock_calls) == 1 + + with assert_setup_component(3, mf.DOMAIN): + await async_setup_component(hass, mf.DOMAIN, CONFIG) + + create_group(hass, "Service Group") + await hass.async_block_till_done() + + entity = hass.states.get("microsoft_face.service_group") + assert entity is None + assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/microsoft_face_detect/test_image_processing.py b/tests/components/microsoft_face_detect/test_image_processing.py index d2e0eb19d57b84..a68da5eca00491 100644 --- a/tests/components/microsoft_face_detect/test_image_processing.py +++ b/tests/components/microsoft_face_detect/test_image_processing.py @@ -1,171 +1,155 @@ """The tests for the microsoft face detect platform.""" from unittest.mock import PropertyMock, patch +import pytest + import homeassistant.components.image_processing as ip import homeassistant.components.microsoft_face as mf from homeassistant.const import ATTR_ENTITY_PICTURE from homeassistant.core import callback -from homeassistant.setup import setup_component - -from tests.common import ( - assert_setup_component, - get_test_home_assistant, - load_fixture, - mock_coro, -) -from tests.components.image_processing import common +from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, load_fixture +from tests.components.image_processing import common -class TestMicrosoftFaceDetectSetup: - """Test class for image processing.""" +CONFIG = { + ip.DOMAIN: { + "platform": "microsoft_face_detect", + "source": {"entity_id": "camera.demo_camera", "name": "test local"}, + "attributes": ["age", "gender"], + }, + "camera": {"platform": "demo"}, + mf.DOMAIN: {"api_key": "12345678abcdef6"}, +} - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() +ENDPOINT_URL = f"https://westus.{mf.FACE_API_URL}" - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - @patch( +@pytest.fixture +def store_mock(): + """Mock update store.""" + with patch( "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=mock_coro(), - ) - def test_setup_platform(self, store_mock): - """Set up platform with one entity.""" - config = { - ip.DOMAIN: { - "platform": "microsoft_face_detect", - "source": {"entity_id": "camera.demo_camera"}, - "attributes": ["age", "gender"], - }, - "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, - } - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - assert self.hass.states.get("image_processing.microsoftface_demo_camera") - - @patch( - "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=mock_coro(), - ) - def test_setup_platform_name(self, store_mock): - """Set up platform with one entity and set name.""" - config = { - ip.DOMAIN: { - "platform": "microsoft_face_detect", - "source": {"entity_id": "camera.demo_camera", "name": "test local"}, - }, - "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, - } - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - assert self.hass.states.get("image_processing.test_local") - - -class TestMicrosoftFaceDetect: - """Test class for image processing.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - self.config = { - ip.DOMAIN: { - "platform": "microsoft_face_detect", - "source": {"entity_id": "camera.demo_camera", "name": "test local"}, - "attributes": ["age", "gender"], - }, - "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, - } - - self.endpoint_url = f"https://westus.{mf.FACE_API_URL}" - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - @patch( + return_value=None, + ) as mock_update_store: + yield mock_update_store + + +@pytest.fixture +def poll_mock(): + """Disable polling.""" + with patch( "homeassistant.components.microsoft_face_detect.image_processing." "MicrosoftFaceDetectEntity.should_poll", new_callable=PropertyMock(return_value=False), + ): + yield + + +async def test_setup_platform(hass, store_mock): + """Set up platform with one entity.""" + config = { + ip.DOMAIN: { + "platform": "microsoft_face_detect", + "source": {"entity_id": "camera.demo_camera"}, + "attributes": ["age", "gender"], + }, + "camera": {"platform": "demo"}, + mf.DOMAIN: {"api_key": "12345678abcdef6"}, + } + + with assert_setup_component(1, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + assert hass.states.get("image_processing.microsoftface_demo_camera") + + +async def test_setup_platform_name(hass, store_mock): + """Set up platform with one entity and set name.""" + config = { + ip.DOMAIN: { + "platform": "microsoft_face_detect", + "source": {"entity_id": "camera.demo_camera", "name": "test local"}, + }, + "camera": {"platform": "demo"}, + mf.DOMAIN: {"api_key": "12345678abcdef6"}, + } + + with assert_setup_component(1, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + assert hass.states.get("image_processing.test_local") + + +async def test_ms_detect_process_image(hass, poll_mock, aioclient_mock): + """Set up and scan a picture and test plates from event.""" + aioclient_mock.get( + ENDPOINT_URL.format("persongroups"), + text=load_fixture("microsoft_face_persongroups.json"), + ) + aioclient_mock.get( + ENDPOINT_URL.format("persongroups/test_group1/persons"), + text=load_fixture("microsoft_face_persons.json"), ) - def test_ms_detect_process_image(self, poll_mock, aioclient_mock): - """Set up and scan a picture and test plates from event.""" - aioclient_mock.get( - self.endpoint_url.format("persongroups"), - text=load_fixture("microsoft_face_persongroups.json"), - ) - aioclient_mock.get( - self.endpoint_url.format("persongroups/test_group1/persons"), - text=load_fixture("microsoft_face_persons.json"), - ) - aioclient_mock.get( - self.endpoint_url.format("persongroups/test_group2/persons"), - text=load_fixture("microsoft_face_persons.json"), - ) - - setup_component(self.hass, ip.DOMAIN, self.config) - self.hass.block_till_done() - - state = self.hass.states.get("camera.demo_camera") - url = f"{self.hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" - - face_events = [] - - @callback - def mock_face_event(event): - """Mock event.""" - face_events.append(event) - - self.hass.bus.listen("image_processing.detect_face", mock_face_event) - - aioclient_mock.get(url, content=b"image") - - aioclient_mock.post( - self.endpoint_url.format("detect"), - text=load_fixture("microsoft_face_detect.json"), - params={"returnFaceAttributes": "age,gender"}, - ) - - common.scan(self.hass, entity_id="image_processing.test_local") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.test_local") - - assert len(face_events) == 1 - assert state.attributes.get("total_faces") == 1 - assert state.state == "1" - - assert face_events[0].data["age"] == 71.0 - assert face_events[0].data["gender"] == "male" - assert face_events[0].data["entity_id"] == "image_processing.test_local" - - # Test that later, if a request is made that results in no face - # being detected, that this is reflected in the state object - aioclient_mock.clear_requests() - aioclient_mock.post( - self.endpoint_url.format("detect"), - text="[]", - params={"returnFaceAttributes": "age,gender"}, - ) - - common.scan(self.hass, entity_id="image_processing.test_local") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.test_local") - - # No more face events were fired - assert len(face_events) == 1 - # Total faces and actual qualified number of faces reset to zero - assert state.attributes.get("total_faces") == 0 - assert state.state == "0" + aioclient_mock.get( + ENDPOINT_URL.format("persongroups/test_group2/persons"), + text=load_fixture("microsoft_face_persons.json"), + ) + + await async_setup_component(hass, ip.DOMAIN, CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("camera.demo_camera") + url = f"{hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" + + face_events = [] + + @callback + def mock_face_event(event): + """Mock event.""" + face_events.append(event) + + hass.bus.async_listen("image_processing.detect_face", mock_face_event) + + aioclient_mock.get(url, content=b"image") + + aioclient_mock.post( + ENDPOINT_URL.format("detect"), + text=load_fixture("microsoft_face_detect.json"), + params={"returnFaceAttributes": "age,gender"}, + ) + + common.async_scan(hass, entity_id="image_processing.test_local") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.test_local") + + assert len(face_events) == 1 + assert state.attributes.get("total_faces") == 1 + assert state.state == "1" + + assert face_events[0].data["age"] == 71.0 + assert face_events[0].data["gender"] == "male" + assert face_events[0].data["entity_id"] == "image_processing.test_local" + + # Test that later, if a request is made that results in no face + # being detected, that this is reflected in the state object + aioclient_mock.clear_requests() + aioclient_mock.post( + ENDPOINT_URL.format("detect"), + text="[]", + params={"returnFaceAttributes": "age,gender"}, + ) + + common.async_scan(hass, entity_id="image_processing.test_local") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.test_local") + + # No more face events were fired + assert len(face_events) == 1 + # Total faces and actual qualified number of faces reset to zero + assert state.attributes.get("total_faces") == 0 + assert state.state == "0" diff --git a/tests/components/microsoft_face_identify/test_image_processing.py b/tests/components/microsoft_face_identify/test_image_processing.py index 856d308816c05c..6b3098375cad8f 100644 --- a/tests/components/microsoft_face_identify/test_image_processing.py +++ b/tests/components/microsoft_face_identify/test_image_processing.py @@ -1,171 +1,156 @@ """The tests for the microsoft face identify platform.""" from unittest.mock import PropertyMock, patch +import pytest + import homeassistant.components.image_processing as ip import homeassistant.components.microsoft_face as mf from homeassistant.const import ATTR_ENTITY_PICTURE, STATE_UNKNOWN from homeassistant.core import callback -from homeassistant.setup import setup_component - -from tests.common import ( - assert_setup_component, - get_test_home_assistant, - load_fixture, - mock_coro, -) -from tests.components.image_processing import common +from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, load_fixture +from tests.components.image_processing import common -class TestMicrosoftFaceIdentifySetup: - """Test class for image processing.""" - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() +@pytest.fixture +def store_mock(): + """Mock update store.""" + with patch( + "homeassistant.components.microsoft_face.MicrosoftFace.update_store", + return_value=None, + ) as mock_update_store: + yield mock_update_store - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - @patch( - "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=mock_coro(), - ) - def test_setup_platform(self, store_mock): - """Set up platform with one entity.""" - config = { - ip.DOMAIN: { - "platform": "microsoft_face_identify", - "source": {"entity_id": "camera.demo_camera"}, - "group": "Test Group1", - }, - "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, - } - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - assert self.hass.states.get("image_processing.microsoftface_demo_camera") - - @patch( - "homeassistant.components.microsoft_face.MicrosoftFace.update_store", - return_value=mock_coro(), - ) - def test_setup_platform_name(self, store_mock): - """Set up platform with one entity and set name.""" - config = { - ip.DOMAIN: { - "platform": "microsoft_face_identify", - "source": {"entity_id": "camera.demo_camera", "name": "test local"}, - "group": "Test Group1", - }, - "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, - } - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - assert self.hass.states.get("image_processing.test_local") - - -class TestMicrosoftFaceIdentify: - """Test class for image processing.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - self.config = { - ip.DOMAIN: { - "platform": "microsoft_face_identify", - "source": {"entity_id": "camera.demo_camera", "name": "test local"}, - "group": "Test Group1", - }, - "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, - } - - self.endpoint_url = f"https://westus.{mf.FACE_API_URL}" - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - @patch( +@pytest.fixture +def poll_mock(): + """Disable polling.""" + with patch( "homeassistant.components.microsoft_face_identify.image_processing." "MicrosoftFaceIdentifyEntity.should_poll", new_callable=PropertyMock(return_value=False), + ): + yield + + +CONFIG = { + ip.DOMAIN: { + "platform": "microsoft_face_identify", + "source": {"entity_id": "camera.demo_camera", "name": "test local"}, + "group": "Test Group1", + }, + "camera": {"platform": "demo"}, + mf.DOMAIN: {"api_key": "12345678abcdef6"}, +} + +ENDPOINT_URL = f"https://westus.{mf.FACE_API_URL}" + + +async def test_setup_platform(hass, store_mock): + """Set up platform with one entity.""" + config = { + ip.DOMAIN: { + "platform": "microsoft_face_identify", + "source": {"entity_id": "camera.demo_camera"}, + "group": "Test Group1", + }, + "camera": {"platform": "demo"}, + mf.DOMAIN: {"api_key": "12345678abcdef6"}, + } + + with assert_setup_component(1, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + assert hass.states.get("image_processing.microsoftface_demo_camera") + + +async def test_setup_platform_name(hass, store_mock): + """Set up platform with one entity and set name.""" + config = { + ip.DOMAIN: { + "platform": "microsoft_face_identify", + "source": {"entity_id": "camera.demo_camera", "name": "test local"}, + "group": "Test Group1", + }, + "camera": {"platform": "demo"}, + mf.DOMAIN: {"api_key": "12345678abcdef6"}, + } + + with assert_setup_component(1, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + assert hass.states.get("image_processing.test_local") + + +async def test_ms_identify_process_image(hass, poll_mock, aioclient_mock): + """Set up and scan a picture and test plates from event.""" + aioclient_mock.get( + ENDPOINT_URL.format("persongroups"), + text=load_fixture("microsoft_face_persongroups.json"), + ) + aioclient_mock.get( + ENDPOINT_URL.format("persongroups/test_group1/persons"), + text=load_fixture("microsoft_face_persons.json"), ) - def test_ms_identify_process_image(self, poll_mock, aioclient_mock): - """Set up and scan a picture and test plates from event.""" - aioclient_mock.get( - self.endpoint_url.format("persongroups"), - text=load_fixture("microsoft_face_persongroups.json"), - ) - aioclient_mock.get( - self.endpoint_url.format("persongroups/test_group1/persons"), - text=load_fixture("microsoft_face_persons.json"), - ) - aioclient_mock.get( - self.endpoint_url.format("persongroups/test_group2/persons"), - text=load_fixture("microsoft_face_persons.json"), - ) - - setup_component(self.hass, ip.DOMAIN, self.config) - self.hass.block_till_done() - - state = self.hass.states.get("camera.demo_camera") - url = f"{self.hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" - - face_events = [] - - @callback - def mock_face_event(event): - """Mock event.""" - face_events.append(event) - - self.hass.bus.listen("image_processing.detect_face", mock_face_event) - - aioclient_mock.get(url, content=b"image") - - aioclient_mock.post( - self.endpoint_url.format("detect"), - text=load_fixture("microsoft_face_detect.json"), - ) - aioclient_mock.post( - self.endpoint_url.format("identify"), - text=load_fixture("microsoft_face_identify.json"), - ) - - common.scan(self.hass, entity_id="image_processing.test_local") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.test_local") - - assert len(face_events) == 1 - assert state.attributes.get("total_faces") == 2 - assert state.state == "David" - - assert face_events[0].data["name"] == "David" - assert face_events[0].data["confidence"] == float(92) - assert face_events[0].data["entity_id"] == "image_processing.test_local" - - # Test that later, if a request is made that results in no face - # being detected, that this is reflected in the state object - aioclient_mock.clear_requests() - aioclient_mock.post(self.endpoint_url.format("detect"), text="[]") - - common.scan(self.hass, entity_id="image_processing.test_local") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.test_local") - - # No more face events were fired - assert len(face_events) == 1 - # Total faces and actual qualified number of faces reset to zero - assert state.attributes.get("total_faces") == 0 - assert state.state == STATE_UNKNOWN + aioclient_mock.get( + ENDPOINT_URL.format("persongroups/test_group2/persons"), + text=load_fixture("microsoft_face_persons.json"), + ) + + await async_setup_component(hass, ip.DOMAIN, CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("camera.demo_camera") + url = f"{hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" + + face_events = [] + + @callback + def mock_face_event(event): + """Mock event.""" + face_events.append(event) + + hass.bus.async_listen("image_processing.detect_face", mock_face_event) + + aioclient_mock.get(url, content=b"image") + + aioclient_mock.post( + ENDPOINT_URL.format("detect"), + text=load_fixture("microsoft_face_detect.json"), + ) + aioclient_mock.post( + ENDPOINT_URL.format("identify"), + text=load_fixture("microsoft_face_identify.json"), + ) + + common.async_scan(hass, entity_id="image_processing.test_local") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.test_local") + + assert len(face_events) == 1 + assert state.attributes.get("total_faces") == 2 + assert state.state == "David" + + assert face_events[0].data["name"] == "David" + assert face_events[0].data["confidence"] == float(92) + assert face_events[0].data["entity_id"] == "image_processing.test_local" + + # Test that later, if a request is made that results in no face + # being detected, that this is reflected in the state object + aioclient_mock.clear_requests() + aioclient_mock.post(ENDPOINT_URL.format("detect"), text="[]") + + common.async_scan(hass, entity_id="image_processing.test_local") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.test_local") + + # No more face events were fired + assert len(face_events) == 1 + # Total faces and actual qualified number of faces reset to zero + assert state.attributes.get("total_faces") == 0 + assert state.state == STATE_UNKNOWN diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index fcd29c18682c77..f36129c223a8e6 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -1,9 +1,11 @@ """The tests for the Mikrotik device tracker platform.""" from datetime import timedelta +import pytest + from homeassistant.components import mikrotik import homeassistant.components.device_tracker as device_tracker -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -15,6 +17,25 @@ DEFAULT_DETECTION_TIME = timedelta(seconds=300) +@pytest.fixture +def mock_device_registry_devices(hass): + """Create device registry devices so the device tracker entities are enabled.""" + dev_reg = dr.async_get(hass) + config_entry = MockConfigEntry(domain="something_else") + + for idx, device in enumerate( + ( + "00:00:00:00:00:01", + "00:00:00:00:00:02", + ) + ): + dev_reg.async_get_or_create( + name=f"Device {idx}", + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, device)}, + ) + + def mock_command(self, cmd, params=None): """Mock the Mikrotik command method.""" if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: @@ -39,7 +60,9 @@ async def test_platform_manually_configured(hass): assert mikrotik.DOMAIN not in hass.data -async def test_device_trackers(hass, legacy_patchable_time): +async def test_device_trackers( + hass, legacy_patchable_time, mock_device_registry_devices +): """Test device_trackers created by mikrotik.""" # test devices are added from wireless list only diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index f92b4689ebf153..fe2facef50ddd2 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -78,6 +78,8 @@ async def test_setup_with_local_config(hass): "ambient_temperature": 20, "set_temperature": 22, "current_power": 0, + "control_signal": 0, + "raw_ambient_temperature": 19, }, ) as mock_fetch, patch( "mill_local.Mill.connect", diff --git a/tests/components/mobile_app/test_binary_sensor.py b/tests/components/mobile_app/test_binary_sensor.py index e379603e079519..ce1c017b4bf167 100644 --- a/tests/components/mobile_app/test_binary_sensor.py +++ b/tests/components/mobile_app/test_binary_sensor.py @@ -1,7 +1,7 @@ """Entity tests for mobile_app.""" from http import HTTPStatus -from homeassistant.const import STATE_OFF +from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers import device_registry as dr @@ -198,7 +198,7 @@ async def test_register_sensor_no_state(hass, create_registrations, webhook_clie assert entity.domain == "binary_sensor" assert entity.name == "Test 1 Is Charging" - assert entity.state == STATE_OFF # Binary sensor defaults to off + assert entity.state == STATE_UNKNOWN reg_resp = await webhook_client.post( webhook_url, @@ -223,7 +223,7 @@ async def test_register_sensor_no_state(hass, create_registrations, webhook_clie assert entity.domain == "binary_sensor" assert entity.name == "Test 1 Backup Is Charging" - assert entity.state == STATE_OFF # Binary sensor defaults to off + assert entity.state == STATE_UNKNOWN async def test_update_sensor_no_state(hass, create_registrations, webhook_client): @@ -270,4 +270,4 @@ async def test_update_sensor_no_state(hass, create_registrations, webhook_client assert json == {"is_charging": {"success": True}} updated_entity = hass.states.get("binary_sensor.test_1_is_charging") - assert updated_entity.state == STATE_OFF # Binary sensor defaults to off + assert updated_entity.state == STATE_UNKNOWN diff --git a/tests/components/mobile_app/test_device_action.py b/tests/components/mobile_app/test_device_action.py index e5b15412e4dd14..dd4d2a55d82519 100644 --- a/tests/components/mobile_app/test_device_action.py +++ b/tests/components/mobile_app/test_device_action.py @@ -11,12 +11,14 @@ async def test_get_actions(hass, push_registration): webhook_id = push_registration["webhook_id"] device_id = hass.data[DOMAIN][DATA_DEVICES][webhook_id].id - assert await async_get_device_automations(hass, "action", device_id) == [ - {"domain": DOMAIN, "device_id": device_id, "type": "notify"} - ] + assert await async_get_device_automations( + hass, device_automation.DeviceAutomationType.ACTION, device_id + ) == [{"domain": DOMAIN, "device_id": device_id, "type": "notify"}] capabilitites = await device_automation._async_get_device_automation_capabilities( - hass, "action", {"domain": DOMAIN, "device_id": device_id, "type": "notify"} + hass, + device_automation.DeviceAutomationType.ACTION, + {"domain": DOMAIN, "device_id": device_id, "type": "notify"}, ) assert "extra_fields" in capabilitites diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 86cc9f0ae679a0..001da68dfbfdf6 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components.mobile_app.const import DOMAIN +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -102,6 +103,38 @@ async def setup_push_receiver(hass, aioclient_mock, hass_admin_user): assert hass.services.has_service("notify", "mobile_app_loaded_late") +@pytest.fixture +async def setup_websocket_channel_only_push(hass, hass_admin_user): + """Set up local push.""" + entry = MockConfigEntry( + data={ + "app_data": {"push_websocket_channel": True}, + "app_id": "io.homeassistant.mobile_app", + "app_name": "mobile_app tests", + "app_version": "1.0", + "device_id": "websocket-push-device-id", + "device_name": "Websocket Push Name", + "manufacturer": "Home Assistant", + "model": "mobile_app", + "os_name": "Linux", + "os_version": "5.0.6", + "secret": "123abc2", + "supports_encryption": False, + "user_id": hass_admin_user.id, + "webhook_id": "websocket-push-webhook-id", + }, + domain=DOMAIN, + source="registration", + title="websocket push test entry", + version=1, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.services.has_service("notify", "mobile_app_websocket_push_name") + + async def test_notify_works(hass, aioclient_mock, setup_push_receiver): """Test notify works.""" assert hass.services.has_service("notify", "mobile_app_test") is True @@ -333,3 +366,39 @@ async def test_notify_ws_not_confirming( ) assert len(aioclient_mock.mock_calls) == 3 + + +async def test_local_push_only(hass, hass_ws_client, setup_websocket_channel_only_push): + """Test a local only push registration.""" + with pytest.raises(HomeAssistantError) as e_info: + assert await hass.services.async_call( + "notify", + "mobile_app_websocket_push_name", + {"message": "Not connected"}, + blocking=True, + ) + + assert str(e_info.value) == "Device not connected to local push notifications" + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "mobile_app/push_notification_channel", + "webhook_id": "websocket-push-webhook-id", + } + ) + + sub_result = await client.receive_json() + assert sub_result["success"] + + assert await hass.services.async_call( + "notify", + "mobile_app_websocket_push_name", + {"message": "Hello world 1"}, + blocking=True, + ) + + msg = await client.receive_json() + assert msg == {"id": 5, "type": "event", "event": {"message": "Hello world 1"}} diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index 295e37ee7d917c..7eb99df8d8f932 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -3,7 +3,7 @@ import pytest -from homeassistant.components.sensor import DEVICE_CLASS_DATE, DEVICE_CLASS_TIMESTAMP +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -284,24 +284,24 @@ async def test_update_sensor_no_state(hass, create_registrations, webhook_client @pytest.mark.parametrize( "device_class,native_value,state_value", [ - (DEVICE_CLASS_DATE, "2021-11-18", "2021-11-18"), + (SensorDeviceClass.DATE, "2021-11-18", "2021-11-18"), ( - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.TIMESTAMP, "2021-11-18T20:25:00+00:00", "2021-11-18T20:25:00+00:00", ), ( - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.TIMESTAMP, "2021-11-18 20:25:00+01:00", "2021-11-18T19:25:00+00:00", ), ( - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.TIMESTAMP, "unavailable", STATE_UNAVAILABLE, ), ( - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.TIMESTAMP, "unknown", STATE_UNKNOWN, ), diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 41b939c71132bd..48b61988de20c6 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -7,7 +7,12 @@ from homeassistant.components.camera import SUPPORT_STREAM as CAMERA_SUPPORT_STREAM from homeassistant.components.mobile_app.const import CONF_SECRET from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN -from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.const import ( + CONF_WEBHOOK_ID, + STATE_HOME, + STATE_NOT_HOME, + STATE_UNKNOWN, +) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -283,7 +288,46 @@ async def test_webhook_requires_encryption(webhook_client, create_registrations) assert webhook_json["error"]["code"] == "encryption_required" -async def test_webhook_update_location(hass, webhook_client, create_registrations): +async def test_webhook_update_location_without_locations( + hass, webhook_client, create_registrations +): + """Test that location can be updated.""" + + # start off with a location set by name + resp = await webhook_client.post( + "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + json={ + "type": "update_location", + "data": {"location_name": STATE_HOME}, + }, + ) + + assert resp.status == HTTPStatus.OK + + state = hass.states.get("device_tracker.test_1_2") + assert state is not None + assert state.state == STATE_HOME + + # set location to an 'unknown' state + resp = await webhook_client.post( + "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + json={ + "type": "update_location", + "data": {"altitude": 123}, + }, + ) + + assert resp.status == HTTPStatus.OK + + state = hass.states.get("device_tracker.test_1_2") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes["altitude"] == 123 + + +async def test_webhook_update_location_with_gps( + hass, webhook_client, create_registrations +): """Test that location can be updated.""" resp = await webhook_client.post( "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), @@ -303,6 +347,86 @@ async def test_webhook_update_location(hass, webhook_client, create_registration assert state.attributes["altitude"] == -10 +async def test_webhook_update_location_with_gps_without_accuracy( + hass, webhook_client, create_registrations +): + """Test that location can be updated.""" + resp = await webhook_client.post( + "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + json={ + "type": "update_location", + "data": {"gps": [1, 2]}, + }, + ) + + assert resp.status == HTTPStatus.OK + + state = hass.states.get("device_tracker.test_1_2") + assert state.state == STATE_UNKNOWN + + +async def test_webhook_update_location_with_location_name( + hass, webhook_client, create_registrations +): + """Test that location can be updated.""" + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={ + ZONE_DOMAIN: [ + { + "name": "zone_name", + "latitude": 1.23, + "longitude": -4.56, + "radius": 200, + "icon": "mdi:test-tube", + }, + ] + }, + ): + await hass.services.async_call(ZONE_DOMAIN, "reload", blocking=True) + + resp = await webhook_client.post( + "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + json={ + "type": "update_location", + "data": {"location_name": "zone_name"}, + }, + ) + + assert resp.status == HTTPStatus.OK + + state = hass.states.get("device_tracker.test_1_2") + assert state.state == "zone_name" + + resp = await webhook_client.post( + "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + json={ + "type": "update_location", + "data": {"location_name": STATE_HOME}, + }, + ) + + assert resp.status == HTTPStatus.OK + + state = hass.states.get("device_tracker.test_1_2") + assert state.state == STATE_HOME + + resp = await webhook_client.post( + "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + json={ + "type": "update_location", + "data": {"location_name": STATE_NOT_HOME}, + }, + ) + + assert resp.status == HTTPStatus.OK + + state = hass.states.get("device_tracker.test_1_2") + assert state.state == STATE_NOT_HOME + + async def test_webhook_enable_encryption(hass, webhook_client, create_registrations): """Test that encryption can be added to a reg initially created without.""" webhook_id = create_registrations[1]["webhook_id"] diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index d36a11e3eabde7..5127bd55ad1c47 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -18,6 +18,7 @@ STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import State @@ -141,7 +142,7 @@ async def test_all_binary_sensor(hass, expected, mock_do_cycle): ( [0x00], True, - STATE_OFF, + STATE_UNKNOWN, STATE_UNAVAILABLE, ), ], diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index bf3e87119227ee..0edd9bdc945e72 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, + SensorStateClass, ) from homeassistant.const import ( CONF_ADDRESS, @@ -62,7 +62,7 @@ CONF_PRECISION: 0, CONF_SCALE: 1, CONF_OFFSET: 0, - CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", diff --git a/tests/components/modern_forms/__init__.py b/tests/components/modern_forms/__init__.py index c6d2a8b3637166..65de87c333de52 100644 --- a/tests/components/modern_forms/__init__.py +++ b/tests/components/modern_forms/__init__.py @@ -1,7 +1,7 @@ """Tests for the Modern Forms integration.""" +from collections.abc import Callable import json -from typing import Callable from aiomodernforms.const import COMMAND_QUERY_STATIC_DATA diff --git a/tests/components/modern_forms/test_sensor.py b/tests/components/modern_forms/test_sensor.py index d18793f51c25e3..638b3ddbacf186 100644 --- a/tests/components/modern_forms/test_sensor.py +++ b/tests/components/modern_forms/test_sensor.py @@ -1,7 +1,8 @@ """Tests for the Modern Forms sensor platform.""" from datetime import datetime -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON, DEVICE_CLASS_TIMESTAMP +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,14 +23,14 @@ async def test_sensors( state = hass.states.get("sensor.modernformsfan_light_sleep_time") assert state assert state.attributes.get(ATTR_ICON) == "mdi:timer-outline" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert state.state == "unknown" # Fan timer remaining time state = hass.states.get("sensor.modernformsfan_fan_sleep_time") assert state assert state.attributes.get(ATTR_ICON) == "mdi:timer-outline" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert state.state == "unknown" @@ -46,12 +47,12 @@ async def test_active_sensors( state = hass.states.get("sensor.modernformsfan_light_sleep_time") assert state assert state.attributes.get(ATTR_ICON) == "mdi:timer-outline" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP datetime.fromisoformat(state.state) # Fan timer remaining time state = hass.states.get("sensor.modernformsfan_fan_sleep_time") assert state assert state.attributes.get(ATTR_ICON) == "mdi:timer-outline" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP datetime.fromisoformat(state.state) diff --git a/tests/components/monoprice/test_media_player.py b/tests/components/monoprice/test_media_player.py index 977d57cb07ca0c..0a11d665395ef5 100644 --- a/tests/components/monoprice/test_media_player.py +++ b/tests/components/monoprice/test_media_player.py @@ -518,7 +518,7 @@ async def test_first_run_with_failing_zones(hass): entry = registry.async_get(ZONE_7_ID) assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_not_first_run_with_failing_zone(hass): diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 57def069d59788..9ef0f78874d690 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -480,6 +480,24 @@ async def test_advanced_options(hass: HomeAssistant) -> None: ) as mock_setup_entry: await hass.async_block_till_done() + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": True} + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_WEBHOOK_SET: True, + CONF_WEBHOOK_SET_OVERWRITE: True, + }, + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_WEBHOOK_SET] + assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] + assert CONF_STREAM_URL_TEMPLATE not in result["data"] + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + result = await hass.config_entries.options.async_init( config_entry.entry_id, context={"show_advanced_options": True} ) diff --git a/tests/components/motioneye/test_sensor.py b/tests/components/motioneye/test_sensor.py index 474b86903088d9..5ab6fc46f49a3b 100644 --- a/tests/components/motioneye/test_sensor.py +++ b/tests/components/motioneye/test_sensor.py @@ -108,7 +108,7 @@ async def test_sensor_actions_can_be_enabled(hass: HomeAssistant) -> None: entry = entity_registry.async_get(TEST_SENSOR_ACTION_ENTITY_ID) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) assert not entity_state diff --git a/tests/components/motioneye/test_switch.py b/tests/components/motioneye/test_switch.py index 05de2f0bbcf42d..09db967e5e3051 100644 --- a/tests/components/motioneye/test_switch.py +++ b/tests/components/motioneye/test_switch.py @@ -160,7 +160,7 @@ async def test_disabled_switches_can_be_enabled(hass: HomeAssistant) -> None: entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION entity_state = hass.states.get(entity_id) assert not entity_state diff --git a/tests/components/mqtt/fixtures/configuration.yaml b/tests/components/mqtt/fixtures/configuration.yaml deleted file mode 100644 index 96c7e57f72bb4a..00000000000000 --- a/tests/components/mqtt/fixtures/configuration.yaml +++ /dev/null @@ -1,4 +0,0 @@ -light: - - platform: mqtt - name: reload - command_topic: "test/set" diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 2a74a75c2414da..16e46faaef8565 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -43,6 +43,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -50,6 +51,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -703,6 +706,26 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ) +@pytest.mark.parametrize( + "topic,value", + [ + ("state_topic", "armed_home"), + ("state_topic", "disarmed"), + ], +) +async def test_encoding_subscribable_topics(hass, mqtt_mock, caplog, topic, value): + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG[alarm_control_panel.DOMAIN], + topic, + value, + ) + + async def test_entity_device_info_with_connection(hass, mqtt_mock): """Test MQTT alarm control panel device registry integration.""" await help_test_entity_device_info_with_connection( @@ -750,3 +773,65 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG ) + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template,tpl_par,tpl_output", + [ + ( + alarm_control_panel.SERVICE_ALARM_ARM_AWAY, + "command_topic", + {"code": "secret"}, + "ARM_AWAY", + "command_template", + "code", + b"s", + ), + ( + alarm_control_panel.SERVICE_ALARM_DISARM, + "command_topic", + {"code": "secret"}, + "DISARM", + "command_template", + "code", + b"s", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, + tpl_par, + tpl_output, +): + """Test publishing MQTT payload with different encoding.""" + domain = alarm_control_panel.DOMAIN + config = DEFAULT_CONFIG[domain] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + tpl_par=tpl_par, + tpl_output=tpl_output, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = alarm_control_panel.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index aebdc8b692e77e..917f046551d986 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -12,6 +12,7 @@ STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -27,6 +28,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -34,6 +36,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_reload_with_config, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -41,7 +45,11 @@ help_test_update_with_json_attrs_not_dict, ) -from tests.common import async_fire_mqtt_message, async_fire_time_changed +from tests.common import ( + assert_setup_component, + async_fire_mqtt_message, + async_fire_time_changed, +) DEFAULT_CONFIG = { binary_sensor.DOMAIN: { @@ -256,7 +264,7 @@ async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "test-topic", "ON") state = hass.states.get("binary_sensor.test") @@ -286,11 +294,11 @@ async def test_invalid_sensor_value_via_mqtt_message(hass, mqtt_mock, caplog): state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "test-topic", "0N") state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN assert "No matching payload found for entity" in caplog.text caplog.clear() assert "No matching payload found for entity" not in caplog.text @@ -325,7 +333,7 @@ async def test_setting_sensor_value_via_mqtt_message_and_template(hass, mqtt_moc await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "test-topic", "") state = hass.states.get("binary_sensor.test") @@ -357,7 +365,7 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "test-topic", "on") state = hass.states.get("binary_sensor.test") @@ -395,7 +403,7 @@ async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_ await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "test-topic", b"\x01") state = hass.states.get("binary_sensor.test") @@ -427,11 +435,11 @@ async def test_setting_sensor_value_via_mqtt_message_empty_template( await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "test-topic", "DEF") state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN assert "Empty template output" in caplog.text async_fire_mqtt_message(hass, "test-topic", "ABC") @@ -757,6 +765,36 @@ async def test_discovery_update_binary_sensor_template(hass, mqtt_mock, caplog): ) +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ("json_attributes_topic", '{ "id": 123 }', "id", 123), + ( + "json_attributes_topic", + '{ "id": 123, "temperature": 34.0 }', + "temperature", + 34.0, + ), + ("state_topic", "ON", None, "on"), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + binary_sensor.DOMAIN, + DEFAULT_CONFIG[binary_sensor.DOMAIN], + topic, + value, + attribute, + attribute_value, + ) + + async def test_discovery_update_unchanged_binary_sensor(hass, mqtt_mock, caplog): """Test update of discovered binary_sensor.""" config1 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN]) @@ -828,3 +866,94 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = binary_sensor.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +async def test_cleanup_triggers_and_restoring_state( + hass, mqtt_mock, caplog, tmp_path, freezer +): + """Test cleanup old triggers at reloading and restoring the state.""" + domain = binary_sensor.DOMAIN + config1 = copy.deepcopy(DEFAULT_CONFIG[domain]) + config1["name"] = "test1" + config1["expire_after"] = 30 + config1["state_topic"] = "test-topic1" + config2 = copy.deepcopy(DEFAULT_CONFIG[domain]) + config2["name"] = "test2" + config2["expire_after"] = 5 + config2["state_topic"] = "test-topic2" + + freezer.move_to("2022-02-02 12:01:00+01:00") + + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + {binary_sensor.DOMAIN: [config1, config2]}, + ) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "test-topic1", "ON") + state = hass.states.get("binary_sensor.test1") + assert state.state == "on" + + async_fire_mqtt_message(hass, "test-topic2", "ON") + state = hass.states.get("binary_sensor.test2") + assert state.state == "on" + + freezer.move_to("2022-02-02 12:01:10+01:00") + + await help_test_reload_with_config( + hass, caplog, tmp_path, domain, [config1, config2] + ) + assert "Clean up expire after trigger for binary_sensor.test1" in caplog.text + assert "Clean up expire after trigger for binary_sensor.test2" not in caplog.text + assert ( + "State recovered after reload for binary_sensor.test1, remaining time before expiring" + in caplog.text + ) + assert "State recovered after reload for binary_sensor.test2" not in caplog.text + + state = hass.states.get("binary_sensor.test1") + assert state.state == "on" + + state = hass.states.get("binary_sensor.test2") + assert state.state == STATE_UNAVAILABLE + + async_fire_mqtt_message(hass, "test-topic1", "OFF") + state = hass.states.get("binary_sensor.test1") + assert state.state == "off" + + async_fire_mqtt_message(hass, "test-topic2", "OFF") + state = hass.states.get("binary_sensor.test2") + assert state.state == "off" + + +async def test_skip_restoring_state_with_over_due_expire_trigger( + hass, mqtt_mock, caplog, freezer +): + """Test restoring a state with over due expire timer.""" + + freezer.move_to("2022-02-02 12:02:00+01:00") + domain = binary_sensor.DOMAIN + config3 = copy.deepcopy(DEFAULT_CONFIG[domain]) + config3["name"] = "test3" + config3["expire_after"] = 10 + config3["state_topic"] = "test-topic3" + fake_state = ha.State( + "binary_sensor.test3", + "on", + {}, + last_changed=datetime.fromisoformat("2022-02-02 12:01:35+01:00"), + ) + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=fake_state, + ), assert_setup_component(1, domain): + assert await async_setup_component(hass, domain, {domain: config3}) + await hass.async_block_till_done() + assert "Skip state recovery after reload for binary_sensor.test3" in caplog.text diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 5eb92db7767daa..a533e0f0ec717e 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -23,6 +23,8 @@ help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -74,6 +76,40 @@ async def test_sending_mqtt_commands(hass, mqtt_mock): assert state.state == "2021-11-08T13:31:44+00:00" +async def test_command_template(hass, mqtt_mock): + """Test the sending of MQTT commands through a command template.""" + assert await async_setup_component( + hass, + button.DOMAIN, + { + button.DOMAIN: { + "command_topic": "command-topic", + "command_template": '{ "{{ value }}": "{{ entity_id }}" }', + "name": "test", + "payload_press": "milky_way_press", + "platform": "mqtt", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("button.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test" + + await hass.services.async_call( + button.DOMAIN, + button.SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", '{ "milky_way_press": "button.test" }', 0, False + ) + mqtt_mock.async_publish.reset_mock() + + async def test_availability_when_connection_lost(hass, mqtt_mock): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( @@ -321,3 +357,37 @@ async def test_valid_device_class(hass, mqtt_mock): assert state.attributes["device_class"] == button.ButtonDeviceClass.RESTART state = hass.states.get("button.test_3") assert "device_class" not in state.attributes + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + (button.SERVICE_PRESS, "command_topic", None, "PRESS", "command_template"), + ], +) +async def test_publishing_with_custom_encoding( + hass, mqtt_mock, caplog, service, topic, parameters, payload, template +): + """Test publishing MQTT payload with different encoding.""" + domain = button.DOMAIN + config = DEFAULT_CONFIG[domain] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = button.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 5db4362b1fbf52..95e8c467a5298e 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -26,6 +26,7 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -238,3 +239,10 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG, "test_topic", b"ON" ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = camera.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 61f04db99d9d8c..3b2da69f94b835 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -6,9 +6,17 @@ import pytest import voluptuous as vol +from homeassistant.components import climate from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP from homeassistant.components.climate.const import ( + ATTR_AUX_HEAT, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, CURRENT_HVAC_ACTIONS, DOMAIN as CLIMATE_DOMAIN, HVAC_MODE_AUTO, @@ -16,6 +24,7 @@ HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, + PRESET_AWAY, PRESET_ECO, PRESET_NONE, SUPPORT_AUX_HEAT, @@ -26,7 +35,7 @@ SUPPORT_TARGET_TEMPERATURE_RANGE, ) from homeassistant.components.mqtt.climate import MQTT_CLIMATE_ATTRIBUTES_BLOCKED -from homeassistant.const import STATE_OFF +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF from homeassistant.setup import async_setup_component from .test_common import ( @@ -39,6 +48,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -46,6 +56,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -494,14 +506,21 @@ async def test_set_away_mode(hass, mqtt_mock): state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" + + mqtt_mock.async_publish.reset_mock() await common.async_set_preset_mode(hass, "away", ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_called_once_with("away-mode-topic", "AN", 0, False) + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "AN", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "off", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "away" await common.async_set_preset_mode(hass, PRESET_NONE, ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_called_once_with("away-mode-topic", "AUS", 0, False) + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "AUS", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "off", 0, False) + mqtt_mock.async_publish.reset_mock() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -509,9 +528,10 @@ async def test_set_away_mode(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() await common.async_set_preset_mode(hass, "away", ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_has_calls( - [call("hold-topic", "off", 0, False), call("away-mode-topic", "AN", 0, False)] - ) + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "AN", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "off", 0, False) + mqtt_mock.async_publish.reset_mock() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "away" @@ -547,23 +567,112 @@ async def test_set_hold(hass, mqtt_mock): state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" await common.async_set_preset_mode(hass, "hold-on", ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_called_once_with("hold-topic", "hold-on", 0, False) + mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "hold-on", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "hold-on" await common.async_set_preset_mode(hass, PRESET_ECO, ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_called_once_with("hold-topic", "eco", 0, False) + mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "eco", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == PRESET_ECO await common.async_set_preset_mode(hass, PRESET_NONE, ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_called_once_with("hold-topic", "off", 0, False) + mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "off", 0, False) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" +async def test_set_preset_away(hass, mqtt_mock): + """Test setting the hold mode and away mode.""" + assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == PRESET_NONE + + await common.async_set_preset_mode(hass, "hold-on", ENTITY_CLIMATE) + mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "hold-on", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "hold-on" + + await common.async_set_preset_mode(hass, PRESET_AWAY, ENTITY_CLIMATE) + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "off", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == PRESET_AWAY + + await common.async_set_preset_mode(hass, "hold-on-again", ENTITY_CLIMATE) + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("hold-topic", "hold-on-again", 0, False) + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "hold-on-again" + + +async def test_set_preset_away_pessimistic(hass, mqtt_mock): + """Test setting the hold mode and away mode in pessimistic mode.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["climate"]["hold_state_topic"] = "hold-state" + config["climate"]["away_mode_state_topic"] = "away-state" + assert await async_setup_component(hass, CLIMATE_DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == PRESET_NONE + + await common.async_set_preset_mode(hass, "hold-on", ENTITY_CLIMATE) + mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "hold-on", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == PRESET_NONE + + async_fire_mqtt_message(hass, "hold-state", "hold-on") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "hold-on" + + await common.async_set_preset_mode(hass, PRESET_AWAY, ENTITY_CLIMATE) + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "off", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "hold-on" + + async_fire_mqtt_message(hass, "away-state", "ON") + async_fire_mqtt_message(hass, "hold-state", "off") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == PRESET_AWAY + + await common.async_set_preset_mode(hass, "hold-on-again", ENTITY_CLIMATE) + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("hold-topic", "hold-on-again", 0, False) + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == PRESET_AWAY + + async_fire_mqtt_message(hass, "hold-state", "hold-on-again") + async_fire_mqtt_message(hass, "away-state", "OFF") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "hold-on-again" + + async def test_set_preset_mode_twice(hass, mqtt_mock): """Test setting of the same mode twice only publishes once.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) @@ -572,14 +681,13 @@ async def test_set_preset_mode_twice(hass, mqtt_mock): state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" await common.async_set_preset_mode(hass, "hold-on", ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_called_once_with("hold-topic", "hold-on", 0, False) + mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "hold-on", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "hold-on" - await common.async_set_preset_mode(hass, "hold-on", ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_not_called() - async def test_set_aux_pessimistic(hass, mqtt_mock): """Test setting of the aux heating in pessimistic mode.""" @@ -792,6 +900,15 @@ async def test_get_with_templates(hass, mqtt_mock, caplog): state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("hvac_action") == "cooling" + # Test ignoring null values + async_fire_mqtt_message(hass, "action", "null") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("hvac_action") == "cooling" + assert ( + "Invalid ['off', 'heating', 'cooling', 'drying', 'idle', 'fan'] action: None, ignoring" + in caplog.text + ) + async def test_set_with_templates(hass, mqtt_mock, caplog): """Test setting various attributes with templates.""" @@ -819,7 +936,9 @@ async def test_set_with_templates(hass, mqtt_mock, caplog): # Hold Mode await common.async_set_preset_mode(hass, PRESET_ECO, ENTITY_CLIMATE) - mqtt_mock.async_publish.assert_called_once_with("hold-topic", "hold: eco", 0, False) + mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("away-mode-topic", "OFF", 0, False) + mqtt_mock.async_publish.assert_any_call("hold-topic", "hold: eco", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == PRESET_ECO @@ -992,6 +1111,43 @@ async def test_unique_id(hass, mqtt_mock): await help_test_unique_id(hass, mqtt_mock, CLIMATE_DOMAIN, config) +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ("action_topic", "heating", ATTR_HVAC_ACTION, "heating"), + ("action_topic", "cooling", ATTR_HVAC_ACTION, "cooling"), + ("aux_state_topic", "ON", ATTR_AUX_HEAT, "on"), + ("away_mode_state_topic", "ON", ATTR_PRESET_MODE, "away"), + ("current_temperature_topic", "22.1", ATTR_CURRENT_TEMPERATURE, 22.1), + ("fan_mode_state_topic", "low", ATTR_FAN_MODE, "low"), + ("hold_state_topic", "mode1", ATTR_PRESET_MODE, "mode1"), + ("mode_state_topic", "cool", None, None), + ("mode_state_topic", "fan_only", None, None), + ("swing_mode_state_topic", "on", ATTR_SWING_MODE, "on"), + ("temperature_low_state_topic", "19.1", ATTR_TARGET_TEMP_LOW, 19.1), + ("temperature_high_state_topic", "22.9", ATTR_TARGET_TEMP_HIGH, 22.9), + ("temperature_state_topic", "19.9", ATTR_TEMPERATURE, 19.9), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + config = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN]) + config["hold_modes"] = ["mode1", "mode2"] + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + CLIMATE_DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + ) + + async def test_discovery_removal_climate(hass, mqtt_mock, caplog): """Test removal of discovered climate.""" data = json.dumps(DEFAULT_CONFIG[CLIMATE_DOMAIN]) @@ -1133,3 +1289,128 @@ async def test_precision_whole(hass, mqtt_mock): state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 24.0 mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + ( + climate.SERVICE_TURN_ON, + "power_command_topic", + None, + "ON", + None, + ), + ( + climate.SERVICE_SET_HVAC_MODE, + "mode_command_topic", + {"hvac_mode": "cool"}, + "cool", + "mode_command_template", + ), + ( + climate.SERVICE_SET_PRESET_MODE, + "away_mode_command_topic", + {"preset_mode": "away"}, + "ON", + None, + ), + ( + climate.SERVICE_SET_PRESET_MODE, + "hold_command_topic", + {"preset_mode": "eco"}, + "eco", + "hold_command_template", + ), + ( + climate.SERVICE_SET_PRESET_MODE, + "hold_command_topic", + {"preset_mode": "some_hold_mode"}, + "some_hold_mode", + "hold_command_template", + ), + ( + climate.SERVICE_SET_FAN_MODE, + "fan_mode_command_topic", + {"fan_mode": "medium"}, + "medium", + "fan_mode_command_template", + ), + ( + climate.SERVICE_SET_SWING_MODE, + "swing_mode_command_topic", + {"swing_mode": "on"}, + "on", + "swing_mode_command_template", + ), + ( + climate.SERVICE_SET_AUX_HEAT, + "aux_command_topic", + {"aux_heat": "on"}, + "ON", + None, + ), + ( + climate.SERVICE_SET_TEMPERATURE, + "temperature_command_topic", + {"temperature": "20.1"}, + 20.1, + "temperature_command_template", + ), + ( + climate.SERVICE_SET_TEMPERATURE, + "temperature_low_command_topic", + { + "temperature": "20.1", + "target_temp_low": "15.1", + "target_temp_high": "29.8", + }, + 15.1, + "temperature_low_command_template", + ), + ( + climate.SERVICE_SET_TEMPERATURE, + "temperature_high_command_topic", + { + "temperature": "20.1", + "target_temp_low": "15.1", + "target_temp_high": "29.8", + }, + 29.8, + "temperature_high_command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, +): + """Test publishing MQTT payload with different encoding.""" + domain = climate.DOMAIN + config = DEFAULT_CONFIG[domain] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = CLIMATE_DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 16af5b8e484846..78c37b1105a4c7 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -4,11 +4,19 @@ import json from unittest.mock import ANY, patch +import yaml + +from homeassistant import config as hass_config from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.const import MQTT_DISCONNECTED from homeassistant.components.mqtt.mixins import MQTT_ATTRIBUTES_BLOCKED -from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + SERVICE_RELOAD, + STATE_UNAVAILABLE, +) from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component @@ -757,6 +765,138 @@ async def help_test_discovery_broken(hass, mqtt_mock, caplog, domain, data1, dat assert state is None +async def help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + domain, + config, + topic, + value, + attribute=None, + attribute_value=None, + init_payload=None, + skip_raw_test=False, +): + """Test handling of incoming encoded payload.""" + + async def _test_encoding( + hass, + entity_id, + topic, + encoded_value, + attribute, + init_payload_topic, + init_payload_value, + ): + state = hass.states.get(entity_id) + + if init_payload_value: + # Sometimes a device needs to have an initialization pay load, e.g. to switch the device on. + async_fire_mqtt_message(hass, init_payload_topic, init_payload_value) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + async_fire_mqtt_message(hass, topic, encoded_value) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + if attribute: + return state.attributes.get(attribute) + + return state.state if state else None + + init_payload_value_utf8 = None + init_payload_value_utf16 = None + # setup test1 default encoding + config1 = copy.deepcopy(config) + if domain == "device_tracker": + config1["unique_id"] = "test1" + else: + config1["name"] = "test1" + config1[topic] = "topic/test1" + # setup test2 alternate encoding + config2 = copy.deepcopy(config) + if domain == "device_tracker": + config2["unique_id"] = "test2" + else: + config2["name"] = "test2" + config2["encoding"] = "utf-16" + config2[topic] = "topic/test2" + # setup test3 raw encoding + config3 = copy.deepcopy(config) + if domain == "device_tracker": + config3["unique_id"] = "test3" + else: + config3["name"] = "test3" + config3["encoding"] = "" + config3[topic] = "topic/test3" + + if init_payload: + config1[init_payload[0]] = "topic/init_payload1" + config2[init_payload[0]] = "topic/init_payload2" + config3[init_payload[0]] = "topic/init_payload3" + init_payload_value_utf8 = init_payload[1].encode("utf-8") + init_payload_value_utf16 = init_payload[1].encode("utf-16") + + await hass.async_block_till_done() + + assert await async_setup_component( + hass, domain, {domain: [config1, config2, config3]} + ) + await hass.async_block_till_done() + + expected_result = attribute_value or value + + # test1 default encoding + assert ( + await _test_encoding( + hass, + f"{domain}.test1", + "topic/test1", + value.encode("utf-8"), + attribute, + "topic/init_payload1", + init_payload_value_utf8, + ) + == expected_result + ) + + # test2 alternate encoding + assert ( + await _test_encoding( + hass, + f"{domain}.test2", + "topic/test2", + value.encode("utf-16"), + attribute, + "topic/init_payload2", + init_payload_value_utf16, + ) + == expected_result + ) + + # test3 raw encoded input + if skip_raw_test: + return + + try: + result = await _test_encoding( + hass, + f"{domain}.test3", + "topic/test3", + value.encode("utf-16"), + attribute, + "topic/init_payload3", + init_payload_value_utf16, + ) + assert result != expected_result + except (AttributeError, TypeError, ValueError): + pass + + async def help_test_entity_device_info_with_identifier(hass, mqtt_mock, domain, config): """Test device registry integration. @@ -1082,7 +1222,7 @@ async def help_test_entity_debug_info_message( "topic": topic, "messages": [ { - "payload": payload, + "payload": str(payload), "qos": 0, "retain": False, "time": start_dt, @@ -1266,3 +1406,175 @@ async def help_test_entity_category(hass, mqtt_mock, domain, config): async_fire_mqtt_message(hass, f"homeassistant/{domain}/{unique_id}/config", data) await hass.async_block_till_done() assert not ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, unique_id) + + +async def help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + tpl_par="value", + tpl_output=None, +): + """Test a service with publishing MQTT payload with different encoding.""" + # prepare config for tests + test_config = { + "test1": {"encoding": None, "cmd_tpl": False}, + "test2": {"encoding": "utf-16", "cmd_tpl": False}, + "test3": {"encoding": "", "cmd_tpl": False}, + "test4": {"encoding": "invalid", "cmd_tpl": False}, + "test5": {"encoding": "", "cmd_tpl": True}, + } + setup_config = [] + service_data = {} + for test_id, test_data in test_config.items(): + test_config_setup = copy.deepcopy(config) + test_config_setup.update( + { + topic: f"cmd/{test_id}", + "name": f"{test_id}", + } + ) + if test_data["encoding"] is not None: + test_config_setup["encoding"] = test_data["encoding"] + if test_data["cmd_tpl"]: + test_config_setup[ + template + ] = f"{{{{ (('%.1f'|format({tpl_par}))[0] if is_number({tpl_par}) else {tpl_par}[0]) | ord | pack('b') }}}}" + setup_config.append(test_config_setup) + + # setup service data + service_data[test_id] = {ATTR_ENTITY_ID: f"{domain}.{test_id}"} + if parameters: + service_data[test_id].update(parameters) + + # setup test entities + assert await async_setup_component( + hass, + domain, + {domain: setup_config}, + ) + await hass.async_block_till_done() + + # 1) test with default encoding + await hass.services.async_call( + domain, + service, + service_data["test1"], + blocking=True, + ) + + mqtt_mock.async_publish.assert_any_call("cmd/test1", str(payload), 0, False) + mqtt_mock.async_publish.reset_mock() + + # 2) test with utf-16 encoding + await hass.services.async_call( + domain, + service, + service_data["test2"], + blocking=True, + ) + mqtt_mock.async_publish.assert_any_call( + "cmd/test2", str(payload).encode("utf-16"), 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # 3) test with no encoding set should fail if payload is a string + await hass.services.async_call( + domain, + service, + service_data["test3"], + blocking=True, + ) + assert ( + f"Can't pass-through payload for publishing {payload} on cmd/test3 with no encoding set, need 'bytes'" + in caplog.text + ) + + # 4) test with invalid encoding set should fail + await hass.services.async_call( + domain, + service, + service_data["test4"], + blocking=True, + ) + assert ( + f"Can't encode payload for publishing {payload} on cmd/test4 with encoding invalid" + in caplog.text + ) + + # 5) test with command template and raw encoding if specified + if not template: + return + + await hass.services.async_call( + domain, + service, + service_data["test5"], + blocking=True, + ) + mqtt_mock.async_publish.assert_any_call( + "cmd/test5", tpl_output or str(payload)[0].encode("utf-8"), 0, False + ) + mqtt_mock.async_publish.reset_mock() + + +async def help_test_reload_with_config(hass, caplog, tmp_path, domain, config): + """Test reloading with supplied config.""" + new_yaml_config_file = tmp_path / "configuration.yaml" + new_yaml_config = yaml.dump({domain: config}) + new_yaml_config_file.write_text(new_yaml_config) + assert new_yaml_config_file.read_text() == new_yaml_config + + with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert "" in caplog.text + + +async def help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config): + """Test reloading an MQTT platform.""" + # Create and test an old config of 2 entities based on the config supplied + old_config_1 = copy.deepcopy(config) + old_config_1["name"] = "test_old_1" + old_config_2 = copy.deepcopy(config) + old_config_2["name"] = "test_old_2" + + assert await async_setup_component( + hass, domain, {domain: [old_config_1, old_config_2]} + ) + await hass.async_block_till_done() + + assert hass.states.get(f"{domain}.test_old_1") + assert hass.states.get(f"{domain}.test_old_2") + assert len(hass.states.async_all(domain)) == 2 + + # Create temporary fixture for configuration.yaml based on the supplied config and test a reload with this new config + new_config_1 = copy.deepcopy(config) + new_config_1["name"] = "test_new_1" + new_config_2 = copy.deepcopy(config) + new_config_2["name"] = "test_new_2" + new_config_3 = copy.deepcopy(config) + new_config_3["name"] = "test_new_3" + + await help_test_reload_with_config( + hass, caplog, tmp_path, domain, [new_config_1, new_config_2, new_config_3] + ) + + assert len(hass.states.async_all(domain)) == 3 + + assert hass.states.get(f"{domain}.test_new_1") + assert hass.states.get(f"{domain}.test_new_2") + assert hass.states.get(f"{domain}.test_new_3") diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 794d143ac832a6..0d24f805cc12eb 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -54,6 +54,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -61,6 +62,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -933,12 +936,11 @@ async def test_set_tilt_templated_and_attributes(hass, mqtt_mock): "position_closed": 0, "set_position_topic": "set-position-topic", "set_position_template": "{{position-1}}", - "tilt_command_template": '\ - {% if state_attr(entity_id, "friendly_name") != "test" %}\ - {{ 5 }}\ - {% else %}\ - {{ 23 }}\ - {% endif %}', + "tilt_command_template": "{" + '"enitity_id": "{{ entity_id }}",' + '"value": {{ value }},' + '"tilt_position": {{ tilt_position }}' + "}", "payload_open": "OPEN", "payload_close": "CLOSE", "payload_stop": "STOP", @@ -950,12 +952,57 @@ async def test_set_tilt_templated_and_attributes(hass, mqtt_mock): await hass.services.async_call( cover.DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: 99}, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: 45}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "tilt-command-topic", + '{"enitity_id": "cover.test","value": 45,"tilt_position": 45}', + 0, + False, + ) + mqtt_mock.async_publish.reset_mock() + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, blocking=True, ) + mqtt_mock.async_publish.assert_called_once_with( + "tilt-command-topic", + '{"enitity_id": "cover.test","value": 100,"tilt_position": 100}', + 0, + False, + ) + mqtt_mock.async_publish.reset_mock() + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + mqtt_mock.async_publish.assert_called_once_with( + "tilt-command-topic", + '{"enitity_id": "cover.test","value": 0,"tilt_position": 0}', + 0, + False, + ) + mqtt_mock.async_publish.reset_mock() + await hass.services.async_call( + cover.DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) mqtt_mock.async_publish.assert_called_once_with( - "tilt-command-topic", "23", 0, False + "tilt-command-topic", + '{"enitity_id": "cover.test","value": 100,"tilt_position": 100}', + 0, + False, ) @@ -2291,7 +2338,7 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_valid_device_class(hass, mqtt_mock): - """Test the setting of a valid sensor class.""" + """Test the setting of a valid device class.""" assert await async_setup_component( hass, cover.DOMAIN, @@ -2311,7 +2358,7 @@ async def test_valid_device_class(hass, mqtt_mock): async def test_invalid_device_class(hass, mqtt_mock): - """Test the setting of an invalid sensor class.""" + """Test the setting of an invalid device class.""" assert await async_setup_component( hass, cover.DOMAIN, @@ -3057,3 +3104,92 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( f"'{CONF_TILT_STATUS_TEMPLATE}' must be set together with '{CONF_TILT_STATUS_TOPIC}'." in caplog.text ) + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + ( + SERVICE_OPEN_COVER, + "command_topic", + None, + "OPEN", + None, + ), + ( + SERVICE_SET_COVER_POSITION, + "set_position_topic", + {ATTR_POSITION: "50"}, + 50, + "set_position_template", + ), + ( + SERVICE_SET_COVER_TILT_POSITION, + "tilt_command_topic", + {ATTR_TILT_POSITION: "45"}, + 45, + "tilt_command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, +): + """Test publishing MQTT payload with different encoding.""" + domain = cover.DOMAIN + config = DEFAULT_CONFIG[domain] + config["position_topic"] = "some-position-topic" + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = cover.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ("state_topic", "open", None, None), + ("state_topic", "closing", None, None), + ("position_topic", "40", "current_position", 40), + ("tilt_status_topic", "60", "current_tilt_position", 60), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + cover.DOMAIN, + DEFAULT_CONFIG[cover.DOMAIN], + topic, + value, + attribute, + attribute_value, + skip_raw_test=True, + ) diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index c3b16e3de438ca..a5359563d926fb 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt import _LOGGER, DOMAIN, debug_info from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import async_initialize_triggers @@ -62,7 +63,9 @@ async def test_get_triggers(hass, device_reg, entity_reg, mqtt_mock): "subtype": "button_1", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -102,7 +105,9 @@ async def test_get_unknown_triggers(hass, device_reg, entity_reg, mqtt_mock): }, ) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, []) @@ -118,7 +123,9 @@ async def test_get_non_existing_triggers(hass, device_reg, entity_reg, mqtt_mock await hass.async_block_till_done() device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, []) @@ -161,7 +168,9 @@ async def test_discover_bad_triggers(hass, device_reg, entity_reg, mqtt_mock): "subtype": "button_1", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -206,14 +215,18 @@ async def test_update_remove_triggers(hass, device_reg, entity_reg, mqtt_mock): expected_triggers2 = [dict(expected_triggers1[0])] expected_triggers2[0]["subtype"] = "button_2" - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers1) # Update trigger async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data2) await hass.async_block_till_done() - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers2) # Remove trigger @@ -972,7 +985,9 @@ async def test_cleanup_trigger(hass, device_reg, entity_reg, mqtt_mock): device_entry = device_reg.async_get_device({("mqtt", "helloworld")}) assert device_entry is not None - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert triggers[0]["type"] == "foo" device_reg.async_remove_device(device_entry.id) @@ -1007,7 +1022,9 @@ async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): device_entry = device_reg.async_get_device({("mqtt", "helloworld")}) assert device_entry is not None - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert triggers[0]["type"] == "foo" async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", "") @@ -1047,7 +1064,9 @@ async def test_cleanup_device_several_triggers(hass, device_reg, entity_reg, mqt device_entry = device_reg.async_get_device({("mqtt", "helloworld")}) assert device_entry is not None - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 2 assert triggers[0]["type"] == "foo" assert triggers[1]["type"] == "foo2" @@ -1059,7 +1078,9 @@ async def test_cleanup_device_several_triggers(hass, device_reg, entity_reg, mqt device_entry = device_reg.async_get_device({("mqtt", "helloworld")}) assert device_entry is not None - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 1 assert triggers[0]["type"] == "foo2" @@ -1102,7 +1123,9 @@ async def test_cleanup_device_with_entity1(hass, device_reg, entity_reg, mqtt_mo device_entry = device_reg.async_get_device({("mqtt", "helloworld")}) assert device_entry is not None - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 3 # 2 binary_sensor triggers + device trigger async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", "") @@ -1112,7 +1135,9 @@ async def test_cleanup_device_with_entity1(hass, device_reg, entity_reg, mqtt_mo device_entry = device_reg.async_get_device({("mqtt", "helloworld")}) assert device_entry is not None - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 2 # 2 binary_sensor triggers async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla2/config", "") @@ -1154,7 +1179,9 @@ async def test_cleanup_device_with_entity2(hass, device_reg, entity_reg, mqtt_mo device_entry = device_reg.async_get_device({("mqtt", "helloworld")}) assert device_entry is not None - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 3 # 2 binary_sensor triggers + device trigger async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla2/config", "") @@ -1164,7 +1191,9 @@ async def test_cleanup_device_with_entity2(hass, device_reg, entity_reg, mqtt_mo device_entry = device_reg.async_get_device({("mqtt", "helloworld")}) assert device_entry is not None - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 1 # device trigger async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", "") diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index c310db335ad6b0..0a3b2499dc2c5a 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1,12 +1,27 @@ """Test MQTT fans.""" +import copy from unittest.mock import patch import pytest from voluptuous.error import MultipleInvalid from homeassistant.components import fan -from homeassistant.components.fan import NotValidPresetModeError -from homeassistant.components.mqtt.fan import MQTT_FAN_ATTRIBUTES_BLOCKED +from homeassistant.components.fan import ( + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + NotValidPresetModeError, +) +from homeassistant.components.mqtt.fan import ( + CONF_OSCILLATION_COMMAND_TOPIC, + CONF_OSCILLATION_STATE_TOPIC, + CONF_PERCENTAGE_COMMAND_TOPIC, + CONF_PERCENTAGE_STATE_TOPIC, + CONF_PRESET_MODE_COMMAND_TOPIC, + CONF_PRESET_MODE_STATE_TOPIC, + MQTT_FAN_ATTRIBUTES_BLOCKED, +) from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES, @@ -25,6 +40,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -32,6 +48,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -1267,6 +1285,42 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ("state_topic", "ON", None, "on"), + (CONF_PRESET_MODE_STATE_TOPIC, "auto", ATTR_PRESET_MODE, "auto"), + (CONF_PERCENTAGE_STATE_TOPIC, "60", ATTR_PERCENTAGE, 60), + ( + CONF_OSCILLATION_STATE_TOPIC, + "oscillate_on", + ATTR_OSCILLATING, + True, + ), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + config = copy.deepcopy(DEFAULT_CONFIG[fan.DOMAIN]) + config[ATTR_PRESET_MODES] = ["eco", "auto"] + config[CONF_PRESET_MODE_COMMAND_TOPIC] = "fan/some_preset_mode_command_topic" + config[CONF_PERCENTAGE_COMMAND_TOPIC] = "fan/some_percentage_command_topic" + config[CONF_OSCILLATION_COMMAND_TOPIC] = "fan/some_oscillation_command_topic" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + fan.DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + ) + + async def test_attributes(hass, mqtt_mock, caplog): """Test attributes.""" assert await async_setup_component( @@ -1663,3 +1717,80 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG ) + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + ( + fan.SERVICE_TURN_ON, + "command_topic", + None, + "ON", + None, + ), + ( + fan.SERVICE_TURN_OFF, + "command_topic", + None, + "OFF", + None, + ), + ( + fan.SERVICE_SET_PRESET_MODE, + "preset_mode_command_topic", + {fan.ATTR_PRESET_MODE: "eco"}, + "eco", + "preset_mode_command_template", + ), + ( + fan.SERVICE_SET_PERCENTAGE, + "percentage_command_topic", + {fan.ATTR_PERCENTAGE: "45"}, + 45, + "percentage_command_template", + ), + ( + fan.SERVICE_OSCILLATE, + "oscillation_command_topic", + {fan.ATTR_OSCILLATING: "on"}, + "oscillate_on", + "oscillation_command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, +): + """Test publishing MQTT payload with different encoding.""" + domain = fan.DOMAIN + config = copy.deepcopy(DEFAULT_CONFIG[domain]) + if topic == "preset_mode_command_topic": + config["preset_modes"] = ["auto", "eco"] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = fan.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 76c0b6e9f8e0f2..62d29c12ee82be 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1,4 +1,5 @@ """Test MQTT humidifiers.""" +import copy from unittest.mock import patch import pytest @@ -12,7 +13,12 @@ SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) -from homeassistant.components.mqtt.humidifier import MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED +from homeassistant.components.mqtt.humidifier import ( + CONF_MODE_COMMAND_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_TARGET_HUMIDITY_STATE_TOPIC, + MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED, +) from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, @@ -35,6 +41,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -42,6 +49,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -672,6 +681,34 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ("state_topic", "ON", None, "on"), + (CONF_MODE_STATE_TOPIC, "auto", ATTR_MODE, "auto"), + (CONF_TARGET_HUMIDITY_STATE_TOPIC, "45", ATTR_HUMIDITY, 45), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + config = copy.deepcopy(DEFAULT_CONFIG[humidifier.DOMAIN]) + config["modes"] = ["eco", "auto"] + config[CONF_MODE_COMMAND_TOPIC] = "humidifier/some_mode_command_topic" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + humidifier.DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + ) + + async def test_attributes(hass, mqtt_mock, caplog): """Test attributes.""" assert await async_setup_component( @@ -1058,3 +1095,73 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG ) + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + ( + humidifier.SERVICE_TURN_ON, + "command_topic", + None, + "ON", + None, + ), + ( + humidifier.SERVICE_TURN_OFF, + "command_topic", + None, + "OFF", + None, + ), + ( + humidifier.SERVICE_SET_MODE, + "mode_command_topic", + {humidifier.ATTR_MODE: "eco"}, + "eco", + "mode_command_template", + ), + ( + humidifier.SERVICE_SET_HUMIDITY, + "target_humidity_command_topic", + {humidifier.ATTR_HUMIDITY: "45"}, + 45, + "target_humidity_command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, +): + """Test publishing MQTT payload with different encoding.""" + domain = humidifier.DOMAIN + config = copy.deepcopy(DEFAULT_CONFIG[domain]) + if topic == "mode_command_topic": + config["modes"] = ["auto", "eco"] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = humidifier.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index acc25b594424e1..9101b895218f9f 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta import json import ssl -from unittest.mock import AsyncMock, MagicMock, call, mock_open, patch +from unittest.mock import ANY, AsyncMock, MagicMock, call, mock_open, patch import pytest import voluptuous as vol @@ -12,13 +12,16 @@ from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.const import ( + ATTR_ASSUMED_STATE, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, ) +import homeassistant.core as ha from homeassistant.core import CoreState, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, template +from homeassistant.helpers.entity import Entity from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -91,7 +94,7 @@ async def test_mqtt_disconnects_on_home_assistant_stop(hass, mqtt_mock): assert mqtt_mock.async_disconnect.called -async def test_publish_(hass, mqtt_mock): +async def test_publish(hass, mqtt_mock): """Test the publish function.""" await mqtt.async_publish(hass, "test-topic", "test-payload") await hass.async_block_till_done() @@ -137,6 +140,140 @@ async def test_publish_(hass, mqtt_mock): ) mqtt_mock.reset_mock() + # test binary pass-through + mqtt.publish( + hass, + "test-topic3", + b"\xde\xad\xbe\xef", + 0, + False, + ) + await hass.async_block_till_done() + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0] == ( + "test-topic3", + b"\xde\xad\xbe\xef", + 0, + False, + ) + mqtt_mock.reset_mock() + + +async def test_convert_outgoing_payload(hass): + """Test the converting of outgoing MQTT payloads without template.""" + command_template = mqtt.MqttCommandTemplate(None, hass=hass) + assert command_template.async_render(b"\xde\xad\xbe\xef") == b"\xde\xad\xbe\xef" + + assert ( + command_template.async_render("b'\\xde\\xad\\xbe\\xef'") + == "b'\\xde\\xad\\xbe\\xef'" + ) + + assert command_template.async_render(1234) == 1234 + + assert command_template.async_render(1234.56) == 1234.56 + + assert command_template.async_render(None) is None + + +async def test_command_template_value(hass): + """Test the rendering of MQTT command template.""" + + variables = {"id": 1234, "some_var": "beer"} + + # test rendering value + tpl = template.Template("{{ value + 1 }}", hass=hass) + cmd_tpl = mqtt.MqttCommandTemplate(tpl, hass=hass) + assert cmd_tpl.async_render(4321) == "4322" + + # test variables at rendering + tpl = template.Template("{{ some_var }}", hass=hass) + cmd_tpl = mqtt.MqttCommandTemplate(tpl, hass=hass) + assert cmd_tpl.async_render(None, variables=variables) == "beer" + + +async def test_command_template_variables(hass, mqtt_mock): + """Test the rendering of enitity_variables.""" + topic = "test/select" + + fake_state = ha.State("select.test", "milk") + + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=fake_state, + ): + assert await async_setup_component( + hass, + "select", + { + "select": { + "platform": "mqtt", + "command_topic": topic, + "name": "Test Select", + "options": ["milk", "beer"], + "command_template": '{"option": "{{ value }}", "entity_id": "{{ entity_id }}", "name": "{{ name }}"}', + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.test_select") + assert state.state == "milk" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": "select.test_select", "option": "beer"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + topic, + '{"option": "beer", "entity_id": "select.test_select", "name": "Test Select"}', + 0, + False, + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("select.test_select") + assert state.state == "beer" + + +async def test_value_template_value(hass): + """Test the rendering of MQTT value template.""" + + variables = {"id": 1234, "some_var": "beer"} + + # test rendering value + tpl = template.Template("{{ value_json.id }}") + val_tpl = mqtt.MqttValueTemplate(tpl, hass=hass) + assert val_tpl.async_render_with_possible_json_value('{"id": 4321}') == "4321" + + # test variables at rendering + tpl = template.Template("{{ value_json.id }} {{ some_var }} {{ code }}") + val_tpl = mqtt.MqttValueTemplate(tpl, hass=hass, config_attributes={"code": 1234}) + assert ( + val_tpl.async_render_with_possible_json_value( + '{"id": 4321}', variables=variables + ) + == "4321 beer 1234" + ) + + # test with default value if an error occurs due to an invalid template + tpl = template.Template("{{ value_json.id | as_datetime }}") + val_tpl = mqtt.MqttValueTemplate(tpl, hass=hass) + assert ( + val_tpl.async_render_with_possible_json_value('{"otherid": 4321}', "my default") + == "my default" + ) + + # test value template with entity + entity = Entity() + entity.hass = hass + tpl = template.Template("{{ value_json.id }}") + val_tpl = mqtt.MqttValueTemplate(tpl, entity=entity) + assert val_tpl.async_render_with_possible_json_value('{"id": 4321}') == "4321" + async def test_service_call_without_topic_does_not_publish(hass, mqtt_mock): """Test the service call if topic is missing.""" @@ -260,6 +397,20 @@ async def test_service_call_with_template_payload_renders_template(hass, mqtt_mo ) assert mqtt_mock.async_publish.called assert mqtt_mock.async_publish.call_args[0][1] == "8" + mqtt_mock.reset_mock() + + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC: "test/topic", + mqtt.ATTR_PAYLOAD_TEMPLATE: "{{ (4+4) | pack('B') }}", + }, + blocking=True, + ) + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] == b"\x08" + mqtt_mock.reset_mock() async def test_service_call_with_bad_template(hass, mqtt_mock): @@ -315,6 +466,30 @@ async def test_service_call_with_ascii_qos_retain_flags(hass, mqtt_mock): assert not mqtt_mock.async_publish.call_args[0][3] +async def test_publish_function_with_bad_encoding_conditions(hass, caplog): + """Test internal publish function with bas use cases.""" + await mqtt.async_publish( + hass, "some-topic", "test-payload", qos=0, retain=False, encoding=None + ) + assert ( + "Can't pass-through payload for publishing test-payload on some-topic with no encoding set, need 'bytes' got " + in caplog.text + ) + caplog.clear() + await mqtt.async_publish( + hass, + "some-topic", + "test-payload", + qos=0, + retain=False, + encoding="invalid_encoding", + ) + assert ( + "Can't encode payload for publishing test-payload on some-topic with encoding invalid_encoding" + in caplog.text + ) + + def test_validate_topic(): """Test topic name/filter validation.""" # Invalid UTF-8, must not contain U+D800 to U+DFFF. @@ -1365,6 +1540,68 @@ async def test_mqtt_ws_get_device_debug_info( assert response["result"] == expected_result +async def test_mqtt_ws_get_device_debug_info_binary( + hass, device_reg, hass_ws_client, mqtt_mock +): + """Test MQTT websocket device debug info.""" + config = { + "device": {"identifiers": ["0AFFD2"]}, + "platform": "mqtt", + "topic": "foobar/image", + "unique_id": "unique", + } + data = json.dumps(config) + + async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data) + await hass.async_block_till_done() + + # Verify device entry is created + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}) + assert device_entry is not None + + small_png = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x04\x00\x00\x00\x04\x08\x06" + b"\x00\x00\x00\xa9\xf1\x9e~\x00\x00\x00\x13IDATx\xdac\xfc\xcf\xc0P\xcf\x80\x04" + b"\x18I\x17\x00\x00\xf2\xae\x05\xfdR\x01\xc2\xde\x00\x00\x00\x00IEND\xaeB`\x82" + ) + async_fire_mqtt_message(hass, "foobar/image", small_png) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 5, "type": "mqtt/device/debug_info", "device_id": device_entry.id} + ) + response = await client.receive_json() + assert response["success"] + expected_result = { + "entities": [ + { + "entity_id": "camera.mqtt_camera", + "subscriptions": [ + { + "topic": "foobar/image", + "messages": [ + { + "payload": str(small_png), + "qos": 0, + "retain": False, + "time": ANY, + "topic": "foobar/image", + } + ], + } + ], + "discovery_data": { + "payload": config, + "topic": "homeassistant/camera/bla/config", + }, + } + ], + "triggers": [], + } + assert response["result"] == expected_result + + async def test_debug_info_multiple_devices(hass, mqtt_mock): """Test we get correct debug_info when multiple devices are present.""" devices = [ @@ -1814,6 +2051,7 @@ async def test_publish_json_from_template(hass, mqtt_mock): assert mqtt_mock.async_publish.call_args[0][1] == test_str +@pytest.mark.usefixtures("mock_integration_frame") async def test_service_info_compatibility(hass, caplog): """Test compatibility with old-style dict. @@ -1828,11 +2066,6 @@ async def test_service_info_compatibility(hass, caplog): timestamp=None, ) - # Ensure first call get logged - assert discovery_info["topic"] == "tasmota/discovery/DC4F220848A2/config" - assert "Detected code that accessed discovery_info['topic']" in caplog.text - - # Ensure second call doesn't get logged - caplog.clear() - assert discovery_info["topic"] == "tasmota/discovery/DC4F220848A2/config" - assert "Detected code that accessed discovery_info['topic']" not in caplog.text + with patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()): + assert discovery_info["topic"] == "tasmota/discovery/DC4F220848A2/config" + assert "Detected integration that accessed discovery_info['topic']" in caplog.text diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 5d9b50252a471c..59263037e657f6 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -11,6 +11,13 @@ from homeassistant.components.mqtt.vacuum.schema import services_to_strings from homeassistant.components.mqtt.vacuum.schema_legacy import ( ALL_SERVICES, + CONF_BATTERY_LEVEL_TOPIC, + CONF_CHARGING_TOPIC, + CONF_CLEANING_TOPIC, + CONF_DOCKED_TOPIC, + CONF_ERROR_TOPIC, + CONF_FAN_SPEED_TOPIC, + CONF_SUPPORTED_FEATURES, MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED, SERVICE_TO_STRING, ) @@ -34,6 +41,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -41,6 +49,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -746,3 +756,139 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, vacuum.DOMAIN, config, "test-topic" ) + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + ( + vacuum.SERVICE_TURN_ON, + "command_topic", + None, + "turn_on", + None, + ), + ( + vacuum.SERVICE_CLEAN_SPOT, + "command_topic", + None, + "clean_spot", + None, + ), + ( + vacuum.SERVICE_SET_FAN_SPEED, + "set_fan_speed_topic", + {"fan_speed": "medium"}, + "medium", + None, + ), + ( + vacuum.SERVICE_SEND_COMMAND, + "send_command_topic", + {"command": "custom command"}, + "custom command", + None, + ), + ( + vacuum.SERVICE_TURN_OFF, + "command_topic", + None, + "turn_off", + None, + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, +): + """Test publishing MQTT payload with different encoding.""" + domain = vacuum.DOMAIN + config = deepcopy(DEFAULT_CONFIG) + config["supported_features"] = [ + "turn_on", + "turn_off", + "clean_spot", + "fan_speed", + "send_command", + ] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = vacuum.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + (CONF_BATTERY_LEVEL_TOPIC, '{ "battery_level": 60 }', "battery_level", 60), + (CONF_CHARGING_TOPIC, '{ "charging": true }', "status", "Stopped"), + (CONF_CLEANING_TOPIC, '{ "cleaning": true }', "status", "Cleaning"), + (CONF_DOCKED_TOPIC, '{ "docked": true }', "status", "Docked"), + ( + CONF_ERROR_TOPIC, + '{ "error": "some error" }', + "status", + "Error: some error", + ), + ( + CONF_FAN_SPEED_TOPIC, + '{ "fan_speed": "medium" }', + "fan_speed", + "medium", + ), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + config = deepcopy(DEFAULT_CONFIG) + config[CONF_SUPPORTED_FEATURES] = [ + "turn_on", + "turn_off", + "pause", + "stop", + "return_home", + "battery", + "status", + "locate", + "clean_spot", + "fan_speed", + "send_command", + ] + + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + vacuum.DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + skip_raw_test=True, + ) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 58b607bb7772e7..dcff826311bbc6 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -153,19 +153,28 @@ payload_off: "off" """ +import copy from unittest.mock import call, patch import pytest -from homeassistant import config as hass_config from homeassistant.components import light from homeassistant.components.mqtt.light.schema_basic import ( + CONF_BRIGHTNESS_COMMAND_TOPIC, + CONF_COLOR_TEMP_COMMAND_TOPIC, + CONF_EFFECT_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_HS_COMMAND_TOPIC, + CONF_RGB_COMMAND_TOPIC, + CONF_RGBW_COMMAND_TOPIC, + CONF_RGBWW_COMMAND_TOPIC, + CONF_WHITE_VALUE_COMMAND_TOPIC, + CONF_XY_COMMAND_TOPIC, MQTT_LIGHT_ATTRIBUTES_BLOCKED, ) from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES, - SERVICE_RELOAD, STATE_OFF, STATE_ON, ) @@ -182,6 +191,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -189,6 +199,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -197,11 +209,7 @@ help_test_update_with_json_attrs_not_dict, ) -from tests.common import ( - assert_setup_component, - async_fire_mqtt_message, - get_fixture_path, -) +from tests.common import assert_setup_component, async_fire_mqtt_message from tests.components.light import common DEFAULT_CONFIG = { @@ -1109,37 +1117,6 @@ async def test_controlling_state_via_topic_with_templates(hass, mqtt_mock): assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes -async def test_controlling_state_via_topic_with_value_template(hass, mqtt_mock, caplog): - """Test the setting of the state with undocumented value_template.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "value_template": "{{ value_json.hello }}", - } - } - - assert await async_setup_component(hass, light.DOMAIN, config) - await hass.async_block_till_done() - - assert "The 'value_template' option is deprecated" in caplog.text - - state = hass.states.get("light.test") - assert state.state == STATE_OFF - - async_fire_mqtt_message(hass, "test_light_rgb/status", '{"hello": "ON"}') - - state = hass.states.get("light.test") - assert state.state == STATE_ON - - async_fire_mqtt_message(hass, "test_light_rgb/status", '{"hello": "OFF"}') - - state = hass.states.get("light.test") - assert state.state == STATE_OFF - - async def test_legacy_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): """Test the sending of command in optimistic mode.""" config = { @@ -2281,7 +2258,7 @@ async def test_on_command_white(hass, mqtt_mock): "platform": "mqtt", "name": "test", "command_topic": "tasmota_B94927/cmnd/POWER", - "value_template": "{{ value_json.POWER }}", + "state_value_template": "{{ value_json.POWER }}", "payload_off": "OFF", "payload_on": "ON", "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", @@ -2590,7 +2567,7 @@ async def test_white_state_update(hass, mqtt_mock): "name": "test", "state_topic": "tasmota_B94927/tele/STATE", "command_topic": "tasmota_B94927/cmnd/POWER", - "value_template": "{{ value_json.POWER }}", + "state_value_template": "{{ value_json.POWER }}", "payload_off": "OFF", "payload_on": "ON", "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", @@ -3376,34 +3353,193 @@ async def test_max_mireds(hass, mqtt_mock): assert state.attributes.get("max_mireds") == 370 -async def test_reloadable(hass, mqtt_mock): - """Test reloading an mqtt light.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test/set", - } - } - - assert await async_setup_component(hass, light.DOMAIN, config) - await hass.async_block_till_done() - - assert hass.states.get("light.test") - assert len(hass.states.async_all("light")) == 1 - - yaml_path = get_fixture_path("configuration.yaml", "mqtt") - - with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - "mqtt", - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all("light")) == 1 - - assert hass.states.get("light.test") is None - assert hass.states.get("light.reload") +@pytest.mark.parametrize( + "service,topic,parameters,payload,template,tpl_par,tpl_output", + [ + ( + light.SERVICE_TURN_ON, + "command_topic", + None, + "ON", + None, + None, + None, + ), + ( + light.SERVICE_TURN_ON, + "white_command_topic", + {"white": "255"}, + 255, + None, + None, + None, + ), + ( + light.SERVICE_TURN_ON, + "brightness_command_topic", + {"color_temp": "200", "brightness": "50"}, + 50, + None, + None, + None, + ), + ( + light.SERVICE_TURN_ON, + "effect_command_topic", + {"rgb_color": [255, 128, 0], "effect": "color_loop"}, + "color_loop", + None, + None, + None, + ), + ( + light.SERVICE_TURN_ON, + "color_temp_command_topic", + {"color_temp": "200"}, + 200, + "color_temp_command_template", + "value", + b"2", + ), + ( + light.SERVICE_TURN_ON, + "rgb_command_topic", + {"rgb_color": [255, 128, 0]}, + "255,128,0", + "rgb_command_template", + "red", + b"2", + ), + ( + light.SERVICE_TURN_ON, + "hs_command_topic", + {"rgb_color": [255, 128, 0]}, + "30.118,100.0", + None, + None, + None, + ), + ( + light.SERVICE_TURN_ON, + "xy_command_topic", + {"hs_color": [30.118, 100.0]}, + "0.611,0.375", + None, + None, + None, + ), + ( + light.SERVICE_TURN_OFF, + "command_topic", + None, + "OFF", + None, + None, + None, + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, + tpl_par, + tpl_output, +): + """Test publishing MQTT payload with different encoding.""" + domain = light.DOMAIN + config = copy.deepcopy(DEFAULT_CONFIG[domain]) + if topic == "effect_command_topic": + config["effect_list"] = ["random", "color_loop"] + elif topic == "white_command_topic": + config["rgb_command_topic"] = "some-cmd-topic" + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + tpl_par=tpl_par, + tpl_output=tpl_output, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = light.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value,init_payload", + [ + ("state_topic", "ON", None, "on", None), + ("brightness_state_topic", "60", "brightness", 60, ("state_topic", "ON")), + ( + "color_mode_state_topic", + "200", + "color_mode", + "200", + ("state_topic", "ON"), + ), + ("color_temp_state_topic", "200", "color_temp", 200, ("state_topic", "ON")), + ("effect_state_topic", "random", "effect", "random", ("state_topic", "ON")), + ("hs_state_topic", "200,50", "hs_color", (200, 50), ("state_topic", "ON")), + ( + "xy_state_topic", + "128,128", + "xy_color", + (128, 128), + ("state_topic", "ON"), + ), + ( + "rgb_state_topic", + "255,0,240", + "rgb_color", + (255, 0, 240), + ("state_topic", "ON"), + ), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value, init_payload +): + """Test handling of incoming encoded payload.""" + config = copy.deepcopy(DEFAULT_CONFIG[light.DOMAIN]) + config[CONF_EFFECT_COMMAND_TOPIC] = "light/CONF_EFFECT_COMMAND_TOPIC" + config[CONF_RGB_COMMAND_TOPIC] = "light/CONF_RGB_COMMAND_TOPIC" + config[CONF_BRIGHTNESS_COMMAND_TOPIC] = "light/CONF_BRIGHTNESS_COMMAND_TOPIC" + config[CONF_COLOR_TEMP_COMMAND_TOPIC] = "light/CONF_COLOR_TEMP_COMMAND_TOPIC" + config[CONF_HS_COMMAND_TOPIC] = "light/CONF_HS_COMMAND_TOPIC" + config[CONF_RGB_COMMAND_TOPIC] = "light/CONF_RGB_COMMAND_TOPIC" + config[CONF_RGBW_COMMAND_TOPIC] = "light/CONF_RGBW_COMMAND_TOPIC" + config[CONF_RGBWW_COMMAND_TOPIC] = "light/CONF_RGBWW_COMMAND_TOPIC" + config[CONF_XY_COMMAND_TOPIC] = "light/CONF_XY_COMMAND_TOPIC" + config[CONF_EFFECT_LIST] = ["colorloop", "random"] + if attribute and attribute == "brightness": + config[CONF_WHITE_VALUE_COMMAND_TOPIC] = "light/CONF_WHITE_VALUE_COMMAND_TOPIC" + + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + light.DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + init_payload, + ) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 677509277c772f..baad644bf6db28 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -87,6 +87,7 @@ brightness: true brightness_scale: 99 """ +import copy import json from unittest.mock import call, patch @@ -115,6 +116,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -122,6 +124,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -1902,3 +1906,110 @@ async def test_max_mireds(hass, mqtt_mock): state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 assert state.attributes.get("max_mireds") == 370 + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template,tpl_par,tpl_output", + [ + ( + light.SERVICE_TURN_ON, + "command_topic", + None, + '{"state": "ON"}', + None, + None, + None, + ), + ( + light.SERVICE_TURN_OFF, + "command_topic", + None, + '{"state": "OFF"}', + None, + None, + None, + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, + tpl_par, + tpl_output, +): + """Test publishing MQTT payload with different encoding.""" + domain = light.DOMAIN + config = copy.deepcopy(DEFAULT_CONFIG[domain]) + if topic == "effect_command_topic": + config["effect_list"] = ["random", "color_loop"] + elif topic == "white_command_topic": + config["rgb_command_topic"] = "some-cmd-topic" + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + tpl_par=tpl_par, + tpl_output=tpl_output, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = light.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value,init_payload", + [ + ( + "state_topic", + '{ "state": "ON", "brightness": 200 }', + "brightness", + 200, + None, + ), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value, init_payload +): + """Test handling of incoming encoded payload.""" + config = copy.deepcopy(DEFAULT_CONFIG[light.DOMAIN]) + config["color_mode"] = True + config["supported_color_modes"] = [ + "color_temp", + "hs", + "xy", + "rgb", + "rgbw", + "rgbww", + ] + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + light.DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + init_payload, + skip_raw_test=True, + ) diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index fe2d9badf7de1c..3a8ecd357c3287 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -26,6 +26,7 @@ If your light doesn't support RGB feature, omit `(red|green|blue)_template`. """ +import copy from unittest.mock import patch import pytest @@ -53,6 +54,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -60,6 +62,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -349,7 +353,9 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): "{{ white_value|d }}," "{{ red|d }}-" "{{ green|d }}-" - "{{ blue|d }}", + "{{ blue|d }}," + "{{ hue|d }}-" + "{{ sat|d }}", "command_off_template": "off", "effect_list": ["colorloop", "random"], "optimistic": True, @@ -384,7 +390,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): await common.async_turn_on(hass, "light.test") mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,,--", 2, False + "test_light_rgb/set", "on,,,,--,-", 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -393,7 +399,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): # Set color_temp await common.async_turn_on(hass, "light.test", color_temp=70) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,70,,--", 2, False + "test_light_rgb/set", "on,,70,,--,-", 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -403,7 +409,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): # Set full brightness await common.async_turn_on(hass, "light.test", brightness=255) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,255,,,--", 2, False + "test_light_rgb/set", "on,255,,,--,-", 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -414,7 +420,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): hass, "light.test", rgb_color=[255, 128, 0], white_value=80 ) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,80,255-128-0", 2, False + "test_light_rgb/set", "on,,,80,255-128-0,30.118-100.0", 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -425,7 +431,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): # Full brightness - normalization of RGB values sent over MQTT await common.async_turn_on(hass, "light.test", rgb_color=[128, 64, 0]) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,,255-127-0", 2, False + "test_light_rgb/set", "on,,,,255-127-0,30.0-100.0", 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -435,7 +441,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): # Set half brightness await common.async_turn_on(hass, "light.test", brightness=128) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,128,,,--", 2, False + "test_light_rgb/set", "on,128,,,--,-", 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -446,7 +452,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): hass, "light.test", rgb_color=[0, 255, 128], white_value=40 ) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,40,0-128-64", 2, False + "test_light_rgb/set", "on,,,40,0-128-64,150.118-100.0", 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -459,7 +465,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): hass, "light.test", rgb_color=[0, 32, 16], white_value=40 ) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,40,0-128-64", 2, False + "test_light_rgb/set", "on,,,40,0-128-64,150.0-100.0", 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -490,7 +496,9 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( "{{ white_value|d }}," "{{ red|d }}-" "{{ green|d }}-" - "{{ blue|d }}", + "{{ blue|d }}," + "{{ hue }}-" + "{{ sat }}", "command_off_template": "off", "state_template": '{{ value.split(",")[0] }}', "brightness_template": '{{ value.split(",")[1] }}', @@ -524,7 +532,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( await common.async_turn_on(hass, "light.test") mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,,--", 0, False + "test_light_rgb/set", "on,,,,--,-", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -533,7 +541,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( # Set color_temp await common.async_turn_on(hass, "light.test", color_temp=70) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,70,,--", 0, False + "test_light_rgb/set", "on,,70,,--,-", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -543,7 +551,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( # Set full brightness await common.async_turn_on(hass, "light.test", brightness=255) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,255,,,--", 0, False + "test_light_rgb/set", "on,255,,,--,-", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -555,7 +563,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( hass, "light.test", rgb_color=[255, 128, 0], white_value=80 ) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,80,255-128-0", 0, False + "test_light_rgb/set", "on,,,80,255-128-0,30.118-100.0", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -566,14 +574,14 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( # Full brightness - normalization of RGB values sent over MQTT await common.async_turn_on(hass, "light.test", rgb_color=[128, 64, 0]) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,,255-127-0", 0, False + "test_light_rgb/set", "on,,,,255-127-0,30.0-100.0", 0, False ) mqtt_mock.async_publish.reset_mock() # Set half brightness await common.async_turn_on(hass, "light.test", brightness=128) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,128,,,--", 0, False + "test_light_rgb/set", "on,128,,,--,-", 0, False ) mqtt_mock.async_publish.reset_mock() @@ -582,7 +590,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( hass, "light.test", rgb_color=[0, 255, 128], white_value=40 ) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,40,0-255-128", 0, False + "test_light_rgb/set", "on,,,40,0-255-128,150.118-100.0", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -592,7 +600,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( hass, "light.test", rgb_color=[0, 32, 16], white_value=40 ) mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", "on,,,40,0-255-127", 0, False + "test_light_rgb/set", "on,,,40,0-255-127,150.0-100.0", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -1085,3 +1093,95 @@ async def test_max_mireds(hass, mqtt_mock): state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 assert state.attributes.get("max_mireds") == 370 + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template,tpl_par,tpl_output", + [ + ( + light.SERVICE_TURN_ON, + "command_topic", + None, + "on,", + None, + None, + None, + ), + ( + light.SERVICE_TURN_OFF, + "command_topic", + None, + "off,", + None, + None, + None, + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, + tpl_par, + tpl_output, +): + """Test publishing MQTT payload with different encoding.""" + domain = light.DOMAIN + config = copy.deepcopy(DEFAULT_CONFIG[domain]) + if topic == "effect_command_topic": + config["effect_list"] = ["random", "color_loop"] + elif topic == "white_command_topic": + config["rgb_command_topic"] = "some-cmd-topic" + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + tpl_par=tpl_par, + tpl_output=tpl_output, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = light.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value,init_payload", + [ + ("state_topic", "on", None, "on", None), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value, init_payload +): + """Test handling of incoming encoded payload.""" + config = copy.deepcopy(DEFAULT_CONFIG[light.DOMAIN]) + config["state_template"] = "{{ value }}" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + light.DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + init_payload, + ) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 8d76e46f32bb45..f29222f97d58e1 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -30,6 +30,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -37,6 +38,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -589,3 +592,73 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG ) + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + ( + SERVICE_LOCK, + "command_topic", + None, + "LOCK", + None, + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, +): + """Test publishing MQTT payload with different encoding.""" + domain = LOCK_DOMAIN + config = DEFAULT_CONFIG[domain] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = LOCK_DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ("state_topic", "LOCKED", None, "locked"), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + LOCK_DOMAIN, + DEFAULT_CONFIG[LOCK_DOMAIN], + topic, + value, + attribute, + attribute_value, + ) diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 797a7b894fc360..c233bf14ab517f 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -36,6 +36,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -43,6 +44,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -640,3 +643,74 @@ async def test_mqtt_payload_out_of_range_error(hass, caplog, mqtt_mock): assert ( "Invalid value for number.test_number: 115.5 (range 5.0 - 110.0)" in caplog.text ) + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + ( + SERVICE_SET_VALUE, + "command_topic", + {ATTR_VALUE: "45"}, + 45, + "command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, +): + """Test publishing MQTT payload with different encoding.""" + domain = NUMBER_DOMAIN + config = DEFAULT_CONFIG[domain] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = number.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ("state_topic", "10", None, "10"), + ("state_topic", "60", None, "60"), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + "number", + DEFAULT_CONFIG["number"], + topic, + value, + attribute, + attribute_value, + ) diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 3d4cd0f5c25636..97f13ba90c09a0 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components import scene -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -18,6 +18,7 @@ help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_unchanged, + help_test_reloadable, help_test_unique_id, ) @@ -33,7 +34,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock): """Test the sending MQTT commands.""" - fake_state = ha.State("scene.test", scene.STATE) + fake_state = ha.State("scene.test", STATE_UNKNOWN) with patch( "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", @@ -54,7 +55,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock): await hass.async_block_till_done() state = hass.states.get("scene.test") - assert state.state == scene.STATE + assert state.state == STATE_UNKNOWN data = {ATTR_ENTITY_ID: "scene.test"} await hass.services.async_call(scene.DOMAIN, SERVICE_TURN_ON, data, blocking=True) @@ -175,3 +176,10 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): await help_test_discovery_broken( hass, mqtt_mock, caplog, scene.DOMAIN, data1, data2 ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = scene.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 2f31ce788fd7a7..c09c0aebca83b1 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -1,4 +1,5 @@ """The tests for mqtt select component.""" +import copy import json from unittest.mock import patch @@ -26,6 +27,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -33,6 +35,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -524,3 +528,70 @@ async def test_mqtt_payload_not_an_option_warning(hass, caplog, mqtt_mock): "Invalid option for select.test_select: 'öl' (valid options: ['milk', 'beer'])" in caplog.text ) + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + ( + select.SERVICE_SELECT_OPTION, + "command_topic", + {"option": "beer"}, + "beer", + "command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, mqtt_mock, caplog, service, topic, parameters, payload, template +): + """Test publishing MQTT payload with different encoding.""" + domain = select.DOMAIN + config = DEFAULT_CONFIG[domain] + config["options"] = ["milk", "beer"] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = select.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ("state_topic", "milk", None, "milk"), + ("state_topic", "beer", None, "beer"), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + config = copy.deepcopy(DEFAULT_CONFIG["select"]) + config["options"] = ["milk", "beer"] + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + "select", + config, + topic, + value, + attribute, + attribute_value, + ) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 680bafb3b2b81d..ad43a7b23533c4 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -29,6 +29,7 @@ help_test_discovery_update_attr, help_test_discovery_update_availability, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_category, help_test_entity_debug_info, help_test_entity_debug_info_max_messages, @@ -42,6 +43,8 @@ help_test_entity_disabled_by_default, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_reload_with_config, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -50,7 +53,11 @@ help_test_update_with_json_attrs_not_dict, ) -from tests.common import async_fire_mqtt_message, async_fire_time_changed +from tests.common import ( + assert_setup_component, + async_fire_mqtt_message, + async_fire_time_changed, +) DEFAULT_CONFIG = { sensor.DOMAIN: {"platform": "mqtt", "name": "test", "state_topic": "test-topic"} @@ -83,27 +90,27 @@ async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): @pytest.mark.parametrize( "device_class,native_value,state_value,log", [ - (sensor.DEVICE_CLASS_DATE, "2021-11-18", "2021-11-18", False), - (sensor.DEVICE_CLASS_DATE, "invalid", STATE_UNKNOWN, True), + (sensor.SensorDeviceClass.DATE, "2021-11-18", "2021-11-18", False), + (sensor.SensorDeviceClass.DATE, "invalid", STATE_UNKNOWN, True), ( - sensor.DEVICE_CLASS_TIMESTAMP, + sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18T20:25:00+00:00", "2021-11-18T20:25:00+00:00", False, ), ( - sensor.DEVICE_CLASS_TIMESTAMP, + sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18 20:25:00+00:00", "2021-11-18T20:25:00+00:00", False, ), ( - sensor.DEVICE_CLASS_TIMESTAMP, + sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18 20:25:00+01:00", "2021-11-18T19:25:00+00:00", False, ), - (sensor.DEVICE_CLASS_TIMESTAMP, "invalid", STATE_UNKNOWN, True), + (sensor.SensorDeviceClass.TIMESTAMP, "invalid", STATE_UNKNOWN, True), ], ) async def test_setting_sensor_native_value_handling_via_mqtt_message( @@ -270,6 +277,7 @@ async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock, caplo sensor.DOMAIN: { "platform": "mqtt", "name": "test", + "state_class": "total", "state_topic": "test-topic", "unit_of_measurement": "fav unit", "last_reset_topic": "last-reset-topic", @@ -300,6 +308,7 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( sensor.DOMAIN: { "platform": "mqtt", "name": "test", + "state_class": "total", "state_topic": "test-topic", "unit_of_measurement": "fav unit", "last_reset_topic": "last-reset-topic", @@ -325,6 +334,7 @@ async def test_setting_sensor_empty_last_reset_via_mqtt_message( sensor.DOMAIN: { "platform": "mqtt", "name": "test", + "state_class": "total", "state_topic": "test-topic", "unit_of_measurement": "fav unit", "last_reset_topic": "last-reset-topic", @@ -348,6 +358,7 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): sensor.DOMAIN: { "platform": "mqtt", "name": "test", + "state_class": "total", "state_topic": "test-topic", "unit_of_measurement": "fav unit", "last_reset_topic": "last-reset-topic", @@ -377,6 +388,7 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message_2( **{ "platform": "mqtt", "name": "test", + "state_class": "total", "state_topic": "test-topic", "unit_of_measurement": "kWh", "value_template": "{{ value_json.value | float / 60000 }}", @@ -919,3 +931,120 @@ async def test_value_template_with_entity_id(hass, mqtt_mock): state = hass.states.get("sensor.test") assert state.state == "101" + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = sensor.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +async def test_cleanup_triggers_and_restoring_state( + hass, mqtt_mock, caplog, tmp_path, freezer +): + """Test cleanup old triggers at reloading and restoring the state.""" + domain = sensor.DOMAIN + config1 = copy.deepcopy(DEFAULT_CONFIG[domain]) + config1["name"] = "test1" + config1["expire_after"] = 30 + config1["state_topic"] = "test-topic1" + config2 = copy.deepcopy(DEFAULT_CONFIG[domain]) + config2["name"] = "test2" + config2["expire_after"] = 5 + config2["state_topic"] = "test-topic2" + + freezer.move_to("2022-02-02 12:01:00+01:00") + + assert await async_setup_component( + hass, + domain, + {domain: [config1, config2]}, + ) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "test-topic1", "100") + state = hass.states.get("sensor.test1") + assert state.state == "100" + + async_fire_mqtt_message(hass, "test-topic2", "200") + state = hass.states.get("sensor.test2") + assert state.state == "200" + + freezer.move_to("2022-02-02 12:01:10+01:00") + + await help_test_reload_with_config( + hass, caplog, tmp_path, domain, [config1, config2] + ) + await hass.async_block_till_done() + + assert "Clean up expire after trigger for sensor.test1" in caplog.text + assert "Clean up expire after trigger for sensor.test2" not in caplog.text + assert ( + "State recovered after reload for sensor.test1, remaining time before expiring" + in caplog.text + ) + assert "State recovered after reload for sensor.test2" not in caplog.text + + state = hass.states.get("sensor.test1") + assert state.state == "100" + + state = hass.states.get("sensor.test2") + assert state.state == STATE_UNAVAILABLE + + async_fire_mqtt_message(hass, "test-topic1", "101") + state = hass.states.get("sensor.test1") + assert state.state == "101" + + async_fire_mqtt_message(hass, "test-topic2", "201") + state = hass.states.get("sensor.test2") + assert state.state == "201" + + +async def test_skip_restoring_state_with_over_due_expire_trigger( + hass, mqtt_mock, caplog, freezer +): + """Test restoring a state with over due expire timer.""" + + freezer.move_to("2022-02-02 12:02:00+01:00") + domain = sensor.DOMAIN + config3 = copy.deepcopy(DEFAULT_CONFIG[domain]) + config3["name"] = "test3" + config3["expire_after"] = 10 + config3["state_topic"] = "test-topic3" + fake_state = ha.State( + "sensor.test3", + "300", + {}, + last_changed=datetime.fromisoformat("2022-02-02 12:01:35+01:00"), + ) + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=fake_state, + ), assert_setup_component(1, domain): + assert await async_setup_component(hass, domain, {domain: config3}) + await hass.async_block_till_done() + assert "Skip state recovery after reload for sensor.test3" in caplog.text + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ("state_topic", "2.21", None, "2.21"), + ("state_topic", "beer", None, "beer"), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + sensor.DOMAIN, + DEFAULT_CONFIG[sensor.DOMAIN], + topic, + value, + attribute, + attribute_value, + ) diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index f53f8ebdab3260..5011f279470ec9 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -44,6 +44,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -51,6 +52,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -502,3 +505,125 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2, payload="{}" ) + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + ( + vacuum.SERVICE_START, + "command_topic", + None, + "start", + None, + ), + ( + vacuum.SERVICE_CLEAN_SPOT, + "command_topic", + None, + "clean_spot", + None, + ), + ( + vacuum.SERVICE_SET_FAN_SPEED, + "set_fan_speed_topic", + {"fan_speed": "medium"}, + "medium", + None, + ), + ( + vacuum.SERVICE_SEND_COMMAND, + "send_command_topic", + {"command": "custom command"}, + "custom command", + None, + ), + ( + vacuum.SERVICE_STOP, + "command_topic", + None, + "stop", + None, + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, +): + """Test publishing MQTT payload with different encoding.""" + domain = vacuum.DOMAIN + config = deepcopy(DEFAULT_CONFIG) + config["supported_features"] = [ + "battery", + "clean_spot", + "fan_speed", + "locate", + "pause", + "return_home", + "send_command", + "start", + "status", + "stop", + ] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = vacuum.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ( + "state_topic", + '{"battery_level": 61, "state": "docked", "fan_speed": "off"}', + None, + "docked", + ), + ( + "state_topic", + '{"battery_level": 61, "state": "cleaning", "fan_speed": "medium"}', + None, + "cleaning", + ), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + vacuum.DOMAIN, + DEFAULT_CONFIG, + topic, + value, + attribute, + attribute_value, + skip_raw_test=True, + ) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index a3ef29d0d080b3..9519d7321ffdd9 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -25,6 +25,7 @@ help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -32,6 +33,8 @@ help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_publishing_with_custom_encoding, + help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, @@ -467,3 +470,80 @@ async def test_entity_debug_info_message(hass, mqtt_mock): await help_test_entity_debug_info_message( hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG ) + + +@pytest.mark.parametrize( + "service,topic,parameters,payload,template", + [ + ( + switch.SERVICE_TURN_ON, + "command_topic", + None, + "ON", + None, + ), + ( + switch.SERVICE_TURN_OFF, + "command_topic", + None, + "OFF", + None, + ), + ], +) +async def test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + service, + topic, + parameters, + payload, + template, +): + """Test publishing MQTT payload with different encoding.""" + domain = switch.DOMAIN + config = DEFAULT_CONFIG[domain] + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): + """Test reloading the MQTT platform.""" + domain = switch.DOMAIN + config = DEFAULT_CONFIG[domain] + await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + + +@pytest.mark.parametrize( + "topic,value,attribute,attribute_value", + [ + ("state_topic", "ON", None, "on"), + ], +) +async def test_encoding_subscribable_topics( + hass, mqtt_mock, caplog, topic, value, attribute, attribute_value +): + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock, + caplog, + switch.DOMAIN, + DEFAULT_CONFIG[switch.DOMAIN], + topic, + value, + attribute, + attribute_value, + ) diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index dfc0bddeec40e8..e1f3de83a0d05d 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -5,6 +5,7 @@ import pytest +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.helpers import device_registry as dr from tests.common import ( @@ -609,7 +610,9 @@ async def test_cleanup_device_with_entity_and_trigger_1( device_entry = device_reg.async_get_device({("mqtt", "helloworld")}) assert device_entry is not None - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 3 # 2 binary_sensor triggers + device trigger async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", "") @@ -669,7 +672,9 @@ async def test_cleanup_device_with_entity2(hass, device_reg, entity_reg, mqtt_mo device_entry = device_reg.async_get_device({("mqtt", "helloworld")}) assert device_entry is not None - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 3 # 2 binary_sensor triggers + device trigger async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", "") diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 1843e495801138..6dd7add37e6c9a 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -1,9 +1,9 @@ """Provide common mysensors fixtures.""" from __future__ import annotations -from collections.abc import AsyncGenerator, Generator +from collections.abc import AsyncGenerator, Callable, Generator import json -from typing import Any, Callable +from typing import Any from unittest.mock import MagicMock, patch from mysensors.persistence import MySensorsJSONDecoder diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index d648aebdefd13e..0774d480c98a41 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -1,23 +1,20 @@ """Provide tests for mysensors sensor platform.""" from __future__ import annotations -from typing import Callable +from collections.abc import Callable from mysensors.sensor import Sensor import pytest from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, POWER_WATT, TEMP_CELSIUS, @@ -71,9 +68,9 @@ async def test_power_sensor( assert state assert state.state == "1200" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT async def test_energy_sensor( @@ -88,9 +85,9 @@ async def test_energy_sensor( assert state assert state.state == "18000" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING async def test_sound_sensor( @@ -153,6 +150,6 @@ async def test_temperature_sensor( assert state assert state.state == temperature - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == unit - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT diff --git a/tests/components/nam/fixtures/diagnostics_data.json b/tests/components/nam/fixtures/diagnostics_data.json new file mode 100644 index 00000000000000..d83e5cc9305cff --- /dev/null +++ b/tests/components/nam/fixtures/diagnostics_data.json @@ -0,0 +1,24 @@ +{ + "bme280_humidity": 45.7, + "bme280_pressure": 1011, + "bme280_temperature": 7.6, + "bmp180_pressure": 1032, + "bmp180_temperature": 7.6, + "bmp280_pressure": 1022, + "bmp280_temperature": 5.6, + "dht22_humidity": 46.2, + "dht22_temperature": 6.3, + "heca_humidity": 50.0, + "heca_temperature": 8.0, + "mhz14a_carbon_dioxide": 865, + "sds011_p1": 19, + "sds011_p2": 11, + "sht3x_humidity": 34.7, + "sht3x_temperature": 6.3, + "signal": -72, + "sps30_p0": 31, + "sps30_p1": 21, + "sps30_p2": 34, + "sps30_p4": 25, + "uptime": 456987 +} diff --git a/tests/components/nam/test_diagnostics.py b/tests/components/nam/test_diagnostics.py new file mode 100644 index 00000000000000..ce7b6d59e78b07 --- /dev/null +++ b/tests/components/nam/test_diagnostics.py @@ -0,0 +1,18 @@ +"""Test NAM diagnostics.""" +import json + +from tests.common import load_fixture +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.components.nam import init_integration + + +async def test_entry_diagnostics(hass, hass_client): + """Test config entry diagnostics.""" + entry = await init_integration(hass) + + diagnostics_data = json.loads(load_fixture("diagnostics_data.json", "nam")) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["info"] == {"host": "10.10.2.3"} + assert result["data"] == diagnostics_data diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index aa05930f7274a8..9fbb55a6474a9f 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -8,7 +8,8 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, - STATE_CLASS_MEASUREMENT, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -17,15 +18,6 @@ ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_CO2, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM10, - DEVICE_CLASS_PM25, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, PERCENTAGE, PRESSURE_HPA, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -62,13 +54,16 @@ async def test_sensor(hass): disabled_by=None, ) - await init_integration(hass) + # Patch return value from utcnow, with offset to make sure the patch is correct + now = utcnow() - timedelta(hours=1) + with patch("homeassistant.components.nam.sensor.utcnow", return_value=now): + await init_integration(hass) state = hass.states.get("sensor.nettigo_air_monitor_bme280_humidity") assert state assert state.state == "45.7" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE entry = registry.async_get("sensor.nettigo_air_monitor_bme280_humidity") @@ -78,8 +73,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_bme280_temperature") assert state assert state.state == "7.6" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS entry = registry.async_get("sensor.nettigo_air_monitor_bme280_temperature") @@ -89,8 +84,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_bme280_pressure") assert state assert state.state == "1011" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PRESSURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_HPA entry = registry.async_get("sensor.nettigo_air_monitor_bme280_pressure") @@ -100,8 +95,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_bmp180_temperature") assert state assert state.state == "7.6" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS entry = registry.async_get("sensor.nettigo_air_monitor_bmp180_temperature") @@ -111,8 +106,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_bmp180_pressure") assert state assert state.state == "1032" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PRESSURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_HPA entry = registry.async_get("sensor.nettigo_air_monitor_bmp180_pressure") @@ -122,8 +117,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_bmp280_temperature") assert state assert state.state == "5.6" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS entry = registry.async_get("sensor.nettigo_air_monitor_bmp280_temperature") @@ -133,8 +128,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_bmp280_pressure") assert state assert state.state == "1022" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PRESSURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_HPA entry = registry.async_get("sensor.nettigo_air_monitor_bmp280_pressure") @@ -144,8 +139,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sht3x_humidity") assert state assert state.state == "34.7" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE entry = registry.async_get("sensor.nettigo_air_monitor_sht3x_humidity") @@ -155,8 +150,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sht3x_temperature") assert state assert state.state == "6.3" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS entry = registry.async_get("sensor.nettigo_air_monitor_sht3x_temperature") @@ -166,8 +161,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_dht22_humidity") assert state assert state.state == "46.2" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE entry = registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") @@ -177,8 +172,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_dht22_temperature") assert state assert state.state == "6.3" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS entry = registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") @@ -188,8 +183,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_heca_humidity") assert state assert state.state == "50.0" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE entry = registry.async_get("sensor.nettigo_air_monitor_heca_humidity") @@ -199,8 +194,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_heca_temperature") assert state assert state.state == "8.0" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS entry = registry.async_get("sensor.nettigo_air_monitor_heca_temperature") @@ -210,8 +205,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_signal_strength") assert state assert state.state == "-72" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SIGNAL_STRENGTH - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SIGNAL_STRENGTH + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SIGNAL_STRENGTH_DECIBELS_MILLIWATT @@ -225,9 +220,9 @@ async def test_sensor(hass): assert state assert ( state.state - == (utcnow() - timedelta(seconds=456987)).replace(microsecond=0).isoformat() + == (now - timedelta(seconds=456987)).replace(microsecond=0).isoformat() ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP assert state.attributes.get(ATTR_STATE_CLASS) is None entry = registry.async_get("sensor.nettigo_air_monitor_uptime") @@ -237,8 +232,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sds011_particulate_matter_10") assert state assert state.state == "19" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -253,8 +248,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sds011_particulate_matter_2_5") assert state assert state.state == "11" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -269,8 +264,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_1_0") assert state assert state.state == "31" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM1 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM1 + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -285,8 +280,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_10") assert state assert state.state == "21" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM10 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -299,8 +294,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_2_5") assert state assert state.state == "34" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PM25 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -315,7 +310,7 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_sps30_particulate_matter_4_0") assert state assert state.state == "25" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -331,8 +326,8 @@ async def test_sensor(hass): state = hass.states.get("sensor.nettigo_air_monitor_mh_z14a_carbon_dioxide") assert state assert state.state == "865" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CO2 - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CO2 + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_MILLION @@ -351,7 +346,7 @@ async def test_sensor_disabled(hass): assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-signal" assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity updated_entry = registry.async_update_entity( @@ -369,7 +364,7 @@ async def test_incompleta_data_after_device_restart(hass): state = hass.states.get("sensor.nettigo_air_monitor_heca_temperature") assert state assert state.state == "8.0" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS future = utcnow() + timedelta(minutes=6) diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 0f1d47c687edcc..ca761e8987f150 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -1,9 +1,14 @@ """Common libraries for test setup.""" +from collections.abc import Awaitable, Callable +import copy +from dataclasses import dataclass import time -from typing import Awaitable, Callable +from typing import Any, Generator, TypeVar from unittest.mock import patch +from google_nest_sdm.auth import AbstractAuth +from google_nest_sdm.device import Device from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.event import EventMessage from google_nest_sdm.event_media import CachePolicy @@ -15,17 +20,22 @@ from tests.common import MockConfigEntry +# Typing helpers +PlatformSetup = Callable[[], Awaitable[None]] +T = TypeVar("T") +YieldFixture = Generator[T, None, None] + PROJECT_ID = "some-project-id" CLIENT_ID = "some-client-id" CLIENT_SECRET = "some-client-secret" +SUBSCRIBER_ID = "projects/example/subscriptions/subscriber-id-9876" CONFIG = { "nest": { "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, - # Required fields for using SDM API "project_id": PROJECT_ID, - "subscriber_id": "projects/example/subscriptions/subscriber-id-9876", + "subscriber_id": SUBSCRIBER_ID, }, } @@ -33,24 +43,64 @@ FAKE_REFRESH_TOKEN = "some-refresh-token" -def create_config_entry(hass, token_expiration_time=None) -> MockConfigEntry: - """Create a ConfigEntry and add it to Home Assistant.""" +def create_token_entry(token_expiration_time=None): + """Create OAuth 'token' data for a ConfigEntry.""" if token_expiration_time is None: token_expiration_time = time.time() + 86400 + return { + "access_token": FAKE_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(SDM_SCOPES), + "token_type": "Bearer", + "expires_at": token_expiration_time, + } + + +def create_config_entry(token_expiration_time=None) -> MockConfigEntry: + """Create a ConfigEntry and add it to Home Assistant.""" config_entry_data = { "sdm": {}, # Indicates new SDM API, not legacy API "auth_implementation": "nest", - "token": { - "access_token": FAKE_TOKEN, - "refresh_token": FAKE_REFRESH_TOKEN, - "scope": " ".join(SDM_SCOPES), - "token_type": "Bearer", - "expires_at": token_expiration_time, - }, + "token": create_token_entry(token_expiration_time), } - config_entry = MockConfigEntry(domain=DOMAIN, data=config_entry_data) - config_entry.add_to_hass(hass) - return config_entry + return MockConfigEntry(domain=DOMAIN, data=config_entry_data) + + +@dataclass +class NestTestConfig: + """Holder for integration configuration.""" + + config: dict[str, Any] + config_entry_data: dict[str, Any] + + +# Exercises mode where all configuration is in configuration.yaml +TEST_CONFIG_YAML_ONLY = NestTestConfig( + config=CONFIG, + config_entry_data={ + "sdm": {}, + "auth_implementation": "nest", + "token": create_token_entry(), + }, +) + +# Exercises mode where subscriber id is created in the config flow, but +# all authentication is defined in configuration.yaml +TEST_CONFIG_HYBRID = NestTestConfig( + config={ + "nest": { + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "project_id": PROJECT_ID, + }, + }, + config_entry_data={ + "sdm": {}, + "auth_implementation": "nest", + "token": create_token_entry(), + "subscriber_id": SUBSCRIBER_ID, + }, +) class FakeSubscriber(GoogleNestSubscriber): @@ -95,29 +145,54 @@ async def async_receive_event(self, event_message: EventMessage): await self._device_manager.async_handle_event(event_message) +DEVICE_ID = "enterprise/project-id/devices/device-id" +DEVICE_COMMAND = f"{DEVICE_ID}:executeCommand" + + +class CreateDevice: + """Fixture used for creating devices.""" + + def __init__( + self, + device_manager: DeviceManager, + auth: AbstractAuth, + ) -> None: + """Initialize CreateDevice.""" + self.device_manager = device_manager + self.auth = auth + self.data = {"traits": {}} + + def create( + self, raw_traits: dict[str, Any] = None, raw_data: dict[str, Any] = None + ) -> None: + """Create a new device with the specifeid traits.""" + data = copy.deepcopy(self.data) + data.update(raw_data if raw_data else {}) + data["traits"].update(raw_traits if raw_traits else {}) + self.device_manager.add_device(Device.MakeDevice(data, auth=self.auth)) + + async def async_setup_sdm_platform( - hass, platform, devices={}, structures={}, with_config=True + hass, + platform, + devices={}, ): """Set up the platform and prerequisites.""" - if with_config: - create_config_entry(hass) + create_config_entry().add_to_hass(hass) subscriber = FakeSubscriber() device_manager = await subscriber.async_get_device_manager() if devices: for device in devices.values(): device_manager.add_device(device) - if structures: - for structure in structures.values(): - device_manager.add_structure(structure) + platforms = [] + if platform: + platforms = [platform] with patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", [platform]), patch( + ), patch("homeassistant.components.nest.PLATFORMS", platforms), patch( "homeassistant.components.nest.api.GoogleNestSubscriber", return_value=subscriber, ): assert await async_setup_component(hass, DOMAIN, CONFIG) await hass.async_block_till_done() - # Disabled to reduce setup burden, and enabled manually by tests that - # need to exercise this - subscriber.cache_policy.fetch = False return subscriber diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 988d9d761fee3d..9b060d38fbed7e 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -1,9 +1,38 @@ """Common libraries for test setup.""" +from __future__ import annotations + +from collections.abc import Generator +import copy +import shutil +from typing import Any +from unittest.mock import patch +import uuid import aiohttp +from google_nest_sdm import diagnostics from google_nest_sdm.auth import AbstractAuth +from google_nest_sdm.device_manager import DeviceManager import pytest +from homeassistant.components.nest import DOMAIN +from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + DEVICE_ID, + SUBSCRIBER_ID, + TEST_CONFIG_HYBRID, + TEST_CONFIG_YAML_ONLY, + CreateDevice, + FakeSubscriber, + NestTestConfig, + PlatformSetup, + YieldFixture, +) + +from tests.common import MockConfigEntry + class FakeAuth(AbstractAuth): """A fake implementation of the auth class that records requests. @@ -63,3 +92,154 @@ async def auth(aiohttp_client): app.router.add_post("/", auth.response_handler) auth.client = await aiohttp_client(app) return auth + + +@pytest.fixture(autouse=True) +def cleanup_media_storage(hass): + """Test cleanup, remove any media storage persisted during the test.""" + tmp_path = str(uuid.uuid4()) + with patch("homeassistant.components.nest.media_source.MEDIA_PATH", new=tmp_path): + yield + shutil.rmtree(hass.config.path(tmp_path), ignore_errors=True) + + +@pytest.fixture +def subscriber() -> YieldFixture[FakeSubscriber]: + """Set up the FakeSusbcriber.""" + subscriber = FakeSubscriber() + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + yield subscriber + + +@pytest.fixture +async def device_manager(subscriber: FakeSubscriber) -> DeviceManager: + """Set up the DeviceManager.""" + return await subscriber.async_get_device_manager() + + +@pytest.fixture +async def device_id() -> str: + """Fixture to set default device id used when creating devices.""" + return DEVICE_ID + + +@pytest.fixture +async def device_type() -> str: + """Fixture to set default device type used when creating devices.""" + return "sdm.devices.types.THERMOSTAT" + + +@pytest.fixture +async def device_traits() -> dict[str, Any]: + """Fixture to set default device traits used when creating devices.""" + return {} + + +@pytest.fixture +async def create_device( + device_manager: DeviceManager, + auth: FakeAuth, + device_id: str, + device_type: str, + device_traits: dict[str, Any], +) -> None: + """Fixture for creating devices.""" + factory = CreateDevice(device_manager, auth) + factory.data.update( + { + "name": device_id, + "type": device_type, + "traits": device_traits, + } + ) + return factory + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture +def subscriber_id() -> str: + """Fixture to let tests override subscriber id regardless of configuration type used.""" + return SUBSCRIBER_ID + + +@pytest.fixture( + params=[TEST_CONFIG_YAML_ONLY, TEST_CONFIG_HYBRID], + ids=["yaml-config-only", "hybrid-config"], +) +def nest_test_config(request) -> NestTestConfig: + """Fixture that sets up the configuration used for the test.""" + return request.param + + +@pytest.fixture +def config( + subscriber_id: str | None, nest_test_config: NestTestConfig +) -> dict[str, Any]: + """Fixture that sets up the configuration.yaml for the test.""" + config = copy.deepcopy(nest_test_config.config) + if CONF_SUBSCRIBER_ID in config.get(DOMAIN, {}): + if subscriber_id: + config[DOMAIN][CONF_SUBSCRIBER_ID] = subscriber_id + else: + del config[DOMAIN][CONF_SUBSCRIBER_ID] + return config + + +@pytest.fixture +def config_entry( + subscriber_id: str | None, nest_test_config: NestTestConfig +) -> MockConfigEntry | None: + """Fixture that sets up the ConfigEntry for the test.""" + if nest_test_config.config_entry_data is None: + return None + data = copy.deepcopy(nest_test_config.config_entry_data) + if CONF_SUBSCRIBER_ID in data: + if subscriber_id: + data[CONF_SUBSCRIBER_ID] = subscriber_id + else: + del data[CONF_SUBSCRIBER_ID] + return MockConfigEntry(domain=DOMAIN, data=data) + + +@pytest.fixture +async def setup_base_platform( + hass: HomeAssistant, + platforms: list[str], + config: dict[str, Any], + config_entry: MockConfigEntry | None, +) -> YieldFixture[PlatformSetup]: + """Fixture to setup the integration platform.""" + if config_entry: + config_entry.add_to_hass(hass) + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ), patch("homeassistant.components.nest.PLATFORMS", platforms): + + async def _setup_func() -> bool: + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + yield _setup_func + + +@pytest.fixture +async def setup_platform( + setup_base_platform: PlatformSetup, subscriber: FakeSubscriber +) -> PlatformSetup: + """Fixture to setup the integration platform and subscriber.""" + return setup_base_platform + + +@pytest.fixture(autouse=True) +def reset_diagnostics() -> Generator[None, None, None]: + """Fixture to reset client library diagnostic counters.""" + yield + diagnostics.reset() diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 835edf0c3a2d6a..894fda09a8f4a7 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -39,7 +39,7 @@ async def test_auth(hass, aioclient_mock): """Exercise authentication library creates valid credentials.""" expiration_time = time.time() + 86400 - create_config_entry(hass, expiration_time) + create_config_entry(expiration_time).add_to_hass(hass) # Prepare to capture credentials in API request. Empty payloads just mean # no devices or structures are loaded. @@ -88,7 +88,7 @@ async def test_auth_expired_token(hass, aioclient_mock): """Verify behavior of an expired token.""" expiration_time = time.time() - 86400 - create_config_entry(hass, expiration_time) + create_config_entry(expiration_time).add_to_hass(hass) # Prepare a token refresh response aioclient_mock.post( diff --git a/tests/components/nest/test_camera_sdm.py b/tests/components/nest/test_camera_sdm.py index 1ac1b4ca6f960c..a4539cf9f8172f 100644 --- a/tests/components/nest/test_camera_sdm.py +++ b/tests/components/nest/test_camera_sdm.py @@ -52,6 +52,7 @@ DATETIME_FORMAT = "YY-MM-DDTHH:MM:SS" DOMAIN = "nest" MOTION_EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." +EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." # Tests can assert that image bytes came from an event or was decoded # from the live stream. @@ -69,7 +70,9 @@ def make_motion_event( - event_id: str = MOTION_EVENT_ID, timestamp: datetime.datetime = None + event_id: str = MOTION_EVENT_ID, + event_session_id: str = EVENT_SESSION_ID, + timestamp: datetime.datetime = None, ) -> EventMessage: """Create an EventMessage for a motion event.""" if not timestamp: @@ -82,7 +85,7 @@ def make_motion_event( "name": DEVICE_ID, "events": { "sdm.devices.events.CameraMotion.Motion": { - "eventSessionId": "CjY5Y3VKaTZwR3o4Y19YbTVfMF...", + "eventSessionId": event_session_id, "eventId": event_id, }, }, @@ -397,12 +400,14 @@ async def test_stream_response_already_expired(hass, auth): async def test_camera_removed(hass, auth): - """Test case where entities are removed and stream tokens expired.""" + """Test case where entities are removed and stream tokens revoked.""" subscriber = await async_setup_camera( hass, DEVICE_TRAITS, auth=auth, ) + # Simplify test setup + subscriber.cache_policy.fetch = False assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") @@ -433,6 +438,35 @@ async def test_camera_removed(hass, auth): assert len(hass.states.async_all()) == 0 +async def test_camera_remove_failure(hass, auth): + """Test case where revoking the stream token fails on unload.""" + await async_setup_camera( + hass, + DEVICE_TRAITS, + auth=auth, + ) + + assert len(hass.states.async_all()) == 1 + cam = hass.states.get("camera.my_camera") + assert cam is not None + assert cam.state == STATE_STREAMING + + # Start a stream, exercising cleanup on remove + auth.responses = [ + make_stream_url_response(), + # Stop command will get a failure response + aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR), + ] + stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") + assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" + + # Unload should succeed even if an RPC fails + for config_entry in hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + async def test_refresh_expired_stream_failure(hass, auth): """Tests a failure when refreshing the stream.""" now = utcnow() @@ -596,56 +630,18 @@ async def test_event_image_expired(hass, auth): assert image.content == IMAGE_BYTES_FROM_STREAM -async def test_event_image_becomes_expired(hass, auth): - """Test fallback for an event event image that has been cleaned up on expiration.""" - subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) - assert len(hass.states.async_all()) == 1 - assert hass.states.get("camera.my_camera") - - event_timestamp = utcnow() - await subscriber.async_receive_event(make_motion_event(timestamp=event_timestamp)) - await hass.async_block_till_done() - - auth.responses = [ - # Fake response from API that returns url image - aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), - # Fake response for the image content fetch - aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), - # Image is refetched after being cleared by expiration alarm - aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), - aiohttp.web.Response(body=b"updated image bytes"), - ] - - image = await async_get_image(hass) - assert image.content == IMAGE_BYTES_FROM_EVENT - - # Event image is still valid before expiration - next_update = event_timestamp + datetime.timedelta(seconds=25) - await fire_alarm(hass, next_update) - - image = await async_get_image(hass) - assert image.content == IMAGE_BYTES_FROM_EVENT - - # Fire an alarm well after expiration, removing image from cache - # Note: This test does not override the "now" logic within the underlying - # python library that tracks active events. Instead, it exercises the - # alarm behavior only. That is, the library may still think the event is - # active even though Home Assistant does not due to patching time. - next_update = event_timestamp + datetime.timedelta(seconds=180) - await fire_alarm(hass, next_update) - - image = await async_get_image(hass) - assert image.content == b"updated image bytes" - - async def test_multiple_event_images(hass, auth): """Test fallback for an event event image that has been cleaned up on expiration.""" subscriber = await async_setup_camera(hass, DEVICE_TRAITS, auth=auth) + # Simplify test setup + subscriber.cache_policy.fetch = False assert len(hass.states.async_all()) == 1 assert hass.states.get("camera.my_camera") event_timestamp = utcnow() - await subscriber.async_receive_event(make_motion_event(timestamp=event_timestamp)) + await subscriber.async_receive_event( + make_motion_event(event_session_id="event-session-1", timestamp=event_timestamp) + ) await hass.async_block_till_done() auth.responses = [ @@ -663,7 +659,11 @@ async def test_multiple_event_images(hass, auth): next_event_timestamp = event_timestamp + datetime.timedelta(seconds=25) await subscriber.async_receive_event( - make_motion_event(event_id="updated-event-id", timestamp=next_event_timestamp) + make_motion_event( + event_id="updated-event-id", + event_session_id="event-session-2", + timestamp=next_event_timestamp, + ) ) await hass.async_block_till_done() diff --git a/tests/components/nest/test_climate_sdm.py b/tests/components/nest/test_climate_sdm.py index 888227b9cdeb64..6b100969ea97a8 100644 --- a/tests/components/nest/test_climate_sdm.py +++ b/tests/components/nest/test_climate_sdm.py @@ -5,7 +5,10 @@ pubsub subscriber. """ -from google_nest_sdm.device import Device +from collections.abc import Awaitable, Callable +from typing import Any + +from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.event import EventMessage import pytest @@ -21,6 +24,7 @@ ATTR_TARGET_TEMP_LOW, CURRENT_HVAC_COOL, CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, FAN_LOW, FAN_OFF, @@ -36,49 +40,83 @@ PRESET_SLEEP, ) from homeassistant.const import ATTR_TEMPERATURE - -from .common import async_setup_sdm_platform +from homeassistant.core import HomeAssistant + +from .common import ( + DEVICE_COMMAND, + DEVICE_ID, + CreateDevice, + FakeSubscriber, + PlatformSetup, +) +from .conftest import FakeAuth from tests.components.climate import common -PLATFORM = "climate" +CreateEvent = Callable[[dict[str, Any]], Awaitable[None]] + +EVENT_ID = "some-event-id" + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return ["climate"] -async def setup_climate(hass, raw_traits=None, auth=None): - """Load Nest climate devices.""" - devices = None - if raw_traits: - traits = raw_traits - traits["sdm.devices.traits.Info"] = {"customName": "My Thermostat"} - devices = { - "some-device-id": Device.MakeDevice( +@pytest.fixture +def device_traits() -> dict[str, Any]: + """Fixture that sets default traits used for devices.""" + return {"sdm.devices.traits.Info": {"customName": "My Thermostat"}} + + +@pytest.fixture +async def create_event( + hass: HomeAssistant, + auth: AbstractAuth, + subscriber: FakeSubscriber, +) -> CreateEvent: + """Fixture to send a pub/sub event.""" + + async def create_event(traits: dict[str, Any]) -> None: + await subscriber.async_receive_event( + EventMessage( { - "name": "some-device-id", - "type": "sdm.devices.types.Thermostat", - "traits": traits, + "eventId": EVENT_ID, + "timestamp": "2019-01-01T00:00:01Z", + "resourceUpdate": { + "name": DEVICE_ID, + "traits": traits, + }, }, auth=auth, - ), - } - return await async_setup_sdm_platform(hass, PLATFORM, devices) + ) + ) + await hass.async_block_till_done() + + return create_event -async def test_no_devices(hass): +async def test_no_devices(hass: HomeAssistant, setup_platform: PlatformSetup) -> None: """Test no devices returned by the api.""" - await setup_climate(hass) + await setup_platform() assert len(hass.states.async_all()) == 0 -async def test_climate_devices(hass): +async def test_climate_devices( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +) -> None: """Test no eligible climate devices returned by the api.""" - await setup_climate(hass, {"sdm.devices.traits.CameraImage": {}}) + create_device.create({"sdm.devices.traits.CameraImage": {}}) + await setup_platform() assert len(hass.states.async_all()) == 0 -async def test_thermostat_off(hass): +async def test_thermostat_off( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +): """Test a thermostat that is not running.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { @@ -90,6 +128,7 @@ async def test_thermostat_off(hass): }, }, ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -112,10 +151,11 @@ async def test_thermostat_off(hass): assert ATTR_FAN_MODES not in thermostat.attributes -async def test_thermostat_heat(hass): +async def test_thermostat_heat( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +): """Test a thermostat that is heating.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": { "status": "HEATING", @@ -132,6 +172,7 @@ async def test_thermostat_heat(hass): }, }, ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -152,10 +193,11 @@ async def test_thermostat_heat(hass): assert ATTR_PRESET_MODES not in thermostat.attributes -async def test_thermostat_cool(hass): +async def test_thermostat_cool( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +): """Test a thermostat that is cooling.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": { "status": "COOLING", @@ -172,6 +214,7 @@ async def test_thermostat_cool(hass): }, }, ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -192,10 +235,11 @@ async def test_thermostat_cool(hass): assert ATTR_PRESET_MODES not in thermostat.attributes -async def test_thermostat_heatcool(hass): +async def test_thermostat_heatcool( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +): """Test a thermostat that is cooling in heatcool mode.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": { "status": "COOLING", @@ -213,6 +257,7 @@ async def test_thermostat_heatcool(hass): }, }, ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -233,10 +278,11 @@ async def test_thermostat_heatcool(hass): assert ATTR_PRESET_MODES not in thermostat.attributes -async def test_thermostat_eco_off(hass): +async def test_thermostat_eco_off( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +) -> None: """Test a thermostat cooling with eco off.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": { "status": "COOLING", @@ -260,6 +306,7 @@ async def test_thermostat_eco_off(hass): }, }, ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -280,10 +327,11 @@ async def test_thermostat_eco_off(hass): assert thermostat.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_NONE] -async def test_thermostat_eco_on(hass): +async def test_thermostat_eco_on( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +) -> None: """Test a thermostat in eco mode.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": { "status": "COOLING", @@ -307,6 +355,7 @@ async def test_thermostat_eco_on(hass): }, }, ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -327,10 +376,11 @@ async def test_thermostat_eco_on(hass): assert thermostat.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_NONE] -async def test_thermostat_eco_heat_only(hass): +async def test_thermostat_eco_heat_only( + hass: HomeAssistant, setup_platform: PlatformSetup, create_device: CreateDevice +) -> None: """Test a thermostat in eco mode that only supports heat.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": { "status": "OFF", @@ -351,12 +401,13 @@ async def test_thermostat_eco_heat_only(hass): "sdm.devices.traits.ThermostatTemperatureSetpoint": {}, }, ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None assert thermostat.state == HVAC_MODE_HEAT - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 29.9 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, @@ -369,19 +420,24 @@ async def test_thermostat_eco_heat_only(hass): assert thermostat.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_NONE] -async def test_thermostat_set_hvac_mode(hass, auth): +async def test_thermostat_set_hvac_mode( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, + create_event: CreateEvent, +) -> None: """Test a thermostat changing hvac modes.""" - subscriber = await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], "mode": "OFF", }, - }, - auth=auth, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -393,7 +449,7 @@ async def test_thermostat_set_hvac_mode(hass, auth): await hass.async_block_till_done() assert auth.method == "post" - assert auth.url == "some-device-id:executeCommand" + assert auth.url == DEVICE_COMMAND assert auth.json == { "command": "sdm.devices.commands.ThermostatMode.SetMode", "params": {"mode": "HEAT"}, @@ -406,48 +462,28 @@ async def test_thermostat_set_hvac_mode(hass, auth): assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF # Simulate pubsub message when mode changes - event = EventMessage( + await create_event( { - "eventId": "some-event-id", - "timestamp": "2019-01-01T00:00:01Z", - "resourceUpdate": { - "name": "some-device-id", - "traits": { - "sdm.devices.traits.ThermostatMode": { - "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "HEAT", - }, - }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEAT", }, - }, - auth=None, + } ) - await subscriber.async_receive_event(event) - await hass.async_block_till_done() # Process dispatch/update signal thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None assert thermostat.state == HVAC_MODE_HEAT - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE # Simulate pubsub message when the thermostat starts heating - event = EventMessage( + await create_event( { - "eventId": "some-event-id", - "timestamp": "2019-01-01T00:00:01Z", - "resourceUpdate": { - "name": "some-device-id", - "traits": { - "sdm.devices.traits.ThermostatHvac": { - "status": "HEATING", - }, - }, + "sdm.devices.traits.ThermostatHvac": { + "status": "HEATING", }, - }, - auth=None, + } ) - await subscriber.async_receive_event(event) - await hass.async_block_till_done() # Process dispatch/update signal thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None @@ -455,19 +491,23 @@ async def test_thermostat_set_hvac_mode(hass, auth): assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT -async def test_thermostat_invalid_hvac_mode(hass, auth): +async def test_thermostat_invalid_hvac_mode( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: """Test setting an hvac_mode that is not supported.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], "mode": "OFF", }, - }, - auth=auth, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -483,10 +523,15 @@ async def test_thermostat_invalid_hvac_mode(hass, auth): assert auth.method is None # No communication with API -async def test_thermostat_set_eco_preset(hass, auth): +async def test_thermostat_set_eco_preset( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, + create_event: CreateEvent, +) -> None: """Test a thermostat put into eco mode.""" - subscriber = await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatEco": { @@ -499,9 +544,9 @@ async def test_thermostat_set_eco_preset(hass, auth): "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], "mode": "OFF", }, - }, - auth=auth, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -515,7 +560,7 @@ async def test_thermostat_set_eco_preset(hass, auth): await hass.async_block_till_done() assert auth.method == "post" - assert auth.url == "some-device-id:executeCommand" + assert auth.url == DEVICE_COMMAND assert auth.json == { "command": "sdm.devices.commands.ThermostatEco.SetMode", "params": {"mode": "MANUAL_ECO"}, @@ -529,26 +574,16 @@ async def test_thermostat_set_eco_preset(hass, auth): assert thermostat.attributes[ATTR_PRESET_MODE] == PRESET_NONE # Simulate pubsub message when mode changes - event = EventMessage( + await create_event( { - "eventId": "some-event-id", - "timestamp": "2019-01-01T00:00:01Z", - "resourceUpdate": { - "name": "some-device-id", - "traits": { - "sdm.devices.traits.ThermostatEco": { - "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "MANUAL_ECO", - "heatCelsius": 15.0, - "coolCelsius": 28.0, - }, - }, + "sdm.devices.traits.ThermostatEco": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "MANUAL_ECO", + "heatCelsius": 15.0, + "coolCelsius": 28.0, }, - }, - auth=auth, + } ) - await subscriber.async_receive_event(event) - await hass.async_block_till_done() # Process dispatch/update signal thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None @@ -561,17 +596,21 @@ async def test_thermostat_set_eco_preset(hass, auth): await hass.async_block_till_done() assert auth.method == "post" - assert auth.url == "some-device-id:executeCommand" + assert auth.url == DEVICE_COMMAND assert auth.json == { "command": "sdm.devices.commands.ThermostatEco.SetMode", "params": {"mode": "OFF"}, } -async def test_thermostat_set_cool(hass, auth): +async def test_thermostat_set_cool( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: """Test a thermostat in cool mode with a temperature change.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { @@ -582,8 +621,8 @@ async def test_thermostat_set_cool(hass, auth): "coolCelsius": 25.0, }, }, - auth=auth, ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -594,17 +633,21 @@ async def test_thermostat_set_cool(hass, auth): await hass.async_block_till_done() assert auth.method == "post" - assert auth.url == "some-device-id:executeCommand" + assert auth.url == DEVICE_COMMAND assert auth.json == { "command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetCool", "params": {"coolCelsius": 24.0}, } -async def test_thermostat_set_heat(hass, auth): +async def test_thermostat_set_heat( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: """Test a thermostat heating mode with a temperature change.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { @@ -614,9 +657,9 @@ async def test_thermostat_set_heat(hass, auth): "sdm.devices.traits.ThermostatTemperatureSetpoint": { "heatCelsius": 19.0, }, - }, - auth=auth, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -627,17 +670,21 @@ async def test_thermostat_set_heat(hass, auth): await hass.async_block_till_done() assert auth.method == "post" - assert auth.url == "some-device-id:executeCommand" + assert auth.url == DEVICE_COMMAND assert auth.json == { "command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetHeat", "params": {"heatCelsius": 20.0}, } -async def test_thermostat_set_heat_cool(hass, auth): +async def test_thermostat_set_heat_cool( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: """Test a thermostat in heatcool mode with a temperature change.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { @@ -648,9 +695,9 @@ async def test_thermostat_set_heat_cool(hass, auth): "heatCelsius": 19.0, "coolCelsius": 25.0, }, - }, - auth=auth, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -663,17 +710,20 @@ async def test_thermostat_set_heat_cool(hass, auth): await hass.async_block_till_done() assert auth.method == "post" - assert auth.url == "some-device-id:executeCommand" + assert auth.url == DEVICE_COMMAND assert auth.json == { "command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetRange", "params": {"heatCelsius": 20.0, "coolCelsius": 24.0}, } -async def test_thermostat_fan_off(hass): +async def test_thermostat_fan_off( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: """Test a thermostat with the fan not running.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.Fan": { "timerMode": "OFF", @@ -687,8 +737,9 @@ async def test_thermostat_fan_off(hass): "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, }, - }, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -707,10 +758,13 @@ async def test_thermostat_fan_off(hass): assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] -async def test_thermostat_fan_on(hass): +async def test_thermostat_fan_on( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: """Test a thermostat with the fan running.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.Fan": { "timerMode": "ON", @@ -726,14 +780,15 @@ async def test_thermostat_fan_on(hass): "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, }, - }, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None assert thermostat.state == HVAC_MODE_FAN_ONLY - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, @@ -746,10 +801,13 @@ async def test_thermostat_fan_on(hass): assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] -async def test_thermostat_cool_with_fan(hass): +async def test_thermostat_cool_with_fan( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: """Test a thermostat cooling while the fan is on.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.Fan": { "timerMode": "ON", @@ -764,12 +822,13 @@ async def test_thermostat_cool_with_fan(hass): }, }, ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None assert thermostat.state == HVAC_MODE_COOL - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, HVAC_MODE_COOL, @@ -781,10 +840,14 @@ async def test_thermostat_cool_with_fan(hass): assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] -async def test_thermostat_set_fan(hass, auth): +async def test_thermostat_set_fan( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: """Test a thermostat enabling the fan.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.Fan": { "timerMode": "ON", @@ -797,9 +860,9 @@ async def test_thermostat_set_fan(hass, auth): "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], "mode": "OFF", }, - }, - auth=auth, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -813,7 +876,7 @@ async def test_thermostat_set_fan(hass, auth): await hass.async_block_till_done() assert auth.method == "post" - assert auth.url == "some-device-id:executeCommand" + assert auth.url == DEVICE_COMMAND assert auth.json == { "command": "sdm.devices.commands.Fan.SetTimer", "params": {"timerMode": "OFF"}, @@ -824,7 +887,7 @@ async def test_thermostat_set_fan(hass, auth): await hass.async_block_till_done() assert auth.method == "post" - assert auth.url == "some-device-id:executeCommand" + assert auth.url == DEVICE_COMMAND assert auth.json == { "command": "sdm.devices.commands.Fan.SetTimer", "params": { @@ -834,10 +897,13 @@ async def test_thermostat_set_fan(hass, auth): } -async def test_thermostat_fan_empty(hass): +async def test_thermostat_fan_empty( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: """Test a fan trait with an empty response.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.Fan": {}, "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, @@ -848,8 +914,9 @@ async def test_thermostat_fan_empty(hass): "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, }, - }, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -874,10 +941,13 @@ async def test_thermostat_fan_empty(hass): assert ATTR_FAN_MODES not in thermostat.attributes -async def test_thermostat_invalid_fan_mode(hass): +async def test_thermostat_invalid_fan_mode( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: """Test setting a fan mode that is not supported.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.Fan": { "timerMode": "ON", @@ -891,14 +961,15 @@ async def test_thermostat_invalid_fan_mode(hass): "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, }, - }, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None assert thermostat.state == HVAC_MODE_FAN_ONLY - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, @@ -915,10 +986,14 @@ async def test_thermostat_invalid_fan_mode(hass): await hass.async_block_till_done() -async def test_thermostat_set_hvac_fan_only(hass, auth): +async def test_thermostat_set_hvac_fan_only( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: """Test a thermostat enabling the fan via hvac_mode.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.Fan": { "timerMode": "OFF", @@ -931,9 +1006,9 @@ async def test_thermostat_set_hvac_fan_only(hass, auth): "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], "mode": "OFF", }, - }, - auth=auth, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -949,24 +1024,28 @@ async def test_thermostat_set_hvac_fan_only(hass, auth): (method, url, json, headers) = auth.captured_requests.pop(0) assert method == "post" - assert url == "some-device-id:executeCommand" + assert url == DEVICE_COMMAND assert json == { "command": "sdm.devices.commands.Fan.SetTimer", "params": {"duration": "43200s", "timerMode": "ON"}, } (method, url, json, headers) = auth.captured_requests.pop(0) assert method == "post" - assert url == "some-device-id:executeCommand" + assert url == DEVICE_COMMAND assert json == { "command": "sdm.devices.commands.ThermostatMode.SetMode", "params": {"mode": "OFF"}, } -async def test_thermostat_target_temp(hass, auth): +async def test_thermostat_target_temp( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, + create_event: CreateEvent, +) -> None: """Test a thermostat changing hvac modes and affected on target temps.""" - subscriber = await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": { "status": "HEATING", @@ -981,9 +1060,9 @@ async def test_thermostat_target_temp(hass, auth): "sdm.devices.traits.ThermostatTemperatureSetpoint": { "heatCelsius": 23.0, }, - }, - auth=auth, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -994,28 +1073,18 @@ async def test_thermostat_target_temp(hass, auth): assert thermostat.attributes[ATTR_TARGET_TEMP_HIGH] is None # Simulate pubsub message changing modes - event = EventMessage( + await create_event( { - "eventId": "some-event-id", - "timestamp": "2019-01-01T00:00:01Z", - "resourceUpdate": { - "name": "some-device-id", - "traits": { - "sdm.devices.traits.ThermostatMode": { - "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "HEATCOOL", - }, - "sdm.devices.traits.ThermostatTemperatureSetpoint": { - "heatCelsius": 22.0, - "coolCelsius": 28.0, - }, - }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEATCOOL", }, - }, - auth=None, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "heatCelsius": 22.0, + "coolCelsius": 28.0, + }, + } ) - await subscriber.async_receive_event(event) - await hass.async_block_till_done() # Process dispatch/update signal thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None @@ -1025,14 +1094,18 @@ async def test_thermostat_target_temp(hass, auth): assert thermostat.attributes[ATTR_TEMPERATURE] is None -async def test_thermostat_missing_mode_traits(hass): +async def test_thermostat_missing_mode_traits( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: """Test a thermostat missing many thermostat traits in api response.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, - }, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -1058,24 +1131,28 @@ async def test_thermostat_missing_mode_traits(hass): assert ATTR_PRESET_MODE not in thermostat.attributes -async def test_thermostat_missing_temperature_trait(hass): +async def test_thermostat_missing_temperature_trait( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: """Test a thermostat missing many thermostat traits in api response.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], "mode": "HEAT", }, - }, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None assert thermostat.state == HVAC_MODE_HEAT - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] is None assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, @@ -1096,14 +1173,18 @@ async def test_thermostat_missing_temperature_trait(hass): assert thermostat.attributes[ATTR_TEMPERATURE] is None -async def test_thermostat_unexpected_hvac_status(hass): +async def test_thermostat_unexpected_hvac_status( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: """Test a thermostat missing many thermostat traits in api response.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "UNEXPECTED"}, - }, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -1126,10 +1207,13 @@ async def test_thermostat_unexpected_hvac_status(hass): assert thermostat.state == HVAC_MODE_OFF -async def test_thermostat_missing_set_point(hass): +async def test_thermostat_missing_set_point( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: """Test a thermostat missing many thermostat traits in api response.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { @@ -1138,12 +1222,13 @@ async def test_thermostat_missing_set_point(hass): }, }, ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None assert thermostat.state == HVAC_MODE_HEAT_COOL - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] is None assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, @@ -1160,18 +1245,22 @@ async def test_thermostat_missing_set_point(hass): assert ATTR_FAN_MODES not in thermostat.attributes -async def test_thermostat_unexepected_hvac_mode(hass): +async def test_thermostat_unexepected_hvac_mode( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: """Test a thermostat missing many thermostat traits in api response.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF", "UNEXPECTED"], "mode": "UNEXPECTED", }, - }, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") @@ -1194,10 +1283,14 @@ async def test_thermostat_unexepected_hvac_mode(hass): assert ATTR_FAN_MODES not in thermostat.attributes -async def test_thermostat_invalid_set_preset_mode(hass, auth): +async def test_thermostat_invalid_set_preset_mode( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: """Test a thermostat set with an invalid preset mode.""" - await setup_climate( - hass, + create_device.create( { "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatEco": { @@ -1206,9 +1299,9 @@ async def test_thermostat_invalid_set_preset_mode(hass, auth): "heatCelsius": 15.0, "coolCelsius": 28.0, }, - }, - auth=auth, + } ) + await setup_platform() assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") diff --git a/tests/components/nest/test_config_flow_legacy.py b/tests/components/nest/test_config_flow_legacy.py index ed4df2c7d84a41..d21920b9e6f107 100644 --- a/tests/components/nest/test_config_flow_legacy.py +++ b/tests/components/nest/test_config_flow_legacy.py @@ -1,193 +1,227 @@ """Tests for the Nest config flow.""" import asyncio -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import patch -from homeassistant import data_entry_flow +from homeassistant import config_entries, data_entry_flow from homeassistant.components.nest import DOMAIN, config_flow from homeassistant.setup import async_setup_component -from tests.common import mock_coro +from tests.common import MockConfigEntry + +CONFIG = {DOMAIN: {"client_id": "bla", "client_secret": "bla"}} async def test_abort_if_no_implementation_registered(hass): """Test we abort if no implementation is registered.""" - flow = config_flow.NestFlowHandler() - flow.hass = hass - result = await flow.async_step_init() - + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "missing_configuration" async def test_abort_if_single_instance_allowed(hass): """Test we abort if Nest is already setup.""" - flow = config_flow.NestFlowHandler() - flow.hass = hass + existing_entry = MockConfigEntry(domain=DOMAIN, data={}) + existing_entry.add_to_hass(hass) - with patch.object(hass.config_entries, "async_entries", return_value=[{}]): - result = await flow.async_step_init() + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "single_instance_allowed" async def test_full_flow_implementation(hass): """Test registering an implementation and finishing flow works.""" - gen_authorize_url = AsyncMock(return_value="https://example.com") - convert_code = AsyncMock(return_value={"access_token": "yoo"}) - config_flow.register_flow_implementation( - hass, "test", "Test", gen_authorize_url, convert_code - ) + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() + # Register an additional implementation to select from during the flow config_flow.register_flow_implementation( hass, "test-other", "Test Other", None, None ) - flow = config_flow.NestFlowHandler() - flow.hass = hass - result = await flow.async_step_init() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" - result = await flow.async_step_init({"flow_impl": "test"}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"flow_impl": "nest"}, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" - assert result["description_placeholders"] == {"url": "https://example.com"} + assert ( + result["description_placeholders"] + .get("url") + .startswith("https://home.nest.com/login/oauth2?client_id=bla") + ) - result = await flow.async_step_link({"code": "123ABC"}) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"]["tokens"] == {"access_token": "yoo"} - assert result["data"]["impl_domain"] == "test" - assert result["title"] == "Nest (via Test)" + def mock_login(auth): + assert auth.pin == "123ABC" + auth.auth_callback({"access_token": "yoo"}) + + with patch( + "homeassistant.components.nest.legacy.local_auth.NestAuth.login", new=mock_login + ), patch( + "homeassistant.components.nest.async_setup_legacy_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123ABC"} + ) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["tokens"] == {"access_token": "yoo"} + assert result["data"]["impl_domain"] == "nest" + assert result["title"] == "Nest (via configuration.yaml)" async def test_not_pick_implementation_if_only_one(hass): - """Test we allow picking implementation if we have two.""" - gen_authorize_url = AsyncMock(return_value="https://example.com") - config_flow.register_flow_implementation( - hass, "test", "Test", gen_authorize_url, None - ) + """Test we pick the default implementation when registered.""" + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() - flow = config_flow.NestFlowHandler() - flow.hass = hass - result = await flow.async_step_init() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" async def test_abort_if_timeout_generating_auth_url(hass): """Test we abort if generating authorize url fails.""" - gen_authorize_url = Mock(side_effect=asyncio.TimeoutError) - config_flow.register_flow_implementation( - hass, "test", "Test", gen_authorize_url, None - ) + with patch( + "homeassistant.components.nest.legacy.local_auth.generate_auth_url", + side_effect=asyncio.TimeoutError, + ): + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() - flow = config_flow.NestFlowHandler() - flow.hass = hass - result = await flow.async_step_init() - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "authorize_url_timeout" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "authorize_url_timeout" async def test_abort_if_exception_generating_auth_url(hass): """Test we abort if generating authorize url blows up.""" - gen_authorize_url = Mock(side_effect=ValueError) - config_flow.register_flow_implementation( - hass, "test", "Test", gen_authorize_url, None - ) + with patch( + "homeassistant.components.nest.legacy.local_auth.generate_auth_url", + side_effect=ValueError, + ): + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() - flow = config_flow.NestFlowHandler() - flow.hass = hass - result = await flow.async_step_init() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "unknown_authorize_url_generation" async def test_verify_code_timeout(hass): """Test verify code timing out.""" - gen_authorize_url = AsyncMock(return_value="https://example.com") - convert_code = Mock(side_effect=asyncio.TimeoutError) - config_flow.register_flow_implementation( - hass, "test", "Test", gen_authorize_url, convert_code - ) + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() - flow = config_flow.NestFlowHandler() - flow.hass = hass - result = await flow.async_step_init() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" - result = await flow.async_step_link({"code": "123ABC"}) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "link" - assert result["errors"] == {"code": "timeout"} + with patch( + "homeassistant.components.nest.legacy.local_auth.NestAuth.login", + side_effect=asyncio.TimeoutError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123ABC"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "link" + assert result["errors"] == {"code": "timeout"} async def test_verify_code_invalid(hass): """Test verify code invalid.""" - gen_authorize_url = AsyncMock(return_value="https://example.com") - convert_code = Mock(side_effect=config_flow.CodeInvalid) - config_flow.register_flow_implementation( - hass, "test", "Test", gen_authorize_url, convert_code - ) + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() - flow = config_flow.NestFlowHandler() - flow.hass = hass - result = await flow.async_step_init() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" - result = await flow.async_step_link({"code": "123ABC"}) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "link" - assert result["errors"] == {"code": "invalid_pin"} + with patch( + "homeassistant.components.nest.legacy.local_auth.NestAuth.login", + side_effect=config_flow.CodeInvalid, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123ABC"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "link" + assert result["errors"] == {"code": "invalid_pin"} async def test_verify_code_unknown_error(hass): """Test verify code unknown error.""" - gen_authorize_url = AsyncMock(return_value="https://example.com") - convert_code = Mock(side_effect=config_flow.NestAuthError) - config_flow.register_flow_implementation( - hass, "test", "Test", gen_authorize_url, convert_code - ) + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() - flow = config_flow.NestFlowHandler() - flow.hass = hass - result = await flow.async_step_init() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" - result = await flow.async_step_link({"code": "123ABC"}) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "link" - assert result["errors"] == {"code": "unknown"} + with patch( + "homeassistant.components.nest.legacy.local_auth.NestAuth.login", + side_effect=config_flow.NestAuthError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123ABC"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "link" + assert result["errors"] == {"code": "unknown"} async def test_verify_code_exception(hass): """Test verify code blows up.""" - gen_authorize_url = AsyncMock(return_value="https://example.com") - convert_code = Mock(side_effect=ValueError) - config_flow.register_flow_implementation( - hass, "test", "Test", gen_authorize_url, convert_code - ) + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() - flow = config_flow.NestFlowHandler() - flow.hass = hass - result = await flow.async_step_init() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" - result = await flow.async_step_link({"code": "123ABC"}) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "link" - assert result["errors"] == {"code": "internal_error"} + with patch( + "homeassistant.components.nest.legacy.local_auth.NestAuth.login", + side_effect=ValueError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"code": "123ABC"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "link" + assert result["errors"] == {"code": "internal_error"} async def test_step_import(hass): """Test that we trigger import when configuring with client.""" with patch("os.path.isfile", return_value=False): - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {"client_id": "bla", "client_secret": "bla"}} - ) + assert await async_setup_component(hass, DOMAIN, CONFIG) await hass.async_block_till_done() flow = hass.config_entries.flow.async_progress()[0] @@ -203,12 +237,11 @@ async def test_step_import_with_token_cache(hass): "homeassistant.components.nest.config_flow.load_json", return_value={"access_token": "yo"}, ), patch( - "homeassistant.components.nest.async_setup_entry", return_value=mock_coro(True) - ): - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {"client_id": "bla", "client_secret": "bla"}} - ) + "homeassistant.components.nest.async_setup_legacy_entry", return_value=True + ) as mock_setup: + assert await async_setup_component(hass, DOMAIN, CONFIG) await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 entry = hass.config_entries.async_entries(DOMAIN)[0] diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index d4af62cb255f50..ab769d4b57cf2d 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -1,16 +1,18 @@ """Test the Google Nest Device Access config flow.""" import copy -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from google_nest_sdm.exceptions import ( AuthException, ConfigurationException, - GoogleNestException, + SubscriberException, ) +from google_nest_sdm.structure import Structure import pytest from homeassistant import config_entries, setup +from homeassistant.components import dhcp from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET @@ -42,6 +44,11 @@ APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" +FAKE_DHCP_DATA = dhcp.DhcpServiceInfo( + ip="127.0.0.2", macaddress="00:11:22:33:44:55", hostname="fake_hostname" +) + + @pytest.fixture def subscriber() -> FakeSubscriber: """Create FakeSubscriber.""" @@ -80,9 +87,7 @@ async def async_pick_flow(self, result: dict, auth_domain: str) -> dict: assert result["type"] == "form" assert result["step_id"] == "pick_implementation" - return await self.hass.config_entries.flow.async_configure( - result["flow_id"], {"implementation": auth_domain} - ) + return await self.async_configure(result, {"implementation": auth_domain}) async def async_oauth_web_flow(self, result: dict) -> None: """Invoke the oauth flow for Web Auth with fake responses.""" @@ -169,9 +174,7 @@ async def async_finish_setup( with patch( "homeassistant.components.nest.async_setup_entry", return_value=True ) as mock_setup: - await self.hass.config_entries.flow.async_configure( - result["flow_id"], user_input - ) + await self.async_configure(result, user_input) assert len(mock_setup.mock_calls) == 1 await self.hass.async_block_till_done() return self.get_config_entry() @@ -436,6 +439,41 @@ async def test_pubsub_subscription(hass, oauth, subscriber): assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID +async def test_pubsub_subscription_strip_whitespace(hass, oauth, subscriber): + """Check that project id has whitespace stripped on entry.""" + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": " " + CLOUD_PROJECT_ID + " "} + ) + await hass.async_block_till_done() + + assert entry.title == "OAuth for Apps" + assert "token" in entry.data + entry.data["token"].pop("expires_at") + assert entry.unique_id == DOMAIN + assert entry.data["token"] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + } + assert "subscriber_id" in entry.data + assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + + async def test_pubsub_subscription_auth_failure(hass, oauth): """Check flow that creates a pub/sub subscription.""" assert await async_setup_configflow(hass) @@ -473,7 +511,7 @@ async def test_pubsub_subscription_failure(hass, oauth): await oauth.async_pubsub_flow(result) with patch( "homeassistant.components.nest.api.GoogleNestSubscriber.create_subscription", - side_effect=GoogleNestException(), + side_effect=SubscriberException(), ): result = await oauth.async_configure( result, {"cloud_project_id": CLOUD_PROJECT_ID} @@ -542,7 +580,7 @@ async def test_pubsub_subscriber_config_entry_reauth(hass, oauth, subscriber): hass, { "auth_implementation": APP_AUTH_DOMAIN, - "subscription_id": SUBSCRIBER_ID, + "subscriber_id": SUBSCRIBER_ID, "cloud_project_id": CLOUD_PROJECT_ID, "token": { "access_token": "some-revoked-token", @@ -552,22 +590,9 @@ async def test_pubsub_subscriber_config_entry_reauth(hass, oauth, subscriber): ) result = await oauth.async_reauth(old_entry.data) await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - - # Configure Pub/Sub - await oauth.async_pubsub_flow(result, cloud_project_id=CLOUD_PROJECT_ID) - - # Verify existing tokens are replaced - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": "other-cloud-project-id"} - ) - await hass.async_block_till_done() - entry = oauth.get_config_entry() + # Entering an updated access token refreshs the config entry. + entry = await oauth.async_finish_setup(result, {"code": "1234"}) entry.data["token"].pop("expires_at") assert entry.unique_id == DOMAIN assert entry.data["token"] == { @@ -577,7 +602,205 @@ async def test_pubsub_subscriber_config_entry_reauth(hass, oauth, subscriber): "expires_in": 60, } assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN - assert ( - "projects/other-cloud-project-id/subscriptions" in entry.data["subscriber_id"] + assert entry.data["subscriber_id"] == SUBSCRIBER_ID + assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + + +async def test_config_entry_title_from_home(hass, oauth, subscriber): + """Test that the Google Home name is used for the config entry title.""" + + device_manager = await subscriber.async_get_device_manager() + device_manager.add_structure( + Structure.MakeStructure( + { + "name": f"enterprise/{PROJECT_ID}/structures/some-structure-id", + "traits": { + "sdm.structures.traits.Info": { + "customName": "Example Home", + }, + }, + } + ) + ) + + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + assert entry.title == "Example Home" + assert "token" in entry.data + assert "subscriber_id" in entry.data + assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + + +async def test_config_entry_title_multiple_homes(hass, oauth, subscriber): + """Test handling of multiple Google Homes authorized.""" + + device_manager = await subscriber.async_get_device_manager() + device_manager.add_structure( + Structure.MakeStructure( + { + "name": f"enterprise/{PROJECT_ID}/structures/id-1", + "traits": { + "sdm.structures.traits.Info": { + "customName": "Example Home #1", + }, + }, + } + ) + ) + device_manager.add_structure( + Structure.MakeStructure( + { + "name": f"enterprise/{PROJECT_ID}/structures/id-2", + "traits": { + "sdm.structures.traits.Info": { + "customName": "Example Home #2", + }, + }, + } + ) ) - assert entry.data["cloud_project_id"] == "other-cloud-project-id" + + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + assert entry.title == "Example Home #1, Example Home #2" + + +async def test_title_failure_fallback(hass, oauth): + """Test exception handling when determining the structure names.""" + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + + mock_subscriber = AsyncMock(FakeSubscriber) + mock_subscriber.async_get_device_manager.side_effect = AuthException() + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=mock_subscriber, + ): + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + assert entry.title == "OAuth for Apps" + assert "token" in entry.data + assert "subscriber_id" in entry.data + assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + + +async def test_structure_missing_trait(hass, oauth, subscriber): + """Test handling the case where a structure has no name set.""" + + device_manager = await subscriber.async_get_device_manager() + device_manager.add_structure( + Structure.MakeStructure( + { + "name": f"enterprise/{PROJECT_ID}/structures/id-1", + # Missing Info trait + "traits": {}, + } + ) + ) + + assert await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) + await oauth.async_oauth_app_flow(result) + + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + await hass.async_block_till_done() + + # Fallback to default name + assert entry.title == "OAuth for Apps" + + +async def test_dhcp_discovery_without_config(hass, oauth): + """Exercise discovery dhcp with no config present (can't run).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=FAKE_DHCP_DATA, + ) + await hass.async_block_till_done() + assert result["type"] == "abort" + assert result["reason"] == "missing_configuration" + + +async def test_dhcp_discovery(hass, oauth): + """Discover via dhcp when config is present.""" + assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=FAKE_DHCP_DATA, + ) + await hass.async_block_till_done() + + # DHCP discovery invokes the config flow + result = await oauth.async_pick_flow(result, WEB_AUTH_DOMAIN) + await oauth.async_oauth_web_flow(result) + entry = await oauth.async_finish_setup(result) + assert entry.title == "OAuth for Web" + + # Discovery does not run once configured + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=FAKE_DHCP_DATA, + ) + await hass.async_block_till_done() + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/nest/test_device_info.py b/tests/components/nest/test_device_info.py index a333a31c2d29ab..a31a155b4bace1 100644 --- a/tests/components/nest/test_device_info.py +++ b/tests/components/nest/test_device_info.py @@ -8,6 +8,7 @@ ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, + ATTR_SUGGESTED_AREA, ) @@ -35,6 +36,7 @@ def test_device_custom_name(): ATTR_NAME: "My Doorbell", ATTR_MANUFACTURER: "Google Nest", ATTR_MODEL: "Doorbell", + ATTR_SUGGESTED_AREA: None, } @@ -60,6 +62,7 @@ def test_device_name_room(): ATTR_NAME: "Some Room", ATTR_MANUFACTURER: "Google Nest", ATTR_MODEL: "Doorbell", + ATTR_SUGGESTED_AREA: "Some Room", } @@ -79,6 +82,7 @@ def test_device_no_name(): ATTR_NAME: "Doorbell", ATTR_MANUFACTURER: "Google Nest", ATTR_MODEL: "Doorbell", + ATTR_SUGGESTED_AREA: None, } @@ -106,4 +110,36 @@ def test_device_invalid_type(): ATTR_NAME: "My Doorbell", ATTR_MANUFACTURER: "Google Nest", ATTR_MODEL: None, + ATTR_SUGGESTED_AREA: None, + } + + +def test_suggested_area(): + """Test the suggested area with different device name and room name.""" + device = Device.MakeDevice( + { + "name": "some-device-id", + "type": "sdm.devices.types.DOORBELL", + "traits": { + "sdm.devices.traits.Info": { + "customName": "My Doorbell", + }, + }, + "parentRelations": [ + {"parent": "some-structure-id", "displayName": "Some Room"} + ], + }, + auth=None, + ) + + device_info = NestDeviceInfo(device) + assert device_info.device_name == "My Doorbell" + assert device_info.device_model == "Doorbell" + assert device_info.device_brand == "Google Nest" + assert device_info.device_info == { + ATTR_IDENTIFIERS: {("nest", "some-device-id")}, + ATTR_NAME: "My Doorbell", + ATTR_MANUFACTURER: "Google Nest", + ATTR_MODEL: "Doorbell", + ATTR_SUGGESTED_AREA: "Some Room", } diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index c9dc9992410d0c..69475fe1437059 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) @@ -119,7 +120,9 @@ async def test_get_triggers(hass): "device_id": device_entry.id, }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -147,7 +150,9 @@ async def test_multiple_devices(hass): entry2 = registry.async_get("camera.camera_2") assert entry2.unique_id == "device-id-2-camera" - triggers = await async_get_device_automations(hass, "trigger", entry1.device_id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, entry1.device_id + ) assert len(triggers) == 1 assert triggers[0] == { "platform": "device", @@ -156,7 +161,9 @@ async def test_multiple_devices(hass): "device_id": entry1.device_id, } - triggers = await async_get_device_automations(hass, "trigger", entry2.device_id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, entry2.device_id + ) assert len(triggers) == 1 assert triggers[0] == { "platform": "device", @@ -191,7 +198,9 @@ async def test_triggers_for_invalid_device_id(hass): assert device_entry_2 is not None with pytest.raises(InvalidDeviceAutomationConfig): - await async_get_device_automations(hass, "trigger", device_entry_2.id) + await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry_2.id + ) async def test_no_triggers(hass): @@ -203,7 +212,9 @@ async def test_no_triggers(hass): entry = registry.async_get("camera.my_camera") assert entry.unique_id == "some-device-id-camera" - triggers = await async_get_device_automations(hass, "trigger", entry.device_id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, entry.device_id + ) assert triggers == [] diff --git a/tests/components/nest/test_diagnostics.py b/tests/components/nest/test_diagnostics.py new file mode 100644 index 00000000000000..b603019da815c8 --- /dev/null +++ b/tests/components/nest/test_diagnostics.py @@ -0,0 +1,95 @@ +"""Test nest diagnostics.""" + +from unittest.mock import patch + +from google_nest_sdm.device import Device +from google_nest_sdm.exceptions import SubscriberException + +from homeassistant.components.nest import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.setup import async_setup_component + +from .common import CONFIG, async_setup_sdm_platform, create_config_entry + +from tests.components.diagnostics import get_diagnostics_for_config_entry + +THERMOSTAT_TYPE = "sdm.devices.types.THERMOSTAT" + + +async def test_entry_diagnostics(hass, hass_client): + """Test config entry diagnostics.""" + devices = { + "some-device-id": Device.MakeDevice( + { + "name": "enterprises/project-id/devices/device-id", + "type": "sdm.devices.types.THERMOSTAT", + "assignee": "enterprises/project-id/structures/structure-id/rooms/room-id", + "traits": { + "sdm.devices.traits.Info": { + "customName": "My Sensor", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, + }, + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 35.0, + }, + }, + "parentRelations": [ + { + "parent": "enterprises/project-id/structures/structure-id/rooms/room-id", + "displayName": "Lobby", + } + ], + }, + auth=None, + ) + } + assert await async_setup_sdm_platform(hass, platform=None, devices=devices) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + config_entry = entries[0] + assert config_entry.state is ConfigEntryState.LOADED + + # Test that only non identifiable device information is returned + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "devices": [ + { + "data": { + "assignee": "**REDACTED**", + "name": "**REDACTED**", + "parentRelations": [ + {"displayName": "**REDACTED**", "parent": "**REDACTED**"} + ], + "traits": { + "sdm.devices.traits.Info": {"customName": "**REDACTED**"}, + "sdm.devices.traits.Humidity": {"ambientHumidityPercent": 35.0}, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1 + }, + }, + "type": "sdm.devices.types.THERMOSTAT", + } + } + ], + } + + +async def test_setup_susbcriber_failure(hass, hass_client): + """Test configuration error.""" + config_entry = create_config_entry() + config_entry.add_to_hass(hass) + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ), patch( + "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", + side_effect=SubscriberException(), + ): + assert await async_setup_component(hass, DOMAIN, CONFIG) + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "error": "No subscriber configured" + } diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index 4a6259991554ea..ee286242a8ce21 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -4,7 +4,12 @@ pubsub subscriber. """ +from __future__ import annotations + +from collections.abc import Mapping import datetime +from typing import Any +from unittest.mock import patch from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage @@ -23,8 +28,15 @@ EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." +EVENT_KEYS = {"device_id", "type", "timestamp"} + + +def event_view(d: Mapping[str, Any]) -> Mapping[str, Any]: + """View of an event with relevant keys for testing.""" + return {key: value for key, value in d.items() if key in EVENT_KEYS} + -async def async_setup_devices(hass, device_type, traits={}): +async def async_setup_devices(hass, device_type, traits={}, auth=None): """Set up the platform and prerequisites.""" devices = { DEVICE_ID: Device.MakeDevice( @@ -33,7 +45,7 @@ async def async_setup_devices(hass, device_type, traits={}): "type": device_type, "traits": traits, }, - auth=None, + auth=auth, ), } return await async_setup_sdm_platform(hass, PLATFORM, devices=devices) @@ -86,13 +98,14 @@ def create_events(events, device_id=DEVICE_ID, timestamp=None): ) -async def test_doorbell_chime_event(hass): +async def test_doorbell_chime_event(hass, auth): """Test a pubsub message for a doorbell event.""" events = async_capture_events(hass, NEST_EVENT) subscriber = await async_setup_devices( hass, "sdm.devices.types.DOORBELL", create_device_traits(["sdm.devices.traits.DoorbellChime"]), + auth, ) registry = er.async_get(hass) @@ -116,11 +129,10 @@ async def test_doorbell_chime_event(hass): event_time = timestamp.replace(microsecond=0) assert len(events) == 1 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "doorbell_chime", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } @@ -144,11 +156,10 @@ async def test_camera_motion_event(hass): event_time = timestamp.replace(microsecond=0) assert len(events) == 1 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } @@ -172,11 +183,10 @@ async def test_camera_sound_event(hass): event_time = timestamp.replace(microsecond=0) assert len(events) == 1 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_sound", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } @@ -200,11 +210,10 @@ async def test_camera_person_event(hass): event_time = timestamp.replace(microsecond=0) assert len(events) == 1 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_person", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } @@ -239,17 +248,15 @@ async def test_camera_multiple_event(hass): event_time = timestamp.replace(microsecond=0) assert len(events) == 2 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } - assert events[1].data == { + assert event_view(events[1].data) == { "device_id": entry.device_id, "type": "camera_person", "timestamp": event_time, - "nest_event_id": EVENT_SESSION_ID, } @@ -305,7 +312,7 @@ async def test_event_message_without_device_event(hass): assert len(events) == 0 -async def test_doorbell_event_thread(hass): +async def test_doorbell_event_thread(hass, auth): """Test a series of pubsub messages in the same thread.""" events = async_capture_events(hass, NEST_EVENT) subscriber = await async_setup_devices( @@ -317,6 +324,7 @@ async def test_doorbell_event_thread(hass): "sdm.devices.traits.CameraPerson", ] ), + auth, ) registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -366,15 +374,14 @@ async def test_doorbell_event_thread(hass): # The event is only published once assert len(events) == 1 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": timestamp1.replace(microsecond=0), - "nest_event_id": EVENT_SESSION_ID, } -async def test_doorbell_event_session_update(hass): +async def test_doorbell_event_session_update(hass, auth): """Test a pubsub message with updates to an existing session.""" events = async_capture_events(hass, NEST_EVENT) subscriber = await async_setup_devices( @@ -387,6 +394,7 @@ async def test_doorbell_event_session_update(hass): "sdm.devices.traits.CameraMotion", ] ), + auth, ) registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -434,15 +442,75 @@ async def test_doorbell_event_session_update(hass): await hass.async_block_till_done() assert len(events) == 2 - assert events[0].data == { + assert event_view(events[0].data) == { "device_id": entry.device_id, "type": "camera_motion", "timestamp": timestamp1.replace(microsecond=0), - "nest_event_id": EVENT_SESSION_ID, } - assert events[1].data == { + assert event_view(events[1].data) == { "device_id": entry.device_id, "type": "camera_person", "timestamp": timestamp2.replace(microsecond=0), - "nest_event_id": EVENT_SESSION_ID, } + + +async def test_structure_update_event(hass): + """Test a pubsub message for a new device being added.""" + events = async_capture_events(hass, NEST_EVENT) + subscriber = await async_setup_devices( + hass, + "sdm.devices.types.DOORBELL", + create_device_traits(["sdm.devices.traits.DoorbellChime"]), + ) + + # Entity for first device is registered + registry = er.async_get(hass) + assert registry.async_get("camera.front") + + new_device = Device.MakeDevice( + { + "name": "device-id-2", + "type": "sdm.devices.types.CAMERA", + "traits": { + "sdm.devices.traits.Info": { + "customName": "Back", + }, + "sdm.devices.traits.CameraLiveStream": {}, + }, + }, + auth=None, + ) + device_manager = await subscriber.async_get_device_manager() + device_manager.add_device(new_device) + + # Entity for new devie has not yet been loaded + assert not registry.async_get("camera.back") + + # Send a message that triggers the device to be loaded + message = EventMessage( + { + "eventId": "some-event-id", + "timestamp": utcnow().isoformat(timespec="seconds"), + "relationUpdate": { + "type": "CREATED", + "subject": "enterprise/example/foo", + "object": "enterprise/example/devices/some-device-id2", + }, + }, + auth=None, + ) + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + await subscriber.async_receive_event(message) + await hass.async_block_till_done() + + # No home assistant events published + assert not events + + assert registry.async_get("camera.front") + # Currently need a manual reload to detect the new entity + assert not registry.async_get("camera.back") diff --git a/tests/components/nest/test_init_sdm.py b/tests/components/nest/test_init_sdm.py index fbfd6305487cc6..381252c6f75c4b 100644 --- a/tests/components/nest/test_init_sdm.py +++ b/tests/components/nest/test_init_sdm.py @@ -3,55 +3,91 @@ The tests fake out the subscriber/devicemanager and simulate setup behavior and failure modes. + +By default all tests use test fixtures that run in each possible configuration +mode (e.g. yaml, ConfigEntry, etc) however some tests override and just run in +relevant modes. """ -import copy import logging +from typing import Any from unittest.mock import patch from google_nest_sdm.exceptions import ( + ApiException, AuthException, ConfigurationException, - GoogleNestException, + SubscriberException, ) +import pytest from homeassistant.components.nest import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.setup import async_setup_component -from .common import CONFIG, async_setup_sdm_platform, create_config_entry +from .common import ( + TEST_CONFIG_HYBRID, + TEST_CONFIG_YAML_ONLY, + FakeSubscriber, + NestTestConfig, + YieldFixture, +) PLATFORM = "sensor" -async def test_setup_success(hass, caplog): - """Test successful setup.""" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to setup the platforms to test.""" + return ["sensor"] + + +@pytest.fixture +def error_caplog(caplog): + """Fixture to capture nest init error messages.""" with caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): - await async_setup_sdm_platform(hass, PLATFORM) - assert not caplog.records + yield caplog - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED + +@pytest.fixture +def warning_caplog(caplog): + """Fixture to capture nest init warning messages.""" + with caplog.at_level(logging.WARNING, logger="homeassistant.components.nest"): + yield caplog + + +@pytest.fixture +def subscriber_side_effect() -> None: + """Fixture to inject failures into FakeSubscriber start.""" + return None -async def async_setup_sdm(hass, config=CONFIG, with_config=True): - """Prepare test setup.""" - if with_config: - create_config_entry(hass) +@pytest.fixture +def failing_subscriber(subscriber_side_effect: Any) -> YieldFixture[FakeSubscriber]: + """Fixture overriding default subscriber behavior to allow failure injection.""" + subscriber = FakeSubscriber() with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", + side_effect=subscriber_side_effect, ): - return await async_setup_component(hass, DOMAIN, config) + yield subscriber -async def test_setup_configuration_failure(hass, caplog): - """Test configuration error.""" - config = copy.deepcopy(CONFIG) - config[DOMAIN]["subscriber_id"] = "invalid-subscriber-format" +async def test_setup_success(hass, error_caplog, setup_platform): + """Test successful setup.""" + await setup_platform() + assert not error_caplog.records + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + - result = await async_setup_sdm(hass, config) - assert result +@pytest.mark.parametrize("subscriber_id", [("invalid-subscriber-format")]) +async def test_setup_configuration_failure( + hass, caplog, subscriber_id, setup_base_platform +): + """Test configuration error.""" + await setup_base_platform() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -62,49 +98,43 @@ async def test_setup_configuration_failure(hass, caplog): assert "Subscription misconfigured. Expected subscriber_id" in caplog.text -async def test_setup_susbcriber_failure(hass, caplog): +@pytest.mark.parametrize("subscriber_side_effect", [SubscriberException()]) +async def test_setup_susbcriber_failure( + hass, error_caplog, failing_subscriber, setup_base_platform +): """Test configuration error.""" - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", - side_effect=GoogleNestException(), - ), caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): - result = await async_setup_sdm(hass) - assert result - assert "Subscriber error:" in caplog.text + await setup_base_platform() + assert "Subscriber error:" in error_caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_RETRY -async def test_setup_device_manager_failure(hass, caplog): - """Test configuration error.""" +async def test_setup_device_manager_failure(hass, error_caplog, setup_base_platform): + """Test device manager api failure.""" with patch( "homeassistant.components.nest.api.GoogleNestSubscriber.start_async" ), patch( "homeassistant.components.nest.api.GoogleNestSubscriber.async_get_device_manager", - side_effect=GoogleNestException(), - ), caplog.at_level( - logging.ERROR, logger="homeassistant.components.nest" + side_effect=ApiException(), ): - result = await async_setup_sdm(hass) - assert result - assert len(caplog.messages) == 1 - assert "Device manager error:" in caplog.text + await setup_base_platform() + + assert len(error_caplog.messages) == 1 + assert "Device manager error:" in error_caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_RETRY -async def test_subscriber_auth_failure(hass, caplog): - """Test configuration error.""" - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", - side_effect=AuthException(), - ): - result = await async_setup_sdm(hass, CONFIG) - assert result +@pytest.mark.parametrize("subscriber_side_effect", [AuthException()]) +async def test_subscriber_auth_failure( + hass, caplog, setup_base_platform, failing_subscriber +): + """Test subscriber throws an authentication error.""" + await setup_base_platform() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -115,70 +145,45 @@ async def test_subscriber_auth_failure(hass, caplog): assert flows[0]["step_id"] == "reauth_confirm" -async def test_setup_missing_subscriber_id(hass, caplog): - """Test missing susbcriber id from config and config entry.""" - config = copy.deepcopy(CONFIG) - del config[DOMAIN]["subscriber_id"] - - with caplog.at_level(logging.WARNING, logger="homeassistant.components.nest"): - result = await async_setup_sdm(hass, config) - assert result - assert "Configuration option" in caplog.text +@pytest.mark.parametrize("subscriber_id", [(None)]) +async def test_setup_missing_subscriber_id(hass, warning_caplog, setup_base_platform): + """Test missing susbcriber id from configuration.""" + await setup_base_platform() + assert "Configuration option" in warning_caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_ERROR -async def test_setup_subscriber_id_config_entry(hass, caplog): - """Test successful setup with subscriber id in ConfigEntry.""" - config = copy.deepcopy(CONFIG) - subscriber_id = config[DOMAIN]["subscriber_id"] - del config[DOMAIN]["subscriber_id"] - - config_entry = create_config_entry(hass) - data = {**config_entry.data} - data["subscriber_id"] = subscriber_id - hass.config_entries.async_update_entry(config_entry, data=data) - - with caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): - await async_setup_sdm_platform(hass, PLATFORM, with_config=False) - assert not caplog.records - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - - -async def test_subscriber_configuration_failure(hass, caplog): +@pytest.mark.parametrize("subscriber_side_effect", [(ConfigurationException())]) +async def test_subscriber_configuration_failure( + hass, error_caplog, setup_base_platform, failing_subscriber +): """Test configuration error.""" - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", - side_effect=ConfigurationException(), - ), caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): - result = await async_setup_sdm(hass, CONFIG) - assert result - assert "Configuration error: " in caplog.text + await setup_base_platform() + assert "Configuration error: " in error_caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_ERROR -async def test_empty_config(hass, caplog): - """Test successful setup.""" - with caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): - result = await async_setup_component(hass, DOMAIN, {}) - assert result - assert not caplog.records +@pytest.mark.parametrize( + "nest_test_config", [NestTestConfig(config={}, config_entry_data=None)] +) +async def test_empty_config(hass, error_caplog, config, setup_platform): + """Test setup is a no-op with not config.""" + await setup_platform() + assert not error_caplog.records entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 0 -async def test_unload_entry(hass, caplog): +async def test_unload_entry(hass, setup_platform): """Test successful unload of a ConfigEntry.""" - await async_setup_sdm_platform(hass, PLATFORM) + await setup_platform() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -189,33 +194,27 @@ async def test_unload_entry(hass, caplog): assert entry.state == ConfigEntryState.NOT_LOADED -async def test_remove_entry(hass, caplog): +@pytest.mark.parametrize( + "nest_test_config,delete_called", + [ + ( + TEST_CONFIG_YAML_ONLY, + False, + ), # User manually created subscriber, preserve on remove + ( + TEST_CONFIG_HYBRID, + True, + ), # Integration created subscriber, garbage collect on remove + ], + ids=["yaml-config-only", "hybrid-config"], +) +async def test_remove_entry(hass, nest_test_config, setup_base_platform, delete_called): """Test successful unload of a ConfigEntry.""" - await async_setup_sdm_platform(hass, PLATFORM) - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - assert entry.state is ConfigEntryState.LOADED - - assert await hass.config_entries.async_remove(entry.entry_id) - - entries = hass.config_entries.async_entries(DOMAIN) - assert not entries - - -async def test_remove_entry_deletes_subscriber(hass, caplog): - """Test ConfigEntry unload deletes a subscription.""" - config = copy.deepcopy(CONFIG) - subscriber_id = config[DOMAIN]["subscriber_id"] - del config[DOMAIN]["subscriber_id"] - - config_entry = create_config_entry(hass) - data = {**config_entry.data} - data["subscriber_id"] = subscriber_id - hass.config_entries.async_update_entry(config_entry, data=data) - - await async_setup_sdm_platform(hass, PLATFORM, with_config=False) + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=FakeSubscriber(), + ): + await setup_base_platform() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -223,27 +222,29 @@ async def test_remove_entry_deletes_subscriber(hass, caplog): assert entry.state is ConfigEntryState.LOADED with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber.subscriber_id" + ), patch( "homeassistant.components.nest.api.GoogleNestSubscriber.delete_subscription", ) as delete: assert await hass.config_entries.async_remove(entry.entry_id) - assert delete.called + assert delete.called == delete_called entries = hass.config_entries.async_entries(DOMAIN) assert not entries -async def test_remove_entry_delete_subscriber_failure(hass, caplog): +@pytest.mark.parametrize( + "nest_test_config", [TEST_CONFIG_HYBRID], ids=["hyrbid-config"] +) +async def test_remove_entry_delete_subscriber_failure( + hass, nest_test_config, setup_base_platform +): """Test a failure when deleting the subscription.""" - config = copy.deepcopy(CONFIG) - subscriber_id = config[DOMAIN]["subscriber_id"] - del config[DOMAIN]["subscriber_id"] - - config_entry = create_config_entry(hass) - data = {**config_entry.data} - data["subscriber_id"] = subscriber_id - hass.config_entries.async_update_entry(config_entry, data=data) - - await async_setup_sdm_platform(hass, PLATFORM, with_config=False) + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=FakeSubscriber(), + ): + await setup_base_platform() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -252,9 +253,10 @@ async def test_remove_entry_delete_subscriber_failure(hass, caplog): with patch( "homeassistant.components.nest.api.GoogleNestSubscriber.delete_subscription", - side_effect=GoogleNestException(), - ): + side_effect=SubscriberException(), + ) as delete: assert await hass.config_entries.async_remove(entry.entry_id) + assert delete.called entries = hass.config_entries.async_entries(DOMAIN) assert not entries diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 95f2afa8a06bfe..015a14fb92c451 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -4,12 +4,17 @@ as media in the media source. """ +from collections.abc import Generator import datetime from http import HTTPStatus +import io +from unittest.mock import patch import aiohttp +import av from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage +import numpy as np import pytest from homeassistant.components import media_source @@ -19,17 +24,25 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.helpers import device_registry as dr from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .common import async_setup_sdm_platform +from .common import ( + CONFIG, + FakeSubscriber, + async_setup_sdm_platform, + create_config_entry, +) + +from tests.common import async_capture_events DOMAIN = "nest" DEVICE_ID = "example/api/device/id" DEVICE_NAME = "Front" PLATFORM = "camera" NEST_EVENT = "nest_event" -EVENT_ID = "1aXEvi9ajKVTdDsXdJda8fzfCa..." -EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF..." +EVENT_ID = "1aXEvi9ajKVTdDsXdJda8fzfCa" +EVENT_SESSION_ID = "CjY5Y3VKaTZwR3o4Y19YbTVfMF" CAMERA_DEVICE_TYPE = "sdm.devices.types.CAMERA" CAMERA_TRAITS = { "sdm.devices.traits.Info": { @@ -49,6 +62,7 @@ "sdm.devices.traits.CameraPerson": {}, "sdm.devices.traits.CameraMotion": {}, } + PERSON_EVENT = "sdm.devices.events.CameraPerson.Person" MOTION_EVENT = "sdm.devices.events.CameraMotion.Motion" @@ -61,6 +75,51 @@ } IMAGE_BYTES_FROM_EVENT = b"test url image bytes" IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"} +NEST_EVENT = "nest_event" + + +def frame_image_data(frame_i, total_frames): + """Generate image content for a frame of a video.""" + img = np.empty((480, 320, 3)) + img[:, :, 0] = 0.5 + 0.5 * np.sin(2 * np.pi * (0 / 3 + frame_i / total_frames)) + img[:, :, 1] = 0.5 + 0.5 * np.sin(2 * np.pi * (1 / 3 + frame_i / total_frames)) + img[:, :, 2] = 0.5 + 0.5 * np.sin(2 * np.pi * (2 / 3 + frame_i / total_frames)) + + img = np.round(255 * img).astype(np.uint8) + img = np.clip(img, 0, 255) + return img + + +@pytest.fixture +def mp4() -> io.BytesIO: + """Generate test mp4 clip.""" + + total_frames = 10 + fps = 10 + output = io.BytesIO() + output.name = "test.mp4" + container = av.open(output, mode="w", format="mp4") + + stream = container.add_stream("libx264", rate=fps) + stream.width = 480 + stream.height = 320 + stream.pix_fmt = "yuv420p" + + for frame_i in range(total_frames): + img = frame_image_data(frame_i, total_frames) + frame = av.VideoFrame.from_ndarray(img, format="rgb24") + for packet in stream.encode(frame): + container.mux(packet) + + # Flush stream + for packet in stream.encode(): + container.mux(packet) + + # Close the file + container.close() + output.seek(0) + + return output async def async_setup_devices(hass, auth, device_type, traits={}, events=[]): @@ -76,10 +135,8 @@ async def async_setup_devices(hass, auth, device_type, traits={}, events=[]): ), } subscriber = await async_setup_sdm_platform(hass, PLATFORM, devices=devices) - if events: - for event in events: - await subscriber.async_receive_event(event) - await hass.async_block_till_done() + # Enable feature for fetching media + subscriber.cache_policy.fetch = True return subscriber @@ -115,6 +172,22 @@ def create_event_message(event_data, timestamp, device_id=None): ) +def create_battery_event_data( + event_type, event_session_id=EVENT_SESSION_ID, event_id="n:2" +): + """Return event payload data for a battery camera event.""" + return { + event_type: { + "eventSessionId": event_session_id, + "eventId": event_id, + }, + "sdm.devices.events.CameraClipPreview.ClipPreview": { + "eventSessionId": event_session_id, + "previewUrl": "https://127.0.0.1/example", + }, + } + + async def test_no_eligible_devices(hass, auth): """Test a media source with no eligible camera devices.""" await async_setup_devices( @@ -133,9 +206,10 @@ async def test_no_eligible_devices(hass, auth): assert not browse.children -async def test_supported_device(hass, auth): +@pytest.mark.parametrize("traits", [CAMERA_TRAITS, BATTERY_CAMERA_TRAITS]) +async def test_supported_device(hass, auth, traits): """Test a media source with a supported camera.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, traits) assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -198,20 +272,8 @@ async def test_integration_unloaded(hass, auth): async def test_camera_event(hass, auth, hass_client): """Test a media source and image created for an event.""" - event_timestamp = dt_util.now() - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - events=[ - create_event( - EVENT_SESSION_ID, - EVENT_ID, - PERSON_EVENT, - timestamp=event_timestamp, - ), - ], + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS ) assert len(hass.states.async_all()) == 1 @@ -223,6 +285,31 @@ async def test_camera_event(hass, auth, hass_client): assert device assert device.name == DEVICE_NAME + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + + # Set up fake media, and publish image events + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event( + EVENT_SESSION_ID, + EVENT_ID, + PERSON_EVENT, + timestamp=event_timestamp, + ) + ) + await hass.async_block_till_done() + + assert len(received_events) == 1 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_person" + event_identifier = received_event.data["nest_event_id"] + # Media root directory browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") assert browse.title == "Nest" @@ -248,7 +335,7 @@ async def test_camera_event(hass, auth, hass_client): # The device expands recent events assert len(browse.children) == 1 assert browse.children[0].domain == DOMAIN - assert browse.children[0].identifier == f"{device.id}/{EVENT_SESSION_ID}" + assert browse.children[0].identifier == f"{device.id}/{event_identifier}" event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) assert browse.children[0].title == f"Person @ {event_timestamp_string}" assert not browse.children[0].can_expand @@ -256,10 +343,10 @@ async def test_camera_event(hass, auth, hass_client): # Browse to the event browse = await media_source.async_browse_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{EVENT_SESSION_ID}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" ) assert browse.domain == DOMAIN - assert browse.identifier == f"{device.id}/{EVENT_SESSION_ID}" + assert browse.identifier == f"{device.id}/{event_identifier}" assert "Person" in browse.title assert not browse.can_expand assert not browse.children @@ -267,16 +354,11 @@ async def test_camera_event(hass, auth, hass_client): # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{EVENT_SESSION_ID}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" ) - assert media.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" assert media.mime_type == "image/jpeg" - auth.responses = [ - aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), - aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), - ] - client = await hass_client() response = await client.get(media.url) assert response.status == HTTPStatus.OK, "Response not matched: %s" % response @@ -286,30 +368,39 @@ async def test_camera_event(hass, auth, hass_client): async def test_event_order(hass, auth): """Test multiple events are in descending timestamp order.""" + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS + ) + + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] event_session_id1 = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." event_timestamp1 = dt_util.now() + await subscriber.async_receive_event( + create_event( + event_session_id1, + EVENT_ID + "1", + PERSON_EVENT, + timestamp=event_timestamp1, + ) + ) + await hass.async_block_till_done() + event_session_id2 = "GXXWRWVeHNUlUU3V3MGV3bUOYW..." event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - events=[ - create_event( - event_session_id1, - EVENT_ID + "1", - PERSON_EVENT, - timestamp=event_timestamp1, - ), - create_event( - event_session_id2, - EVENT_ID + "2", - MOTION_EVENT, - timestamp=event_timestamp2, - ), - ], + await subscriber.async_receive_event( + create_event( + event_session_id2, + EVENT_ID + "2", + MOTION_EVENT, + timestamp=event_timestamp2, + ), ) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -331,20 +422,224 @@ async def test_event_order(hass, auth): # Motion event is most recent assert len(browse.children) == 2 assert browse.children[0].domain == DOMAIN - assert browse.children[0].identifier == f"{device.id}/{event_session_id2}" event_timestamp_string = event_timestamp2.strftime(DATE_STR_FORMAT) assert browse.children[0].title == f"Motion @ {event_timestamp_string}" assert not browse.children[0].can_expand - assert not browse.can_play + assert not browse.children[0].can_play # Person event is next assert browse.children[1].domain == DOMAIN - - assert browse.children[1].identifier == f"{device.id}/{event_session_id1}" event_timestamp_string = event_timestamp1.strftime(DATE_STR_FORMAT) assert browse.children[1].title == f"Person @ {event_timestamp_string}" assert not browse.children[1].can_expand - assert not browse.can_play + assert not browse.children[1].can_play + + +async def test_multiple_image_events_in_session(hass, auth, hass_client): + """Test multiple events published within the same event session.""" + event_session_id = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." + event_timestamp1 = dt_util.now() + event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT + b"-1"), + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT + b"-2"), + ] + await subscriber.async_receive_event( + # First camera sees motion then it recognizes a person + create_event( + event_session_id, + EVENT_ID + "1", + MOTION_EVENT, + timestamp=event_timestamp1, + ) + ) + await hass.async_block_till_done() + await subscriber.async_receive_event( + create_event( + event_session_id, + EVENT_ID + "2", + PERSON_EVENT, + timestamp=event_timestamp2, + ), + ) + await hass.async_block_till_done() + + assert len(received_events) == 2 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_motion" + event_identifier1 = received_event.data["nest_event_id"] + received_event = received_events[1] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_person" + event_identifier2 = received_event.data["nest_event_id"] + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert browse.can_expand + + # Person event is most recent + assert len(browse.children) == 2 + event = browse.children[0] + assert event.domain == DOMAIN + assert event.identifier == f"{device.id}/{event_identifier2}" + event_timestamp_string = event_timestamp2.strftime(DATE_STR_FORMAT) + assert event.title == f"Person @ {event_timestamp_string}" + assert not event.can_expand + assert not event.can_play + + # Motion event is next + event = browse.children[1] + assert event.domain == DOMAIN + assert event.identifier == f"{device.id}/{event_identifier1}" + event_timestamp_string = event_timestamp1.strftime(DATE_STR_FORMAT) + assert event.title == f"Motion @ {event_timestamp_string}" + assert not event.can_expand + assert not event.can_play + + # Resolve the most recent event + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier2}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier2}" + assert media.mime_type == "image/jpeg" + + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT + b"-2" + + # Resolving the event links to the media + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" + assert media.mime_type == "image/jpeg" + + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT + b"-1" + + +async def test_multiple_clip_preview_events_in_session(hass, auth, hass_client): + """Test multiple events published within the same event session.""" + event_timestamp1 = dt_util.now() + event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + + # Publish two events: First motion, then a person is recognized. Both + # events share a single clip. + auth.responses = [ + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), + timestamp=event_timestamp1, + ) + ) + await hass.async_block_till_done() + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(PERSON_EVENT), + timestamp=event_timestamp2, + ) + ) + await hass.async_block_till_done() + + assert len(received_events) == 2 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_motion" + event_identifier1 = received_event.data["nest_event_id"] + received_event = received_events[1] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_person" + event_identifier2 = received_event.data["nest_event_id"] + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert browse.can_expand + + # The two distinct events are combined in a single clip preview + assert len(browse.children) == 1 + event = browse.children[0] + assert event.domain == DOMAIN + event_timestamp_string = event_timestamp1.strftime(DATE_STR_FORMAT) + assert event.identifier == f"{device.id}/{event_identifier2}" + assert event.title == f"Motion, Person @ {event_timestamp_string}" + assert not event.can_expand + assert event.can_play + + # Resolve media for each event that was published and they will resolve + # to the same clip preview media clip object. + # Resolve media for the first event + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" + assert media.mime_type == "video/mp4" + + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT + + # Resolve media for the second event + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + ) + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" + assert media.mime_type == "video/mp4" + + response = await client.get(media.url) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT async def test_browse_invalid_device_id(hass, auth): @@ -426,38 +721,38 @@ async def test_resolve_invalid_event_id(hass, auth): assert device assert device.name == DEVICE_NAME - with pytest.raises(Unresolvable): - await media_source.async_resolve_media( - hass, - f"{const.URI_SCHEME}{DOMAIN}/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW...", - ) + # Assume any event ID can be resolved to a media url. Fetching the actual media may fail + # if the ID is not valid. Content type is inferred based on the capabilities of the device. + media = await media_source.async_resolve_media( + hass, + f"{const.URI_SCHEME}{DOMAIN}/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + ) + assert ( + media.url == f"/api/nest/event_media/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW..." + ) + assert media.mime_type == "image/jpeg" -async def test_camera_event_clip_preview(hass, auth, hass_client): +async def test_camera_event_clip_preview(hass, auth, hass_client, mp4): """Test an event for a battery camera video clip.""" + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS + ) + + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + + auth.responses = [ + aiohttp.web.Response(body=mp4.getvalue()), + ] event_timestamp = dt_util.now() - event_data = { - "sdm.devices.events.CameraMotion.Motion": { - "eventSessionId": EVENT_SESSION_ID, - "eventId": "n:2", - }, - "sdm.devices.events.CameraClipPreview.ClipPreview": { - "eventSessionId": EVENT_SESSION_ID, - "previewUrl": "https://127.0.0.1/example", - }, - } - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - BATTERY_CAMERA_TRAITS, - events=[ - create_event_message( - event_data, - timestamp=event_timestamp, - ), - ], + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), + timestamp=event_timestamp, + ) ) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -468,6 +763,24 @@ async def test_camera_event_clip_preview(hass, auth, hass_client): assert device assert device.name == DEVICE_NAME + # Verify events are published correctly + assert len(received_events) == 1 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_motion" + event_identifier = received_event.data["nest_event_id"] + + # List devices + browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + assert browse.children[0].identifier == device.id + assert browse.children[0].title == "Front: Recent Events" + assert ( + browse.children[0].thumbnail + == f"/api/nest/event_media/{device.id}/{event_identifier}/thumbnail" + ) # Browse to the device browse = await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" @@ -476,32 +789,55 @@ async def test_camera_event_clip_preview(hass, auth, hass_client): assert browse.identifier == device.id assert browse.title == "Front: Recent Events" assert browse.can_expand + assert not browse.thumbnail # The device expands recent events assert len(browse.children) == 1 assert browse.children[0].domain == DOMAIN - actual_event_id = browse.children[0].identifier + assert browse.children[0].identifier == f"{device.id}/{event_identifier}" event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) assert browse.children[0].title == f"Motion @ {event_timestamp_string}" assert not browse.children[0].can_expand assert len(browse.children[0].children) == 0 assert browse.children[0].can_play + # No thumbnail support for mp4 clips yet + assert ( + browse.children[0].thumbnail + == f"/api/nest/event_media/{device.id}/{event_identifier}/thumbnail" + ) + + # Verify received event and media ids match + assert browse.children[0].identifier == f"{device.id}/{event_identifier}" + + # Browse to the event + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" + ) + assert browse.domain == DOMAIN + event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) + assert browse.title == f"Motion @ {event_timestamp_string}" + assert not browse.can_expand + assert len(browse.children) == 0 + assert browse.can_play # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{actual_event_id}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" ) - assert media.url == f"/api/nest/event_media/{actual_event_id}" + assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" assert media.mime_type == "video/mp4" - auth.responses = [ - aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), - ] - client = await hass_client() response = await client.get(media.url) assert response.status == HTTPStatus.OK, "Response not matched: %s" % response contents = await response.read() - assert contents == IMAGE_BYTES_FROM_EVENT + assert contents == mp4.getvalue() + + # Verify thumbnail for mp4 clip + response = await client.get( + f"/api/nest/event_media/{device.id}/{event_identifier}/thumbnail" + ) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + await response.read() # Animated gif format not tested async def test_event_media_render_invalid_device_id(hass, auth, hass_client): @@ -533,21 +869,23 @@ async def test_event_media_render_invalid_event_id(hass, auth, hass_client): async def test_event_media_failure(hass, auth, hass_client): """Test event media fetch sees a failure from the server.""" + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS + ) + + auth.responses = [ + aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR), + ] event_timestamp = dt_util.now() - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - events=[ - create_event( - EVENT_SESSION_ID, - EVENT_ID, - PERSON_EVENT, - timestamp=event_timestamp, - ), - ], + await subscriber.async_receive_event( + create_event( + EVENT_SESSION_ID, + EVENT_ID, + PERSON_EVENT, + timestamp=event_timestamp, + ), ) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -565,10 +903,6 @@ async def test_event_media_failure(hass, auth, hass_client): assert media.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" assert media.mime_type == "image/jpeg" - auth.responses = [ - aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR), - ] - client = await hass_client() response = await client.get(media.url) assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR, ( @@ -578,21 +912,7 @@ async def test_event_media_failure(hass, auth, hass_client): async def test_media_permission_unauthorized(hass, auth, hass_client, hass_admin_user): """Test case where user does not have permissions to view media.""" - event_timestamp = dt_util.now() - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - events=[ - create_event( - EVENT_SESSION_ID, - EVENT_ID, - PERSON_EVENT, - timestamp=event_timestamp, - ), - ], - ) + await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -603,7 +923,7 @@ async def test_media_permission_unauthorized(hass, auth, hass_client, hass_admin assert device assert device.name == DEVICE_NAME - media_url = f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + media_url = f"/api/nest/event_media/{device.id}/some-event-id" # Empty policy with no access to the entity hass_admin_user.mock_policy({}) @@ -658,6 +978,10 @@ async def test_multiple_devices(hass, auth, hass_client): # Send events for device #1 for i in range(0, 5): + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] await subscriber.async_receive_event( create_event( f"event-session-id-{i}", @@ -666,6 +990,7 @@ async def test_multiple_devices(hass, auth, hass_client): device_id=device_id1, ) ) + await hass.async_block_till_done() browse = await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device1.id}" @@ -678,11 +1003,16 @@ async def test_multiple_devices(hass, auth, hass_client): # Send events for device #2 for i in range(0, 3): + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] await subscriber.async_receive_event( create_event( f"other-id-{i}", f"event-id{i}", PERSON_EVENT, device_id=device_id2 ) ) + await hass.async_block_till_done() browse = await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device1.id}" @@ -692,3 +1022,412 @@ async def test_multiple_devices(hass, auth, hass_client): hass, f"{const.URI_SCHEME}{DOMAIN}/{device2.id}" ) assert len(browse.children) == 3 + + +@pytest.fixture +def event_store() -> Generator[None, None, None]: + """Persist changes to event store immediately.""" + with patch( + "homeassistant.components.nest.media_source.STORAGE_SAVE_DELAY_SECONDS", + new=0, + ): + yield + + +async def test_media_store_persistence(hass, auth, hass_client, event_store): + """Test the disk backed media store persistence.""" + nest_device = Device.MakeDevice( + { + "name": DEVICE_ID, + "type": CAMERA_DEVICE_TYPE, + "traits": BATTERY_CAMERA_TRAITS, + }, + auth=auth, + ) + + subscriber = FakeSubscriber() + device_manager = await subscriber.async_get_device_manager() + device_manager.add_device(nest_device) + # Fetch media for events when published + subscriber.cache_policy.fetch = True + + config_entry = create_config_entry() + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + assert await async_setup_component(hass, DOMAIN, CONFIG) + await hass.async_block_till_done() + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + auth.responses = [ + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), timestamp=event_timestamp + ) + ) + await hass.async_block_till_done() + + # Browse to event + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) + assert browse.children[0].title == f"Motion @ {event_timestamp_string}" + assert not browse.children[0].can_expand + assert browse.children[0].can_play + event_identifier = browse.children[0].identifier + + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}" + ) + assert media.url == f"/api/nest/event_media/{event_identifier}" + assert media.mime_type == "video/mp4" + + # Fetch event media + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT + + # Ensure event media store persists to disk + await hass.async_block_till_done() + + # Unload the integration. + assert config_entry.state == ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + # Now rebuild the entire integration and verify that all persisted storage + # can be re-loaded from disk. + subscriber = FakeSubscriber() + device_manager = await subscriber.async_get_device_manager() + device_manager.add_device(nest_device) + + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" + ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=subscriber, + ): + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Verify event metadata exists + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert len(browse.children) == 1 + assert browse.children[0].domain == DOMAIN + event_timestamp_string = event_timestamp.strftime(DATE_STR_FORMAT) + assert browse.children[0].title == f"Motion @ {event_timestamp_string}" + assert not browse.children[0].can_expand + assert browse.children[0].can_play + event_identifier = browse.children[0].identifier + + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}" + ) + assert media.url == f"/api/nest/event_media/{event_identifier}" + assert media.mime_type == "video/mp4" + + # Verify media exists + response = await client.get(media.url) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT + + +async def test_media_store_save_filesystem_error(hass, auth, hass_client): + """Test a filesystem error writing event media.""" + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS + ) + + auth.responses = [ + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + event_timestamp = dt_util.now() + # The client fetches the media from the server, but has a failure when + # persisting the media to disk. + client = await hass_client() + with patch("homeassistant.components.nest.media_source.open", side_effect=OSError): + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), + timestamp=event_timestamp, + ) + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert len(browse.children) == 1 + event = browse.children[0] + + media = await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{event.identifier}" + ) + assert media.url == f"/api/nest/event_media/{event.identifier}" + assert media.mime_type == "video/mp4" + + # We fail to retrieve the media from the server since the origin filesystem op failed + client = await hass_client() + response = await client.get(media.url) + assert response.status == HTTPStatus.NOT_FOUND, ( + "Response not matched: %s" % response + ) + + +async def test_media_store_load_filesystem_error(hass, auth, hass_client): + """Test a filesystem error reading event media.""" + subscriber = await async_setup_devices( + hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS + ) + + assert len(hass.states.async_all()) == 1 + camera = hass.states.get("camera.front") + assert camera is not None + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + + auth.responses = [ + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), + timestamp=event_timestamp, + ) + ) + await hass.async_block_till_done() + + assert len(received_events) == 1 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_motion" + event_identifier = received_event.data["nest_event_id"] + + client = await hass_client() + + # Fetch the media from the server, and simluate a failure reading from disk + client = await hass_client() + with patch("homeassistant.components.nest.media_source.open", side_effect=OSError): + response = await client.get( + f"/api/nest/event_media/{device.id}/{event_identifier}" + ) + assert response.status == HTTPStatus.NOT_FOUND, ( + "Response not matched: %s" % response + ) + + +async def test_camera_event_media_eviction(hass, auth, hass_client): + """Test media files getting evicted from the cache.""" + + # Set small cache size for testing eviction + with patch("homeassistant.components.nest.EVENT_MEDIA_CACHE_SIZE", new=5): + subscriber = await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + BATTERY_CAMERA_TRAITS, + ) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Browse to the device + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert browse.can_expand + + # No events published yet + assert len(browse.children) == 0 + + event_timestamp = dt_util.now() + for i in range(0, 7): + auth.responses = [aiohttp.web.Response(body=f"image-bytes-{i}".encode())] + ts = event_timestamp + datetime.timedelta(seconds=i) + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data( + MOTION_EVENT, event_session_id=f"event-session-{i}" + ), + timestamp=ts, + ) + ) + await hass.async_block_till_done() + + # Cache is limited to 5 events removing media as the cache is filled + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert len(browse.children) == 5 + + auth.responses = [ + aiohttp.web.Response(body=b"image-bytes-7"), + ] + ts = event_timestamp + datetime.timedelta(seconds=8) + # Simulate a failure case removing the media on cache eviction + with patch( + "homeassistant.components.nest.media_source.os.remove", side_effect=OSError + ) as mock_remove: + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data( + MOTION_EVENT, event_session_id="event-session-7" + ), + timestamp=ts, + ) + ) + await hass.async_block_till_done() + assert mock_remove.called + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert len(browse.children) == 5 + child_events = iter(browse.children) + + # Verify all other content is still persisted correctly + client = await hass_client() + for i in reversed(range(3, 8)): + child_event = next(child_events) + response = await client.get(f"/api/nest/event_media/{child_event.identifier}") + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == f"image-bytes-{i}".encode() + await hass.async_block_till_done() + + +async def test_camera_image_resize(hass, auth, hass_client): + """Test scaling a thumbnail for an event image.""" + event_timestamp = dt_util.now() + subscriber = await async_setup_devices( + hass, + auth, + CAMERA_DEVICE_TYPE, + CAMERA_TRAITS, + events=[ + create_event( + EVENT_SESSION_ID, + EVENT_ID, + PERSON_EVENT, + timestamp=event_timestamp, + ), + ], + ) + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Capture any events published + received_events = async_capture_events(hass, NEST_EVENT) + + auth.responses = [ + aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), + aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), + ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event( + EVENT_SESSION_ID, + EVENT_ID, + PERSON_EVENT, + timestamp=event_timestamp, + ) + ) + await hass.async_block_till_done() + + assert len(received_events) == 1 + received_event = received_events[0] + assert received_event.data["device_id"] == device.id + assert received_event.data["type"] == "camera_person" + event_identifier = received_event.data["nest_event_id"] + + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == f"{device.id}/{event_identifier}" + assert "Person" in browse.title + assert not browse.can_expand + assert not browse.children + assert ( + browse.thumbnail + == f"/api/nest/event_media/{device.id}/{event_identifier}/thumbnail" + ) + + client = await hass_client() + response = await client.get(browse.thumbnail) + assert response.status == HTTPStatus.OK, "Response not matched: %s" % response + contents = await response.read() + assert contents == IMAGE_BYTES_FROM_EVENT + + # The event thumbnail is used for the device thumbnail + browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert len(browse.children) == 1 + assert browse.children[0].identifier == device.id + assert browse.children[0].title == "Front: Recent Events" + assert ( + browse.children[0].thumbnail + == f"/api/nest/event_media/{device.id}/{event_identifier}/thumbnail" + ) + + # Browse to device. No thumbnail is needed for the device on the device page + browse = await media_source.async_browse_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert browse.title == "Front: Recent Events" + assert not browse.thumbnail + assert len(browse.children) == 1 diff --git a/tests/components/nest/test_sensor_sdm.py b/tests/components/nest/test_sensor_sdm.py index 72b2ecfc529f65..bb49f13b3eb4be 100644 --- a/tests/components/nest/test_sensor_sdm.py +++ b/tests/components/nest/test_sensor_sdm.py @@ -5,8 +5,10 @@ pubsub subscriber. """ -from google_nest_sdm.device import Device +from typing import Any + from google_nest_sdm.event import EventMessage +import pytest from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT from homeassistant.const import ( @@ -17,43 +19,39 @@ PERCENTAGE, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import async_setup_sdm_platform +from .common import DEVICE_ID, CreateDevice, FakeSubscriber, PlatformSetup -PLATFORM = "sensor" -THERMOSTAT_TYPE = "sdm.devices.types.THERMOSTAT" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to setup the platforms to test.""" + return ["sensor"] -async def async_setup_sensor(hass, devices={}, structures={}): - """Set up the platform and prerequisites.""" - return await async_setup_sdm_platform(hass, PLATFORM, devices, structures) +@pytest.fixture +def device_traits() -> dict[str, Any]: + """Fixture that sets default traits used for devices.""" + return {"sdm.devices.traits.Info": {"customName": "My Sensor"}} -async def test_thermostat_device(hass): +async def test_thermostat_device( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +): """Test a thermostat with temperature and humidity sensors.""" - devices = { - "some-device-id": Device.MakeDevice( - { - "name": "some-device-id", - "type": THERMOSTAT_TYPE, - "traits": { - "sdm.devices.traits.Info": { - "customName": "My Sensor", - }, - "sdm.devices.traits.Temperature": { - "ambientTemperatureCelsius": 25.1, - }, - "sdm.devices.traits.Humidity": { - "ambientHumidityPercent": 35.0, - }, - }, + create_device.create( + { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, }, - auth=None, - ) - } - await async_setup_sensor(hass, devices) + "sdm.devices.traits.Humidity": { + "ambientHumidityPercent": 35.0, + }, + } + ) + await setup_platform() temperature = hass.states.get("sensor.my_sensor_temperature") assert temperature is not None @@ -71,12 +69,12 @@ async def test_thermostat_device(hass): registry = er.async_get(hass) entry = registry.async_get("sensor.my_sensor_temperature") - assert entry.unique_id == "some-device-id-temperature" + assert entry.unique_id == f"{DEVICE_ID}-temperature" assert entry.original_name == "My Sensor Temperature" assert entry.domain == "sensor" entry = registry.async_get("sensor.my_sensor_humidity") - assert entry.unique_id == "some-device-id-humidity" + assert entry.unique_id == f"{DEVICE_ID}-humidity" assert entry.original_name == "My Sensor Humidity" assert entry.domain == "sensor" @@ -84,12 +82,12 @@ async def test_thermostat_device(hass): device = device_registry.async_get(entry.device_id) assert device.name == "My Sensor" assert device.model == "Thermostat" - assert device.identifiers == {("nest", "some-device-id")} + assert device.identifiers == {("nest", DEVICE_ID)} -async def test_no_devices(hass): +async def test_no_devices(hass: HomeAssistant, setup_platform: PlatformSetup): """Test no devices returned by the api.""" - await async_setup_sensor(hass) + await setup_platform() temperature = hass.states.get("sensor.my_sensor_temperature") assert temperature is None @@ -98,19 +96,12 @@ async def test_no_devices(hass): assert humidity is None -async def test_device_no_sensor_traits(hass): +async def test_device_no_sensor_traits( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Test a device with applicable sensor traits.""" - devices = { - "some-device-id": Device.MakeDevice( - { - "name": "some-device-id", - "type": THERMOSTAT_TYPE, - "traits": {}, - }, - auth=None, - ) - } - await async_setup_sensor(hass, devices) + create_device.create({}) + await setup_platform() temperature = hass.states.get("sensor.my_sensor_temperature") assert temperature is None @@ -119,52 +110,45 @@ async def test_device_no_sensor_traits(hass): assert humidity is None -async def test_device_name_from_structure(hass): +@pytest.mark.parametrize("device_traits", [{}]) # Disable default name +async def test_device_name_from_structure( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Test a device without a custom name, inferring name from structure.""" - devices = { - "some-device-id": Device.MakeDevice( - { - "name": "some-device-id", - "type": THERMOSTAT_TYPE, - "traits": { - "sdm.devices.traits.Temperature": { - "ambientTemperatureCelsius": 25.2, - }, - }, - "parentRelations": [ - {"parent": "some-structure-id", "displayName": "Some Room"} - ], + create_device.create( + raw_traits={ + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.2, }, - auth=None, - ) - } - await async_setup_sensor(hass, devices) + }, + raw_data={ + "parentRelations": [ + {"parent": "some-structure-id", "displayName": "Some Room"} + ], + }, + ) + await setup_platform() temperature = hass.states.get("sensor.some_room_temperature") assert temperature is not None assert temperature.state == "25.2" -async def test_event_updates_sensor(hass): +async def test_event_updates_sensor( + hass: HomeAssistant, + subscriber: FakeSubscriber, + create_device: CreateDevice, + setup_platform: PlatformSetup, +) -> None: """Test a pubsub message received by subscriber to update temperature.""" - devices = { - "some-device-id": Device.MakeDevice( - { - "name": "some-device-id", - "type": THERMOSTAT_TYPE, - "traits": { - "sdm.devices.traits.Info": { - "customName": "My Sensor", - }, - "sdm.devices.traits.Temperature": { - "ambientTemperatureCelsius": 25.1, - }, - }, + create_device.create( + { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, }, - auth=None, - ) - } - subscriber = await async_setup_sensor(hass, devices) + } + ) + await setup_platform() temperature = hass.states.get("sensor.my_sensor_temperature") assert temperature is not None @@ -176,7 +160,7 @@ async def test_event_updates_sensor(hass): "eventId": "some-event-id", "timestamp": "2019-01-01T00:00:01Z", "resourceUpdate": { - "name": "some-device-id", + "name": DEVICE_ID, "traits": { "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 26.2, @@ -194,26 +178,19 @@ async def test_event_updates_sensor(hass): assert temperature.state == "26.2" -async def test_device_with_unknown_type(hass): +@pytest.mark.parametrize("device_type", ["some-unknown-type"]) +async def test_device_with_unknown_type( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Test a device without a custom name, inferring name from structure.""" - devices = { - "some-device-id": Device.MakeDevice( - { - "name": "some-device-id", - "type": "some-unknown-type", - "traits": { - "sdm.devices.traits.Info": { - "customName": "My Sensor", - }, - "sdm.devices.traits.Temperature": { - "ambientTemperatureCelsius": 25.1, - }, - }, + create_device.create( + { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.1, }, - auth=None, - ) - } - await async_setup_sensor(hass, devices) + } + ) + await setup_platform() temperature = hass.states.get("sensor.my_sensor_temperature") assert temperature is not None @@ -221,7 +198,7 @@ async def test_device_with_unknown_type(hass): registry = er.async_get(hass) entry = registry.async_get("sensor.my_sensor_temperature") - assert entry.unique_id == "some-device-id-temperature" + assert entry.unique_id == f"{DEVICE_ID}-temperature" assert entry.original_name == "My Sensor Temperature" assert entry.domain == "sensor" @@ -229,29 +206,21 @@ async def test_device_with_unknown_type(hass): device = device_registry.async_get(entry.device_id) assert device.name == "My Sensor" assert device.model is None - assert device.identifiers == {("nest", "some-device-id")} + assert device.identifiers == {("nest", DEVICE_ID)} -async def test_temperature_rounding(hass): +async def test_temperature_rounding( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Test the rounding of overly precise temperatures.""" - devices = { - "some-device-id": Device.MakeDevice( - { - "name": "some-device-id", - "type": THERMOSTAT_TYPE, - "traits": { - "sdm.devices.traits.Info": { - "customName": "My Sensor", - }, - "sdm.devices.traits.Temperature": { - "ambientTemperatureCelsius": 25.15678, - }, - }, + create_device.create( + { + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 25.15678, }, - auth=None, - ) - } - await async_setup_sensor(hass, devices) + } + ) + await setup_platform() temperature = hass.states.get("sensor.my_sensor_temperature") assert temperature.state == "25.2" diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 55083171a1af80..784b428e8d0e27 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -106,5 +106,5 @@ def selected_platforms(platforms): """Restrict loaded platforms to list given.""" with patch("homeassistant.components.netatmo.PLATFORMS", platforms), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", - ), patch("homeassistant.components.webhook.async_generate_url"): + ), patch("homeassistant.components.netatmo.webhook_generate_url"): yield diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 45c8dc48b224d9..b1cee88ee2ff08 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -345,7 +345,7 @@ async def fake_post(*args, **kwargs): ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ) as mock_webhook: mock_auth.return_value.async_post_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -437,7 +437,7 @@ async def fake_post_no_data(*args, **kwargs): ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ): mock_auth.return_value.async_post_request.side_effect = fake_post_no_data mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -475,7 +475,7 @@ async def fake_post(*args, **kwargs): ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ): mock_auth.return_value.async_post_request.side_effect = fake_post mock_auth.return_value.async_get_image.side_effect = fake_post diff --git a/tests/components/netatmo/test_device_trigger.py b/tests/components/netatmo/test_device_trigger.py index 7e014d2648f9f9..bbd4dd1e5ec728 100644 --- a/tests/components/netatmo/test_device_trigger.py +++ b/tests/components/netatmo/test_device_trigger.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.netatmo import DOMAIN as NETATMO_DOMAIN from homeassistant.components.netatmo.const import ( CLIMATE_TRIGGERS, @@ -98,7 +99,7 @@ async def test_get_triggers( triggers = [ trigger for trigger in await async_get_device_automations( - hass, "trigger", device_entry.id + hass, DeviceAutomationType.TRIGGER, device_entry.id ) if trigger["domain"] == NETATMO_DOMAIN ] diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py new file mode 100644 index 00000000000000..3c4a2090f1b18b --- /dev/null +++ b/tests/components/netatmo/test_diagnostics.py @@ -0,0 +1,92 @@ +"""Test the Netatmo diagnostics.""" +from unittest.mock import AsyncMock, patch + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.setup import async_setup_component + +from .common import fake_post_request + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, hass_client, config_entry): + """Test config entry diagnostics.""" + with patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", + ) as mock_auth, patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.netatmo.webhook_generate_url" + ): + mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + assert await async_setup_component(hass, "netatmo", {}) + + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + # ignore for tests + result["info"]["data"]["token"].pop("expires_at") + result["info"].pop("entry_id") + + assert result["info"] == { + "data": { + "auth_implementation": "cloud", + "token": { + "access_token": REDACTED, + "expires_in": 60, + "refresh_token": REDACTED, + "scope": [ + "read_station", + "read_camera", + "access_camera", + "write_camera", + "read_presence", + "access_presence", + "write_presence", + "read_homecoach", + "read_smokedetector", + "read_thermostat", + "write_thermostat", + ], + "type": "Bearer", + }, + "webhook_id": REDACTED, + }, + "disabled_by": None, + "domain": "netatmo", + "options": { + "weather_areas": { + "Home avg": { + "area_name": "Home avg", + "lat_ne": REDACTED, + "lat_sw": REDACTED, + "lon_ne": REDACTED, + "lon_sw": REDACTED, + "mode": "avg", + "show_on_map": False, + }, + "Home max": { + "area_name": "Home max", + "lat_ne": REDACTED, + "lat_sw": REDACTED, + "lon_ne": REDACTED, + "lon_sw": REDACTED, + "mode": "max", + "show_on_map": True, + }, + } + }, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "title": "Mock Title", + "unique_id": "netatmo", + "version": 1, + "webhook_registered": False, + } + + for home in result["data"]["AsyncClimateTopology"]["homes"]: + assert home["coordinates"] == REDACTED diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 418854a61a2fda..950a45f1e4a1aa 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -58,7 +58,7 @@ async def test_setup_component(hass, config_entry): ) as mock_auth, patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ) as mock_impl, patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ) as mock_webhook: mock_auth.return_value.async_post_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -96,7 +96,7 @@ async def fake_post(*args, **kwargs): with patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ) as mock_impl, patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ) as mock_webhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, patch( @@ -160,7 +160,7 @@ async def test_setup_without_https(hass, config_entry, caplog): ) as mock_auth, patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ) as mock_async_generate_url: mock_auth.return_value.async_post_request.side_effect = fake_post_request mock_async_generate_url.return_value = "http://example.com" @@ -196,7 +196,7 @@ async def test_setup_with_cloud(hass, config_entry): ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ): mock_auth.return_value.async_post_request.side_effect = fake_post_request assert await async_setup_component( @@ -259,7 +259,7 @@ async def test_setup_with_cloudhook(hass): ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ): mock_auth.return_value.async_post_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -291,7 +291,7 @@ async def test_setup_component_api_error(hass, config_entry): ) as mock_auth, patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ) as mock_impl, patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ): mock_auth.return_value.async_post_request.side_effect = ( pyatmo.exceptions.ApiError() @@ -314,7 +314,7 @@ async def test_setup_component_api_timeout(hass, config_entry): ) as mock_auth, patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ) as mock_impl, patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ): mock_auth.return_value.async_post_request.side_effect = ( asyncio.exceptions.TimeoutError() @@ -341,7 +341,7 @@ async def test_setup_component_with_delay(hass, config_entry): ) as mock_dropwebhook, patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ) as mock_impl, patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ) as mock_webhook, patch( "pyatmo.AbstractAsyncAuth.async_post_request", side_effect=fake_post_request ) as mock_post_request, patch( @@ -415,7 +415,7 @@ async def test_setup_component_invalid_token_scope(hass): ) as mock_auth, patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ) as mock_impl, patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ) as mock_webhook: mock_auth.return_value.async_post_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -456,7 +456,7 @@ async def fake_ensure_valid_token(*args, **kwargs): ) as mock_auth, patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ) as mock_impl, patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ) as mock_webhook, patch( "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" ) as mock_session: diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 6abbb646055ce4..d28df01beccdf4 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -98,7 +98,7 @@ async def fake_post_request_no_data(*args, **kwargs): ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( - "homeassistant.components.webhook.async_generate_url" + "homeassistant.components.netatmo.webhook_generate_url" ): mock_auth.return_value.async_post_request.side_effect = ( fake_post_request_no_data diff --git a/tests/components/netgear/conftest.py b/tests/components/netgear/conftest.py deleted file mode 100644 index f60b9be62a5acf..00000000000000 --- a/tests/components/netgear/conftest.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Configure Netgear tests.""" -from unittest.mock import patch - -import pytest - - -@pytest.fixture(name="bypass_setup", autouse=True) -def bypass_setup_fixture(): - """Mock component setup.""" - with patch( - "homeassistant.components.netgear.device_tracker.async_get_scanner", - return_value=None, - ): - yield diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py index bdb68d79ab2b86..33c634e250a8ff 100644 --- a/tests/components/netgear/test_config_flow.py +++ b/tests/components/netgear/test_config_flow.py @@ -1,13 +1,19 @@ """Tests for the Netgear config flow.""" from unittest.mock import Mock, patch -from pynetgear import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USER +from pynetgear import DEFAULT_USER import pytest from homeassistant import data_entry_flow from homeassistant.components import ssdp -from homeassistant.components.netgear.const import CONF_CONSIDER_HOME, DOMAIN, PORT_80 -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER +from homeassistant.components.netgear.const import ( + CONF_CONSIDER_HOME, + DOMAIN, + MODELS_PORT_5555, + PORT_80, + PORT_5555, +) +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -19,6 +25,7 @@ from tests.common import MockConfigEntry URL = "http://routerlogin.net" +URL_SSL = "https://routerlogin.net" SERIAL = "5ER1AL0000001" ROUTER_INFOS = { @@ -43,6 +50,7 @@ "DeviceModeCapability": "0;1", } TITLE = f"{ROUTER_INFOS['ModelName']} - {ROUTER_INFOS['DeviceName']}" +TITLE_INCOMPLETE = ROUTER_INFOS["ModelName"] HOST = "10.0.0.1" SERIAL_2 = "5ER1AL0000002" @@ -61,6 +69,34 @@ def mock_controller_service(): "homeassistant.components.netgear.async_setup_entry", return_value=True ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: service_mock.return_value.get_info = Mock(return_value=ROUTER_INFOS) + service_mock.return_value.port = 80 + service_mock.return_value.ssl = False + yield service_mock + + +@pytest.fixture(name="service_5555") +def mock_controller_service_5555(): + """Mock a successful service.""" + with patch( + "homeassistant.components.netgear.async_setup_entry", return_value=True + ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: + service_mock.return_value.get_info = Mock(return_value=ROUTER_INFOS) + service_mock.return_value.port = 5555 + service_mock.return_value.ssl = True + yield service_mock + + +@pytest.fixture(name="service_incomplete") +def mock_controller_service_incomplete(): + """Mock a successful service.""" + router_infos = ROUTER_INFOS.copy() + router_infos.pop("DeviceName") + with patch( + "homeassistant.components.netgear.async_setup_entry", return_value=True + ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: + service_mock.return_value.get_info = Mock(return_value=router_infos) + service_mock.return_value.port = 80 + service_mock.return_value.ssl = False yield service_mock @@ -68,7 +104,7 @@ def mock_controller_service(): def mock_controller_service_failed(): """Mock a failed service.""" with patch("homeassistant.components.netgear.router.Netgear") as service_mock: - service_mock.return_value.login = Mock(return_value=None) + service_mock.return_value.login_try_port = Mock(return_value=None) service_mock.return_value.get_info = Mock(return_value=None) yield service_mock @@ -86,8 +122,6 @@ async def test_user(hass, service): result["flow_id"], { CONF_HOST: HOST, - CONF_PORT: PORT, - CONF_SSL: SSL, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, }, @@ -102,47 +136,48 @@ async def test_user(hass, service): assert result["data"][CONF_PASSWORD] == PASSWORD -async def test_import_required(hass, service): - """Test import step, with required config only.""" +async def test_user_connect_error(hass, service_failed): + """Test user step with connection failure.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_PASSWORD: PASSWORD} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == SERIAL - assert result["title"] == TITLE - assert result["data"].get(CONF_HOST) == DEFAULT_HOST - assert result["data"].get(CONF_PORT) == DEFAULT_PORT - assert result["data"].get(CONF_SSL) is False - assert result["data"].get(CONF_USERNAME) == DEFAULT_USER - assert result["data"][CONF_PASSWORD] == PASSWORD - + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" -async def test_import_required_login_failed(hass, service_failed): - """Test import step, with required config only, while wrong password or connection issue.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_PASSWORD: PASSWORD} + # Have to provide all config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "config"} -async def test_import_all(hass, service): - """Test import step, with all config provided.""" +async def test_user_incomplete_info(hass, service_incomplete): + """Test user step with incomplete device info.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # Have to provide all config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { CONF_HOST: HOST, - CONF_PORT: PORT, - CONF_SSL: SSL, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["result"].unique_id == SERIAL - assert result["title"] == TITLE + assert result["title"] == TITLE_INCOMPLETE assert result["data"].get(CONF_HOST) == HOST assert result["data"].get(CONF_PORT) == PORT assert result["data"].get(CONF_SSL) == SSL @@ -150,24 +185,6 @@ async def test_import_all(hass, service): assert result["data"][CONF_PASSWORD] == PASSWORD -async def test_import_all_connection_failed(hass, service_failed): - """Test import step, with all config provided, while wrong host.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_HOST: HOST, - CONF_PORT: PORT, - CONF_SSL: SSL, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "config"} - - async def test_abort_if_already_setup(hass, service): """Test we abort if the router is already setup.""" MockConfigEntry( @@ -176,15 +193,6 @@ async def test_abort_if_already_setup(hass, service): unique_id=SERIAL, ).add_to_hass(hass) - # Should fail, same SERIAL (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - # Should fail, same SERIAL (flow) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -258,6 +266,38 @@ async def test_ssdp(hass, service): assert result["data"][CONF_PASSWORD] == PASSWORD +async def test_ssdp_port_5555(hass, service_5555): + """Test ssdp step with port 5555.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=SSDP_URL_SLL, + upnp={ + ssdp.ATTR_UPNP_MODEL_NUMBER: MODELS_PORT_5555[0], + ssdp.ATTR_UPNP_PRESENTATION_URL: URL_SSL, + ssdp.ATTR_UPNP_SERIAL: SERIAL, + }, + ), + ) + 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"], {CONF_PASSWORD: PASSWORD} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == TITLE + assert result["data"].get(CONF_HOST) == HOST + assert result["data"].get(CONF_PORT) == PORT_5555 + assert result["data"].get(CONF_SSL) is True + assert result["data"].get(CONF_USERNAME) == DEFAULT_USER + assert result["data"][CONF_PASSWORD] == PASSWORD + + async def test_options_flow(hass, service): """Test specifying non default settings using options flow.""" config_entry = MockConfigEntry( diff --git a/tests/components/nexia/test_switch.py b/tests/components/nexia/test_switch.py new file mode 100644 index 00000000000000..9b6661f0d3d9f1 --- /dev/null +++ b/tests/components/nexia/test_switch.py @@ -0,0 +1,11 @@ +"""The switch tests for the nexia platform.""" + +from homeassistant.const import STATE_ON + +from .util import async_init_integration + + +async def test_hold_switch(hass): + """Test creation of the hold switch.""" + await async_init_integration(hass) + assert hass.states.get("switch.nick_office_hold").state == STATE_ON diff --git a/tests/components/nina/__init__.py b/tests/components/nina/__init__.py new file mode 100644 index 00000000000000..92697378293bd5 --- /dev/null +++ b/tests/components/nina/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nina integration.""" diff --git a/tests/components/nina/fixtures/sample_regions.json b/tests/components/nina/fixtures/sample_regions.json new file mode 100644 index 00000000000000..b25106e8138f06 --- /dev/null +++ b/tests/components/nina/fixtures/sample_regions.json @@ -0,0 +1 @@ +{"metadaten":{"kennung":"urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs_2021-07-31","kennungInhalt":"urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs","version":"2021-07-31","nameKurz":"Regionalschlüssel","nameLang":"Gemeinden, dargestellt durch den Amtlichen Regionalschlüssel (ARS) des Statistischen Bundesamtes","nameTechnisch":"Regionalschluessel","herausgebernameLang":"Statistisches Bundesamt, Wiesbaden","herausgebernameKurz":"Destatis","beschreibung":"Diese Codeliste stellt alle Gemeinden Deutschlands durch den Amtlichen Regionalschlüssel (ARS) dar, wie im Gemeindeverzeichnis des Statistischen Bundesamtes enthalten. Darüber hinaus enthält die Codeliste für die Stadtstaaten Hamburg, Bremen und Berlin Einträge für Stadt-/Ortsteile bzw. Stadtbezirke. Diese Einträge sind mit einem entsprechenden Hinweis versehen.","versionBeschreibung":null,"aenderungZurVorversion":"Mehrere Aenderungen","handbuchVersion":"1.0","xoevHandbuch":false,"gueltigAb":1627682400000,"bezugsorte":[]},"spalten":[{"spaltennameLang":"SCHLUESSEL","spaltennameTechnisch":"SCHLUESSEL","datentyp":"string","codeSpalte":true,"verwendung":{"code":"REQUIRED"},"empfohleneCodeSpalte":true},{"spaltennameLang":"Bezeichnung","spaltennameTechnisch":"Bezeichnung","datentyp":"string","codeSpalte":false,"verwendung":{"code":"REQUIRED"},"empfohleneCodeSpalte":false},{"spaltennameLang":"Hinweis","spaltennameTechnisch":"Hinweis","datentyp":"string","codeSpalte":false,"verwendung":{"code":"OPTIONAL"},"empfohleneCodeSpalte":false}],"daten":[["010010000000","Flensburg, Stadt",null],["010020000000","Kiel, Landeshauptstadt",null],["010030000000","Lübeck, Hansestadt",null],["010040000000","Neumünster, Stadt",null],["010510011011","Brunsbüttel, Stadt",null],["010510044044","Heide, Stadt",null],["010515163003","Averlak",null],["010515163010","Brickeln",null],["010515163012","Buchholz",null],["010515163016","Burg (Dithmarschen)",null],["010515163022","Dingen",null],["010515163024","Eddelak",null],["010515163026","Eggstedt",null],["010515163032","Frestedt",null],["010515163037","Großenrade",null],["010515163051","Hochdonn",null],["010515163064","Kuden",null],["010515163089","Quickborn",null],["010515163097","Sankt Michaelisdonn",null],["010515163110","Süderhastedt",null],["010515166021","Diekhusen-Fahrstedt",null],["010515166034","Friedrichskoog",null],["010515166046","Helse",null],["010515166057","Kaiser-Wilhelm-Koog",null],["010515166062","Kronprinzenkoog",null],["010515166072","Marne, Stadt",null],["010515166073","Marnerdeich",null],["010515166076","Neufeld",null],["010515166077","Neufelderkoog",null],["010515166090","Ramhusen",null],["010515166103","Schmedeswurth",null],["010515166118","Trennewurth",null],["010515166119","Volsemenhusen",null],["010515169005","Barkenholm",null],["010515169008","Bergewöhrden",null],["010515169019","Dellstedt",null],["010515169020","Delve",null],["010515169023","Dörpling",null],["010515169030","Fedderingen",null],["010515169035","Gaushorn",null],["010515169036","Glüsing",null],["010515169038","Groven",null],["010515169047","Hemme",null],["010515169049","Hennstedt",null],["010515169052","Hövede",null],["010515169053","Hollingstedt",null],["010515169058","Karolinenkoog",null],["010515169060","Kleve",null],["010515169061","Krempel",null],["010515169065","Lehe",null],["010515169068","Linden",null],["010515169071","Lunden",null],["010515169080","Norderheistedt",null],["010515169088","Pahlen",null],["010515169092","Rehm-Flehde-Bargen",null],["010515169096","Sankt Annen",null],["010515169100","Schalkholz",null],["010515169102","Schlichting",null],["010515169114","Tellingstedt",null],["010515169117","Tielenhemme",null],["010515169120","Wallen",null],["010515169125","Welmbüttel",null],["010515169131","Westerborstel",null],["010515169133","Wiemerstedt",null],["010515169136","Wrohm",null],["010515169139","Süderdorf",null],["010515169141","Süderheistedt",null],["010515172048","Hemmingstedt",null],["010515172067","Lieth",null],["010515172069","Lohe-Rickelshof",null],["010515172075","Neuenkirchen",null],["010515172081","Norderwöhrden",null],["010515172082","Nordhastedt",null],["010515172087","Ostrohe",null],["010515172107","Stelle-Wittenwurth",null],["010515172113","Wöhrden",null],["010515172122","Weddingstedt",null],["010515172130","Wesseln",null],["010515175001","Albersdorf",null],["010515175002","Arkebek",null],["010515175004","Bargenstedt",null],["010515175006","Barlt",null],["010515175015","Bunsoh",null],["010515175017","Busenwurth",null],["010515175027","Elpersbüttel",null],["010515175028","Epenwöhrden",null],["010515175039","Gudendorf",null],["010515175054","Immenstedt",null],["010515175063","Krumstedt",null],["010515175074","Meldorf, Stadt",null],["010515175078","Nindorf",null],["010515175083","Odderade",null],["010515175085","Offenbüttel",null],["010515175086","Osterrade",null],["010515175098","Sarzbüttel",null],["010515175099","Schafstedt",null],["010515175104","Schrum",null],["010515175126","Wennbüttel",null],["010515175134","Windbergen",null],["010515175135","Wolmersdorf",null],["010515175137","Nordermeldorf",null],["010515175138","Tensbüttel-Röst",null],["010515178013","Büsum",null],["010515178014","Büsumer Deichhausen",null],["010515178033","Friedrichsgabekoog",null],["010515178043","Hedwigenkoog",null],["010515178045","Hellschen-Heringsand-Unterschaar",null],["010515178050","Hillgroven",null],["010515178079","Norddeich",null],["010515178084","Oesterdeichstrich",null],["010515178093","Reinsbüttel",null],["010515178105","Schülp",null],["010515178108","Strübbel",null],["010515178109","Süderdeich",null],["010515178121","Warwerort",null],["010515178127","Wesselburen, Stadt",null],["010515178128","Wesselburener Deichhausen",null],["010515178129","Wesselburenerkoog",null],["010515178132","Westerdeichstrich",null],["010515178140","Oesterwurth",null],["010530032032","Geesthacht, Stadt",null],["010530083083","Lauenburg/ Elbe, Stadt",null],["010530090090","Mölln, Stadt",null],["010530100100","Ratzeburg, Stadt",null],["010530116116","Schwarzenbek, Stadt",null],["010530129129","Wentorf bei Hamburg",null],["010535308008","Behlendorf",null],["010535308009","Berkenthin",null],["010535308011","Bliestorf",null],["010535308024","Düchelsdorf",null],["010535308034","Göldenitz",null],["010535308061","Kastorf",null],["010535308067","Klempau",null],["010535308075","Krummesse",null],["010535308094","Niendorf bei Berkenthin",null],["010535308103","Rondeshagen",null],["010535308120","Sierksrade",null],["010535313002","Alt-Mölln",null],["010535313005","Bälau",null],["010535313013","Borstorf",null],["010535313014","Breitenfelde",null],["010535313037","Grambek",null],["010535313056","Hornbek",null],["010535313084","Lehmrade",null],["010535313095","Niendorf/ Stecknitz",null],["010535313113","Schretstaken",null],["010535313125","Talkau",null],["010535313134","Woltersdorf",null],["010535318010","Besenthal",null],["010535318015","Bröthen",null],["010535318020","Büchen",null],["010535318029","Fitzen",null],["010535318035","Göttin",null],["010535318046","Gudow",null],["010535318048","Güster",null],["010535318064","Klein Pampau",null],["010535318080","Langenlehsten",null],["010535318092","Müssen",null],["010535318104","Roseburg",null],["010535318115","Schulendorf",null],["010535318119","Siebeneichen",null],["010535318126","Tramm",null],["010535318132","Witzeeze",null],["010535323003","Aumühle",null],["010535323012","Börnsen",null],["010535323023","Dassendorf",null],["010535323028","Escheburg",null],["010535323050","Hamwarde",null],["010535323053","Hohenhorn",null],["010535323072","Kröppelshagen-Fahrendorf",null],["010535323131","Wiershop",null],["010535323133","Wohltorf",null],["010535323135","Worth",null],["010535343006","Basedow",null],["010535343019","Buchhorst",null],["010535343022","Dalldorf",null],["010535343058","Juliusburg",null],["010535343073","Krüzen",null],["010535343074","Krukow",null],["010535343082","Lanze",null],["010535343087","Lütau",null],["010535343111","Schnakenbek",null],["010535343128","Wangelau",null],["010535358001","Albsfelde",null],["010535358004","Bäk",null],["010535358016","Brunsmark",null],["010535358018","Buchholz",null],["010535358026","Einhaus",null],["010535358030","Fredeburg",null],["010535358033","Giesensdorf",null],["010535358040","Groß Disnack",null],["010535358041","Groß Grönau",null],["010535358043","Groß Sarau",null],["010535358051","Harmsdorf",null],["010535358054","Hollenbek",null],["010535358057","Horst",null],["010535358062","Kittlitz",null],["010535358066","Klein Zecher",null],["010535358078","Kulpin",null],["010535358088","Mechow",null],["010535358093","Mustin",null],["010535358098","Pogeez",null],["010535358102","Römnitz",null],["010535358107","Salem",null],["010535358110","Schmilau",null],["010535358117","Seedorf",null],["010535358123","Sterley",null],["010535358136","Ziethen",null],["010535373007","Basthorst",null],["010535373017","Brunstorf",null],["010535373021","Dahmker",null],["010535373027","Elmenhorst",null],["010535373031","Fuhlenhagen",null],["010535373036","Grabau",null],["010535373042","Groß Pampau",null],["010535373045","Grove",null],["010535373047","Gülzow",null],["010535373049","Hamfelde",null],["010535373052","Havekost",null],["010535373059","Kankelau",null],["010535373060","Kasseburg",null],["010535373070","Köthel",null],["010535373071","Kollow",null],["010535373076","Kuddewörde",null],["010535373089","Möhnsen",null],["010535373091","Mühlenrade",null],["010535373106","Sahms",null],["010535391025","Duvensee",null],["010535391038","Grinau",null],["010535391039","Groß Boden",null],["010535391044","Groß Schenkenberg",null],["010535391068","Klinkrade",null],["010535391069","Koberg",null],["010535391077","Kühsen",null],["010535391079","Labenz",null],["010535391081","Lankau",null],["010535391085","Linau",null],["010535391086","Lüchow",null],["010535391096","Nusse",null],["010535391097","Panten",null],["010535391099","Poggensee",null],["010535391101","Ritzerau",null],["010535391108","Sandesneben",null],["010535391109","Schiphorst",null],["010535391112","Schönberg",null],["010535391114","Schürensöhlen",null],["010535391118","Siebenbäumen",null],["010535391121","Sirksfelde",null],["010535391122","Steinhorst",null],["010535391124","Stubben",null],["010535391127","Walksfelde",null],["010535391130","Wentorf (Amt Sandesneben)",null],["010539105105","Sachsenwald (Forstgutsbez.),gemfr.Geb.",null],["010540033033","Friedrichstadt, Stadt",null],["010540056056","Husum, Stadt",null],["010540108108","Reußenköge",null],["010540138138","Tönning, Stadt",null],["010540168168","Sylt",null],["010545417035","Garding, Kirchspiel",null],["010545417036","Garding, Stadt",null],["010545417040","Grothusenkoog",null],["010545417063","Katharinenheerd",null],["010545417072","Kotzenbüll",null],["010545417090","Norderfriedrichskoog",null],["010545417095","Oldenswort",null],["010545417100","Osterhever",null],["010545417104","Poppenbüll",null],["010545417113","Sankt Peter-Ording",null],["010545417134","Tating",null],["010545417135","Tetenbüll",null],["010545417140","Tümlauer Koog",null],["010545417145","Vollerwiek",null],["010545417148","Welt",null],["010545417150","Westerhever",null],["010545439046","Hörnum (Sylt)",null],["010545439061","Kampen (Sylt)",null],["010545439078","List auf Sylt",null],["010545439149","Wenningstedt-Braderup (Sylt)",null],["010545453003","Ahrenviöl",null],["010545453004","Ahrenviölfeld",null],["010545453011","Behrendorf",null],["010545453013","Bondelum",null],["010545453041","Haselund",null],["010545453057","Immenstedt",null],["010545453079","Löwenstedt",null],["010545453092","Norstedt",null],["010545453101","Oster-Ohrstedt",null],["010545453118","Schwesing",null],["010545453123","Sollwitt",null],["010545453144","Viöl",null],["010545453152","Wester-Ohrstedt",null],["010545459039","Gröde",null],["010545459050","Hallig Hooge",null],["010545459074","Langeneß",null],["010545459103","Pellworm",null],["010545488005","Alkersum",null],["010545488015","Borgsum",null],["010545488025","Dunsum",null],["010545488083","Midlum",null],["010545488085","Nebel",null],["010545488087","Nieblum",null],["010545488089","Norddorf auf Amrum",null],["010545488094","Oevenum",null],["010545488098","Oldsum",null],["010545488129","Süderende",null],["010545488143","Utersum",null],["010545488158","Witsum",null],["010545488160","Wittdün auf Amrum",null],["010545488163","Wrixum",null],["010545488164","Wyk auf Föhr, Stadt",null],["010545489001","Achtrup",null],["010545489009","Aventoft",null],["010545489016","Bosbüll",null],["010545489017","Braderup",null],["010545489018","Bramstedtlund",null],["010545489022","Dagebüll",null],["010545489027","Ellhöft",null],["010545489034","Friedrich-Wilhelm-Lübke-Koog",null],["010545489048","Holm",null],["010545489055","Humptrup",null],["010545489062","Karlum",null],["010545489065","Klanxbüll",null],["010545489068","Klixbüll",null],["010545489073","Ladelund",null],["010545489076","Leck",null],["010545489077","Lexgaard",null],["010545489086","Neukirchen",null],["010545489088","Niebüll, Stadt",null],["010545489109","Risum-Lindholm",null],["010545489110","Rodenäs",null],["010545489124","Sprakebüll",null],["010545489125","Stadum",null],["010545489126","Stedesand",null],["010545489131","Süderlügum",null],["010545489136","Tinningstedt",null],["010545489142","Uphusum",null],["010545489154","Westre",null],["010545489165","Galmsbüll",null],["010545489166","Emmelsbüll-Horsbüll",null],["010545489167","Enge-Sande",null],["010545492007","Arlewatt",null],["010545492023","Drage",null],["010545492026","Elisabeth-Sophien-Koog",null],["010545492032","Fresendelf",null],["010545492042","Hattstedt",null],["010545492043","Hattstedtermarsch",null],["010545492052","Horstedt",null],["010545492054","Hude",null],["010545492070","Koldenbüttel",null],["010545492084","Mildstedt",null],["010545492091","Nordstrand",null],["010545492096","Oldersbek",null],["010545492097","Olderup",null],["010545492099","Ostenfeld (Husum)",null],["010545492105","Ramstedt",null],["010545492106","Rantrum",null],["010545492116","Schwabstedt",null],["010545492119","Seeth",null],["010545492120","Simonsberg",null],["010545492130","Süderhöft",null],["010545492132","Südermarsch",null],["010545492141","Uelvesbüll",null],["010545492156","Winnert",null],["010545492157","Wisch",null],["010545492159","Wittbek",null],["010545492161","Witzwort",null],["010545492162","Wobbenbüll",null],["010545494002","Ahrenshöft",null],["010545494006","Almdorf",null],["010545494010","Bargum",null],["010545494012","Bohmstedt",null],["010545494014","Bordelum",null],["010545494019","Bredstedt, Stadt",null],["010545494020","Breklum",null],["010545494024","Drelsdorf",null],["010545494037","Goldebek",null],["010545494038","Goldelund",null],["010545494045","Högel",null],["010545494059","Joldelund",null],["010545494071","Kolkerheide",null],["010545494075","Langenhorn",null],["010545494080","Lütjenholm",null],["010545494093","Ockholm",null],["010545494121","Sönnebüll",null],["010545494128","Struckum",null],["010545494146","Vollstedt",null],["010550001001","Ahrensbök",null],["010550004004","Bad Schwartau, Stadt",null],["010550007007","Bosau",null],["010550010010","Dahme",null],["010550012012","Eutin, Stadt",null],["010550016016","Grömitz",null],["010550018018","Grube",null],["010550021021","Heiligenhafen, Stadt",null],["010550025025","Kellenhusen (Ostsee)",null],["010550028028","Malente",null],["010550032032","Neustadt in Holstein, Stadt",null],["010550033033","Oldenburg in Holstein, Stadt",null],["010550035035","Ratekau",null],["010550040040","Stockelsdorf",null],["010550041041","Süsel",null],["010550042042","Timmendorfer Strand",null],["010550044044","Scharbeutz",null],["010550046046","Fehmarn, Stadt",null],["010555543014","Göhl",null],["010555543015","Gremersdorf",null],["010555543017","Großenbrode",null],["010555543022","Heringsdorf",null],["010555543031","Neukirchen",null],["010555543043","Wangels",null],["010555546006","Beschendorf",null],["010555546011","Damlos",null],["010555546020","Harmsdorf",null],["010555546023","Kabelhorst",null],["010555546027","Lensahn",null],["010555546029","Manhagen",null],["010555546036","Riepsdorf",null],["010555591002","Altenkrempe",null],["010555591024","Kasseedorf",null],["010555591037","Schashagen",null],["010555591038","Schönwalde am Bungsberg",null],["010555591039","Sierksdorf",null],["010560002002","Barmstedt, Stadt",null],["010560005005","Bönningstedt",null],["010560015015","Elmshorn, Stadt",null],["010560018018","Halstenbek",null],["010560021021","Hasloh",null],["010560025025","Helgoland",null],["010560039039","Pinneberg, Stadt",null],["010560041041","Quickborn, Stadt",null],["010560043043","Rellingen",null],["010560044044","Schenefeld, Stadt",null],["010560048048","Tornesch, Stadt",null],["010560049049","Uetersen, Stadt",null],["010560050050","Wedel, Stadt",null],["010565616029","Klein Nordende",null],["010565616030","Klein Offenseth-Sparrieshoop",null],["010565616031","Kölln-Reisiek",null],["010565616033","Seester",null],["010565616042","Raa-Besenbek",null],["010565616045","Seestermühe",null],["010565616046","Seeth-Ekholt",null],["010565636006","Bokel",null],["010565636010","Brande-Hörnerkirchen",null],["010565636038","Osterhorn",null],["010565636051","Westerhorn",null],["010565660003","Bevern",null],["010565660004","Bilsen",null],["010565660008","Bokholt-Hanredder",null],["010565660011","Bullenkuhlen",null],["010565660014","Ellerhoop",null],["010565660017","Groß Offenseth-Aspern",null],["010565660022","Heede",null],["010565660026","Hemdingen",null],["010565660034","Langeln",null],["010565660035","Lutzhorn",null],["010565687009","Borstel-Hohenraden",null],["010565687013","Ellerbek",null],["010565687032","Kummerfeld",null],["010565687040","Prisdorf",null],["010565687047","Tangstedt",null],["010565690001","Appen",null],["010565690016","Groß Nordende",null],["010565690019","Haselau",null],["010565690020","Haseldorf",null],["010565690023","Heidgraben",null],["010565690024","Heist",null],["010565690027","Hetlingen",null],["010565690028","Holm",null],["010565690036","Moorrege",null],["010565690037","Neuendeich",null],["010570001001","Ascheberg (Holstein)",null],["010570008008","Bönebüttel",null],["010570009009","Bösdorf",null],["010570057057","Plön, Stadt",null],["010570062062","Preetz, Stadt",null],["010570091091","Schwentinental, Stadt",null],["010575727004","Behrensdorf (Ostsee)",null],["010575727007","Blekendorf",null],["010575727013","Dannau",null],["010575727021","Giekau",null],["010575727026","Helmstorf",null],["010575727027","Högsdorf",null],["010575727029","Hohenfelde",null],["010575727030","Hohwacht (Ostsee)",null],["010575727034","Kirchnüchel",null],["010575727035","Klamp",null],["010575727038","Kletkamp",null],["010575727048","Lütjenburg, Stadt",null],["010575727055","Panker",null],["010575727076","Schwartbuck",null],["010575727082","Tröndel",null],["010575739015","Dersau",null],["010575739017","Dörnick",null],["010575739022","Grebin",null],["010575739032","Kalübbe",null],["010575739045","Lebrade",null],["010575739053","Nehmten",null],["010575739065","Rantzau",null],["010575739067","Rathjensdorf",null],["010575739089","Wittmoldt",null],["010575747002","Barmissen",null],["010575747010","Boksee",null],["010575747011","Bothkamp",null],["010575747023","Großbarkau",null],["010575747031","Honigsee",null],["010575747033","Kirchbarkau",null],["010575747037","Klein Barkau",null],["010575747042","Kühren",null],["010575747046","Lehmkuhlen",null],["010575747047","Löptin",null],["010575747054","Nettelsee",null],["010575747058","Pohnsdorf",null],["010575747059","Postfeld",null],["010575747066","Rastorf",null],["010575747070","Schellhorn",null],["010575747084","Wahlstorf",null],["010575747086","Warnau",null],["010575755003","Barsbek",null],["010575755006","Bendfeld",null],["010575755012","Brodersdorf",null],["010575755018","Fahren",null],["010575755020","Fiefbergen",null],["010575755028","Höhndorf",null],["010575755039","Köhn",null],["010575755040","Krokau",null],["010575755041","Krummbek",null],["010575755043","Laboe",null],["010575755049","Lutterbek",null],["010575755056","Passade",null],["010575755060","Prasdorf",null],["010575755063","Probsteierhagen",null],["010575755073","Schönberg (Holstein)",null],["010575755078","Stakendorf",null],["010575755079","Stein",null],["010575755081","Stoltenberg",null],["010575755087","Wendtorf",null],["010575755088","Wisch",null],["010575775016","Dobersdorf",null],["010575775044","Lammershagen",null],["010575775050","Martensrade",null],["010575775052","Mucheln",null],["010575775072","Schlesen",null],["010575775077","Selent",null],["010575775090","Fargau-Pratjau",null],["010575782025","Heikendorf",null],["010575782051","Mönkeberg",null],["010575782074","Schönkirchen",null],["010575785005","Belau",null],["010575785024","Großharrie",null],["010575785068","Rendswühren",null],["010575785069","Ruhwinkel",null],["010575785071","Schillsdorf",null],["010575785080","Stolpe",null],["010575785083","Tasdorf",null],["010575785085","Wankendorf",null],["010580005005","Altenholz",null],["010580034034","Büdelsdorf, Stadt",null],["010580043043","Eckernförde, Stadt",null],["010580092092","Kronshagen",null],["010580135135","Rendsburg, Stadt",null],["010580169169","Wasbek",null],["010585803001","Achterwehr",null],["010585803028","Bredenbek",null],["010585803050","Felde",null],["010585803093","Krummwisch",null],["010585803104","Melsdorf",null],["010585803126","Ottendorf",null],["010585803130","Quarnbek",null],["010585803171","Westensee",null],["010585822037","Dänischenhagen",null],["010585822116","Noer",null],["010585822150","Schwedeneck",null],["010585822157","Strande",null],["010585824051","Felm",null],["010585824058","Gettorf",null],["010585824096","Lindau",null],["010585824110","Neudorf-Bornstein",null],["010585824112","Neuwittenbek",null],["010585824121","Osdorf",null],["010585824142","Schinkel",null],["010585824165","Tüttendorf",null],["010585830019","Böhnhusen",null],["010585830053","Flintbek",null],["010585830145","Schönhorst",null],["010585830160","Techelsdorf",null],["010585833003","Alt Duvenstedt",null],["010585833054","Fockbek",null],["010585833118","Nübbel",null],["010585833136","Rickert",null],["010585847010","Bargstall",null],["010585847029","Breiholz",null],["010585847036","Christiansholm",null],["010585847047","Elsdorf-Westermühlen",null],["010585847055","Friedrichsgraben",null],["010585847056","Friedrichsholm",null],["010585847070","Hamdorf",null],["010585847078","Hohn",null],["010585847089","Königshügel",null],["010585847097","Lohe-Föhrden",null],["010585847129","Prinzenmoor",null],["010585847154","Sophienhamm",null],["010585853031","Brinjahe",null],["010585853048","Embühren",null],["010585853068","Haale",null],["010585853071","Hamweddel",null],["010585853075","Hörsten",null],["010585853086","Jevenstedt",null],["010585853101","Luhnstedt",null],["010585853148","Schülp b. Rendsburg",null],["010585853155","Stafstedt",null],["010585853172","Westerrönfeld",null],["010585859018","Blumenthal",null],["010585859105","Mielkendorf",null],["010585859107","Molfsee",null],["010585859138","Rodenbek",null],["010585859139","Rumohr",null],["010585859141","Schierensee",null],["010585864011","Bargstedt",null],["010585864021","Bokel",null],["010585864023","Borgdorf-Seedorf",null],["010585864027","Brammer",null],["010585864038","Dätgen",null],["010585864045","Eisendorf",null],["010585864046","Ellerdorf",null],["010585864049","Emkendorf",null],["010585864059","Gnutz",null],["010585864065","Groß Vollstedt",null],["010585864091","Krogaspe",null],["010585864094","Langwedel",null],["010585864117","Nortorf, Stadt",null],["010585864120","Oldenhütten",null],["010585864147","Schülp b. Nortorf",null],["010585864163","Timmaspe",null],["010585864168","Warder",null],["010585888026","Bovenau",null],["010585888073","Haßmoor",null],["010585888122","Ostenfeld (Rendsburg)",null],["010585888124","Osterrönfeld",null],["010585888132","Rade b. Rendsburg",null],["010585888140","Schacht-Audorf",null],["010585888146","Schülldorf",null],["010585889016","Bissee",null],["010585889022","Bordesholm",null],["010585889033","Brügge",null],["010585889063","Grevenkrug",null],["010585889064","Groß Buchwald",null],["010585889076","Hoffeld",null],["010585889098","Loop",null],["010585889108","Mühbrook",null],["010585889109","Negenharrie",null],["010585889133","Reesdorf",null],["010585889143","Schmalstede",null],["010585889144","Schönbek",null],["010585889153","Sören",null],["010585889170","Wattenbek",null],["010585890008","Ascheffel",null],["010585890024","Borgstedt",null],["010585890030","Brekendorf",null],["010585890035","Bünsdorf",null],["010585890039","Damendorf",null],["010585890066","Groß Wittensee",null],["010585890069","Haby",null],["010585890080","Holtsee",null],["010585890081","Holzbunge",null],["010585890083","Hütten",null],["010585890088","Klein Wittensee",null],["010585890111","Neu Duvenstedt",null],["010585890123","Osterby",null],["010585890127","Owschlag",null],["010585890152","Sehestedt",null],["010585890175","Ahlefeld-Bistensee",null],["010585893004","Altenhof",null],["010585893012","Barkelsby",null],["010585893032","Brodersby",null],["010585893040","Damp",null],["010585893042","Dörphof",null],["010585893052","Fleckeby",null],["010585893057","Gammelby",null],["010585893067","Güby",null],["010585893082","Holzdorf",null],["010585893084","Hummelfeld",null],["010585893087","Karby",null],["010585893090","Kosel",null],["010585893099","Loose",null],["010585893102","Goosefeld",null],["010585893137","Rieseby",null],["010585893162","Thumby",null],["010585893166","Waabs",null],["010585893173","Windeby",null],["010585893174","Winnemark",null],["010585895007","Arpsdorf",null],["010585895009","Aukrug",null],["010585895013","Beldorf",null],["010585895014","Bendorf",null],["010585895015","Beringstedt",null],["010585895025","Bornholt",null],["010585895044","Ehndorf",null],["010585895061","Gokels",null],["010585895062","Grauel",null],["010585895072","Hanerau-Hademarschen",null],["010585895074","Heinkenborstel",null],["010585895077","Hohenwestedt",null],["010585895085","Jahrsdorf",null],["010585895100","Lütjenwestedt",null],["010585895103","Meezen",null],["010585895106","Mörel",null],["010585895113","Nienborstel",null],["010585895115","Nindorf",null],["010585895119","Oldenbüttel",null],["010585895125","Osterstedt",null],["010585895128","Padenstedt",null],["010585895131","Rade b. Hohenwestedt",null],["010585895134","Remmels",null],["010585895151","Seefeld",null],["010585895156","Steenfeld",null],["010585895158","Tackesdorf",null],["010585895159","Tappendorf",null],["010585895161","Thaden",null],["010585895164","Todenbüttel",null],["010585895167","Wapelfeld",null],["010590045045","Kappeln, Stadt",null],["010590075075","Schleswig, Stadt",null],["010590113113","Glücksburg (Ostsee), Stadt",null],["010590120120","Harrislee",null],["010590183183","Handewitt",null],["010595912107","Eggebek",null],["010595912128","Janneby",null],["010595912131","Jerrishoe",null],["010595912132","Jörl",null],["010595912138","Langstedt",null],["010595912162","Sollerup",null],["010595912169","Süderhackstedt",null],["010595912174","Wanderup",null],["010595915012","Borgwedel",null],["010595915018","Busdorf",null],["010595915019","Dannewerk",null],["010595915026","Fahrdorf",null],["010595915032","Geltorf",null],["010595915043","Jagel",null],["010595915056","Lottorf",null],["010595915078","Selk",null],["010595919101","Tastrup",null],["010595919103","Ausacker",null],["010595919116","Großsolt",null],["010595919126","Hürup",null],["010595919127","Husby",null],["010595919141","Maasbüll",null],["010595919182","Freienwill",null],["010595920002","Arnis, Stadt",null],["010595920034","Grödersby",null],["010595920067","Oersberg",null],["010595920068","Rabenkirchen-Faulück",null],["010595937106","Dollerup",null],["010595937118","Grundhof",null],["010595937137","Langballig",null],["010595937145","Munkbrarup",null],["010595937157","Ringsberg",null],["010595937176","Wees",null],["010595937178","Westerholz",null],["010595940159","Sieverstedt",null],["010595940171","Tarp",null],["010595940184","Oeversee",null],["010595949076","Schnarup-Thumby",null],["010595949161","Sörup",null],["010595949185","Mittelangeln",null],["010595952105","Böxlund",null],["010595952115","Großenwiehe",null],["010595952123","Hörup",null],["010595952124","Holt",null],["010595952129","Jardelund",null],["010595952143","Medelby",null],["010595952144","Meyn",null],["010595952149","Nordhackstedt",null],["010595952151","Osterby",null],["010595952158","Schafflund",null],["010595952173","Wallsbüll",null],["010595952177","Weesby",null],["010595952179","Lindewitt",null],["010595974006","Böel",null],["010595974055","Loit",null],["010595974060","Mohrkirch",null],["010595974063","Norderbrarup",null],["010595974065","Nottfeld",null],["010595974070","Rügge",null],["010595974072","Saustrup",null],["010595974074","Scheggerott",null],["010595974080","Steinfeld",null],["010595974083","Süderbrarup",null],["010595974094","Ulsnis",null],["010595974095","Wagersrott",null],["010595974187","Boren",null],["010595987008","Böklund",null],["010595987037","Havetoft",null],["010595987042","Idstedt",null],["010595987049","Klappholz",null],["010595987062","Neuberend",null],["010595987073","Schaalby",null],["010595987081","Stolk",null],["010595987082","Struxdorf",null],["010595987084","Süderfahrenstedt",null],["010595987086","Taarstedt",null],["010595987090","Tolk",null],["010595987093","Uelsby",null],["010595987097","Twedt",null],["010595987098","Nübel",null],["010595987189","Brodersby-Goltoft",null],["010595990102","Ahneby",null],["010595990109","Esgrus",null],["010595990112","Gelting",null],["010595990121","Hasselberg",null],["010595990136","Kronsgaard",null],["010595990142","Maasholm",null],["010595990147","Nieby",null],["010595990148","Niesgrau",null],["010595990152","Pommerby",null],["010595990154","Rabel",null],["010595990155","Rabenholz",null],["010595990163","Stangheck",null],["010595990164","Steinberg",null],["010595990167","Sterup",null],["010595990168","Stoltebüll",null],["010595990186","Steinbergkirche",null],["010595993010","Bollingstedt",null],["010595993023","Ellingstedt",null],["010595993039","Hollingstedt",null],["010595993041","Hüsby",null],["010595993044","Jübek",null],["010595993057","Lürschau",null],["010595993077","Schuby",null],["010595993079","Silberstedt",null],["010595993092","Treia",null],["010595996001","Alt Bennebek",null],["010595996005","Bergenhusen",null],["010595996009","Börm",null],["010595996020","Dörpstedt",null],["010595996024","Erfde",null],["010595996035","Groß Rheide",null],["010595996050","Klein Bennebek",null],["010595996051","Klein Rheide",null],["010595996053","Kropp",null],["010595996058","Meggerdorf",null],["010595996087","Tetenhusen",null],["010595996088","Tielen",null],["010595996096","Wohlde",null],["010595996188","Stapel",null],["010600004004","Bad Bramstedt, Stadt",null],["010600005005","Bad Segeberg, Stadt",null],["010600019019","Ellerau",null],["010600039039","Henstedt-Ulzburg",null],["010600044044","Kaltenkirchen, Stadt",null],["010600063063","Norderstedt, Stadt",null],["010600092092","Wahlstedt, Stadt",null],["010605005003","Armstedt",null],["010605005009","Bimöhlen",null],["010605005013","Borstel",null],["010605005021","Föhrden-Barl",null],["010605005023","Fuhlendorf",null],["010605005027","Großenaspe",null],["010605005031","Hagen",null],["010605005033","Hardebek",null],["010605005035","Hasenkrug",null],["010605005037","Heidmoor",null],["010605005040","Hitzhusen",null],["010605005056","Mönkloh",null],["010605005095","Weddelbrook",null],["010605005099","Wiemersdorf",null],["010605024012","Bornhöved",null],["010605024017","Damsdorf",null],["010605024026","Gönnebek",null],["010605024072","Schmalensee",null],["010605024080","Stocksee",null],["010605024086","Tarbek",null],["010605024087","Tensfeld",null],["010605024089","Trappenkamp",null],["010605034043","Itzstedt",null],["010605034046","Kayhude",null],["010605034058","Nahe",null],["010605034065","Oering",null],["010605034076","Seth",null],["010605034085","Sülfeld",null],["010605043002","Alveslohe",null],["010605043034","Hartenholm",null],["010605043036","Hasenmoor",null],["010605043054","Lentföhrden",null],["010605043064","Nützen",null],["010605043073","Schmalfeld",null],["010605048042","Hüttblek",null],["010605048045","Kattendorf",null],["010605048047","Kisdorf",null],["010605048066","Oersdorf",null],["010605048077","Sievershütten",null],["010605048082","Struvenhütten",null],["010605048084","Stuvenborn",null],["010605048094","Wakendorf II",null],["010605048100","Winsen",null],["010605053007","Bark",null],["010605053008","Bebensee",null],["010605053022","Fredesdorf",null],["010605053029","Groß Niendorf",null],["010605053041","Högersdorf",null],["010605053051","Kükels",null],["010605053053","Leezen",null],["010605053057","Mözen",null],["010605053062","Neversdorf",null],["010605053074","Schwissel",null],["010605053088","Todesfelde",null],["010605053101","Wittenborn",null],["010605063011","Boostedt",null],["010605063016","Daldorf",null],["010605063028","Groß Kummerfeld",null],["010605063038","Heidmühlen",null],["010605063052","Latendorf",null],["010605063068","Rickling",null],["010605086006","Bahrenhof",null],["010605086010","Blunk",null],["010605086015","Bühnsdorf",null],["010605086018","Dreggers",null],["010605086020","Fahrenkrug",null],["010605086024","Geschendorf",null],["010605086025","Glasau",null],["010605086030","Groß Rönnau",null],["010605086048","Klein Gladebrügge",null],["010605086049","Klein Rönnau",null],["010605086050","Krems II",null],["010605086059","Negernbötel",null],["010605086060","Nehms",null],["010605086061","Neuengörs",null],["010605086067","Pronstorf",null],["010605086069","Rohlstorf",null],["010605086070","Schackendorf",null],["010605086071","Schieren",null],["010605086075","Seedorf",null],["010605086079","Stipsdorf",null],["010605086081","Strukdorf",null],["010605086090","Travenhorst",null],["010605086091","Traventhal",null],["010605086093","Wakendorf I",null],["010605086096","Weede",null],["010605086097","Wensin",null],["010605086098","Westerrade",null],["010609014014","Buchholz (Forstgutsbez.),gemfr. Gebiet",null],["010610029029","Glückstadt, Stadt",null],["010610046046","Itzehoe, Stadt",null],["010610113113","Wilster, Stadt",null],["010615104005","Auufer",null],["010615104016","Breitenberg",null],["010615104017","Breitenburg",null],["010615104053","Kollmoor",null],["010615104058","Kronsmoor",null],["010615104061","Lägerdorf",null],["010615104068","Moordiek",null],["010615104072","Münsterdorf",null],["010615104079","Oelixdorf",null],["010615104109","Westermoor",null],["010615104115","Wittenbergen",null],["010615134004","Altenmoor",null],["010615134012","Blomesche Wildnis",null],["010615134015","Borsfleth",null],["010615134027","Engelbrechtsche Wildnis",null],["010615134037","Herzhorn",null],["010615134041","Hohenfelde",null],["010615134044","Horst (Holstein)",null],["010615134050","Kiebitzreihe",null],["010615134054","Krempdorf",null],["010615134074","Neuendorf b. Elmshorn",null],["010615134101","Sommerland",null],["010615134118","Kollmar",null],["010615138008","Bekdorf",null],["010615138010","Bekmünde",null],["010615138024","Drage",null],["010615138034","Heiligenstedten",null],["010615138035","Heiligenstedtenerkamp",null],["010615138039","Hodorf",null],["010615138040","Hohenaspe",null],["010615138045","Huje",null],["010615138047","Kaaks",null],["010615138052","Kleve",null],["010615138059","Krummendiek",null],["010615138065","Lohbarbek",null],["010615138067","Mehlbek",null],["010615138070","Moorhusen",null],["010615138082","Oldendorf",null],["010615138083","Ottenbüttel",null],["010615138084","Peissen",null],["010615138098","Schlotfeld",null],["010615138100","Silzen",null],["010615138114","Winseldorf",null],["010615153006","Bahrenfleth",null],["010615153022","Dägeling",null],["010615153026","Elskop",null],["010615153030","Grevenkop",null],["010615153055","Krempe, Stadt",null],["010615153056","Kremperheide",null],["010615153057","Krempermoor",null],["010615153073","Neuenbrook",null],["010615153092","Rethwisch",null],["010615153104","Süderau",null],["010615168001","Aasbüttel",null],["010615168003","Agethorst",null],["010615168011","Besdorf",null],["010615168013","Bokelrehm",null],["010615168014","Bokhorst",null],["010615168021","Christinenthal",null],["010615168031","Gribbohm",null],["010615168033","Hadenfeld",null],["010615168043","Holstenniendorf",null],["010615168048","Kaisborstel",null],["010615168066","Looft",null],["010615168076","Nienbüttel",null],["010615168078","Nutteln",null],["010615168081","Oldenborstel",null],["010615168085","Pöschendorf",null],["010615168087","Puls",null],["010615168091","Reher",null],["010615168097","Schenefeld",null],["010615168105","Vaale",null],["010615168106","Vaalermoor",null],["010615168107","Wacken",null],["010615168108","Warringholz",null],["010615179002","Aebtissinwisch",null],["010615179007","Beidenfleth",null],["010615179018","Brokdorf",null],["010615179020","Büttel",null],["010615179023","Dammfleth",null],["010615179025","Ecklak",null],["010615179060","Kudensee",null],["010615179062","Landrecht",null],["010615179063","Landscheide",null],["010615179077","Nortorf",null],["010615179095","Sankt Margarethen",null],["010615179102","Stördorf",null],["010615179110","Wewelsfleth",null],["010615179119","Neuendorf-Sachsenbande",null],["010615189019","Brokstedt",null],["010615189028","Fitzbek",null],["010615189036","Hennstedt",null],["010615189038","Hingstheide",null],["010615189042","Hohenlockstedt",null],["010615189049","Kellinghusen, Stadt",null],["010615189064","Lockstedt",null],["010615189071","Mühlenbarbek",null],["010615189080","Oeschebüttel",null],["010615189086","Poyenberg",null],["010615189088","Quarnstedt",null],["010615189089","Rade",null],["010615189093","Rosdorf",null],["010615189096","Sarlhusen",null],["010615189103","Störkathen",null],["010615189111","Wiedenborstel",null],["010615189112","Willenscharen",null],["010615189116","Wrist",null],["010615189117","Wulfsmoor",null],["010620001001","Ahrensburg, Stadt",null],["010620004004","Bad Oldesloe, Stadt",null],["010620006006","Bargteheide, Stadt",null],["010620009009","Barsbüttel",null],["010620018018","Glinde, Stadt",null],["010620023023","Großhansdorf",null],["010620053053","Oststeinbek",null],["010620060060","Reinbek, Stadt",null],["010620061061","Reinfeld (Holstein), Stadt",null],["010620076076","Tangstedt",null],["010620090090","Ammersbek",null],["010625207019","Grabau",null],["010625207046","Meddewade",null],["010625207050","Neritz",null],["010625207056","Pölitz",null],["010625207062","Rethwisch",null],["010625207065","Rümpel",null],["010625207089","Lasbek",null],["010625207091","Steinburg",null],["010625207092","Travenbrück",null],["010625218005","Bargfeld-Stegen",null],["010625218014","Delingsdorf",null],["010625218016","Elmenhorst",null],["010625218027","Hammoor",null],["010625218036","Jersbek",null],["010625218051","Nienwohld",null],["010625218078","Todendorf",null],["010625218081","Tremsbüttel",null],["010625244003","Badendorf",null],["010625244008","Barnitz",null],["010625244025","Hamberge",null],["010625244031","Heidekamp",null],["010625244032","Heilshoop",null],["010625244039","Klein Wesenberg",null],["010625244048","Mönkhagen",null],["010625244059","Rehhorst",null],["010625244083","Westerau",null],["010625244087","Zarpen",null],["010625244093","Feldhorst",null],["010625244094","Wesenberg",null],["010625262011","Braak",null],["010625262035","Hoisdorf",null],["010625262069","Siek",null],["010625262071","Stapelfeld",null],["010625262088","Brunsbek",null],["010625270020","Grande",null],["010625270021","Grönwohld",null],["010625270022","Großensee",null],["010625270026","Hamfelde",null],["010625270033","Hohenfelde",null],["010625270040","Köthel",null],["010625270045","Lütjensee",null],["010625270058","Rausdorf",null],["010625270082","Trittau",null],["010625270086","Witzhave",null],["020000000000","Hamburg, Freie und Hansestadt",null],["021010101101","Hamburg-Altstadt, OT 101","Stadt-/Ortsteil bzw. Stadtbezirk"],["021010102102","Hamburg-Altstadt, OT 102","Stadt-/Ortsteil bzw. Stadtbezirk"],["021020103103","HafenCity, OT 103","Stadt-/Ortsteil bzw. Stadtbezirk"],["021020104104","HafenCity, OT 104","Stadt-/Ortsteil bzw. Stadtbezirk"],["021030105105","Neustadt, OT 105","Stadt-/Ortsteil bzw. Stadtbezirk"],["021030106106","Neustadt, OT 106","Stadt-/Ortsteil bzw. Stadtbezirk"],["021030107107","Neustadt, OT 107","Stadt-/Ortsteil bzw. Stadtbezirk"],["021030108108","Neustadt, OT 108","Stadt-/Ortsteil bzw. Stadtbezirk"],["021040109109","St. Pauli, OT 109","Stadt-/Ortsteil bzw. Stadtbezirk"],["021040110110","St. Pauli, OT 110","Stadt-/Ortsteil bzw. Stadtbezirk"],["021040111111","St. Pauli, OT 111","Stadt-/Ortsteil bzw. Stadtbezirk"],["021040112112","St. Pauli, OT 112","Stadt-/Ortsteil bzw. Stadtbezirk"],["021050113113","St. Georg, OT 113","Stadt-/Ortsteil bzw. Stadtbezirk"],["021050114114","St. Georg, OT 114","Stadt-/Ortsteil bzw. Stadtbezirk"],["021060115115","Hammerbrook, OT 115","Stadt-/Ortsteil bzw. Stadtbezirk"],["021060116116","Hammerbrook, OT 116","Stadt-/Ortsteil bzw. Stadtbezirk"],["021060117117","Hammerbrook, OT 117","Stadt-/Ortsteil bzw. Stadtbezirk"],["021060118118","Hammerbrook, OT 118","Stadt-/Ortsteil bzw. Stadtbezirk"],["021070119119","Borgfelde, OT 119","Stadt-/Ortsteil bzw. Stadtbezirk"],["021070120120","Borgfelde, OT 120","Stadt-/Ortsteil bzw. Stadtbezirk"],["021080121121","Hamm, OT 121","Stadt-/Ortsteil bzw. Stadtbezirk"],["021080122122","Hamm, OT 122","Stadt-/Ortsteil bzw. Stadtbezirk"],["021080123123","Hamm, OT 123","Stadt-/Ortsteil bzw. Stadtbezirk"],["021080124124","Hamm, OT 124","Stadt-/Ortsteil bzw. Stadtbezirk"],["021080125125","Hamm, OT 125","Stadt-/Ortsteil bzw. Stadtbezirk"],["021080126126","Hamm, OT 126","Stadt-/Ortsteil bzw. Stadtbezirk"],["021080127127","Hamm, OT 127","Stadt-/Ortsteil bzw. Stadtbezirk"],["021110128128","Horn, OT 128","Stadt-/Ortsteil bzw. Stadtbezirk"],["021110129129","Horn, OT 129","Stadt-/Ortsteil bzw. Stadtbezirk"],["021120130130","Billstedt, OT 130","Stadt-/Ortsteil bzw. Stadtbezirk"],["021130131131","Billbrook, OT 131","Stadt-/Ortsteil bzw. Stadtbezirk"],["021140132132","Rothenburgsort, OT 132","Stadt-/Ortsteil bzw. Stadtbezirk"],["021140133133","Rothenburgsort, OT 133","Stadt-/Ortsteil bzw. Stadtbezirk"],["021150134134","Veddel, OT 134","Stadt-/Ortsteil bzw. Stadtbezirk"],["021160135135","Wilhelmsburg, OT 135","Stadt-/Ortsteil bzw. Stadtbezirk"],["021160136136","Wilhelmsburg, OT 136","Stadt-/Ortsteil bzw. Stadtbezirk"],["021160137137","Wilhelmsburg, OT 137","Stadt-/Ortsteil bzw. Stadtbezirk"],["021170138138","Kleiner Grasbrook, OT 138","Stadt-/Ortsteil bzw. Stadtbezirk"],["021180139139","Steinwerder, OT 139","Stadt-/Ortsteil bzw. Stadtbezirk"],["021190140140","Waltershof, OT 140","Stadt-/Ortsteil bzw. Stadtbezirk"],["021200141141","Finkenwerder, OT 141","Stadt-/Ortsteil bzw. Stadtbezirk"],["021210142142","Neuwerk, OT 142","Stadt-/Ortsteil bzw. Stadtbezirk"],["021220150150","Seeleute/Binnenschiffer, OT 150","Stadt-/Ortsteil bzw. Stadtbezirk"],["022010201201","Altona-Altstadt, OT 201","Stadt-/Ortsteil bzw. Stadtbezirk"],["022010202202","Altona-Altstadt, OT 202","Stadt-/Ortsteil bzw. Stadtbezirk"],["022010203203","Altona-Altstadt, OT 203","Stadt-/Ortsteil bzw. Stadtbezirk"],["022010204204","Altona-Altstadt, OT 204","Stadt-/Ortsteil bzw. Stadtbezirk"],["022010205205","Altona-Altstadt, OT 205","Stadt-/Ortsteil bzw. Stadtbezirk"],["022010206206","Altona-Altstadt, OT 206","Stadt-/Ortsteil bzw. Stadtbezirk"],["022020207207","Sternschanze, OT 207","Stadt-/Ortsteil bzw. Stadtbezirk"],["022030208208","Altona-Nord, OT 208","Stadt-/Ortsteil bzw. Stadtbezirk"],["022030209209","Altona-Nord, OT 209","Stadt-/Ortsteil bzw. Stadtbezirk"],["022030210210","Altona-Nord, OT 210","Stadt-/Ortsteil bzw. Stadtbezirk"],["022040211211","Ottensen, OT 211","Stadt-/Ortsteil bzw. Stadtbezirk"],["022040212212","Ottensen, OT 212","Stadt-/Ortsteil bzw. Stadtbezirk"],["022040213213","Ottensen, OT 213","Stadt-/Ortsteil bzw. Stadtbezirk"],["022040214214","Ottensen, OT 214","Stadt-/Ortsteil bzw. Stadtbezirk"],["022050215215","Bahrenfeld, OT 215","Stadt-/Ortsteil bzw. Stadtbezirk"],["022050216216","Bahrenfeld, OT 216","Stadt-/Ortsteil bzw. Stadtbezirk"],["022050217217","Bahrenfeld, OT 217","Stadt-/Ortsteil bzw. Stadtbezirk"],["022060218218","Groß Flottbek, OT 218","Stadt-/Ortsteil bzw. Stadtbezirk"],["022070219219","Othmarschen, OT 219","Stadt-/Ortsteil bzw. Stadtbezirk"],["022080220220","Lurup, OT 220","Stadt-/Ortsteil bzw. Stadtbezirk"],["022090221221","Osdorf, OT 221","Stadt-/Ortsteil bzw. Stadtbezirk"],["022100222222","Nienstedten, OT 222","Stadt-/Ortsteil bzw. Stadtbezirk"],["022110223223","Blankenese, OT 223","Stadt-/Ortsteil bzw. Stadtbezirk"],["022110224224","Blankenese, OT 224","Stadt-/Ortsteil bzw. Stadtbezirk"],["022120225225","Iserbrook, OT 225","Stadt-/Ortsteil bzw. Stadtbezirk"],["022130226226","Sülldorf, OT 226","Stadt-/Ortsteil bzw. Stadtbezirk"],["022140227227","Rissen, OT 227","Stadt-/Ortsteil bzw. Stadtbezirk"],["023010301301","Eimsbüttel, OT 301","Stadt-/Ortsteil bzw. Stadtbezirk"],["023010302302","Eimsbüttel, OT 302","Stadt-/Ortsteil bzw. Stadtbezirk"],["023010303303","Eimsbüttel, OT 303","Stadt-/Ortsteil bzw. Stadtbezirk"],["023010304304","Eimsbüttel, OT 304","Stadt-/Ortsteil bzw. Stadtbezirk"],["023010305305","Eimsbüttel, OT 305","Stadt-/Ortsteil bzw. Stadtbezirk"],["023010306306","Eimsbüttel, OT 306","Stadt-/Ortsteil bzw. Stadtbezirk"],["023010307307","Eimsbüttel, OT 307","Stadt-/Ortsteil bzw. Stadtbezirk"],["023010308308","Eimsbüttel, OT 308","Stadt-/Ortsteil bzw. Stadtbezirk"],["023010309309","Eimsbüttel, OT 309","Stadt-/Ortsteil bzw. Stadtbezirk"],["023010310310","Eimsbüttel, OT 310","Stadt-/Ortsteil bzw. Stadtbezirk"],["023020311311","Rotherbaum, OT 311","Stadt-/Ortsteil bzw. Stadtbezirk"],["023020312312","Rotherbaum, OT 312","Stadt-/Ortsteil bzw. Stadtbezirk"],["023030313313","Harvestehude, OT 313","Stadt-/Ortsteil bzw. Stadtbezirk"],["023030314314","Harvestehude, OT 314","Stadt-/Ortsteil bzw. Stadtbezirk"],["023040315315","Hoheluft-West, OT 315","Stadt-/Ortsteil bzw. Stadtbezirk"],["023040316316","Hoheluft-West, OT 316","Stadt-/Ortsteil bzw. Stadtbezirk"],["023050317317","Lokstedt, OT 317","Stadt-/Ortsteil bzw. Stadtbezirk"],["023060318318","Niendorf, OT 318","Stadt-/Ortsteil bzw. Stadtbezirk"],["023070319319","Schnelsen, OT 319","Stadt-/Ortsteil bzw. Stadtbezirk"],["023080320320","Eidelstedt, OT 320","Stadt-/Ortsteil bzw. Stadtbezirk"],["023090321321","Stellingen, OT 321","Stadt-/Ortsteil bzw. Stadtbezirk"],["024010401401","Hoheluft-Ost, OT 401","Stadt-/Ortsteil bzw. Stadtbezirk"],["024010402402","Hoheluft-Ost, OT 402","Stadt-/Ortsteil bzw. Stadtbezirk"],["024020403403","Eppendorf, OT 403","Stadt-/Ortsteil bzw. Stadtbezirk"],["024020404404","Eppendorf, OT 404","Stadt-/Ortsteil bzw. Stadtbezirk"],["024020405405","Eppendorf, OT 405","Stadt-/Ortsteil bzw. Stadtbezirk"],["024030406406","Gross Borstel, OT 406","Stadt-/Ortsteil bzw. Stadtbezirk"],["024040407407","Alsterdorf, OT 407","Stadt-/Ortsteil bzw. Stadtbezirk"],["024050408408","Winterhude, OT 408","Stadt-/Ortsteil bzw. Stadtbezirk"],["024050409409","Winterhude, OT 409","Stadt-/Ortsteil bzw. Stadtbezirk"],["024050410410","Winterhude, OT 410","Stadt-/Ortsteil bzw. Stadtbezirk"],["024050411411","Winterhude, OT 411","Stadt-/Ortsteil bzw. Stadtbezirk"],["024050412412","Winterhude, OT 412","Stadt-/Ortsteil bzw. Stadtbezirk"],["024050413413","Winterhude, OT 413","Stadt-/Ortsteil bzw. Stadtbezirk"],["024060414414","Uhlenhorst, OT 414","Stadt-/Ortsteil bzw. Stadtbezirk"],["024060415415","Uhlenhorst, OT 415","Stadt-/Ortsteil bzw. Stadtbezirk"],["024070416416","Hohenfelde, OT 416","Stadt-/Ortsteil bzw. Stadtbezirk"],["024070417417","Hohenfelde, OT 417","Stadt-/Ortsteil bzw. Stadtbezirk"],["024080418418","Barmbek-Süd, OT 418","Stadt-/Ortsteil bzw. Stadtbezirk"],["024080419419","Barmbek-Süd, OT 419","Stadt-/Ortsteil bzw. Stadtbezirk"],["024080420420","Barmbek-Süd, OT 420","Stadt-/Ortsteil bzw. Stadtbezirk"],["024080421421","Barmbek-Süd, OT 421","Stadt-/Ortsteil bzw. Stadtbezirk"],["024080422422","Barmbek-Süd, OT 422","Stadt-/Ortsteil bzw. Stadtbezirk"],["024080423423","Barmbek-Süd, OT 423","Stadt-/Ortsteil bzw. Stadtbezirk"],["024090424424","Dulsberg, OT 424","Stadt-/Ortsteil bzw. Stadtbezirk"],["024090425425","Dulsberg, OT 425","Stadt-/Ortsteil bzw. Stadtbezirk"],["024100426426","Barmbek-Nord, OT 426","Stadt-/Ortsteil bzw. Stadtbezirk"],["024100427427","Barmbek-Nord, OT 427","Stadt-/Ortsteil bzw. Stadtbezirk"],["024100428428","Barmbek-Nord, OT 428","Stadt-/Ortsteil bzw. Stadtbezirk"],["024100429429","Barmbek-Nord, OT 429","Stadt-/Ortsteil bzw. Stadtbezirk"],["024110430430","Ohlsdorf, OT 430","Stadt-/Ortsteil bzw. Stadtbezirk"],["024120431431","Fuhlsbüttel, OT 431","Stadt-/Ortsteil bzw. Stadtbezirk"],["024130432432","Langenhorn, OT 432","Stadt-/Ortsteil bzw. Stadtbezirk"],["025010501501","Eilbek, OT 501","Stadt-/Ortsteil bzw. Stadtbezirk"],["025010502502","Eilbek, OT 502","Stadt-/Ortsteil bzw. Stadtbezirk"],["025010503503","Eilbek, OT 503","Stadt-/Ortsteil bzw. Stadtbezirk"],["025010504504","Eilbek, OT 504","Stadt-/Ortsteil bzw. Stadtbezirk"],["025020505505","Wandsbek, OT 505","Stadt-/Ortsteil bzw. Stadtbezirk"],["025020506506","Wandsbek, OT 506","Stadt-/Ortsteil bzw. Stadtbezirk"],["025020507507","Wandsbek, OT 507","Stadt-/Ortsteil bzw. Stadtbezirk"],["025020508508","Wandsbek, OT 508","Stadt-/Ortsteil bzw. Stadtbezirk"],["025020509509","Wandsbek, OT 509","Stadt-/Ortsteil bzw. Stadtbezirk"],["025030510510","Marienthal, OT 510","Stadt-/Ortsteil bzw. Stadtbezirk"],["025030511511","Marienthal, OT 511","Stadt-/Ortsteil bzw. Stadtbezirk"],["025040512512","Jenfeld, OT 512","Stadt-/Ortsteil bzw. Stadtbezirk"],["025050513513","Tonndorf, OT 513","Stadt-/Ortsteil bzw. Stadtbezirk"],["025060514514","Farmsen-Berne, OT 514","Stadt-/Ortsteil bzw. Stadtbezirk"],["025070515515","Bramfeld, OT 515","Stadt-/Ortsteil bzw. Stadtbezirk"],["025080516516","Steilshoop, OT 516","Stadt-/Ortsteil bzw. Stadtbezirk"],["025090517517","Wellingsbüttel, OT 517","Stadt-/Ortsteil bzw. Stadtbezirk"],["025100518518","Sasel, OT 518","Stadt-/Ortsteil bzw. Stadtbezirk"],["025110519519","Poppenbüttel, OT 519","Stadt-/Ortsteil bzw. Stadtbezirk"],["025120520520","Hummelsbüttel, OT 520","Stadt-/Ortsteil bzw. Stadtbezirk"],["025130521521","Lemsahl-Mellingstedt, OT 521","Stadt-/Ortsteil bzw. Stadtbezirk"],["025140522522","Duvenstedt, OT 522","Stadt-/Ortsteil bzw. Stadtbezirk"],["025150523523","Wohldorf-Ohlstedt, OT 523","Stadt-/Ortsteil bzw. Stadtbezirk"],["025160524524","Bergstedt, OT 524","Stadt-/Ortsteil bzw. Stadtbezirk"],["025170525525","Volksdorf, OT 525","Stadt-/Ortsteil bzw. Stadtbezirk"],["025180526526","Rahlstedt, OT 526","Stadt-/Ortsteil bzw. Stadtbezirk"],["026010601601","Lohbrügge, OT 601","Stadt-/Ortsteil bzw. Stadtbezirk"],["026020602602","Bergedorf, OT 602","Stadt-/Ortsteil bzw. Stadtbezirk"],["026020603603","Bergedorf, OT 603","Stadt-/Ortsteil bzw. Stadtbezirk"],["026030604604","Curslack, OT 604","Stadt-/Ortsteil bzw. Stadtbezirk"],["026040605605","Altengamme, OT 605","Stadt-/Ortsteil bzw. Stadtbezirk"],["026050606606","Neuengamme, OT 606","Stadt-/Ortsteil bzw. Stadtbezirk"],["026060607607","Kirchwerder, OT 607","Stadt-/Ortsteil bzw. Stadtbezirk"],["026070608608","Ochsenwerder, OT 608","Stadt-/Ortsteil bzw. Stadtbezirk"],["026080609609","Reitbrook, OT 609","Stadt-/Ortsteil bzw. Stadtbezirk"],["026090610610","Allermöhe, OT 610","Stadt-/Ortsteil bzw. Stadtbezirk"],["026100611611","Billwerder, OT 611","Stadt-/Ortsteil bzw. Stadtbezirk"],["026110612612","Moorfleet, OT 612","Stadt-/Ortsteil bzw. Stadtbezirk"],["026120613613","Tatenberg, OT 613","Stadt-/Ortsteil bzw. Stadtbezirk"],["026130614614","Spadenland, OT 614","Stadt-/Ortsteil bzw. Stadtbezirk"],["026140615615","Neuallermöhe, OT 615","Stadt-/Ortsteil bzw. Stadtbezirk"],["027010701701","Harburg, OT 701","Stadt-/Ortsteil bzw. Stadtbezirk"],["027010702702","Harburg, OT 702","Stadt-/Ortsteil bzw. Stadtbezirk"],["027020703703","Neuland, OT 703","Stadt-/Ortsteil bzw. Stadtbezirk"],["027030704704","Gut Moor, OT 704","Stadt-/Ortsteil bzw. Stadtbezirk"],["027040705705","Wilstorf, OT 705","Stadt-/Ortsteil bzw. Stadtbezirk"],["027050706706","Rönneburg, OT 706","Stadt-/Ortsteil bzw. Stadtbezirk"],["027060707707","Langenbek, OT 707","Stadt-/Ortsteil bzw. Stadtbezirk"],["027070708708","Sinstorf, OT 708","Stadt-/Ortsteil bzw. Stadtbezirk"],["027080709709","Marmstorf, OT 709","Stadt-/Ortsteil bzw. Stadtbezirk"],["027090710710","Eissendorf, OT 710","Stadt-/Ortsteil bzw. Stadtbezirk"],["027100711711","Heimfeld, OT 711","Stadt-/Ortsteil bzw. Stadtbezirk"],["027110712712","Moorburg, OT 712","Stadt-/Ortsteil bzw. Stadtbezirk"],["027120713713","Altenwerder, OT 713","Stadt-/Ortsteil bzw. Stadtbezirk"],["027130714714","Hausbruch, OT 714","Stadt-/Ortsteil bzw. Stadtbezirk"],["027140715715","Neugraben-Fischbek, OT 715","Stadt-/Ortsteil bzw. Stadtbezirk"],["027150716716","Francop, OT 716","Stadt-/Ortsteil bzw. Stadtbezirk"],["027160717717","Neuenfelde, OT 717","Stadt-/Ortsteil bzw. Stadtbezirk"],["027170718718","Cranz, OT 718","Stadt-/Ortsteil bzw. Stadtbezirk"],["031010000000","Braunschweig, Stadt",null],["031020000000","Salzgitter, Stadt",null],["031030000000","Wolfsburg, Stadt",null],["031510009009","Gifhorn, Stadt",null],["031510025025","Sassenburg",null],["031510040040","Wittingen, Stadt",null],["031515401002","Barwedel",null],["031515401004","Bokensdorf",null],["031515401014","Jembke",null],["031515401020","Osloß",null],["031515401030","Tappenbeck",null],["031515401039","Weyhausen",null],["031515402003","Bergfeld",null],["031515402005","Brome, Flecken",null],["031515402008","Ehra-Lessien",null],["031515402021","Parsau",null],["031515402024","Rühen",null],["031515402031","Tiddische",null],["031515402032","Tülau",null],["031515403007","Dedelstorf",null],["031515403011","Hankensbüttel",null],["031515403019","Obernholz",null],["031515403028","Sprakensehl",null],["031515403029","Steinhorst",null],["031515404006","Calberlah",null],["031515404013","Isenbüttel",null],["031515404022","Ribbesbüttel",null],["031515404037","Wasbüttel",null],["031515405012","Hillerse",null],["031515405015","Leiferde",null],["031515405017","Meinersen",null],["031515405018","Müden (Aller)",null],["031515406001","Adenbüttel",null],["031515406016","Meine",null],["031515406023","Rötgesbüttel",null],["031515406027","Schwülper",null],["031515406034","Vordorf",null],["031515406041","Didderse",null],["031515407010","Groß Oesingen",null],["031515407026","Schönewörde",null],["031515407033","Ummern",null],["031515407035","Wagenhoff",null],["031515407036","Wahrenholz",null],["031515407038","Wesendorf",null],["031519501501","Giebel, gemfr. Gebiet",null],["031530002002","Bad Harzburg, Stadt",null],["031530007007","Langelsheim, Stadt",null],["031530008008","Liebenburg",null],["031530012012","Seesen, Stadt",null],["031530016016","Braunlage, Stadt",null],["031530017017","Goslar, Stadt",null],["031530018018","Clausthal-Zellerfeld, Berg- und Universitätsstadt",null],["031535401006","Hahausen",null],["031535401009","Lutter am Barenberge, Flecken",null],["031535401014","Wallmoden",null],["031539504504","Harz (Landkreis Goslar), gemfr. Gebiet",null],["031540013013","Königslutter am Elm, Stadt",null],["031540014014","Lehre",null],["031540019019","Schöningen, Stadt",null],["031540028028","Helmstedt, Stadt",null],["031545401008","Grasleben",null],["031545401015","Mariental",null],["031545401016","Querenhorst",null],["031545401018","Rennau",null],["031545402002","Beierstedt",null],["031545402006","Gevensleben",null],["031545402012","Jerxheim",null],["031545402027","Söllingen",null],["031545403005","Frellstedt",null],["031545403017","Räbke",null],["031545403021","Süpplingen",null],["031545403022","Süpplingenburg",null],["031545403025","Warberg",null],["031545403026","Wolsdorf",null],["031545404001","Bahrdorf",null],["031545404004","Danndorf",null],["031545404007","Grafhorst",null],["031545404009","Groß Twülpstedt",null],["031545404024","Velpke",null],["031549501501","Brunsleberfeld, gemfr. Gebiet",null],["031549502502","Helmstedt, gemfr. Gebiet",null],["031549503503","Königslutter, gemfr. Gebiet",null],["031549504504","Mariental, gemfr. Gebiet",null],["031549506506","Schöningen, gemfr. Gebiet",null],["031550001001","Bad Gandersheim, Stadt",null],["031550002002","Bodenfelde, Flecken",null],["031550003003","Dassel, Stadt",null],["031550005005","Hardegsen, Stadt",null],["031550006006","Kalefeld",null],["031550007007","Katlenburg-Lindau",null],["031550009009","Moringen, Stadt",null],["031550010010","Nörten-Hardenberg, Flecken",null],["031550011011","Northeim, Stadt",null],["031550012012","Uslar, Stadt",null],["031550013013","Einbeck, Stadt",null],["031559501501","Solling (Landkreis Northeim), gemfr. Geb.",null],["031570001001","Edemissen",null],["031570002002","Hohenhameln",null],["031570005005","Lengede",null],["031570006006","Peine, Stadt",null],["031570007007","Vechelde",null],["031570008008","Wendeburg",null],["031570009009","Ilsede",null],["031580006006","Cremlingen",null],["031580037037","Wolfenbüttel, Stadt",null],["031580039039","Schladen-Werla",null],["031585402002","Baddeckenstedt",null],["031585402004","Burgdorf",null],["031585402011","Elbe",null],["031585402016","Haverlah",null],["031585402018","Heere",null],["031585402028","Sehlde",null],["031585403005","Cramme",null],["031585403010","Dorstadt",null],["031585403014","Flöthe",null],["031585403019","Heiningen",null],["031585403023","Ohrum",null],["031585403038","Börßum",null],["031585406009","Dettum",null],["031585406012","Erkerode",null],["031585406013","Evessen",null],["031585406030","Sickte",null],["031585406033","Veltheim (Ohe)",null],["031585407007","Dahlum",null],["031585407008","Denkte",null],["031585407017","Hedeper",null],["031585407021","Kissenbrück",null],["031585407022","Kneitlingen",null],["031585407025","Roklum",null],["031585407027","Schöppenstedt, Stadt",null],["031585407031","Uehrde",null],["031585407032","Vahlberg",null],["031585407035","Winnigstedt",null],["031585407036","Wittmar",null],["031585407040","Remlingen-Semmenstedt",null],["031589501501","Am Großen Rhode, gemfr. Gebiet",null],["031589502502","Barnstorf-Warle, gemfr. Gebiet",null],["031589503503","Voigtsdahlum, gemfr. Gebiet",null],["031590001001","Adelebsen, Flecken",null],["031590002002","Bad Grund (Harz)",null],["031590003003","Bad Lauterberg im Harz, Stadt",null],["031590004004","Bad Sachsa, Stadt",null],["031590007007","Bovenden, Flecken",null],["031590010010","Duderstadt, Stadt",null],["031590013013","Friedland",null],["031590015015","Gleichen",null],["031590016016","Göttingen, Stadt",null],["031590017017","Hann. Münden, Stadt",null],["031590019019","Herzberg am Harz, Stadt",null],["031590026026","Osterode am Harz, Stadt",null],["031590029029","Rosdorf",null],["031590034034","Staufenberg",null],["031590036036","Walkenried",null],["031595401008","Bühren",null],["031595401009","Dransfeld, Stadt",null],["031595401021","Jühnde",null],["031595401024","Niemetal",null],["031595401031","Scheden",null],["031595402005","Bilshausen",null],["031595402006","Bodensee",null],["031595402014","Gieboldehausen, Flecken",null],["031595402022","Krebeck",null],["031595402025","Obernfeld",null],["031595402027","Rhumspringe",null],["031595402028","Rollshausen",null],["031595402030","Rüdershausen",null],["031595402037","Wollbrandshausen",null],["031595402038","Wollershausen",null],["031595403012","Elbingerode",null],["031595403018","Hattorf am Harz",null],["031595403020","Hörden am Harz",null],["031595403039","Wulften am Harz",null],["031595404011","Ebergötzen",null],["031595404023","Landolfshausen",null],["031595404032","Seeburg",null],["031595404033","Seulingen",null],["031595404035","Waake",null],["031599501501","Harz (Landkreis Göttingen), gemfr. Geb.",null],["032410001001","Hannover, Landeshauptstadt",null],["032410002002","Barsinghausen, Stadt",null],["032410003003","Burgdorf, Stadt",null],["032410004004","Burgwedel, Stadt",null],["032410005005","Garbsen, Stadt",null],["032410006006","Gehrden, Stadt",null],["032410007007","Hemmingen, Stadt",null],["032410008008","Isernhagen",null],["032410009009","Laatzen, Stadt",null],["032410010010","Langenhagen, Stadt",null],["032410011011","Lehrte, Stadt",null],["032410012012","Neustadt am Rübenberge, Stadt",null],["032410013013","Pattensen, Stadt",null],["032410014014","Ronnenberg, Stadt",null],["032410015015","Seelze, Stadt",null],["032410016016","Sehnde, Stadt",null],["032410017017","Springe, Stadt",null],["032410018018","Uetze",null],["032410019019","Wedemark",null],["032410020020","Wennigsen (Deister)",null],["032410021021","Wunstorf, Stadt",null],["032510007007","Bassum, Stadt",null],["032510012012","Diepholz, Stadt",null],["032510037037","Stuhr",null],["032510040040","Sulingen, Stadt",null],["032510041041","Syke, Stadt",null],["032510042042","Twistringen, Stadt",null],["032510044044","Wagenfeld",null],["032510047047","Weyhe",null],["032515401009","Brockum",null],["032515401020","Hüde",null],["032515401022","Lembruch",null],["032515401023","Lemförde, Flecken",null],["032515401025","Marl",null],["032515401029","Quernheim",null],["032515401036","Stemshorn",null],["032515402005","Barnstorf, Flecken",null],["032515402013","Drebber",null],["032515402014","Drentwede",null],["032515402017","Eydelstedt",null],["032515403002","Asendorf",null],["032515403026","Martfeld",null],["032515403033","Schwarme",null],["032515403049","Bruchhausen-Vilsen, Flecken",null],["032515404003","Bahrenborstel",null],["032515404004","Barenburg, Flecken",null],["032515404018","Freistatt",null],["032515404021","Kirchdorf",null],["032515404043","Varrel",null],["032515404045","Wehrbleck",null],["032515405006","Barver",null],["032515405011","Dickel",null],["032515405019","Hemsloh",null],["032515405030","Rehden",null],["032515405046","Wetschen",null],["032515406001","Affinghausen",null],["032515406015","Ehrenburg",null],["032515406028","Neuenkirchen",null],["032515406031","Scholen",null],["032515406032","Schwaförden",null],["032515406038","Sudwalde",null],["032515407008","Borstel",null],["032515407024","Maasen",null],["032515407027","Mellinghausen",null],["032515407034","Siedenburg, Flecken",null],["032515407035","Staffhorst",null],["032520001001","Aerzen, Flecken",null],["032520002002","Bad Münder am Deister, Stadt",null],["032520003003","Bad Pyrmont, Stadt",null],["032520004004","Coppenbrügge, Flecken",null],["032520005005","Emmerthal",null],["032520006006","Hameln, Stadt",null],["032520007007","Hessisch Oldendorf, Stadt",null],["032520008008","Salzhemmendorf, Flecken",null],["032540002002","Alfeld (Leine), Stadt",null],["032540003003","Algermissen",null],["032540005005","Bad Salzdetfurth, Stadt",null],["032540008008","Bockenem, Stadt",null],["032540011011","Diekholzen",null],["032540014014","Elze, Stadt",null],["032540017017","Giesen",null],["032540020020","Harsum",null],["032540021021","Hildesheim, Stadt",null],["032540022022","Holle",null],["032540026026","Nordstemmen",null],["032540028028","Sarstedt, Stadt",null],["032540029029","Schellerten",null],["032540032032","Söhlde",null],["032540042042","Freden (Leine)",null],["032540044044","Lamspringe",null],["032540045045","Sibbesse",null],["032545406013","Eime, Flecken",null],["032545406041","Duingen, Flecken",null],["032545406043","Gronau (Leine), Stadt",null],["032550008008","Delligsen, Flecken",null],["032550023023","Holzminden, Stadt",null],["032555401002","Bevern, Flecken",null],["032555401015","Golmbach",null],["032555401021","Holenberg",null],["032555401030","Negenborn",null],["032555403004","Boffzen",null],["032555403009","Derental",null],["032555403014","Fürstenberg",null],["032555403026","Lauenförde, Flecken",null],["032555408003","Bodenwerder, Münchhausenstadt",null],["032555408005","Brevörde",null],["032555408016","Halle",null],["032555408017","Hehlen",null],["032555408019","Heinsen",null],["032555408020","Heyen",null],["032555408025","Kirchbrak",null],["032555408031","Ottenstein, Flecken",null],["032555408032","Pegestorf",null],["032555408033","Polle, Flecken",null],["032555408035","Vahlbruch",null],["032555409001","Arholzen",null],["032555409007","Deensen",null],["032555409010","Dielmissen",null],["032555409012","Eimen",null],["032555409013","Eschershausen, Stadt",null],["032555409018","Heinade",null],["032555409022","Holzen",null],["032555409027","Lenne",null],["032555409028","Lüerdissen",null],["032555409034","Stadtoldendorf, Stadt",null],["032555409036","Wangelnstedt",null],["032559501501","Boffzen, gemfr. Gebiet",null],["032559502502","Eimen, gemfr. Gebiet",null],["032559503503","Eschershausen, gemfr. Gebiet",null],["032559504504","Grünenplan, gemfr. Gebiet",null],["032559505505","Holzminden, gemfr. Gebiet",null],["032559506506","Merxhausen, gemfr. Gebiet",null],["032559508508","Wenzen, gemfr. Gebiet",null],["032560022022","Nienburg (Weser), Stadt",null],["032560025025","Rehburg-Loccum, Stadt",null],["032560030030","Steyerberg, Flecken",null],["032565402005","Drakenburg, Flecken",null],["032565402011","Haßbergen",null],["032565402012","Heemsen",null],["032565402027","Rohrsen",null],["032565405002","Binnen",null],["032565405019","Liebenau, Flecken",null],["032565405023","Pennigsehl",null],["032565406001","Balge",null],["032565406021","Marklohe",null],["032565406036","Wietzen",null],["032565407020","Linsburg",null],["032565407026","Rodewald",null],["032565407029","Steimbke",null],["032565407031","Stöckse",null],["032565408004","Diepenau, Flecken",null],["032565408024","Raddestorf",null],["032565408033","Uchte, Flecken",null],["032565408034","Warmsen",null],["032565409003","Bücken, Flecken",null],["032565409007","Eystrup",null],["032565409008","Gandesbergen",null],["032565409009","Hämelhausen",null],["032565409010","Hassel (Weser)",null],["032565409013","Hilgermissen",null],["032565409014","Hoya, Stadt",null],["032565409015","Hoyerhagen",null],["032565409028","Schweringen",null],["032565409035","Warpe",null],["032565410006","Estorf",null],["032565410016","Husum",null],["032565410017","Landesbergen",null],["032565410018","Leese",null],["032565410032","Stolzenau",null],["032570003003","Auetal",null],["032570009009","Bückeburg, Stadt",null],["032570028028","Obernkirchen, Stadt",null],["032570031031","Rinteln, Stadt",null],["032570035035","Stadthagen, Stadt",null],["032575401001","Ahnsen",null],["032575401005","Bad Eilsen",null],["032575401008","Buchholz",null],["032575401012","Heeßen",null],["032575401022","Luhden",null],["032575402007","Beckedorf",null],["032575402015","Heuerßen",null],["032575402020","Lindhorst",null],["032575402021","Lüdersfeld",null],["032575403006","Bad Nenndorf, Stadt",null],["032575403011","Haste",null],["032575403016","Hohnhorst",null],["032575403036","Suthfeld",null],["032575404019","Lauenhagen",null],["032575404023","Meerbeck",null],["032575404025","Niedernwöhren",null],["032575404027","Nordsehl",null],["032575404030","Pollhagen",null],["032575404037","Wiedensahl, Flecken",null],["032575405013","Helpsen",null],["032575405014","Hespe",null],["032575405026","Nienstädt",null],["032575405034","Seggebruch",null],["032575406002","Apelern",null],["032575406017","Hülsede",null],["032575406018","Lauenau, Flecken",null],["032575406024","Messenkamp",null],["032575406029","Pohle",null],["032575406032","Rodenberg, Stadt",null],["032575407004","Auhagen",null],["032575407010","Hagenburg, Flecken",null],["032575407033","Sachsenhagen, Stadt",null],["032575407038","Wölpinghausen",null],["033510004004","Bergen, Stadt",null],["033510006006","Celle, Stadt",null],["033510010010","Faßberg",null],["033510012012","Hambühren",null],["033510023023","Wietze",null],["033510024024","Winsen (Aller)",null],["033510025025","Eschede",null],["033510026026","Südheide",null],["033515402005","Bröckel",null],["033515402007","Eicklingen",null],["033515402017","Langlingen",null],["033515402022","Wienhausen, Klostergemeinde",null],["033515403002","Ahnsbeck",null],["033515403003","Beedenbostel",null],["033515403008","Eldingen",null],["033515403015","Hohne",null],["033515403016","Lachendorf",null],["033515404001","Adelheidsdorf",null],["033515404018","Nienhagen",null],["033515404021","Wathlingen",null],["033519501501","Lohheide, gemfr. Bezirk",null],["033520011011","Cuxhaven, Stadt",null],["033520032032","Loxstedt",null],["033520050050","Schiffdorf",null],["033520059059","Beverstedt",null],["033520060060","Hagen im Bremischen",null],["033520061061","Wurster Nordseeküste",null],["033520062062","Geestland, Stadt",null],["033525404002","Armstorf",null],["033525404024","Hollnseth",null],["033525404029","Lamstedt",null],["033525404036","Mittelstenahe",null],["033525404052","Stinstedt",null],["033525407020","Hechthausen",null],["033525407022","Hemmoor, Stadt",null],["033525407044","Osten",null],["033525411004","Belum",null],["033525411008","Bülkau",null],["033525411025","Ihlienworth",null],["033525411038","Neuenkirchen",null],["033525411039","Neuhaus (Oste), Flecken",null],["033525411041","Nordleda",null],["033525411042","Oberndorf",null],["033525411043","Odisheim",null],["033525411045","Osterbruch",null],["033525411046","Otterndorf, Stadt",null],["033525411051","Steinau",null],["033525411055","Wanna",null],["033525411056","Wingst",null],["033525411063","Cadenberge",null],["033530005005","Buchholz in der Nordheide, Stadt",null],["033530026026","Neu Wulmstorf",null],["033530029029","Rosengarten",null],["033530031031","Seevetal",null],["033530032032","Stelle",null],["033530040040","Winsen (Luhe), Stadt",null],["033535401007","Drage",null],["033535401023","Marschacht",null],["033535401033","Tespe",null],["033535402002","Asendorf",null],["033535402004","Brackel",null],["033535402009","Egestorf",null],["033535402016","Hanstedt",null],["033535402024","Marxen",null],["033535402036","Undeloh",null],["033535403001","Appel",null],["033535403008","Drestedt",null],["033535403014","Halvesbostel",null],["033535403019","Hollenstedt",null],["033535403025","Moisburg",null],["033535403028","Regesbostel",null],["033535403039","Wenzendorf",null],["033535404003","Bendestorf",null],["033535404017","Harmstorf",null],["033535404020","Jesteburg",null],["033535405010","Eyendorf",null],["033535405011","Garlstorf",null],["033535405012","Garstedt",null],["033535405013","Gödenstorf",null],["033535405030","Salzhausen",null],["033535405034","Toppenstedt",null],["033535405037","Vierhöfen",null],["033535405042","Wulfsen",null],["033535406006","Dohren",null],["033535406015","Handeloh",null],["033535406018","Heidenau",null],["033535406021","Kakenstorf",null],["033535406022","Königsmoor",null],["033535406027","Otter",null],["033535406035","Tostedt",null],["033535406038","Welle",null],["033535406041","Wistedt",null],["033545403005","Gartow, Flecken",null],["033545403007","Gorleben",null],["033545403010","Höhbeck",null],["033545403020","Prezelle",null],["033545403021","Schnackenburg, Stadt",null],["033545406003","Damnatz",null],["033545406004","Dannenberg (Elbe), Stadt",null],["033545406006","Göhrde",null],["033545406008","Gusborn",null],["033545406009","Hitzacker (Elbe), Stadt",null],["033545406011","Jameln",null],["033545406012","Karwitz",null],["033545406014","Langendorf",null],["033545406019","Neu Darchau",null],["033545406027","Zernien",null],["033545407001","Bergen an der Dumme, Flecken",null],["033545407002","Clenze, Flecken",null],["033545407013","Küsten",null],["033545407015","Lemgow",null],["033545407016","Luckau (Wendland)",null],["033545407017","Lübbow",null],["033545407018","Lüchow (Wendland), Stadt",null],["033545407022","Schnega",null],["033545407023","Trebel",null],["033545407024","Waddeweitz",null],["033545407025","Woltersdorf",null],["033545407026","Wustrow (Wendland), Stadt",null],["033549501501","Gartow, gemfr. Gebiet",null],["033549502502","Göhrde, gemfr. Gebiet",null],["033550001001","Adendorf",null],["033550009009","Bleckede, Stadt",null],["033550022022","Lüneburg, Hansestadt",null],["033550049049","Amt Neuhaus",null],["033555401002","Amelinghausen",null],["033555401008","Betzendorf",null],["033555401027","Oldendorf (Luhe)",null],["033555401029","Rehlingen",null],["033555401034","Soderstorf",null],["033555402004","Bardowick, Flecken",null],["033555402007","Barum",null],["033555402017","Handorf",null],["033555402023","Mechtersen",null],["033555402028","Radbruch",null],["033555402039","Vögelsen",null],["033555402042","Wittorf",null],["033555403010","Boitze",null],["033555403012","Dahlem",null],["033555403013","Dahlenburg, Flecken",null],["033555403025","Nahrendorf",null],["033555403037","Tosterglope",null],["033555404020","Kirchgellersen",null],["033555404031","Reppenstedt",null],["033555404035","Südergellersen",null],["033555404041","Westergellersen",null],["033555405006","Barnstedt",null],["033555405014","Deutsch Evern",null],["033555405016","Embsen",null],["033555405024","Melbeck",null],["033555406005","Barendorf",null],["033555406026","Neetze",null],["033555406030","Reinstorf",null],["033555406036","Thomasburg",null],["033555406038","Vastorf",null],["033555406040","Wendisch Evern",null],["033555407003","Artlenburg, Flecken",null],["033555407011","Brietlingen",null],["033555407015","Echem",null],["033555407018","Hittbergen",null],["033555407019","Hohnstorf (Elbe)",null],["033555407021","Lüdersburg",null],["033555407032","Rullstorf",null],["033555407033","Scharnebeck",null],["033560002002","Grasberg",null],["033560005005","Lilienthal",null],["033560007007","Osterholz-Scharmbeck, Stadt",null],["033560008008","Ritterhude",null],["033560009009","Schwanewede",null],["033560011011","Worpswede",null],["033565401001","Axstedt",null],["033565401003","Hambergen",null],["033565401004","Holste",null],["033565401006","Lübberstedt",null],["033565401010","Vollersode",null],["033570008008","Bremervörde, Stadt",null],["033570016016","Gnarrenburg",null],["033570039039","Rotenburg (Wümme), Stadt",null],["033570041041","Scheeßel",null],["033570051051","Visselhövede, Stadt",null],["033575401006","Bothel",null],["033575401009","Brockel",null],["033575401024","Hemsbünde",null],["033575401025","Hemslingen",null],["033575401031","Kirchwalsede",null],["033575401054","Westerwalsede",null],["033575402015","Fintel",null],["033575402023","Helvesiek",null],["033575402033","Lauenbrück",null],["033575402046","Stemmen",null],["033575402049","Vahlde",null],["033575403002","Alfstedt",null],["033575403004","Basdahl",null],["033575403012","Ebersdorf",null],["033575403027","Hipstedt",null],["033575403035","Oerel",null],["033575404003","Anderlingen",null],["033575404011","Deinstedt",null],["033575404014","Farven",null],["033575404036","Ostereistedt",null],["033575404038","Rhade",null],["033575404040","Sandbostel",null],["033575404042","Seedorf",null],["033575404043","Selsingen",null],["033575405017","Groß Meckelsen",null],["033575405019","Hamersen",null],["033575405029","Kalbe",null],["033575405032","Klein Meckelsen",null],["033575405034","Lengenbostel",null],["033575405044","Sittensen",null],["033575405048","Tiste",null],["033575405050","Vierden",null],["033575405056","Wohnste",null],["033575406001","Ahausen",null],["033575406005","Bötersen",null],["033575406020","Hassendorf",null],["033575406022","Hellwege",null],["033575406028","Horstedt",null],["033575406037","Reeßum",null],["033575406045","Sottrum",null],["033575407007","Breddorf",null],["033575407010","Bülstedt",null],["033575407026","Hepstedt",null],["033575407030","Kirchtimke",null],["033575407047","Tarmstedt",null],["033575407052","Vorwerk",null],["033575407053","Westertimke",null],["033575407055","Wilstedt",null],["033575408013","Elsdorf",null],["033575408018","Gyhum",null],["033575408021","Heeslingen",null],["033575408057","Zeven, Stadt",null],["033580002002","Bispingen",null],["033580008008","Bad Fallingbostel, Stadt",null],["033580016016","Munster, Stadt",null],["033580017017","Neuenkirchen",null],["033580019019","Schneverdingen, Stadt",null],["033580021021","Soltau, Stadt",null],["033580023023","Wietzendorf",null],["033580024024","Walsrode, Stadt",null],["033585401001","Ahlden (Aller), Flecken",null],["033585401006","Eickeloh",null],["033585401011","Grethem",null],["033585401012","Hademstorf",null],["033585401014","Hodenhagen",null],["033585402003","Böhme",null],["033585402009","Frankenfeld",null],["033585402013","Häuslingen",null],["033585402018","Rethem (Aller), Stadt",null],["033585403005","Buchholz (Aller)",null],["033585403007","Essel",null],["033585403010","Gilten",null],["033585403015","Lindwedel",null],["033585403020","Schwarmstedt",null],["033589501501","Osterheide, gemfr. Bezirk",null],["033590010010","Buxtehude, Hansestadt",null],["033590013013","Drochtersen",null],["033590028028","Jork",null],["033590038038","Stade, Hansestadt",null],["033595401003","Apensen",null],["033595401006","Beckdorf",null],["033595401037","Sauensiek",null],["033595402011","Deinste",null],["033595402017","Fredenbeck",null],["033595402031","Kutenholz",null],["033595403002","Ahlerstedt",null],["033595403005","Bargstedt",null],["033595403008","Brest",null],["033595403023","Harsefeld, Flecken",null],["033595405001","Agathenburg",null],["033595405007","Bliedersdorf",null],["033595405012","Dollern",null],["033595405027","Horneburg, Flecken",null],["033595405034","Nottensdorf",null],["033595406020","Grünendeich",null],["033595406021","Guderhandviertel",null],["033595406026","Hollern-Twielenfleth",null],["033595406032","Mittelnkirchen",null],["033595406033","Neuenkirchen",null],["033595406039","Steinkirchen",null],["033595407004","Balje",null],["033595407018","Freiburg (Elbe), Flecken",null],["033595407030","Krummendeich",null],["033595407035","Oederquart",null],["033595407040","Wischhafen",null],["033595409009","Burweg",null],["033595409014","Düdenbüttel",null],["033595409015","Engelschoff",null],["033595409016","Estorf",null],["033595409019","Großenwörden",null],["033595409022","Hammah",null],["033595409024","Heinbockel",null],["033595409025","Himmelpforten",null],["033595409029","Kranenburg",null],["033595409036","Oldendorf",null],["033600004004","Bienenbüttel",null],["033600025025","Uelzen, Hansestadt",null],["033605404015","Oetzen",null],["033605404016","Rätzlingen",null],["033605404018","Rosche",null],["033605404022","Stoetze",null],["033605404024","Suhlendorf",null],["033605405007","Eimke",null],["033605405009","Gerdau",null],["033605405023","Suderburg",null],["033605407001","Altenmedingen",null],["033605407002","Bad Bevensen, Stadt",null],["033605407003","Barum",null],["033605407006","Ebstorf,Klosterflecken",null],["033605407008","Emmendorf",null],["033605407010","Hanstedt",null],["033605407011","Himbergen",null],["033605407012","Jelmstorf",null],["033605407014","Natendorf",null],["033605407017","Römstedt",null],["033605407019","Schwienau",null],["033605407026","Weste",null],["033605407029","Wriedel",null],["033605408005","Bad Bodenteich, Flecken",null],["033605408013","Lüder",null],["033605408020","Soltendieck",null],["033605408030","Wrestedt",null],["033610001001","Achim, Stadt",null],["033610003003","Dörverden",null],["033610005005","Kirchlinteln",null],["033610006006","Langwedel, Flecken",null],["033610008008","Ottersberg, Flecken",null],["033610009009","Oyten",null],["033610012012","Verden (Aller), Stadt",null],["033615401002","Blender",null],["033615401004","Emtinghausen",null],["033615401010","Riede",null],["033615401013","Thedinghausen",null],["034010000000","Delmenhorst, Stadt",null],["034020000000","Emden, Stadt",null],["034030000000","Oldenburg (Oldenburg), Stadt",null],["034040000000","Osnabrück, Stadt",null],["034050000000","Wilhelmshaven, Stadt",null],["034510001001","Apen",null],["034510002002","Bad Zwischenahn",null],["034510004004","Edewecht",null],["034510005005","Rastede",null],["034510007007","Westerstede, Stadt",null],["034510008008","Wiefelstede",null],["034520001001","Aurich, Stadt",null],["034520002002","Baltrum",null],["034520006006","Großefehn",null],["034520007007","Großheide",null],["034520011011","Hinte",null],["034520012012","Ihlow",null],["034520013013","Juist, Inselgemeinde",null],["034520014014","Krummhörn",null],["034520019019","Norden, Stadt",null],["034520020020","Norderney, Stadt",null],["034520023023","Südbrookmerland",null],["034520025025","Wiesmoor, Stadt",null],["034520027027","Dornum",null],["034525401015","Leezdorf",null],["034525401017","Marienhafe, Flecken",null],["034525401021","Osteel",null],["034525401022","Rechtsupweg",null],["034525401024","Upgant-Schott",null],["034525401026","Wirdum",null],["034525403003","Berumbur",null],["034525403008","Hage, Flecken",null],["034525403009","Hagermarsch",null],["034525403010","Halbemond",null],["034525403016","Lütetsburg",null],["034529501501","Nordseeinsel Memmert, gemfr. Gebiet",null],["034530001001","Barßel",null],["034530002002","Bösel",null],["034530003003","Cappeln (Oldenburg)",null],["034530004004","Cloppenburg, Stadt",null],["034530005005","Emstek",null],["034530006006","Essen (Oldenburg)",null],["034530007007","Friesoythe, Stadt",null],["034530008008","Garrel",null],["034530009009","Lastrup",null],["034530010010","Lindern (Oldenburg)",null],["034530011011","Löningen, Stadt",null],["034530012012","Molbergen",null],["034530013013","Saterland",null],["034540010010","Emsbüren",null],["034540014014","Geeste",null],["034540018018","Haren (Ems), Stadt",null],["034540019019","Haselünne, Stadt",null],["034540032032","Lingen (Ems), Stadt",null],["034540035035","Meppen, Stadt",null],["034540041041","Papenburg, Stadt",null],["034540044044","Rhede (Ems)",null],["034540045045","Salzbergen",null],["034540054054","Twist",null],["034545401007","Dersum",null],["034545401008","Dörpen",null],["034545401020","Heede",null],["034545401025","Kluse",null],["034545401030","Lehe",null],["034545401037","Neubörger",null],["034545401038","Neulehe",null],["034545401056","Walchum",null],["034545401060","Wippingen",null],["034545402001","Andervenne",null],["034545402003","Beesten",null],["034545402012","Freren, Stadt",null],["034545402036","Messingen",null],["034545402053","Thuine",null],["034545403009","Dohren",null],["034545403021","Herzlake",null],["034545403026","Lähden",null],["034545404013","Fresenburg",null],["034545404029","Lathen",null],["034545404039","Niederlangen",null],["034545404040","Oberlangen",null],["034545404043","Renkenberge",null],["034545404052","Sustrum",null],["034545405002","Bawinkel",null],["034545405015","Gersten",null],["034545405017","Handrup",null],["034545405028","Langen",null],["034545405031","Lengerich",null],["034545405059","Wettrup",null],["034545406004","Bockhorst",null],["034545406006","Breddenberg",null],["034545406011","Esterwegen",null],["034545406022","Hilkenbrook",null],["034545406051","Surwold",null],["034545407005","Börger",null],["034545407016","Groß Berßen",null],["034545407023","Hüven",null],["034545407024","Klein Berßen",null],["034545407047","Sögel",null],["034545407048","Spahnharrenstätte",null],["034545407050","Stavern",null],["034545407058","Werpeloh",null],["034545408034","Lünne",null],["034545408046","Schapen",null],["034545408049","Spelle",null],["034545409027","Lahn",null],["034545409033","Lorup",null],["034545409042","Rastdorf",null],["034545409055","Vrees",null],["034545409057","Werlte, Stadt",null],["034550007007","Jever, Stadt",null],["034550014014","Sande",null],["034550015015","Schortens, Stadt",null],["034550020020","Wangerland",null],["034550021021","Wangerooge, Nordseebad",null],["034550025025","Bockhorn",null],["034550026026","Varel, Stadt",null],["034550027027","Zetel",null],["034560001001","Bad Bentheim, Stadt",null],["034560015015","Nordhorn, Stadt",null],["034560025025","Wietmarschen",null],["034565401002","Emlichheim",null],["034565401009","Hoogstede",null],["034565401012","Laar",null],["034565401019","Ringe",null],["034565402004","Esche",null],["034565402005","Georgsdorf",null],["034565402013","Lage",null],["034565402014","Neuenhaus, Stadt",null],["034565402017","Osterwald",null],["034565403003","Engden",null],["034565403010","Isterberg",null],["034565403016","Ohne",null],["034565403018","Quendorf",null],["034565403020","Samern",null],["034565403027","Schüttorf, Stadt",null],["034565404006","Getelo",null],["034565404007","Gölenkamp",null],["034565404008","Halle",null],["034565404011","Itterbeck",null],["034565404023","Uelsen",null],["034565404024","Wielen",null],["034565404026","Wilsum",null],["034570002002","Borkum, Stadt",null],["034570012012","Jemgum",null],["034570013013","Leer (Ostfriesland), Stadt",null],["034570014014","Moormerland",null],["034570017017","Ostrhauderfehn",null],["034570018018","Rhauderfehn",null],["034570020020","Uplengen",null],["034570021021","Weener, Stadt",null],["034570022022","Westoverledingen",null],["034570024024","Bunde",null],["034575402003","Brinkum",null],["034575402009","Firrel",null],["034575402010","Hesel",null],["034575402011","Holtland",null],["034575402015","Neukamperfehn",null],["034575402019","Schwerinsdorf",null],["034575403006","Detern, Flecken",null],["034575403008","Filsum",null],["034575403016","Nortmoor",null],["034579501501","Insel Lütje Hörn, gemfr. Gebiet",null],["034580003003","Dötlingen",null],["034580005005","Ganderkesee",null],["034580007007","Großenkneten",null],["034580009009","Hatten",null],["034580010010","Hude (Oldb)",null],["034580013013","Wardenburg",null],["034580014014","Wildeshausen, Stadt",null],["034585401001","Beckeln",null],["034585401002","Colnrade",null],["034585401004","Dünsen",null],["034585401006","Groß Ippener",null],["034585401008","Harpstedt, Flecken",null],["034585401011","Kirchseelte",null],["034585401012","Prinzhöfte",null],["034585401015","Winkelsett",null],["034590003003","Bad Essen",null],["034590004004","Bad Iburg, Stadt",null],["034590005005","Bad Laer",null],["034590006006","Bad Rothenfelde",null],["034590008008","Belm",null],["034590012012","Bissendorf",null],["034590013013","Bohmte",null],["034590014014","Bramsche, Stadt",null],["034590015015","Dissen am Teutoburger Wald, Stadt",null],["034590019019","Georgsmarienhütte, Stadt",null],["034590020020","Hagen am Teutoburger Wald",null],["034590021021","Hasbergen",null],["034590022022","Hilter am Teutoburger Wald",null],["034590024024","Melle, Stadt",null],["034590029029","Ostercappeln",null],["034590033033","Wallenhorst",null],["034590034034","Glandorf",null],["034595401007","Badbergen",null],["034595401025","Menslage",null],["034595401028","Nortrup",null],["034595401030","Quakenbrück, Stadt",null],["034595402001","Alfhausen",null],["034595402002","Ankum",null],["034595402010","Bersenbrück, Stadt",null],["034595402016","Eggermühlen",null],["034595402018","Gehrde",null],["034595402023","Kettenkamp",null],["034595402031","Rieste",null],["034595403009","Berge",null],["034595403011","Bippen",null],["034595403017","Fürstenau, Stadt",null],["034595404026","Merzen",null],["034595404027","Neuenkirchen",null],["034595404032","Voltlage",null],["034600001001","Bakum",null],["034600002002","Damme, Stadt",null],["034600003003","Dinklage, Stadt",null],["034600004004","Goldenstedt",null],["034600005005","Holdorf",null],["034600006006","Lohne (Oldenburg), Stadt",null],["034600007007","Neuenkirchen-Vörden",null],["034600008008","Steinfeld (Oldenburg)",null],["034600009009","Vechta, Stadt",null],["034600010010","Visbek",null],["034610001001","Berne",null],["034610002002","Brake (Unterweser), Stadt",null],["034610003003","Butjadingen",null],["034610004004","Elsfleth, Stadt",null],["034610005005","Jade",null],["034610006006","Lemwerder",null],["034610007007","Nordenham, Stadt",null],["034610008008","Ovelgönne",null],["034610009009","Stadland",null],["034620005005","Friedeburg",null],["034620007007","Langeoog",null],["034620014014","Spiekeroog",null],["034620019019","Wittmund, Stadt",null],["034625401002","Dunum",null],["034625401003","Esens, Stadt",null],["034625401006","Holtgast",null],["034625401008","Moorweg",null],["034625401010","Neuharlingersiel",null],["034625401015","Stedesdorf",null],["034625401017","Werdum",null],["034625402001","Blomberg",null],["034625402004","Eversmeer",null],["034625402009","Nenndorf",null],["034625402011","Neuschoo",null],["034625402012","Ochtersum",null],["034625402013","Schweindorf",null],["034625402016","Utarp",null],["034625402018","Westerholt",null],["039019999999","Nds-Küstengewässer(Gemarkung Nordsee)",null],["040110000000","Bremen, Stadt",null],["040110111111","Altstadt","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110112112","Bahnhofsvorstadt","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110113113","Ostertor","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110122122","Industriehäfen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110123123","Stadtbremisches Überseehafengebiet Bremerhaven","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110124124","Neustädter Hafen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110125125","Hohentorshafen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110211211","Alte Neustadt","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110212212","Hohentor","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110213213","Neustadt","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110214214","Südervorstadt","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110215215","Gartenstadt Süd","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110216216","Buntentor","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110217217","Neuenland","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110218218","Huckelriede","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110231231","Habenhausen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110232232","Arsten","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110233233","Kattenturm","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110234234","Kattenesch","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110241241","Mittelshuchting","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110242242","Sodenmatt","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110243243","Kirchhuchting","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110244244","Grolland","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110251251","Woltmershausen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110252252","Rablinghausen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110261261","Seehausen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110271271","Strom","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110311311","Steintor","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110312312","Fesenfeld","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110313313","Peterswerder","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110314314","Hulsberg","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110321321","Neu-Schwachhausen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110322322","Bürgerpark","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110323323","Barkhof","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110324324","Riensberg","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110325325","Radio Bremen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110326326","Schwachhausen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110327327","Gete","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110331331","Gartenstadt Vahr","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110332332","Neue Vahr Nord","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110334334","Neue Vahr Südwest","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110335335","Neue Vahr Südost","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110341341","Horn","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110342342","Lehe","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110343343","Lehesterdeich","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110351351","Borgfeld","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110361361","Oberneuland","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110371371","Ellener Feld","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110372372","Ellenerbrok-Schevemoor","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110373373","Tenever","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110374374","Osterholz","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110375375","Blockdiek","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110381381","Sebaldsbrück","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110382382","Hastedt","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110383383","Hemelingen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110384384","Arbergen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110385385","Mahndorf","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110411411","Blockland","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110421421","Regensburger Straße","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110422422","Findorff-Bürgerweide","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110423423","Weidedamm","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110424424","In den Hufen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110431431","Utbremen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110432432","Steffensweg","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110433433","Westend","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110434434","Walle","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110435435","Osterfeuerberg","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110436436","Hohweg","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110437437","Überseestadt","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110441441","Lindenhof","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110442442","Gröpelingen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110443443","Ohlenhof","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110444444","In den Wischen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110445445","Oslebshausen","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110511511","Burg-Grambke","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110512512","Werderland","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110513513","Burgdamm","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110514514","Lesum","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110515515","St. Magnus","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110521521","Vegesack","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110522522","Grohn","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110523523","Schönebeck","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110524524","Aumund-Hammersbeck","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110525525","Fähr-Lobbendorf","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110531531","Blumenthal","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110532532","Rönnebeck","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110533533","Lüssum-Bockhorn","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110534534","Farge","Stadt-/Ortsteil bzw. Stadtbezirk"],["040110535535","Rekum","Stadt-/Ortsteil bzw. Stadtbezirk"],["040120000000","Bremerhaven, Stadt",null],["051110000000","Düsseldorf, Stadt",null],["051120000000","Duisburg, Stadt",null],["051130000000","Essen, Stadt",null],["051140000000","Krefeld, Stadt",null],["051160000000","Mönchengladbach, Stadt",null],["051170000000","Mülheim an der Ruhr, Stadt",null],["051190000000","Oberhausen, Stadt",null],["051200000000","Remscheid, Stadt",null],["051220000000","Solingen, Klingenstadt",null],["051240000000","Wuppertal, Stadt",null],["051540004004","Bedburg-Hau",null],["051540008008","Emmerich am Rhein, Stadt",null],["051540012012","Geldern, Stadt",null],["051540016016","Goch, Stadt",null],["051540020020","Issum",null],["051540024024","Kalkar, Stadt",null],["051540028028","Kerken",null],["051540032032","Kevelaer, Stadt",null],["051540036036","Kleve, Stadt",null],["051540040040","Kranenburg",null],["051540044044","Rees, Stadt",null],["051540048048","Rheurdt",null],["051540052052","Straelen, Stadt",null],["051540056056","Uedem",null],["051540060060","Wachtendonk",null],["051540064064","Weeze",null],["051580004004","Erkrath, Fundort des Neanderthalers, Stadt",null],["051580008008","Haan, Stadt",null],["051580012012","Heiligenhaus, Stadt",null],["051580016016","Hilden, Stadt",null],["051580020020","Langenfeld (Rheinland), Stadt",null],["051580024024","Mettmann, Stadt",null],["051580026026","Monheim am Rhein, Stadt",null],["051580028028","Ratingen, Stadt",null],["051580032032","Velbert, Stadt",null],["051580036036","Wülfrath, Stadt",null],["051620004004","Dormagen, Stadt",null],["051620008008","Grevenbroich, Stadt",null],["051620012012","Jüchen, Stadt",null],["051620016016","Kaarst, Stadt",null],["051620020020","Korschenbroich, Stadt",null],["051620022022","Meerbusch, Stadt",null],["051620024024","Neuss, Stadt",null],["051620028028","Rommerskirchen",null],["051660004004","Brüggen, Burggemeinde",null],["051660008008","Grefrath, Sport- und Freizeitgemeinde",null],["051660012012","Kempen, Stadt",null],["051660016016","Nettetal, Stadt",null],["051660020020","Niederkrüchten",null],["051660024024","Schwalmtal",null],["051660028028","Tönisvorst, Stadt",null],["051660032032","Viersen, Stadt",null],["051660036036","Willich, Stadt",null],["051700004004","Alpen",null],["051700008008","Dinslaken, Stadt",null],["051700012012","Hamminkeln, Stadt",null],["051700016016","Hünxe",null],["051700020020","Kamp-Lintfort, Stadt",null],["051700024024","Moers, Stadt",null],["051700028028","Neukirchen-Vluyn, Stadt",null],["051700032032","Rheinberg, Stadt",null],["051700036036","Schermbeck",null],["051700040040","Sonsbeck",null],["051700044044","Voerde (Niederrhein), Stadt",null],["051700048048","Wesel, Stadt",null],["051700052052","Xanten, Stadt",null],["053140000000","Bonn, Stadt",null],["053150000000","Köln, Stadt",null],["053160000000","Leverkusen, Stadt",null],["053340002002","Aachen, Stadt",null],["053340004004","Alsdorf, Stadt",null],["053340008008","Baesweiler, Stadt",null],["053340012012","Eschweiler, Stadt",null],["053340016016","Herzogenrath, Stadt",null],["053340020020","Monschau, Stadt",null],["053340024024","Roetgen, Tor zur Eifel",null],["053340028028","Simmerath",null],["053340032032","Stolberg (Rhld.), Kupferstadt",null],["053340036036","Würselen, Stadt",null],["053580004004","Aldenhoven",null],["053580008008","Düren, Stadt",null],["053580012012","Heimbach, Stadt",null],["053580016016","Hürtgenwald",null],["053580020020","Inden",null],["053580024024","Jülich, Stadt",null],["053580028028","Kreuzau",null],["053580032032","Langerwehe",null],["053580036036","Linnich, Stadt",null],["053580040040","Merzenich",null],["053580044044","Nideggen, Stadt",null],["053580048048","Niederzier",null],["053580052052","Nörvenich",null],["053580056056","Titz",null],["053580060060","Vettweiß",null],["053620004004","Bedburg, Stadt",null],["053620008008","Bergheim, Stadt",null],["053620012012","Brühl, Stadt",null],["053620016016","Elsdorf, Stadt",null],["053620020020","Erftstadt, Stadt",null],["053620024024","Frechen, Stadt",null],["053620028028","Hürth, Stadt",null],["053620032032","Kerpen, Kolpingstadt",null],["053620036036","Pulheim, Stadt",null],["053620040040","Wesseling, Stadt",null],["053660004004","Bad Münstereifel, Stadt",null],["053660008008","Blankenheim",null],["053660012012","Dahlem",null],["053660016016","Euskirchen, Stadt",null],["053660020020","Hellenthal",null],["053660024024","Kall",null],["053660028028","Mechernich, Stadt",null],["053660032032","Nettersheim",null],["053660036036","Schleiden, Stadt",null],["053660040040","Weilerswist",null],["053660044044","Zülpich, Stadt",null],["053700004004","Erkelenz, Stadt",null],["053700008008","Gangelt",null],["053700012012","Geilenkirchen, Stadt",null],["053700016016","Heinsberg, Stadt",null],["053700020020","Hückelhoven, Stadt",null],["053700024024","Selfkant",null],["053700028028","Übach-Palenberg, Stadt",null],["053700032032","Waldfeucht",null],["053700036036","Wassenberg, Stadt",null],["053700040040","Wegberg, Stadt",null],["053740004004","Bergneustadt, Stadt",null],["053740008008","Engelskirchen",null],["053740012012","Gummersbach, Stadt",null],["053740016016","Hückeswagen, Schloss-Stadt",null],["053740020020","Lindlar",null],["053740024024","Marienheide",null],["053740028028","Morsbach",null],["053740032032","Nümbrecht",null],["053740036036","Radevormwald, Stadt auf der Höhe",null],["053740040040","Reichshof",null],["053740044044","Waldbröl, Stadt",null],["053740048048","Wiehl, Stadt",null],["053740052052","Wipperfürth, Hansestadt",null],["053780004004","Bergisch Gladbach, Stadt",null],["053780008008","Burscheid, Stadt",null],["053780012012","Kürten",null],["053780016016","Leichlingen (Rheinland), Blütenstadt",null],["053780020020","Odenthal",null],["053780024024","Overath, Stadt",null],["053780028028","Rösrath, Stadt",null],["053780032032","Wermelskirchen, Stadt",null],["053820004004","Alfter",null],["053820008008","Bad Honnef, Stadt",null],["053820012012","Bornheim, Stadt",null],["053820016016","Eitorf",null],["053820020020","Hennef (Sieg), Stadt",null],["053820024024","Königswinter, Stadt",null],["053820028028","Lohmar, Stadt",null],["053820032032","Meckenheim, Stadt",null],["053820036036","Much",null],["053820040040","Neunkirchen-Seelscheid",null],["053820044044","Niederkassel, Stadt",null],["053820048048","Rheinbach, Stadt",null],["053820052052","Ruppichteroth",null],["053820056056","Sankt Augustin, Stadt",null],["053820060060","Siegburg, Stadt",null],["053820064064","Swisttal",null],["053820068068","Troisdorf, Stadt",null],["053820072072","Wachtberg",null],["053820076076","Windeck",null],["055120000000","Bottrop, Stadt",null],["055130000000","Gelsenkirchen, Stadt",null],["055150000000","Münster, Stadt",null],["055540004004","Ahaus, Stadt",null],["055540008008","Bocholt, Stadt",null],["055540012012","Borken, Stadt",null],["055540016016","Gescher, Glockenstadt",null],["055540020020","Gronau (Westf.), Stadt",null],["055540024024","Heek",null],["055540028028","Heiden",null],["055540032032","Isselburg, Stadt",null],["055540036036","Legden",null],["055540040040","Raesfeld",null],["055540044044","Reken",null],["055540048048","Rhede, Stadt",null],["055540052052","Schöppingen",null],["055540056056","Stadtlohn, Stadt",null],["055540060060","Südlohn",null],["055540064064","Velen, Stadt",null],["055540068068","Vreden, Stadt",null],["055580004004","Ascheberg",null],["055580008008","Billerbeck, Stadt",null],["055580012012","Coesfeld, Stadt",null],["055580016016","Dülmen, Stadt",null],["055580020020","Havixbeck",null],["055580024024","Lüdinghausen, Stadt",null],["055580028028","Nordkirchen",null],["055580032032","Nottuln",null],["055580036036","Olfen, Stadt",null],["055580040040","Rosendahl",null],["055580044044","Senden",null],["055620004004","Castrop-Rauxel, Stadt",null],["055620008008","Datteln, Stadt",null],["055620012012","Dorsten, Stadt",null],["055620014014","Gladbeck, Stadt",null],["055620016016","Haltern am See, Stadt",null],["055620020020","Herten, Stadt",null],["055620024024","Marl, Stadt",null],["055620028028","Oer-Erkenschwick, Stadt",null],["055620032032","Recklinghausen, Stadt",null],["055620036036","Waltrop, Stadt",null],["055660004004","Altenberge",null],["055660008008","Emsdetten, Stadt",null],["055660012012","Greven, Stadt",null],["055660016016","Hörstel, Stadt",null],["055660020020","Hopsten",null],["055660024024","Horstmar, Stadt der Burgmannshöfe",null],["055660028028","Ibbenbüren, Stadt",null],["055660032032","Ladbergen",null],["055660036036","Laer",null],["055660040040","Lengerich, Stadt",null],["055660044044","Lienen",null],["055660048048","Lotte",null],["055660052052","Metelen",null],["055660056056","Mettingen",null],["055660060060","Neuenkirchen",null],["055660064064","Nordwalde",null],["055660068068","Ochtrup, Stadt",null],["055660072072","Recke",null],["055660076076","Rheine, Stadt",null],["055660080080","Saerbeck, NRW-Klimakommune",null],["055660084084","Steinfurt, Stadt",null],["055660088088","Tecklenburg, Stadt",null],["055660092092","Westerkappeln",null],["055660096096","Wettringen",null],["055700004004","Ahlen, Stadt",null],["055700008008","Beckum, Stadt",null],["055700012012","Beelen",null],["055700016016","Drensteinfurt, Stadt",null],["055700020020","Ennigerloh, Stadt",null],["055700024024","Everswinkel",null],["055700028028","Oelde, Stadt",null],["055700032032","Ostbevern",null],["055700036036","Sassenberg, Stadt",null],["055700040040","Sendenhorst, Stadt",null],["055700044044","Telgte, Stadt",null],["055700048048","Wadersloh",null],["055700052052","Warendorf, Stadt",null],["057110000000","Bielefeld, Stadt",null],["057540004004","Borgholzhausen, Stadt",null],["057540008008","Gütersloh, Stadt",null],["057540012012","Halle (Westf.), Stadt",null],["057540016016","Harsewinkel, Die Mähdrescherstadt",null],["057540020020","Herzebrock-Clarholz",null],["057540024024","Langenberg",null],["057540028028","Rheda-Wiedenbrück, Stadt",null],["057540032032","Rietberg, Stadt",null],["057540036036","Schloß Holte-Stukenbrock, Stadt",null],["057540040040","Steinhagen",null],["057540044044","Verl, Stadt",null],["057540048048","Versmold, Stadt",null],["057540052052","Werther (Westf.), Stadt",null],["057580004004","Bünde, Stadt",null],["057580008008","Enger, Widukindstadt",null],["057580012012","Herford, Hansestadt",null],["057580016016","Hiddenhausen",null],["057580020020","Kirchlengern",null],["057580024024","Löhne, Stadt",null],["057580028028","Rödinghausen",null],["057580032032","Spenge, Stadt",null],["057580036036","Vlotho, Stadt",null],["057620004004","Bad Driburg, Stadt",null],["057620008008","Beverungen, Stadt",null],["057620012012","Borgentreich, Orgelstadt",null],["057620016016","Brakel, Stadt",null],["057620020020","Höxter, Stadt",null],["057620024024","Marienmünster, Stadt",null],["057620028028","Nieheim, Stadt",null],["057620032032","Steinheim, Stadt",null],["057620036036","Warburg, Hansestadt",null],["057620040040","Willebadessen, Stadt",null],["057660004004","Augustdorf",null],["057660008008","Bad Salzuflen, Stadt",null],["057660012012","Barntrup, Stadt",null],["057660016016","Blomberg, Stadt",null],["057660020020","Detmold, Stadt",null],["057660024024","Dörentrup",null],["057660028028","Extertal",null],["057660032032","Horn-Bad Meinberg, Stadt",null],["057660036036","Kalletal",null],["057660040040","Lage, Stadt",null],["057660044044","Lemgo, Stadt",null],["057660048048","Leopoldshöhe",null],["057660052052","Lügde, Stadt der Osterräder",null],["057660056056","Oerlinghausen, Stadt",null],["057660060060","Schieder-Schwalenberg, Stadt",null],["057660064064","Schlangen",null],["057700004004","Bad Oeynhausen, Stadt",null],["057700008008","Espelkamp, Stadt",null],["057700012012","Hille",null],["057700016016","Hüllhorst",null],["057700020020","Lübbecke, Stadt",null],["057700024024","Minden, Stadt",null],["057700028028","Petershagen, Stadt",null],["057700032032","Porta Westfalica, Stadt",null],["057700036036","Preußisch Oldendorf, Stadt",null],["057700040040","Rahden, Stadt",null],["057700044044","Stemwede",null],["057740004004","Altenbeken",null],["057740008008","Bad Lippspringe, Stadt",null],["057740012012","Borchen",null],["057740016016","Büren, Stadt",null],["057740020020","Delbrück, Stadt",null],["057740024024","Hövelhof, Sennegemeinde",null],["057740028028","Lichtenau, Stadt",null],["057740032032","Paderborn, Stadt",null],["057740036036","Salzkotten, Stadt",null],["057740040040","Bad Wünnenberg, Stadt",null],["059110000000","Bochum, Stadt",null],["059130000000","Dortmund, Stadt",null],["059140000000","Hagen, Stadt der FernUniversität",null],["059150000000","Hamm, Stadt",null],["059160000000","Herne, Stadt",null],["059540004004","Breckerfeld, Hansestadt",null],["059540008008","Ennepetal, Stadt der Kluterthöhle",null],["059540012012","Gevelsberg, Stadt",null],["059540016016","Hattingen, Stadt",null],["059540020020","Herdecke, Stadt",null],["059540024024","Schwelm, Stadt",null],["059540028028","Sprockhövel, Stadt",null],["059540032032","Wetter (Ruhr), Stadt",null],["059540036036","Witten, Stadt",null],["059580004004","Arnsberg, Stadt",null],["059580008008","Bestwig",null],["059580012012","Brilon, Stadt",null],["059580016016","Eslohe (Sauerland)",null],["059580020020","Hallenberg, Stadt",null],["059580024024","Marsberg, Stadt",null],["059580028028","Medebach, Hansestadt",null],["059580032032","Meschede, Kreis- und Hochschulstadt",null],["059580036036","Olsberg, Stadt",null],["059580040040","Schmallenberg, Stadt",null],["059580044044","Sundern (Sauerland), Stadt",null],["059580048048","Winterberg, Stadt",null],["059620004004","Altena, Stadt",null],["059620008008","Balve, Stadt",null],["059620012012","Halver, Stadt",null],["059620016016","Hemer, Stadt",null],["059620020020","Herscheid",null],["059620024024","Iserlohn, Stadt",null],["059620028028","Kierspe, Stadt",null],["059620032032","Lüdenscheid, Stadt",null],["059620036036","Meinerzhagen, Stadt",null],["059620040040","Menden (Sauerland), Stadt",null],["059620044044","Nachrodt-Wiblingwerde",null],["059620048048","Neuenrade, Stadt",null],["059620052052","Plettenberg, Stadt",null],["059620056056","Schalksmühle",null],["059620060060","Werdohl, Stadt",null],["059660004004","Attendorn, Hansestadt",null],["059660008008","Drolshagen, Stadt",null],["059660012012","Finnentrop",null],["059660016016","Kirchhundem",null],["059660020020","Lennestadt, Stadt",null],["059660024024","Olpe, Stadt",null],["059660028028","Wenden",null],["059700004004","Bad Berleburg, Stadt",null],["059700008008","Burbach",null],["059700012012","Erndtebrück",null],["059700016016","Freudenberg, Stadt",null],["059700020020","Hilchenbach, Stadt",null],["059700024024","Kreuztal, Stadt",null],["059700028028","Bad Laasphe, Stadt",null],["059700032032","Netphen, Stadt",null],["059700036036","Neunkirchen",null],["059700040040","Siegen, Universitätsstadt",null],["059700044044","Wilnsdorf",null],["059740004004","Anröchte",null],["059740008008","Bad Sassendorf",null],["059740012012","Ense",null],["059740016016","Erwitte, Stadt",null],["059740020020","Geseke, Stadt",null],["059740024024","Lippetal",null],["059740028028","Lippstadt, Stadt",null],["059740032032","Möhnesee",null],["059740036036","Rüthen, Stadt",null],["059740040040","Soest, Stadt",null],["059740044044","Warstein, Stadt",null],["059740048048","Welver",null],["059740052052","Werl, Stadt",null],["059740056056","Wickede (Ruhr)",null],["059780004004","Bergkamen, Stadt",null],["059780008008","Bönen",null],["059780012012","Fröndenberg/Ruhr, Stadt",null],["059780016016","Holzwickede",null],["059780020020","Kamen, Stadt",null],["059780024024","Lünen, Stadt",null],["059780028028","Schwerte, Hansestadt an der Ruhr",null],["059780032032","Selm, Stadt",null],["059780036036","Unna, Stadt",null],["059780040040","Werne, Stadt",null],["064110000000","Darmstadt, Wissenschaftsstadt",null],["064120000000","Frankfurt am Main, Stadt",null],["064130000000","Offenbach am Main, Stadt",null],["064140000000","Wiesbaden, Landeshauptstadt",null],["064310001001","Abtsteinach",null],["064310002002","Bensheim, Stadt",null],["064310003003","Biblis",null],["064310004004","Birkenau",null],["064310005005","Bürstadt, Stadt",null],["064310006006","Einhausen",null],["064310007007","Fürth",null],["064310008008","Gorxheimertal",null],["064310009009","Grasellenbach",null],["064310010010","Groß-Rohrheim",null],["064310011011","Heppenheim (Bergstraße), Kreisstadt",null],["064310012012","Hirschhorn (Neckar), Stadt",null],["064310013013","Lampertheim, Stadt",null],["064310014014","Lautertal (Odenwald)",null],["064310015015","Lindenfels, Stadt",null],["064310016016","Lorsch, Karolingerstadt",null],["064310017017","Mörlenbach",null],["064310018018","Neckarsteinach, Stadt",null],["064310019019","Rimbach",null],["064310020020","Viernheim, Stadt",null],["064310021021","Wald-Michelbach",null],["064310022022","Zwingenberg, Stadt",null],["064319200200","Michelbuch, gemfr. Gebiet",null],["064320001001","Alsbach-Hähnlein",null],["064320002002","Babenhausen, Stadt",null],["064320003003","Bickenbach",null],["064320004004","Dieburg, Stadt",null],["064320005005","Eppertshausen",null],["064320006006","Erzhausen",null],["064320007007","Fischbachtal",null],["064320008008","Griesheim, Stadt",null],["064320009009","Groß-Bieberau, Stadt",null],["064320010010","Groß-Umstadt, Stadt",null],["064320011011","Groß-Zimmern",null],["064320012012","Messel",null],["064320013013","Modautal",null],["064320014014","Mühltal",null],["064320015015","Münster (Hessen)",null],["064320016016","Ober-Ramstadt, Stadt",null],["064320017017","Otzberg",null],["064320018018","Pfungstadt, Stadt",null],["064320019019","Reinheim, Stadt",null],["064320020020","Roßdorf",null],["064320021021","Schaafheim",null],["064320022022","Seeheim-Jugenheim",null],["064320023023","Weiterstadt, Stadt",null],["064330001001","Biebesheim am Rhein",null],["064330002002","Bischofsheim",null],["064330003003","Büttelborn",null],["064330004004","Gernsheim, Schöfferstadt",null],["064330005005","Ginsheim-Gustavsburg, Stadt",null],["064330006006","Groß-Gerau, Stadt",null],["064330007007","Kelsterbach, Stadt",null],["064330008008","Mörfelden-Walldorf, Stadt",null],["064330009009","Nauheim",null],["064330010010","Raunheim, Stadt",null],["064330011011","Riedstadt, Büchnerstadt",null],["064330012012","Rüsselsheim am Main, Stadt",null],["064330013013","Stockstadt am Rhein",null],["064330014014","Trebur",null],["064340001001","Bad Homburg v. d. Höhe, Stadt",null],["064340002002","Friedrichsdorf, Stadt",null],["064340003003","Glashütten",null],["064340004004","Grävenwiesbach",null],["064340005005","Königstein im Taunus, Stadt",null],["064340006006","Kronberg im Taunus, Stadt",null],["064340007007","Neu-Anspach, Stadt",null],["064340008008","Oberursel (Taunus), Stadt",null],["064340009009","Schmitten",null],["064340010010","Steinbach (Taunus), Stadt",null],["064340011011","Usingen, Stadt",null],["064340012012","Wehrheim",null],["064340013013","Weilrod",null],["064350001001","Bad Orb, Stadt",null],["064350002002","Bad Soden-Salmünster, Stadt",null],["064350003003","Biebergemünd",null],["064350004004","Birstein",null],["064350005005","Brachttal",null],["064350006006","Bruchköbel, Stadt",null],["064350007007","Erlensee, Stadt",null],["064350008008","Flörsbachtal",null],["064350009009","Freigericht",null],["064350010010","Gelnhausen, Barbarossast., Krst.",null],["064350011011","Großkrotzenburg",null],["064350012012","Gründau",null],["064350013013","Hammersbach",null],["064350014014","Hanau, Brüder-Grimm-Stadt",null],["064350015015","Hasselroth",null],["064350016016","Jossgrund",null],["064350017017","Langenselbold, Stadt",null],["064350018018","Linsengericht",null],["064350019019","Maintal, Stadt",null],["064350020020","Neuberg",null],["064350021021","Nidderau, Stadt",null],["064350022022","Niederdorfelden",null],["064350023023","Rodenbach",null],["064350024024","Ronneburg",null],["064350025025","Schlüchtern, Stadt",null],["064350026026","Schöneck",null],["064350027027","Sinntal",null],["064350028028","Steinau an der Straße, Brüder-Grimm-Stadt",null],["064350029029","Wächtersbach, Stadt",null],["064359200200","Gutsbezirk Spessart, gemfr. Gebiet",null],["064360001001","Bad Soden am Taunus, Stadt",null],["064360002002","Eppstein, Stadt",null],["064360003003","Eschborn, Stadt",null],["064360004004","Flörsheim am Main, Stadt",null],["064360005005","Hattersheim am Main, Stadt",null],["064360006006","Hochheim am Main, Stadt",null],["064360007007","Hofheim am Taunus, Kreisstadt",null],["064360008008","Kelkheim (Taunus), Stadt",null],["064360009009","Kriftel",null],["064360010010","Liederbach am Taunus",null],["064360011011","Schwalbach am Taunus, Stadt",null],["064360012012","Sulzbach (Taunus)",null],["064370001001","Bad König, Stadt",null],["064370003003","Brensbach",null],["064370004004","Breuberg, Stadt",null],["064370005005","Brombachtal",null],["064370006006","Erbach, Kreisstadt",null],["064370007007","Fränkisch-Crumbach",null],["064370009009","Höchst i. Odw.",null],["064370010010","Lützelbach",null],["064370011011","Michelstadt, Stadt",null],["064370012012","Mossautal",null],["064370013013","Reichelsheim (Odenwald)",null],["064370016016","Oberzent, Stadt",null],["064380001001","Dietzenbach, Kreisstadt",null],["064380002002","Dreieich, Stadt",null],["064380003003","Egelsbach",null],["064380004004","Hainburg",null],["064380005005","Heusenstamm, Stadt",null],["064380006006","Langen (Hessen), Stadt",null],["064380007007","Mainhausen",null],["064380008008","Mühlheim am Main, Stadt",null],["064380009009","Neu-Isenburg, Stadt",null],["064380010010","Obertshausen, Stadt",null],["064380011011","Rodgau, Stadt",null],["064380012012","Rödermark, Stadt",null],["064380013013","Seligenstadt, Einhardstadt",null],["064390001001","Aarbergen",null],["064390002002","Bad Schwalbach, Kreisstadt",null],["064390003003","Eltville am Rhein, Stadt",null],["064390004004","Geisenheim, Hochschulstadt",null],["064390005005","Heidenrod",null],["064390006006","Hohenstein",null],["064390007007","Hünstetten",null],["064390008008","Idstein, Hochschulstadt",null],["064390009009","Kiedrich",null],["064390010010","Lorch, Stadt",null],["064390011011","Niedernhausen",null],["064390012012","Oestrich-Winkel, Stadt",null],["064390013013","Rüdesheim am Rhein, Stadt",null],["064390014014","Schlangenbad",null],["064390015015","Taunusstein, Stadt",null],["064390016016","Waldems",null],["064390017017","Walluf",null],["064400001001","Altenstadt",null],["064400002002","Bad Nauheim, Stadt",null],["064400003003","Bad Vilbel, Stadt",null],["064400004004","Büdingen, Stadt",null],["064400005005","Butzbach, Friedrich-Ludwig-Weidig-Stadt",null],["064400006006","Echzell",null],["064400007007","Florstadt, Stadt",null],["064400008008","Friedberg (Hessen), Kreisstadt",null],["064400009009","Gedern, Stadt",null],["064400010010","Glauburg",null],["064400011011","Hirzenhain",null],["064400012012","Karben, Stadt",null],["064400013013","Kefenrod",null],["064400014014","Limeshain",null],["064400015015","Münzenberg, Stadt",null],["064400016016","Nidda, Stadt",null],["064400017017","Niddatal, Stadt",null],["064400018018","Ober-Mörlen",null],["064400019019","Ortenberg, Stadt",null],["064400020020","Ranstadt",null],["064400021021","Reichelsheim (Wetterau), Stadt",null],["064400022022","Rockenberg",null],["064400023023","Rosbach v. d. Höhe, Stadt",null],["064400024024","Wölfersheim",null],["064400025025","Wöllstadt",null],["065310001001","Allendorf (Lumda), Stadt",null],["065310002002","Biebertal",null],["065310003003","Buseck",null],["065310004004","Fernwald",null],["065310005005","Gießen, Universitätsstadt",null],["065310006006","Grünberg, Stadt",null],["065310007007","Heuchelheim a. d. Lahn",null],["065310008008","Hungen, Stadt",null],["065310009009","Langgöns",null],["065310010010","Laubach, Stadt",null],["065310011011","Lich, Stadt",null],["065310012012","Linden, Stadt",null],["065310013013","Lollar, Stadt",null],["065310014014","Pohlheim, Stadt",null],["065310015015","Rabenau",null],["065310016016","Reiskirchen",null],["065310017017","Staufenberg, Stadt",null],["065310018018","Wettenberg",null],["065320001001","Aßlar, Stadt",null],["065320002002","Bischoffen",null],["065320003003","Braunfels, Stadt",null],["065320004004","Breitscheid",null],["065320005005","Dietzhölztal",null],["065320006006","Dillenburg, Oranienstadt",null],["065320007007","Driedorf",null],["065320008008","Ehringshausen",null],["065320009009","Eschenburg",null],["065320010010","Greifenstein",null],["065320011011","Haiger, Stadt",null],["065320012012","Herborn, Stadt",null],["065320013013","Hohenahr",null],["065320014014","Hüttenberg",null],["065320015015","Lahnau",null],["065320016016","Leun, Stadt",null],["065320017017","Mittenaar",null],["065320018018","Schöffengrund",null],["065320019019","Siegbach",null],["065320020020","Sinn",null],["065320021021","Solms, Stadt",null],["065320022022","Waldsolms",null],["065320023023","Wetzlar, Stadt",null],["065330001001","Beselich",null],["065330002002","Brechen",null],["065330003003","Bad Camberg, Stadt",null],["065330004004","Dornburg",null],["065330005005","Elbtal",null],["065330006006","Elz",null],["065330007007","Hadamar, Stadt",null],["065330008008","Hünfelden",null],["065330009009","Limburg a. d. Lahn, Kreisstadt",null],["065330010010","Löhnberg",null],["065330011011","Mengerskirchen, Marktflecken",null],["065330012012","Merenberg, Marktflecken",null],["065330013013","Runkel, Stadt",null],["065330014014","Selters (Taunus)",null],["065330015015","Villmar, Marktflecken",null],["065330016016","Waldbrunn (Westerwald)",null],["065330017017","Weilburg, Stadt",null],["065330018018","Weilmünster, Marktflecken",null],["065330019019","Weinbach",null],["065340001001","Amöneburg, Stadt",null],["065340002002","Angelburg",null],["065340003003","Bad Endbach",null],["065340004004","Biedenkopf, Stadt",null],["065340005005","Breidenbach",null],["065340006006","Cölbe",null],["065340007007","Dautphetal",null],["065340008008","Ebsdorfergrund",null],["065340009009","Fronhausen",null],["065340010010","Gladenbach, Stadt",null],["065340011011","Kirchhain, Stadt",null],["065340012012","Lahntal",null],["065340013013","Lohra",null],["065340014014","Marburg, Universitätsstadt",null],["065340015015","Münchhausen",null],["065340016016","Neustadt (Hessen), Stadt",null],["065340017017","Rauschenberg, Stadt",null],["065340018018","Stadtallendorf, Stadt",null],["065340019019","Steffenberg",null],["065340020020","Weimar (Lahn)",null],["065340021021","Wetter (Hessen), Stadt",null],["065340022022","Wohratal",null],["065350001001","Alsfeld, Stadt",null],["065350002002","Antrifttal",null],["065350003003","Feldatal",null],["065350004004","Freiensteinau",null],["065350005005","Gemünden (Felda)",null],["065350006006","Grebenau, Stadt",null],["065350007007","Grebenhain",null],["065350008008","Herbstein, Stadt",null],["065350009009","Homberg (Ohm), Stadt",null],["065350010010","Kirtorf, Stadt",null],["065350011011","Lauterbach (Hessen), Kreisstadt",null],["065350012012","Lautertal (Vogelsberg)",null],["065350013013","Mücke",null],["065350014014","Romrod, Stadt",null],["065350015015","Schlitz, Stadt",null],["065350016016","Schotten, Stadt",null],["065350017017","Schwalmtal",null],["065350018018","Ulrichstein, Stadt",null],["065350019019","Wartenberg",null],["066110000000","Kassel, documenta-Stadt",null],["066310001001","Bad Salzschlirf",null],["066310002002","Burghaun, Marktgemeinde",null],["066310003003","Dipperz",null],["066310004004","Ebersburg",null],["066310005005","Ehrenberg (Rhön)",null],["066310006006","Eichenzell",null],["066310007007","Eiterfeld, Marktgemeinde",null],["066310008008","Flieden",null],["066310009009","Fulda, Stadt",null],["066310010010","Gersfeld (Rhön), Stadt",null],["066310011011","Großenlüder",null],["066310012012","Hilders, Marktgemeinde",null],["066310013013","Hofbieber",null],["066310014014","Hosenfeld",null],["066310015015","Hünfeld, Konrad-Zuse-Stadt",null],["066310016016","Kalbach",null],["066310017017","Künzell",null],["066310018018","Neuhof",null],["066310019019","Nüsttal",null],["066310020020","Petersberg",null],["066310021021","Poppenhausen (Wasserkuppe)",null],["066310022022","Rasdorf, Point-Alpha-Gemeinde",null],["066310023023","Tann (Rhön), Stadt",null],["066320001001","Alheim",null],["066320002002","Bad Hersfeld, Kreisstadt",null],["066320003003","Bebra, Stadt",null],["066320004004","Breitenbach a. Herzberg",null],["066320005005","Cornberg",null],["066320006006","Friedewald",null],["066320007007","Hauneck",null],["066320008008","Haunetal",null],["066320009009","Heringen (Werra), Stadt",null],["066320010010","Hohenroda",null],["066320011011","Kirchheim",null],["066320012012","Ludwigsau",null],["066320013013","Nentershausen",null],["066320014014","Neuenstein",null],["066320015015","Niederaula, Marktgemeinde",null],["066320016016","Philippsthal (Werra), Marktgemeinde",null],["066320017017","Ronshausen",null],["066320018018","Rotenburg a. d. Fulda, Stadt",null],["066320019019","Schenklengsfeld",null],["066320020020","Wildeck",null],["066330001001","Ahnatal",null],["066330002002","Bad Karlshafen, Stadt",null],["066330003003","Baunatal, Stadt",null],["066330004004","Breuna",null],["066330005005","Calden",null],["066330006006","Bad Emstal",null],["066330007007","Espenau",null],["066330008008","Fuldabrück",null],["066330009009","Fuldatal",null],["066330010010","Grebenstein, Stadt",null],["066330011011","Habichtswald",null],["066330012012","Helsa",null],["066330013013","Hofgeismar, Stadt",null],["066330014014","Immenhausen, Stadt",null],["066330015015","Kaufungen",null],["066330016016","Liebenau, Stadt",null],["066330017017","Lohfelden",null],["066330018018","Naumburg, Stadt",null],["066330019019","Nieste",null],["066330020020","Niestetal",null],["066330022022","Reinhardshagen",null],["066330023023","Schauenburg",null],["066330024024","Söhrewald",null],["066330025025","Trendelburg, Stadt",null],["066330026026","Vellmar, Stadt",null],["066330028028","Wolfhagen, Hans-Staden-Stadt",null],["066330029029","Zierenberg, Stadt",null],["066330030030","Wesertal",null],["066339200200","Gutsbezirk Reinhardswald, gemfr. Gebiet",null],["066340001001","Borken (Hessen), Stadt",null],["066340002002","Edermünde",null],["066340003003","Felsberg, Stadt",null],["066340004004","Frielendorf, Marktflecken",null],["066340005005","Fritzlar, Dom- und Kaiserstadt",null],["066340006006","Gilserberg",null],["066340007007","Gudensberg, Stadt",null],["066340008008","Guxhagen",null],["066340009009","Homberg (Efze), Reformationsstadt, Kreisstadt",null],["066340010010","Jesberg",null],["066340011011","Knüllwald",null],["066340012012","Körle",null],["066340013013","Malsfeld",null],["066340014014","Melsungen, Stadt",null],["066340015015","Morschen",null],["066340016016","Neuental",null],["066340017017","Neukirchen, Stadt",null],["066340018018","Niedenstein, Stadt",null],["066340019019","Oberaula",null],["066340020020","Ottrau",null],["066340021021","Schrecksbach",null],["066340022022","Schwalmstadt, Konfirmationsstadt",null],["066340023023","Schwarzenborn, Stadt",null],["066340024024","Spangenberg, Liebenbachstadt",null],["066340025025","Wabern",null],["066340026026","Willingshausen",null],["066340027027","Bad Zwesten",null],["066350001001","Allendorf (Eder)",null],["066350002002","Bad Arolsen, Stadt",null],["066350003003","Bad Wildungen, Stadt",null],["066350004004","Battenberg (Eder), Stadt",null],["066350005005","Bromskirchen",null],["066350006006","Burgwald",null],["066350007007","Diemelsee",null],["066350008008","Diemelstadt, Stadt",null],["066350009009","Edertal, Nationalparkgemeinde",null],["066350010010","Frankenau, Nationalparkstadt",null],["066350011011","Frankenberg (Eder), Philipp-Soldan-Stadt",null],["066350012012","Gemünden (Wohra), Stadt",null],["066350013013","Haina (Kloster)",null],["066350014014","Hatzfeld (Eder), Stadt",null],["066350015015","Korbach, Hansestadt, Kreisstadt",null],["066350016016","Lichtenfels, Stadt",null],["066350017017","Rosenthal, Stadt",null],["066350018018","Twistetal",null],["066350019019","Vöhl, Nationalparkgemeinde",null],["066350020020","Volkmarsen, Stadt",null],["066350021021","Waldeck, Stadt",null],["066350022022","Willingen (Upland)",null],["066360001001","Bad Sooden-Allendorf, Stadt",null],["066360002002","Berkatal",null],["066360003003","Eschwege, Kreisstadt",null],["066360004004","Großalmerode, Stadt",null],["066360005005","Herleshausen",null],["066360006006","Hessisch Lichtenau, Stadt",null],["066360007007","Meinhard",null],["066360008008","Meißner",null],["066360009009","Neu-Eichenberg",null],["066360010010","Ringgau",null],["066360011011","Sontra, Stadt",null],["066360012012","Waldkappel, Stadt",null],["066360013013","Wanfried, Stadt",null],["066360014014","Wehretal",null],["066360015015","Weißenborn",null],["066360016016","Witzenhausen, Stadt",null],["066369200200","Gutsbezirk Kaufunger Wald, gemfr. Gebiet",null],["070009999999","Gemeinsames deutsch-luxemburgisches Hoheitsgebiet",null],["071110000000","Koblenz, Stadt",null],["071310007007","Bad Neuenahr-Ahrweiler, Stadt",null],["071310070070","Remagen, Stadt",null],["071310077077","Sinzig, Stadt",null],["071310090090","Grafschaft",null],["071315001001","Adenau, Stadt",null],["071315001004","Antweiler",null],["071315001005","Aremberg",null],["071315001008","Barweiler",null],["071315001009","Bauler",null],["071315001015","Dankerath",null],["071315001018","Dorsel",null],["071315001021","Eichenbach",null],["071315001022","Fuchshofen",null],["071315001026","Harscheid",null],["071315001028","Herschbroich",null],["071315001030","Hoffeld",null],["071315001032","Honerath",null],["071315001033","Hümmel",null],["071315001034","Insul",null],["071315001037","Kaltenborn",null],["071315001042","Kottenborn",null],["071315001044","Leimbach",null],["071315001050","Meuspath",null],["071315001051","Müllenbach",null],["071315001052","Müsch",null],["071315001058","Nürburg",null],["071315001062","Ohlenhard",null],["071315001065","Pomster",null],["071315001066","Quiddelbach",null],["071315001069","Reifferscheid",null],["071315001072","Rodder",null],["071315001074","Schuld",null],["071315001075","Senscheid",null],["071315001076","Sierscheid",null],["071315001079","Trierscheid",null],["071315001082","Wershofen",null],["071315001083","Wiesemscheid",null],["071315001084","Wimbach",null],["071315001085","Winnerath",null],["071315001086","Wirft",null],["071315001501","Dümpelfeld",null],["071315002002","Ahrbrück",null],["071315002003","Altenahr",null],["071315002011","Berg",null],["071315002017","Dernau",null],["071315002027","Heckenbach",null],["071315002029","Hönningen",null],["071315002036","Kalenborn",null],["071315002039","Kesseling",null],["071315002040","Kirchsahr",null],["071315002047","Lind",null],["071315002049","Mayschoß",null],["071315002068","Rech",null],["071315003006","Bad Breisig, Stadt",null],["071315003014","Brohl-Lützing",null],["071315003025","Gönnersdorf",null],["071315003081","Waldorf",null],["071315004016","Dedenbach",null],["071315004041","Königsfeld",null],["071315004054","Niederdürenbach",null],["071315004055","Niederzissen",null],["071315004059","Oberdürenbach",null],["071315004060","Oberzissen",null],["071315004073","Schalkenbach",null],["071315004201","Brenk",null],["071315004202","Burgbrohl",null],["071315004204","Galenberg",null],["071315004205","Glees",null],["071315004206","Hohenleimbach",null],["071315004208","Spessart",null],["071315004209","Wassenach",null],["071315004210","Wehr",null],["071315004211","Weibern",null],["071315004502","Kempenich",null],["071325003018","Daaden, Stadt",null],["071325003019","Derschen",null],["071325003026","Emmerzhausen",null],["071325003036","Friedewald",null],["071325003050","Herdorf, Stadt",null],["071325003068","Mauden",null],["071325003075","Niederdreisbach",null],["071325003079","Nisterberg",null],["071325003101","Schutzbach",null],["071325003113","Weitefeld",null],["071325006007","Birkenbeul",null],["071325006010","Bitzen",null],["071325006013","Breitscheidt",null],["071325006014","Bruchertseifen",null],["071325006028","Etzbach",null],["071325006034","Forst",null],["071325006038","Fürthen",null],["071325006044","Hamm (Sieg)",null],["071325006077","Niederirsen",null],["071325006091","Pracht",null],["071325006096","Roth",null],["071325006102","Seelbach bei Hamm (Sieg)",null],["071325007012","Brachbach",null],["071325007037","Friesenhagen",null],["071325007045","Harbach",null],["071325007063","Kirchen (Sieg), Stadt",null],["071325007072","Mudersbach",null],["071325007076","Niederfischbach",null],["071325008008","Birken-Honigsessen",null],["071325008011","Mittelhof",null],["071325008054","Hövels",null],["071325008080","Katzwinkel (Sieg)",null],["071325008105","Selbach (Sieg)",null],["071325008117","Wissen, Stadt",null],["071325009002","Alsdorf",null],["071325009006","Betzdorf, Stadt",null],["071325009020","Dickendorf",null],["071325009024","Elben",null],["071325009025","Elkenroth",null],["071325009030","Fensdorf",null],["071325009039","Gebhardshain",null],["071325009042","Grünebach",null],["071325009059","Kausen",null],["071325009066","Malberg",null],["071325009071","Molzhain",null],["071325009073","Nauroth",null],["071325009095","Rosenheim (Landkreis Altenkirchen)",null],["071325009098","Scheuerfeld",null],["071325009107","Steinebach/ Sieg",null],["071325009108","Steineroth",null],["071325009111","Wallmenroth",null],["071325010001","Almersbach",null],["071325010004","Bachenberg",null],["071325010005","Berzhausen",null],["071325010009","Birnbach",null],["071325010015","Bürdenbach",null],["071325010016","Burglahr",null],["071325010017","Busenhausen",null],["071325010022","Eichelhardt",null],["071325010023","Eichen",null],["071325010027","Ersfeld",null],["071325010029","Eulenberg",null],["071325010031","Fiersbach",null],["071325010032","Flammersfeld",null],["071325010033","Fluterschen",null],["071325010035","Forstmehren",null],["071325010040","Gieleroth",null],["071325010041","Giershausen",null],["071325010043","Güllesheim",null],["071325010046","Hasselbach",null],["071325010047","Helmenzen",null],["071325010048","Helmeroth",null],["071325010049","Hemmelzen",null],["071325010051","Heupelzen",null],["071325010052","Hilgenroth",null],["071325010053","Hirz-Maulsbach",null],["071325010055","Horhausen (Westerwald)",null],["071325010056","Idelberg",null],["071325010057","Ingelbach",null],["071325010058","Isert",null],["071325010060","Kescheid",null],["071325010061","Kettenhausen",null],["071325010062","Kircheib",null],["071325010064","Kraam",null],["071325010065","Krunkel",null],["071325010067","Mammelzen",null],["071325010069","Mehren",null],["071325010070","Michelbach (Westerwald)",null],["071325010078","Niedersteinebach",null],["071325010081","Obererbach (Westerwald)",null],["071325010082","Oberirsen",null],["071325010083","Oberlahr",null],["071325010085","Obersteinebach",null],["071325010086","Oberwambach",null],["071325010087","Ölsen",null],["071325010088","Orfgen",null],["071325010089","Peterslahr",null],["071325010090","Pleckhausen",null],["071325010092","Racksen",null],["071325010093","Reiferscheid",null],["071325010094","Rettersen",null],["071325010097","Rott",null],["071325010099","Schöneberg",null],["071325010100","Schürdt",null],["071325010103","Seelbach (Westerwald)",null],["071325010104","Seifen",null],["071325010106","Sörth",null],["071325010109","Stürzelbach",null],["071325010110","Volkerzen",null],["071325010112","Walterschen",null],["071325010114","Werkhausen",null],["071325010115","Weyerbusch",null],["071325010116","Willroth",null],["071325010118","Wölmersen",null],["071325010119","Ziegenhain",null],["071325010201","Berod bei Hachenburg",null],["071325010501","Altenkirchen (Westerwald), Stadt",null],["071325010502","Neitersen",null],["071330006006","Bad Kreuznach, Stadt",null],["071335001003","Altenbamberg",null],["071335001012","Biebelsheim",null],["071335001030","Feilbingert",null],["071335001031","Frei-Laubersheim",null],["071335001032","Fürfeld",null],["071335001037","Hackenheim",null],["071335001039","Hallgarten",null],["071335001045","Hochstätten",null],["071335001069","Neu-Bamberg",null],["071335001078","Pfaffen-Schwabenheim",null],["071335001080","Pleitersheim",null],["071335001104","Tiefenthal",null],["071335001106","Volxheim",null],["071335006002","Allenfeld",null],["071335006004","Argenschwang",null],["071335006013","Bockenau",null],["071335006014","Boos",null],["071335006015","Braunweiler",null],["071335006019","Burgsponheim",null],["071335006021","Dalberg",null],["071335006027","Duchroth",null],["071335006033","Gebroth",null],["071335006036","Gutenberg",null],["071335006040","Hargesheim",null],["071335006044","Hergenfeld",null],["071335006048","Hüffelsheim",null],["071335006061","Mandel",null],["071335006068","Münchwald",null],["071335006070","Niederhausen",null],["071335006071","Norheim",null],["071335006074","Oberhausen an der Nahe",null],["071335006075","Oberstreit",null],["071335006086","Roxheim",null],["071335006088","Sankt Katharinen",null],["071335006089","Schloßböckelheim",null],["071335006098","Sommerloch",null],["071335006099","Spabrücken",null],["071335006100","Spall",null],["071335006101","Sponheim",null],["071335006105","Traisen",null],["071335006107","Waldböckelheim",null],["071335006109","Wallhausen",null],["071335006112","Weinsheim",null],["071335006115","Winterbach",null],["071335006117","Rüdesheim",null],["071335009008","Bärenbach",null],["071335009010","Becherbach bei Kirn",null],["071335009016","Brauweiler",null],["071335009038","Hahnenbach",null],["071335009041","Heimweiler",null],["071335009042","Heinzenberg",null],["071335009043","Hennweiler",null],["071335009046","Hochstetten-Dhaun",null],["071335009047","Horbach",null],["071335009052","Kirn, Stadt",null],["071335009059","Limbach",null],["071335009063","Meckenbach",null],["071335009073","Oberhausen bei Kirn",null],["071335009077","Otzweiler",null],["071335009096","Simmertal",null],["071335009113","Weitersborn",null],["071335009201","Bruschied",null],["071335009202","Kellenbach",null],["071335009203","Königsau",null],["071335009204","Schneppenbach",null],["071335009205","Schwarzerden",null],["071335010001","Abtweiler",null],["071335010005","Auen",null],["071335010009","Bärweiler",null],["071335010011","Becherbach",null],["071335010017","Breitenheim",null],["071335010020","Callbach",null],["071335010022","Daubach",null],["071335010024","Desloch",null],["071335010049","Hundsbach",null],["071335010050","Ippenschied",null],["071335010051","Jeckenbach",null],["071335010053","Kirschroth",null],["071335010055","Langenthal",null],["071335010057","Lauschied",null],["071335010058","Lettweiler",null],["071335010060","Löllbach",null],["071335010062","Martinstein",null],["071335010064","Meddersheim",null],["071335010065","Meisenheim, Stadt",null],["071335010066","Merxheim",null],["071335010067","Monzingen",null],["071335010072","Nußbaum",null],["071335010076","Odernheim am Glan",null],["071335010081","Raumbach",null],["071335010082","Rehbach",null],["071335010083","Rehborn",null],["071335010084","Reiffelbach",null],["071335010090","Schmittweiler",null],["071335010092","Schweinschied",null],["071335010094","Seesbach",null],["071335010102","Staudernheim",null],["071335010111","Weiler bei Monzingen",null],["071335010116","Winterburg",null],["071335010501","Bad Sobernheim, Stadt",null],["071335011018","Bretzenheim",null],["071335011023","Daxweiler",null],["071335011025","Dörrebach",null],["071335011026","Dorsheim",null],["071335011028","Eckenroth",null],["071335011035","Guldental",null],["071335011054","Langenlonsheim",null],["071335011056","Laubenheim",null],["071335011085","Roth",null],["071335011087","Rümmelsheim",null],["071335011091","Schöneberg",null],["071335011093","Schweppenhausen",null],["071335011095","Seibersbach",null],["071335011103","Stromberg, Stadt",null],["071335011108","Waldlaubersheim",null],["071335011110","Warmsroth",null],["071335011114","Windesheim",null],["071340045045","Idar-Oberstein, Stadt",null],["071345001005","Baumholder, Stadt",null],["071345001007","Berglangenbach",null],["071345001008","Berschweiler bei Baumholder",null],["071345001021","Eckersweiler",null],["071345001026","Fohren-Linden",null],["071345001027","Frauenberg",null],["071345001033","Hahnweiler",null],["071345001036","Heimbach",null],["071345001051","Leitzweiler",null],["071345001054","Mettweiler",null],["071345001068","Reichenbach",null],["071345001073","Rohrbach",null],["071345001074","Rückweiler",null],["071345001075","Ruschberg",null],["071345002001","Abentheuer",null],["071345002002","Achtelsbach",null],["071345002010","Birkenfeld, Stadt",null],["071345002011","Börfink",null],["071345002015","Brücken",null],["071345002016","Buhlenberg",null],["071345002018","Dambach",null],["071345002020","Dienstweiler",null],["071345002022","Elchweiler",null],["071345002023","Ellenberg",null],["071345002024","Ellweiler",null],["071345002029","Gimbweiler",null],["071345002031","Gollenberg",null],["071345002034","Hattgenstein",null],["071345002042","Hoppstädten-Weiersbach",null],["071345002048","Kronweiler",null],["071345002050","Leisel",null],["071345002053","Meckenbach",null],["071345002057","Niederbrombach",null],["071345002058","Niederhambach",null],["071345002061","Nohen",null],["071345002062","Oberbrombach",null],["071345002063","Oberhambach",null],["071345002070","Rimsberg",null],["071345002071","Rinzenberg",null],["071345002072","Rötsweiler-Nockenthal",null],["071345002078","Schmißberg",null],["071345002080","Schwollen",null],["071345002084","Siesbach",null],["071345002085","Sonnenberg-Winnenberg",null],["071345002094","Wilzenberg-Hußweiler",null],["071345005003","Allenbach",null],["071345005004","Asbach",null],["071345005006","Bergen",null],["071345005009","Berschweiler bei Kirn",null],["071345005012","Bollenbach",null],["071345005013","Breitenthal",null],["071345005014","Bruchweiler",null],["071345005017","Bundenbach",null],["071345005019","Dickesbach",null],["071345005025","Fischbach",null],["071345005028","Gerach",null],["071345005030","Gösenroth",null],["071345005032","Griebelschied",null],["071345005035","Hausen",null],["071345005037","Hellertshausen",null],["071345005038","Herborn",null],["071345005039","Herrstein",null],["071345005040","Hettenrodt",null],["071345005041","Hintertiefenbach",null],["071345005043","Horbruch",null],["071345005044","Hottenbach",null],["071345005046","Kempfeld",null],["071345005047","Kirschweiler",null],["071345005049","Krummenau",null],["071345005052","Mackenrodt",null],["071345005055","Mittelreidenbach",null],["071345005056","Mörschied",null],["071345005059","Niederhosenbach",null],["071345005060","Niederwörresbach",null],["071345005064","Oberhosenbach",null],["071345005065","Oberkirn",null],["071345005066","Oberreidenbach",null],["071345005067","Oberwörresbach",null],["071345005069","Rhaunen",null],["071345005076","Schauren",null],["071345005077","Schmidthachenbach",null],["071345005079","Schwerbach",null],["071345005081","Sensweiler",null],["071345005082","Sien",null],["071345005083","Sienhachenbach",null],["071345005086","Sonnschied",null],["071345005087","Stipshausen",null],["071345005088","Sulzbach",null],["071345005089","Veitsrodt",null],["071345005090","Vollmersbach",null],["071345005091","Weiden",null],["071345005092","Weitersbach",null],["071345005093","Wickenrodt",null],["071345005095","Wirschweiler",null],["071345005502","Langweiler",null],["071355001007","Beilstein",null],["071355001012","Bremm",null],["071355001015","Briedern",null],["071355001017","Bruttig-Fankel",null],["071355001020","Cochem, Stadt",null],["071355001021","Dohr",null],["071355001024","Ediger-Eller",null],["071355001025","Ellenz-Poltersdorf",null],["071355001027","Ernst",null],["071355001029","Faid",null],["071355001036","Greimersburg",null],["071355001049","Klotten",null],["071355001053","Lieg",null],["071355001056","Lütz",null],["071355001060","Mesenich",null],["071355001065","Moselkern",null],["071355001066","Müden (Mosel)",null],["071355001069","Nehren",null],["071355001072","Pommern",null],["071355001079","Senheim",null],["071355001082","Treis-Karden",null],["071355001086","Valwig",null],["071355001090","Wirfus",null],["071355002009","Binningen",null],["071355002011","Brachtendorf",null],["071355002014","Brieden",null],["071355002016","Brohl",null],["071355002022","Dünfus",null],["071355002023","Düngenheim",null],["071355002026","Eppenberg",null],["071355002028","Eulgem",null],["071355002031","Forst (Eifel)",null],["071355002033","Gamlen",null],["071355002038","Hambuch",null],["071355002040","Hauroth",null],["071355002042","Illerich",null],["071355002043","Kaifenheim",null],["071355002044","Kail",null],["071355002045","Kaisersesch, Stadt",null],["071355002046","Kalenborn",null],["071355002051","Landkern",null],["071355002052","Laubach",null],["071355002058","Masburg",null],["071355002062","Möntenich",null],["071355002067","Müllenbach",null],["071355002075","Roes",null],["071355002084","Urmersbach",null],["071355002093","Zettingen",null],["071355002502","Leienkaul",null],["071355003002","Alflen",null],["071355003005","Auderath",null],["071355003008","Beuren",null],["071355003018","Büchel",null],["071355003030","Filz",null],["071355003034","Gevenich",null],["071355003035","Gillenbeuren",null],["071355003048","Kliding",null],["071355003057","Lutzerath",null],["071355003078","Schmitt",null],["071355003083","Ulmen, Stadt",null],["071355003085","Urschmitt",null],["071355003087","Wagenhausen",null],["071355003089","Weiler",null],["071355003091","Wollmerath",null],["071355003501","Bad Bertrich",null],["071355005001","Alf",null],["071355005003","Altlay",null],["071355005004","Altstrimmig",null],["071355005010","Blankenrath",null],["071355005013","Briedel",null],["071355005019","Bullay",null],["071355005032","Forst (Hunsrück)",null],["071355005037","Grenderich",null],["071355005039","Haserich",null],["071355005041","Hesweiler",null],["071355005054","Liesenich",null],["071355005061","Mittelstrimmig",null],["071355005064","Moritzheim",null],["071355005068","Neef",null],["071355005070","Panzweiler",null],["071355005071","Peterswald-Löffelscheid",null],["071355005073","Pünderich",null],["071355005074","Reidenhausen",null],["071355005076","Sankt Aldegund",null],["071355005077","Schauren",null],["071355005080","Sosberg",null],["071355005081","Tellig",null],["071355005088","Walhausen",null],["071355005092","Zell (Mosel), Stadt",null],["071370003003","Andernach, Stadt",null],["071370068068","Mayen, Stadt",null],["071370203203","Bendorf, Stadt",null],["071375001056","Kretz",null],["071375001057","Kruft",null],["071375001081","Nickenich",null],["071375001088","Plaidt",null],["071375001096","Saffig",null],["071375002023","Einig",null],["071375002027","Gappenach",null],["071375002029","Gering",null],["071375002030","Gierschnach",null],["071375002041","Kalt",null],["071375002048","Kerben",null],["071375002053","Kollig",null],["071375002065","Lonnig",null],["071375002070","Mertloch",null],["071375002080","Naunheim",null],["071375002086","Ochtendung",null],["071375002087","Pillig",null],["071375002089","Polch, Stadt",null],["071375002095","Rüber",null],["071375002102","Trimbs",null],["071375002112","Welling",null],["071375002114","Wierschem",null],["071375002501","Münstermaifeld, Stadt",null],["071375003001","Acht",null],["071375003004","Anschau",null],["071375003006","Arft",null],["071375003007","Baar",null],["071375003011","Bermel",null],["071375003014","Boos",null],["071375003019","Ditscheid",null],["071375003025","Ettringen",null],["071375003034","Hausten",null],["071375003035","Herresbach",null],["071375003036","Hirten",null],["071375003043","Kehrig",null],["071375003049","Kirchwald",null],["071375003055","Kottenheim",null],["071375003060","Langenfeld",null],["071375003061","Langscheid",null],["071375003063","Lind",null],["071375003066","Luxem",null],["071375003074","Monreal",null],["071375003077","Münk",null],["071375003079","Nachtsheim",null],["071375003092","Reudelsterz",null],["071375003097","Sankt Johann",null],["071375003099","Siebenbach",null],["071375003105","Virneburg",null],["071375003110","Weiler",null],["071375003113","Welschenbach",null],["071375004008","Bell",null],["071375004069","Mendig, Stadt",null],["071375004093","Rieden",null],["071375004101","Thür",null],["071375004106","Volkesfeld",null],["071375007218","Niederwerth",null],["071375007224","Urbar",null],["071375007226","Vallendar, Stadt",null],["071375007229","Weitersburg",null],["071375008202","Bassenheim",null],["071375008209","Kaltenengers",null],["071375008211","Kettig",null],["071375008216","Mülheim-Kärlich, Stadt",null],["071375008222","Sankt Sebastian",null],["071375008225","Urmitz",null],["071375008228","Weißenthurm, Stadt",null],["071375009201","Alken",null],["071375009204","Brey",null],["071375009205","Brodenbach",null],["071375009206","Burgen",null],["071375009207","Dieblich",null],["071375009208","Hatzenport",null],["071375009212","Kobern-Gondorf",null],["071375009214","Löf",null],["071375009215","Macken",null],["071375009217","Niederfell",null],["071375009219","Nörtershausen",null],["071375009220","Oberfell",null],["071375009221","Rhens, Stadt",null],["071375009223","Spay",null],["071375009227","Waldesch",null],["071375009230","Winningen",null],["071375009231","Wolken",null],["071375009504","Lehmen",null],["071380045045","Neuwied, Stadt",null],["071385001003","Asbach",null],["071385001044","Neustadt (Wied)",null],["071385001077","Windhagen",null],["071385001080","Buchholz (Westerwald)",null],["071385002004","Bad Hönningen, Stadt",null],["071385002024","Hammerstein",null],["071385002038","Leutesdorf",null],["071385002063","Rheinbrohl",null],["071385003012","Dierdorf, Stadt",null],["071385003023","Großmaischeid",null],["071385003031","Isenburg",null],["071385003034","Kleinmaischeid",null],["071385003069","Stebach",null],["071385003201","Marienhausen",null],["071385004009","Dattenberg",null],["071385004037","Leubsdorf",null],["071385004041","Linz am Rhein, Stadt",null],["071385004055","Ockenfels",null],["071385004068","Sankt Katharinen (Landkreis Neuwied)",null],["071385004075","Vettelschoß",null],["071385004501","Kasbach-Ohlenberg",null],["071385005011","Dernbach",null],["071385005013","Döttesfeld",null],["071385005014","Dürrholz",null],["071385005025","Hanroth",null],["071385005027","Harschbach",null],["071385005040","Linkenbach",null],["071385005048","Niederhofen",null],["071385005050","Niederwambach",null],["071385005052","Oberdreis",null],["071385005057","Puderbach",null],["071385005058","Ratzert",null],["071385005059","Raubach",null],["071385005064","Rodenbach bei Puderbach",null],["071385005070","Steimel",null],["071385005074","Urbach",null],["071385005078","Woldert",null],["071385007008","Bruchhausen",null],["071385007019","Erpel",null],["071385007062","Rheinbreitbach",null],["071385007073","Unkel, Stadt",null],["071385009002","Anhausen",null],["071385009005","Bonefeld",null],["071385009006","Breitscheid",null],["071385009007","Hausen (Wied)",null],["071385009010","Datzeroth",null],["071385009015","Ehlscheid",null],["071385009026","Hardert",null],["071385009030","Hümmerich",null],["071385009036","Kurtscheid",null],["071385009042","Meinborn",null],["071385009043","Melsbach",null],["071385009047","Niederbreitbach",null],["071385009053","Oberhonnefeld-Gierend",null],["071385009054","Oberraden",null],["071385009061","Rengsdorf",null],["071385009065","Roßbach",null],["071385009066","Rüscheid",null],["071385009071","Straßenhaus",null],["071385009072","Thalhausen",null],["071385009076","Waldbreitbach",null],["071400501501","Boppard, Stadt",null],["071405003001","Alterkülz",null],["071405003009","Bell (Hunsrück)",null],["071405003010","Beltheim",null],["071405003018","Braunshorn",null],["071405003021","Buch",null],["071405003042","Gödenroth",null],["071405003046","Hasselbach",null],["071405003055","Hollnich",null],["071405003064","Kastellaun, Stadt",null],["071405003073","Korweiler",null],["071405003095","Michelbach",null],["071405003131","Roth",null],["071405003147","Spesenroth",null],["071405003153","Uhler",null],["071405003202","Dommershausen",null],["071405003204","Mastershausen",null],["071405003502","Lahr",null],["071405003503","Mörsdorf",null],["071405003504","Zilshausen",null],["071405004006","Bärenbach",null],["071405004007","Belg",null],["071405004024","Büchenbeuren",null],["071405004028","Dickenschied",null],["071405004029","Dill",null],["071405004030","Dillendorf",null],["071405004040","Gehlweiler",null],["071405004041","Gemünden",null],["071405004044","Hahn",null],["071405004048","Hecken",null],["071405004049","Heinzenbach",null],["071405004050","Henau",null],["071405004053","Hirschfeld (Hunsrück)",null],["071405004062","Kappel",null],["071405004067","Kirchberg (Hunsrück), Stadt",null],["071405004071","Kludenbach",null],["071405004081","Laufersweiler",null],["071405004082","Lautzenhausen",null],["071405004086","Lindenschied",null],["071405004090","Maitzborn",null],["071405004094","Metzenhausen",null],["071405004105","Nieder Kostenz",null],["071405004107","Niedersohren",null],["071405004109","Niederweiler",null],["071405004111","Ober Kostenz",null],["071405004120","Raversbeuren",null],["071405004122","Reckershausen",null],["071405004128","Rödelhausen",null],["071405004129","Rödern",null],["071405004130","Rohrbach",null],["071405004135","Schlierschied",null],["071405004141","Schwarzen",null],["071405004145","Sohren",null],["071405004146","Sohrschied",null],["071405004151","Todenroth",null],["071405004154","Unzenberg",null],["071405004159","Wahlenau",null],["071405004163","Womrath",null],["071405004164","Woppenroth",null],["071405004165","Würrich",null],["071405008002","Altweidelbach",null],["071405008003","Argenthal",null],["071405008008","Belgweiler",null],["071405008011","Benzweiler",null],["071405008012","Bergenhausen",null],["071405008015","Biebern",null],["071405008020","Bubach",null],["071405008023","Budenbach",null],["071405008027","Dichtelbach",null],["071405008035","Ellern (Hunsrück)",null],["071405008037","Erbach",null],["071405008039","Fronhofen",null],["071405008056","Holzbach",null],["071405008058","Horn",null],["071405008065","Keidelheim",null],["071405008068","Kisselbach",null],["071405008070","Klosterkumbd",null],["071405008076","Külz (Hunsrück)",null],["071405008077","Kümbdchen",null],["071405008079","Laubach",null],["071405008085","Liebshausen",null],["071405008092","Mengerschied",null],["071405008096","Mörschbach",null],["071405008099","Mutterschied",null],["071405008100","Nannhausen",null],["071405008101","Neuerkirch",null],["071405008106","Niederkumbd",null],["071405008113","Ohlweiler",null],["071405008115","Oppertshausen",null],["071405008118","Pleizenhausen",null],["071405008119","Ravengiersburg",null],["071405008121","Rayerschied",null],["071405008123","Reich",null],["071405008125","Rheinböllen, Stadt",null],["071405008126","Riegenroth",null],["071405008127","Riesweiler",null],["071405008134","Sargenroth",null],["071405008138","Schnorbach",null],["071405008139","Schönborn",null],["071405008144","Simmern/ Hunsrück, Stadt",null],["071405008148","Steinbach",null],["071405008150","Tiefenbach",null],["071405008158","Wahlbach",null],["071405008166","Wüschheim",null],["071405009005","Badenhard",null],["071405009014","Bickenbach",null],["071405009016","Birkheim",null],["071405009025","Damscheid",null],["071405009031","Dörth",null],["071405009036","Emmelshausen, Stadt",null],["071405009043","Gondershausen",null],["071405009045","Halsenbach",null],["071405009047","Hausbay",null],["071405009060","Hungenroth",null],["071405009063","Karbach",null],["071405009075","Kratzenburg",null],["071405009080","Laudert",null],["071405009084","Leiningen",null],["071405009087","Lingerhahn",null],["071405009089","Maisborn",null],["071405009093","Mermuth",null],["071405009098","Mühlpfad",null],["071405009102","Ney",null],["071405009104","Niederburg",null],["071405009108","Niedert",null],["071405009110","Norath",null],["071405009112","Oberwesel, Stadt",null],["071405009116","Perscheid",null],["071405009117","Pfalzfeld",null],["071405009133","Sankt Goar, Stadt",null],["071405009140","Schwall",null],["071405009149","Thörlingen",null],["071405009155","Urbar",null],["071405009156","Utzenhain",null],["071405009161","Wiebelsheim",null],["071405009201","Beulich",null],["071405009205","Morshausen",null],["071410075075","Lahnstein, Stadt",null],["071415003002","Altendiez",null],["071415003005","Aull",null],["071415003014","Birlenbach",null],["071415003021","Charlottenberg",null],["071415003022","Cramberg",null],["071415003029","Diez, Stadt",null],["071415003030","Dörnberg",null],["071415003038","Eppenrod",null],["071415003045","Geilnau",null],["071415003049","Gückingen",null],["071415003052","Hambach",null],["071415003053","Heistenbach",null],["071415003057","Hirschberg",null],["071415003059","Holzappel",null],["071415003061","Holzheim",null],["071415003062","Horhausen",null],["071415003064","Isselbach",null],["071415003076","Langenscheid",null],["071415003077","Laurenburg",null],["071415003124","Scheidt",null],["071415003130","Steinsberg",null],["071415003133","Wasenbach",null],["071415003503","Balduinstein",null],["071415007009","Berg",null],["071415007012","Bettendorf",null],["071415007015","Bogel",null],["071415007019","Buch",null],["071415007035","Ehr",null],["071415007037","Endlichhofen",null],["071415007040","Eschbach",null],["071415007047","Gemmerich",null],["071415007055","Himmighofen",null],["071415007060","Holzhausen an der Haide",null],["071415007063","Hunzel",null],["071415007067","Kasdorf",null],["071415007070","Kehlbach",null],["071415007078","Lautert",null],["071415007080","Lipporn",null],["071415007084","Marienfels",null],["071415007085","Miehlen",null],["071415007092","Nastätten, Stadt",null],["071415007094","Niederbachheim",null],["071415007097","Niederwallmenach",null],["071415007100","Oberbachheim",null],["071415007104","Obertiefenbach",null],["071415007105","Oberwallmenach",null],["071415007107","Oelsberg",null],["071415007110","Hainau",null],["071415007116","Rettershain",null],["071415007120","Ruppertshofen",null],["071415007131","Strüth",null],["071415007134","Weidenbach",null],["071415007137","Welterod",null],["071415007140","Winterwerb",null],["071415007502","Diethardt",null],["071415009004","Auel",null],["071415009016","Bornich",null],["071415009023","Dachsenhausen",null],["071415009024","Dahlheim",null],["071415009031","Dörscheid",null],["071415009042","Filsen",null],["071415009066","Kamp-Bornhofen",null],["071415009069","Kaub, Stadt",null],["071415009072","Kestert",null],["071415009079","Lierschied",null],["071415009083","Lykershausen",null],["071415009099","Nochern",null],["071415009108","Osterspai",null],["071415009109","Patersberg",null],["071415009112","Prath",null],["071415009114","Reichenberg",null],["071415009115","Reitzenhain",null],["071415009121","Sankt Goarshausen, Loreleystadt, Stadt",null],["071415009122","Sauerthal",null],["071415009136","Weisel",null],["071415009138","Weyer",null],["071415009501","Braubach, Stadt",null],["071415010003","Attenhausen",null],["071415010006","Bad Ems, Stadt",null],["071415010008","Becheln",null],["071415010025","Dausenau",null],["071415010026","Dessighofen",null],["071415010027","Dienethal",null],["071415010033","Dornholzhausen",null],["071415010041","Fachbach",null],["071415010044","Frücht",null],["071415010046","Geisig",null],["071415010058","Hömberg",null],["071415010071","Kemmenau",null],["071415010082","Lollschied",null],["071415010086","Miellen",null],["071415010087","Misselberg",null],["071415010091","Nassau, Stadt",null],["071415010098","Nievern",null],["071415010103","Obernhof",null],["071415010106","Oberwies",null],["071415010111","Pohl",null],["071415010127","Schweighausen",null],["071415010128","Seelbach",null],["071415010129","Singhofen",null],["071415010132","Sulzbach",null],["071415010135","Weinähr",null],["071415010139","Winden",null],["071415010141","Zimmerschied",null],["071415010201","Arzbach",null],["071415011001","Allendorf",null],["071415011010","Berghausen",null],["071415011011","Berndroth",null],["071415011013","Biebrich",null],["071415011018","Bremberg",null],["071415011020","Burgschwalbach",null],["071415011032","Dörsdorf",null],["071415011034","Ebertshausen",null],["071415011036","Eisighofen",null],["071415011039","Ergeshausen",null],["071415011043","Flacht",null],["071415011050","Gutenacker",null],["071415011051","Hahnstätten",null],["071415011054","Herold",null],["071415011065","Kaltenholzhausen",null],["071415011068","Katzenelnbogen, Stadt",null],["071415011073","Klingelbach",null],["071415011074","Kördorf",null],["071415011081","Lohrheim",null],["071415011088","Mittelfischbach",null],["071415011089","Mudershausen",null],["071415011093","Netzbach",null],["071415011095","Niederneisen",null],["071415011096","Niedertiefenbach",null],["071415011101","Oberfischbach",null],["071415011102","Oberneisen",null],["071415011113","Reckenroth",null],["071415011117","Rettert",null],["071415011118","Roth",null],["071415011125","Schiesheim",null],["071415011126","Schönborn",null],["071435001206","Bad Marienberg (Westerwald), Stadt",null],["071435001211","Bölsberg",null],["071435001216","Dreisbach",null],["071435001222","Fehl-Ritzhausen",null],["071435001227","Großseifen",null],["071435001231","Hahn bei Marienberg",null],["071435001234","Hardt",null],["071435001243","Hof",null],["071435001248","Kirburg",null],["071435001253","Langenbach bei Kirburg",null],["071435001255","Lautzenbrücken",null],["071435001264","Mörlen",null],["071435001270","Neunkhausen",null],["071435001277","Nisterau",null],["071435001279","Nistertal",null],["071435001280","Norken",null],["071435001297","Stockhausen-Illfurth",null],["071435001300","Unnau",null],["071435002202","Alpenrod",null],["071435002204","Astert",null],["071435002205","Atzelgift",null],["071435002212","Borod",null],["071435002215","Dreifelden",null],["071435002223","Gehlert",null],["071435002225","Giesenhausen",null],["071435002229","Hachenburg, Stadt",null],["071435002235","Hattert",null],["071435002236","Heimborn",null],["071435002240","Heuzert",null],["071435002241","Höchstenbach",null],["071435002250","Kroppach",null],["071435002252","Kundert",null],["071435002257","Limbach",null],["071435002258","Linden",null],["071435002259","Lochum",null],["071435002260","Luckenbach",null],["071435002261","Marzhausen",null],["071435002262","Merkelbach",null],["071435002265","Mörsbach",null],["071435002267","Mudenbach",null],["071435002268","Mündersbach",null],["071435002269","Müschenbach",null],["071435002276","Nister",null],["071435002287","Roßbach",null],["071435002294","Steinebach an der Wied",null],["071435002296","Stein-Wingert",null],["071435002299","Streithausen",null],["071435002301","Wahlrod",null],["071435002306","Welkenbach",null],["071435002310","Wied",null],["071435002313","Winkelbach",null],["071435003030","Hilgert",null],["071435003031","Hillscheid",null],["071435003032","Höhr-Grenzhausen, Stadt",null],["071435003040","Kammerforst",null],["071435004005","Boden",null],["071435004008","Daubach",null],["071435004013","Eitelborn",null],["071435004020","Gackenbach",null],["071435004021","Girod",null],["071435004023","Görgeshausen",null],["071435004024","Großholbach",null],["071435004026","Heilberscheid",null],["071435004027","Heiligenroth",null],["071435004033","Holler",null],["071435004034","Horbach",null],["071435004036","Hübingen",null],["071435004039","Kadenbach",null],["071435004048","Montabaur, Stadt",null],["071435004051","Nentershausen",null],["071435004052","Neuhäusel",null],["071435004053","Niederelbert",null],["071435004054","Niedererbach",null],["071435004055","Nomborn",null],["071435004057","Oberelbert",null],["071435004065","Ruppach-Goldhausen",null],["071435004071","Simmern",null],["071435004072","Stahlhofen",null],["071435004077","Untershausen",null],["071435004079","Welschneudorf",null],["071435005001","Alsbach",null],["071435005006","Breitenau",null],["071435005007","Caan",null],["071435005009","Deesen",null],["071435005038","Hundsdorf",null],["071435005050","Nauort",null],["071435005059","Oberhaid",null],["071435005062","Ransbach-Baumbach, Stadt",null],["071435005068","Sessenbach",null],["071435005082","Wirscheid",null],["071435005084","Wittgert",null],["071435006214","Bretthausen",null],["071435006218","Elsoff (Westerwald)",null],["071435006237","Hellenhahn-Schellenberg",null],["071435006244","Homberg",null],["071435006245","Hüblingen",null],["071435006246","Irmtraut",null],["071435006256","Liebenscheid",null],["071435006271","Neunkirchen",null],["071435006272","Neustadt/ Westerwald",null],["071435006274","Niederroßbach",null],["071435006278","Nister-Möhrendorf",null],["071435006282","Oberrod",null],["071435006283","Oberroßbach",null],["071435006285","Rehe",null],["071435006286","Rennerod, Stadt",null],["071435006291","Salzburg",null],["071435006292","Seck",null],["071435006295","Stein-Neukirch",null],["071435006302","Waigandshain",null],["071435006303","Waldmühlen",null],["071435006309","Westernohe",null],["071435006311","Willingen",null],["071435006315","Zehnhausen bei Rennerod",null],["071435007015","Ellenhausen",null],["071435007018","Freilingen",null],["071435007019","Freirachdorf",null],["071435007022","Goddert",null],["071435007025","Hartenfels",null],["071435007029","Herschbach",null],["071435007041","Krümmel",null],["071435007044","Marienrachdorf",null],["071435007045","Maroth",null],["071435007046","Maxsain",null],["071435007056","Nordhofen",null],["071435007061","Quirnbach",null],["071435007064","Rückeroth",null],["071435007066","Schenkelberg",null],["071435007067","Selters (Westerwald), Stadt",null],["071435007069","Sessenhausen",null],["071435007075","Steinen",null],["071435007078","Vielbach",null],["071435007085","Wölferlingen",null],["071435007221","Ewighausen",null],["071435007305","Weidenhahn",null],["071435008011","Dreikirchen",null],["071435008037","Hundsangen",null],["071435008058","Obererbach",null],["071435008074","Steinefrenz",null],["071435008080","Weroth",null],["071435008203","Arnshöfen",null],["071435008208","Berod bei Wallmerod",null],["071435008210","Bilkheim",null],["071435008220","Ettinghausen",null],["071435008232","Hahn am See",null],["071435008239","Herschbach (Oberwesterwald)",null],["071435008251","Kuhnhöfen",null],["071435008263","Meudt",null],["071435008266","Molsberg",null],["071435008273","Niederahr",null],["071435008281","Oberahr",null],["071435008290","Salz",null],["071435008304","Wallmerod",null],["071435008316","Zehnhausen bei Wallmerod",null],["071435008501","Elbingen",null],["071435008502","Mähren",null],["071435009200","Ailertchen",null],["071435009207","Bellingen",null],["071435009209","Berzhahn",null],["071435009213","Brandscheid",null],["071435009219","Enspel",null],["071435009224","Gemünden",null],["071435009226","Girkenroth",null],["071435009228","Guckheim",null],["071435009230","Härtlingen",null],["071435009233","Halbs",null],["071435009238","Hergenroth",null],["071435009242","Höhn",null],["071435009247","Kaden",null],["071435009249","Kölbingen",null],["071435009254","Langenhahn",null],["071435009284","Pottum",null],["071435009288","Rotenhain",null],["071435009289","Rothenbach",null],["071435009293","Stahlhofen am Wiesensee",null],["071435009298","Stockum-Püschen",null],["071435009307","Weltersburg",null],["071435009308","Westerburg, Stadt",null],["071435009312","Willmenrod",null],["071435009314","Winnen",null],["071435010003","Bannberscheid",null],["071435010010","Dernbach (Westerwald)",null],["071435010012","Ebernhahn",null],["071435010028","Helferskirchen",null],["071435010042","Leuterod",null],["071435010047","Mogendorf",null],["071435010049","Moschheim",null],["071435010060","Ötzingen",null],["071435010070","Siershahn",null],["071435010073","Staudt",null],["071435010081","Wirges, Stadt",null],["071435010275","Niedersayn",null],["072110000000","Trier, Stadt",null],["072310134134","Wittlich, Stadt",null],["072310502502","Morbach",null],["072315001008","Bernkastel-Kues, Stadt",null],["072315001012","Brauneberg",null],["072315001016","Burgen",null],["072315001030","Erden",null],["072315001040","Gornhausen",null],["072315001041","Graach an der Mosel",null],["072315001056","Hochscheid",null],["072315001066","Kesten",null],["072315001070","Kleinich",null],["072315001071","Kommen",null],["072315001075","Lieser",null],["072315001076","Lösnich",null],["072315001077","Longkamp",null],["072315001081","Maring-Noviand",null],["072315001086","Minheim",null],["072315001087","Monzelfeld",null],["072315001090","Mülheim an der Mosel",null],["072315001092","Neumagen-Dhron",null],["072315001105","Piesport",null],["072315001125","Ürzig",null],["072315001126","Veldenz",null],["072315001133","Wintrich",null],["072315001136","Zeltingen-Rachtig",null],["072315006006","Berglicht",null],["072315006017","Burtscheid",null],["072315006018","Deuselbach",null],["072315006019","Dhronecken",null],["072315006032","Etgert",null],["072315006035","Gielert",null],["072315006042","Gräfendhron",null],["072315006054","Hilscheid",null],["072315006058","Horath",null],["072315006064","Immert",null],["072315006078","Lückenburg",null],["072315006079","Malborn",null],["072315006083","Merschbach",null],["072315006093","Neunkirchen",null],["072315006112","Rorodt",null],["072315006115","Schönberg",null],["072315006122","Talling",null],["072315006123","Thalfang",null],["072315006202","Breit",null],["072315006203","Büdlich",null],["072315006204","Heidenburg",null],["072315008001","Altrich",null],["072315008003","Arenrath",null],["072315008007","Bergweiler",null],["072315008009","Bettenfeld",null],["072315008010","Binsfeld",null],["072315008013","Bruch",null],["072315008021","Dierfeld",null],["072315008022","Dierscheid",null],["072315008023","Dodenburg",null],["072315008024","Dreis",null],["072315008025","Eckfeld",null],["072315008026","Eisenschmitt",null],["072315008031","Esch",null],["072315008036","Gipperath",null],["072315008037","Gladbach",null],["072315008044","Greimerath",null],["072315008046","Großlittgen",null],["072315008049","Hasborn",null],["072315008050","Heckenmünster",null],["072315008051","Heidweiler",null],["072315008053","Hetzerath",null],["072315008062","Hupperath",null],["072315008065","Karl",null],["072315008069","Klausen",null],["072315008074","Laufeld",null],["072315008080","Manderscheid, Stadt",null],["072315008082","Meerfeld",null],["072315008085","Minderlittgen",null],["072315008091","Musweiler",null],["072315008095","Niederöfflingen",null],["072315008096","Niederscheidweiler",null],["072315008100","Oberöfflingen",null],["072315008101","Oberscheidweiler",null],["072315008103","Osann-Monzel",null],["072315008104","Pantenburg",null],["072315008107","Platten",null],["072315008108","Plein",null],["072315008111","Rivenich",null],["072315008113","Salmtal",null],["072315008114","Schladt",null],["072315008116","Schwarzenborn",null],["072315008117","Sehlem",null],["072315008127","Wallscheid",null],["072315008503","Landscheid",null],["072315008504","Niersbach",null],["072315009004","Bausendorf",null],["072315009005","Bengel",null],["072315009014","Burg (Mosel)",null],["072315009020","Diefenbach",null],["072315009029","Enkirch",null],["072315009033","Flußbach",null],["072315009057","Hontheim",null],["072315009067","Kinderbeuern",null],["072315009068","Kinheim",null],["072315009072","Kröv",null],["072315009110","Reil",null],["072315009120","Starkenburg",null],["072315009124","Traben-Trarbach, Stadt",null],["072315009132","Willwerscheid",null],["072315009206","Lötzbeuren",null],["072315009501","Irmenach",null],["072320018018","Bitburg, Stadt",null],["072325001201","Arzfeld",null],["072325001211","Dackscheid",null],["072325001212","Dahnen",null],["072325001213","Daleiden",null],["072325001214","Dasburg",null],["072325001217","Eilscheid",null],["072325001220","Eschfeld",null],["072325001221","Euscheid",null],["072325001229","Großkampenberg",null],["072325001233","Hargarten",null],["072325001234","Harspelt",null],["072325001240","Herzfeld",null],["072325001245","Irrhausen",null],["072325001246","Jucken",null],["072325001247","Kesfeld",null],["072325001248","Kickeshausen",null],["072325001249","Kinzenburg",null],["072325001253","Krautscheid",null],["072325001254","Lambertsberg",null],["072325001255","Lascheid",null],["072325001258","Lauperath",null],["072325001259","Leidenborn",null],["072325001260","Lichtenborn",null],["072325001261","Lierfeld",null],["072325001262","Lünebach",null],["072325001263","Lützkampen",null],["072325001264","Manderscheid",null],["072325001267","Mauel",null],["072325001270","Merlscheid",null],["072325001277","Niederpierscheid",null],["072325001285","Oberpierscheid",null],["072325001287","Olmscheid",null],["072325001291","Pintesfeld",null],["072325001293","Plütscheid",null],["072325001294","Preischeid",null],["072325001297","Reiff",null],["072325001298","Reipeldingen",null],["072325001301","Roscheid",null],["072325001309","Sengerich",null],["072325001310","Sevenig (Our)",null],["072325001315","Strickscheid",null],["072325001322","Waxweiler",null],["072325001333","Üttfeld",null],["072325005001","Affler",null],["072325005002","Alsdorf",null],["072325005003","Altscheid",null],["072325005004","Ammeldingen an der Our",null],["072325005005","Ammeldingen bei Neuerburg",null],["072325005008","Bauler",null],["072325005011","Berkoth",null],["072325005012","Berscheid",null],["072325005016","Biesdorf",null],["072325005019","Bollendorf",null],["072325005022","Burg",null],["072325005025","Dauwelshausen",null],["072325005028","Echternacherbrück",null],["072325005031","Emmelbaum",null],["072325005033","Ernzen",null],["072325005037","Ferschweiler",null],["072325005038","Fischbach-Oberraden",null],["072325005040","Geichlingen",null],["072325005041","Gemünd",null],["072325005042","Gentingen",null],["072325005047","Heilbach",null],["072325005049","Herbstmühle",null],["072325005053","Holsthum",null],["072325005054","Hommerdingen",null],["072325005056","Hütten",null],["072325005059","Hüttingen bei Lahr",null],["072325005063","Irrel",null],["072325005064","Karlshausen",null],["072325005065","Kaschenbach",null],["072325005066","Keppeshausen",null],["072325005067","Körperich",null],["072325005068","Koxhausen",null],["072325005069","Kruchten",null],["072325005072","Lahr",null],["072325005073","Leimbach",null],["072325005078","Menningen",null],["072325005080","Mettendorf",null],["072325005082","Minden",null],["072325005084","Muxerath",null],["072325005085","Nasingen",null],["072325005088","Neuerburg, Stadt",null],["072325005089","Niedergeckler",null],["072325005090","Niederraden",null],["072325005093","Niederweis",null],["072325005094","Niehl",null],["072325005095","Nusbaum",null],["072325005096","Obergeckler",null],["072325005102","Utscheid",null],["072325005103","Peffingen",null],["072325005106","Plascheid",null],["072325005108","Prümzurlay",null],["072325005110","Rodershausen",null],["072325005112","Roth an der Our",null],["072325005114","Schankweiler",null],["072325005116","Scheitenkorb",null],["072325005117","Scheuern",null],["072325005121","Sevenig bei Neuerburg",null],["072325005122","Sinspelt",null],["072325005127","Übereisenbach",null],["072325005128","Uppershausen",null],["072325005130","Waldhof-Falkenstein",null],["072325005131","Wallendorf",null],["072325005132","Weidingen",null],["072325005138","Zweifelscheid",null],["072325005218","Eisenach",null],["072325005225","Gilzem",null],["072325006202","Auw bei Prüm",null],["072325006206","Bleialf",null],["072325006207","Brandscheid",null],["072325006208","Buchet",null],["072325006209","Büdesheim",null],["072325006216","Dingdorf",null],["072325006222","Feuerscheid",null],["072325006223","Fleringen",null],["072325006224","Giesdorf",null],["072325006226","Weinsheim",null],["072325006227","Gondenbrett",null],["072325006230","Großlangenfeld",null],["072325006231","Habscheid",null],["072325006236","Heckhuscheid",null],["072325006238","Heisdorf",null],["072325006250","Kleinlangenfeld",null],["072325006256","Lasel",null],["072325006265","Masthorn",null],["072325006266","Matzerath",null],["072325006271","Mützenich",null],["072325006272","Neuendorf",null],["072325006276","Niederlauch",null],["072325006279","Nimshuscheid",null],["072325006280","Nimsreuland",null],["072325006283","Oberlascheid",null],["072325006284","Oberlauch",null],["072325006288","Olzheim",null],["072325006290","Orlenbach",null],["072325006292","Pittenbach",null],["072325006295","Pronsfeld",null],["072325006296","Prüm, Stadt",null],["072325006300","Rommersheim",null],["072325006302","Roth bei Prüm",null],["072325006304","Schönecken",null],["072325006305","Schwirzheim",null],["072325006307","Seiwerath",null],["072325006308","Sellerich",null],["072325006318","Wallersheim",null],["072325006320","Watzerath",null],["072325006321","Wawern",null],["072325006327","Winringen",null],["072325006328","Winterscheid",null],["072325006329","Winterspelt",null],["072325006332","Hersdorf",null],["072325007006","Auw an der Kyll",null],["072325007010","Beilingen",null],["072325007050","Herforst",null],["072325007055","Hosten",null],["072325007104","Philippsheim",null],["072325007107","Preist",null],["072325007123","Speicher, Stadt",null],["072325007289","Orenhofen",null],["072325007311","Spangdahlem",null],["072325008007","Badem",null],["072325008009","Baustert",null],["072325008013","Bettingen",null],["072325008014","Bickendorf",null],["072325008015","Biersdorf am See",null],["072325008017","Birtlingen",null],["072325008020","Brecht",null],["072325008024","Dahlem",null],["072325008026","Dockendorf",null],["072325008027","Dudeldorf",null],["072325008029","Echtershausen",null],["072325008030","Ehlenz",null],["072325008032","Enzen",null],["072325008034","Eßlingen",null],["072325008035","Etteldorf",null],["072325008036","Feilsdorf",null],["072325008039","Fließem",null],["072325008043","Gindorf",null],["072325008044","Gondorf",null],["072325008045","Halsdorf",null],["072325008046","Hamm",null],["072325008048","Heilenbach",null],["072325008057","Hütterscheid",null],["072325008058","Hüttingen an der Kyll",null],["072325008060","Idenheim",null],["072325008061","Idesheim",null],["072325008062","Ingendorf",null],["072325008070","Kyllburg, Stadt",null],["072325008071","Kyllburgweiler",null],["072325008074","Ließem",null],["072325008075","Malberg",null],["072325008076","Malbergweich",null],["072325008077","Meckel",null],["072325008079","Messerich",null],["072325008081","Metterich",null],["072325008083","Mülbach",null],["072325008086","Nattenheim",null],["072325008087","Neidenbach",null],["072325008091","Niederstedem",null],["072325008092","Niederweiler",null],["072325008097","Oberstedem",null],["072325008098","Oberweiler",null],["072325008099","Oberweis",null],["072325008100","Olsdorf",null],["072325008101","Orsfeld",null],["072325008105","Pickließem",null],["072325008109","Rittersdorf",null],["072325008111","Röhl",null],["072325008113","Sankt Thomas",null],["072325008115","Scharfbillig",null],["072325008118","Schleid",null],["072325008119","Seffern",null],["072325008120","Sefferweich",null],["072325008124","Stockem",null],["072325008125","Sülm",null],["072325008126","Trimport",null],["072325008129","Usch",null],["072325008133","Wettlingen",null],["072325008134","Wiersdorf",null],["072325008135","Wilsecker",null],["072325008137","Wolsfeld",null],["072325008203","Balesfeld",null],["072325008210","Burbach",null],["072325008228","Gransdorf",null],["072325008273","Neuheilenbach",null],["072325008282","Oberkail",null],["072325008306","Seinsfeld",null],["072325008313","Steinborn",null],["072325008331","Zendscheid",null],["072325008501","Wißmannsdorf",null],["072325008502","Brimingen",null],["072335001006","Betteldorf",null],["072335001008","Bleckhausen",null],["072335001011","Brockscheid",null],["072335001014","Darscheid",null],["072335001016","Demerath",null],["072335001017","Deudesfeld",null],["072335001018","Dockweiler",null],["072335001020","Dreis-Brück",null],["072335001021","Ellscheid",null],["072335001025","Gefell",null],["072335001027","Gillenfeld",null],["072335001030","Hinterweiler",null],["072335001031","Hörscheid",null],["072335001034","Immerath",null],["072335001039","Kirchweiler",null],["072335001040","Kradenbach",null],["072335001042","Mehren",null],["072335001043","Meisburg",null],["072335001046","Mückeln",null],["072335001049","Nerdlen",null],["072335001052","Niederstadtfeld",null],["072335001055","Oberstadtfeld",null],["072335001061","Sarmersbach",null],["072335001062","Saxler",null],["072335001063","Schalkenmehren",null],["072335001064","Schönbach",null],["072335001065","Schutz",null],["072335001067","Steineberg",null],["072335001068","Steiningen",null],["072335001070","Strohn",null],["072335001071","Strotzbüsch",null],["072335001074","Udler",null],["072335001075","Üdersdorf",null],["072335001077","Utzerath",null],["072335001079","Wallenborn",null],["072335001081","Weidenbach",null],["072335001084","Winkel (Eifel)",null],["072335001501","Daun, Stadt",null],["072335004003","Beinhausen",null],["072335004010","Boxberg",null],["072335004032","Hörschhausen",null],["072335004037","Katzwinkel",null],["072335004048","Neichen",null],["072335004201","Arbach",null],["072335004202","Bereborn",null],["072335004203","Berenbach",null],["072335004205","Bodenbach",null],["072335004206","Bongard",null],["072335004207","Borler",null],["072335004208","Brücktal",null],["072335004210","Drees",null],["072335004212","Gelenberg",null],["072335004213","Gunderath",null],["072335004215","Höchstberg",null],["072335004216","Horperath",null],["072335004217","Kaperich",null],["072335004218","Kelberg",null],["072335004220","Kirsbach",null],["072335004221","Kötterichen",null],["072335004222","Kolverath",null],["072335004224","Lirstal",null],["072335004225","Mannebach",null],["072335004226","Mosbruch",null],["072335004228","Nitz",null],["072335004230","Oberelz",null],["072335004233","Reimerath",null],["072335004234","Retterath",null],["072335004236","Sassen",null],["072335004242","Uersfeld",null],["072335004243","Ueß",null],["072335004244","Welcherath",null],["072335006002","Basberg",null],["072335006004","Berlingen",null],["072335006005","Berndorf",null],["072335006007","Birgel",null],["072335006019","Dohm-Lammersdorf",null],["072335006022","Esch",null],["072335006023","Feusdorf",null],["072335006026","Gerolstein, Stadt",null],["072335006028","Gönnersdorf",null],["072335006029","Hillesheim, Stadt",null],["072335006033","Hohenfels-Essingen",null],["072335006035","Jünkerath",null],["072335006036","Kalenborn-Scheuern",null],["072335006038","Kerpen (Eifel)",null],["072335006041","Lissendorf",null],["072335006050","Neroth",null],["072335006053","Oberbettingen",null],["072335006054","Oberehe-Stroheich",null],["072335006056","Pelm",null],["072335006058","Rockeskyll",null],["072335006060","Salm",null],["072335006076","Üxheim",null],["072335006080","Walsdorf",null],["072335006083","Wiesbaum",null],["072335006204","Birresborn",null],["072335006209","Densborn",null],["072335006211","Duppach",null],["072335006214","Hallschlag",null],["072335006219","Kerschenbach",null],["072335006223","Kopp",null],["072335006227","Mürlenbach",null],["072335006229","Nohn",null],["072335006232","Ormont",null],["072335006235","Reuth",null],["072335006237","Scheid",null],["072335006239","Schüller",null],["072335006240","Stadtkyll",null],["072335006241","Steffeln",null],["072355001005","Bescheid",null],["072355001008","Beuren (Hochwald)",null],["072355001014","Damflos",null],["072355001030","Geisfeld",null],["072355001035","Grimburg",null],["072355001036","Gusenburg",null],["072355001045","Hermeskeil, Stadt",null],["072355001047","Hinzert-Pölert",null],["072355001092","Naurath (Wald)",null],["072355001093","Neuhütten",null],["072355001112","Rascheid",null],["072355001114","Reinsfeld",null],["072355001153","Züsch",null],["072355003055","Kanzem",null],["072355003068","Konz, Stadt",null],["072355003095","Nittel",null],["072355003096","Oberbillig",null],["072355003101","Onsdorf",null],["072355003106","Pellingen",null],["072355003132","Tawern",null],["072355003133","Temmels",null],["072355003143","Wasserliesch",null],["072355003144","Wawern",null],["072355003146","Wellen",null],["072355003148","Wiltingen",null],["072355004010","Bonerath",null],["072355004021","Farschweiler",null],["072355004037","Gusterath",null],["072355004038","Gutweiler",null],["072355004044","Herl",null],["072355004046","Hinzenburg",null],["072355004050","Holzerath",null],["072355004056","Kasel",null],["072355004070","Korlingen",null],["072355004080","Lorscheid",null],["072355004085","Mertesdorf",null],["072355004090","Morscheid",null],["072355004100","Ollmuth",null],["072355004103","Osburg",null],["072355004107","Pluwig",null],["072355004116","Riveris",null],["072355004124","Schöndorf",null],["072355004129","Sommerau",null],["072355004135","Thomm",null],["072355004141","Waldrach",null],["072355006004","Bekond",null],["072355006015","Detzem",null],["072355006019","Ensch",null],["072355006022","Fell",null],["072355006026","Föhren",null],["072355006060","Kenn",null],["072355006063","Klüsserath",null],["072355006067","Köwerich",null],["072355006074","Leiwen",null],["072355006077","Longen",null],["072355006078","Longuich",null],["072355006083","Mehring",null],["072355006091","Naurath (Eifel)",null],["072355006108","Pölich",null],["072355006115","Riol",null],["072355006120","Schleich",null],["072355006125","Schweich, Stadt",null],["072355006134","Thörnich",null],["072355006207","Trittenheim",null],["072355007001","Aach",null],["072355007027","Franzenheim",null],["072355007048","Hockweiler",null],["072355007051","Igel",null],["072355007069","Kordel",null],["072355007073","Langsur",null],["072355007094","Newel",null],["072355007111","Ralingen",null],["072355007137","Trierweiler",null],["072355007151","Zemmer",null],["072355007501","Welschbillig",null],["072355008002","Ayl",null],["072355008003","Baldringen",null],["072355008025","Fisch",null],["072355008028","Freudenburg",null],["072355008033","Greimerath",null],["072355008040","Heddert",null],["072355008043","Hentern",null],["072355008052","Irsch",null],["072355008057","Kastel-Staadt",null],["072355008058","Kell am See",null],["072355008062","Kirf",null],["072355008072","Lampaden",null],["072355008081","Mandern",null],["072355008082","Mannebach",null],["072355008098","Ockfen",null],["072355008104","Palzem",null],["072355008105","Paschel",null],["072355008118","Saarburg, Stadt",null],["072355008119","Schillingen",null],["072355008122","Schoden",null],["072355008123","Schömerich",null],["072355008126","Serrig",null],["072355008131","Taben-Rodt",null],["072355008136","Trassem",null],["072355008140","Vierherrenborn",null],["072355008142","Waldweiler",null],["072355008149","Wincheringen",null],["072355008152","Zerf",null],["072355008154","Merzkirchen",null],["073110000000","Frankenthal (Pfalz), Stadt",null],["073120000000","Kaiserslautern, Stadt",null],["073130000000","Landau in der Pfalz, Stadt",null],["073140000000","Ludwigshafen am Rhein, Stadt",null],["073150000000","Mainz, Stadt",null],["073160000000","Neustadt an der Weinstraße, Stadt",null],["073170000000","Pirmasens, Stadt",null],["073180000000","Speyer, Stadt",null],["073190000000","Worms, Stadt",null],["073200000000","Zweibrücken, Stadt",null],["073310003003","Alzey, Stadt",null],["073315001001","Albig",null],["073315001005","Bechenheim",null],["073315001007","Bechtolsheim",null],["073315001008","Bermersheim vor der Höhe",null],["073315001010","Biebelnheim",null],["073315001012","Bornheim",null],["073315001014","Dintesheim",null],["073315001020","Eppelsheim",null],["073315001021","Erbes-Büdesheim",null],["073315001022","Esselborn",null],["073315001024","Flomborn",null],["073315001025","Flonheim",null],["073315001026","Framersheim",null],["073315001027","Freimersheim",null],["073315001031","Gau-Heppenheim",null],["073315001032","Gau-Odernheim",null],["073315001042","Kettenheim",null],["073315001043","Lonsheim",null],["073315001044","Mauchenheim",null],["073315001050","Nack",null],["073315001051","Nieder-Wiesen",null],["073315001052","Ober-Flörsheim",null],["073315001053","Offenheim",null],["073315001067","Wahlheim",null],["073315002002","Alsheim",null],["073315002018","Eich",null],["073315002034","Gimbsheim",null],["073315002038","Hamm am Rhein",null],["073315002045","Mettenheim",null],["073315003023","Flörsheim-Dalsheim",null],["073315003041","Hohen-Sülzen",null],["073315003046","Mölsheim",null],["073315003047","Mörstadt",null],["073315003048","Monsheim",null],["073315003054","Offstein",null],["073315003066","Wachenheim",null],["073315005017","Eckelsheim",null],["073315005030","Gau-Bickelheim",null],["073315005035","Gumbsheim",null],["073315005060","Siefersheim",null],["073315005062","Stein-Bockenheim",null],["073315005070","Wendelsheim",null],["073315005072","Wöllstein",null],["073315005075","Wonsheim",null],["073315006004","Armsheim",null],["073315006019","Ensheim",null],["073315006029","Gabsheim",null],["073315006033","Gau-Weinheim",null],["073315006056","Partenheim",null],["073315006058","Saulheim",null],["073315006059","Schornsheim",null],["073315006061","Spiesheim",null],["073315006063","Sulzheim",null],["073315006064","Udenheim",null],["073315006065","Vendersheim",null],["073315006068","Wallertheim",null],["073315006073","Wörrstadt, Stadt",null],["073315007006","Bechtheim",null],["073315007009","Bermersheim",null],["073315007011","Hochborn",null],["073315007015","Dittelsheim-Heßloch",null],["073315007028","Frettenheim",null],["073315007036","Gundersheim",null],["073315007037","Gundheim",null],["073315007039","Hangen-Weisheim",null],["073315007049","Monzernheim",null],["073315007055","Osthofen, Stadt",null],["073315007071","Westhofen",null],["073320002002","Bad Dürkheim, Stadt",null],["073320024024","Grünstadt, Stadt",null],["073320025025","Haßloch",null],["073325001009","Deidesheim, Stadt",null],["073325001017","Forst an der Weinstraße",null],["073325001035","Meckenheim",null],["073325001039","Niederkirchen bei Deidesheim",null],["073325001043","Ruppertsberg",null],["073325002005","Bobenheim am Berg",null],["073325002008","Dackenheim",null],["073325002015","Erpolzheim",null],["073325002019","Freinsheim, Stadt",null],["073325002026","Herxheim am Berg",null],["073325002028","Kallstadt",null],["073325002049","Weisenheim am Berg",null],["073325002050","Weisenheim am Sand",null],["073325005014","Elmstein",null],["073325005016","Esthal",null],["073325005018","Frankeneck",null],["073325005032","Lambrecht (Pfalz), Stadt",null],["073325005034","Lindenberg",null],["073325005037","Neidenfels",null],["073325005048","Weidenthal",null],["073325006013","Ellerstadt",null],["073325006020","Friedelsheim",null],["073325006022","Gönnheim",null],["073325006046","Wachenheim an der Weinstraße, Stadt",null],["073325007001","Altleiningen",null],["073325007003","Battenberg (Pfalz)",null],["073325007004","Bissersheim",null],["073325007006","Bockenheim an der Weinstraße",null],["073325007007","Carlsberg",null],["073325007010","Dirmstein",null],["073325007012","Ebertsheim",null],["073325007021","Gerolsheim",null],["073325007023","Großkarlbach",null],["073325007027","Hettenleidelheim",null],["073325007029","Kindenheim",null],["073325007030","Kirchheim an der Weinstraße",null],["073325007031","Kleinkarlbach",null],["073325007033","Laumersheim",null],["073325007036","Mertesheim",null],["073325007038","Neuleiningen",null],["073325007040","Obersülzen",null],["073325007041","Obrigheim (Pfalz)",null],["073325007042","Quirnheim",null],["073325007044","Tiefenthal",null],["073325007047","Wattenheim",null],["073335002019","Eisenberg (Pfalz), Stadt",null],["073335002038","Kerzenheim",null],["073335002060","Ramsen",null],["073335003001","Albisheim (Pfrimm)",null],["073335003006","Biedesheim",null],["073335003012","Bubenheim",null],["073335003017","Dreisen",null],["073335003018","Einselthum",null],["073335003026","Göllheim",null],["073335003032","Immesheim",null],["073335003041","Lautersheim",null],["073335003058","Ottersheim",null],["073335003064","Rüssingen",null],["073335003074","Standenbühl",null],["073335003081","Weitersweiler",null],["073335003501","Zellertal",null],["073335004005","Bennhausen",null],["073335004007","Bischheim",null],["073335004010","Bolanden",null],["073335004013","Dannenfels",null],["073335004022","Gauersheim",null],["073335004031","Ilbesheim",null],["073335004035","Jakobsweiler",null],["073335004039","Kirchheimbolanden, Stadt",null],["073335004040","Kriegsfeld",null],["073335004045","Marnheim",null],["073335004046","Mörsfeld",null],["073335004047","Morschheim",null],["073335004056","Oberwiesen",null],["073335004057","Orbis",null],["073335004062","Rittersheim",null],["073335004076","Stetten",null],["073335006009","Börrstadt",null],["073335006011","Breunigweiler",null],["073335006020","Falkenstein",null],["073335006027","Gonbach",null],["073335006030","Höringen",null],["073335006033","Imsbach",null],["073335006042","Lohnsfeld",null],["073335006048","Münchweiler an der Alsenz",null],["073335006069","Schweisweiler",null],["073335006071","Sippersfeld",null],["073335006075","Steinbach am Donnersberg",null],["073335006080","Wartenberg-Rohrbach",null],["073335006503","Winnweiler",null],["073335007003","Alsenz",null],["073335007004","Bayerfeld-Steckweiler",null],["073335007008","Bisterschied",null],["073335007014","Dielkirchen",null],["073335007016","Dörrmoschel",null],["073335007021","Finkenbach-Gersweiler",null],["073335007023","Gaugrehweiler",null],["073335007024","Gehrweiler",null],["073335007025","Gerbach",null],["073335007028","Gundersweiler",null],["073335007034","Imsweiler",null],["073335007036","Kalkofen",null],["073335007037","Katzenbach",null],["073335007043","Mannweiler-Cölln",null],["073335007049","Münsterappel",null],["073335007050","Niederhausen an der Appel",null],["073335007051","Niedermoschel",null],["073335007053","Oberhausen an der Appel",null],["073335007054","Obermoschel, Stadt",null],["073335007055","Oberndorf",null],["073335007061","Ransweiler",null],["073335007065","Ruppertsecken",null],["073335007066","Sankt Alban",null],["073335007067","Schiersfeld",null],["073335007068","Schönborn",null],["073335007072","Sitters",null],["073335007073","Stahlberg",null],["073335007077","Teschenmoschel",null],["073335007078","Unkenbach",null],["073335007079","Waldgrehweiler",null],["073335007083","Winterborn",null],["073335007084","Würzweiler",null],["073335007201","Rathskirchen",null],["073335007202","Reichsthal",null],["073335007203","Seelen",null],["073335007502","Rockenhausen, Stadt",null],["073340007007","Germersheim, Stadt",null],["073340501501","Wörth am Rhein, Stadt",null],["073345001001","Bellheim",null],["073345001014","Knittelsheim",null],["073345001023","Ottersheim bei Landau",null],["073345001036","Zeiskam",null],["073345002002","Berg (Pfalz)",null],["073345002008","Hagenbach, Stadt",null],["073345002021","Neuburg am Rhein",null],["073345002027","Scheibenhardt",null],["073345003009","Hatzenbühl",null],["073345003012","Jockgrim",null],["073345003022","Neupotz",null],["073345003024","Rheinzabern",null],["073345004004","Erlenbach bei Kandel",null],["073345004005","Freckenfeld",null],["073345004013","Kandel, Stadt",null],["073345004020","Minfeld",null],["073345004030","Steinweiler",null],["073345004031","Vollmersweiler",null],["073345004034","Winden",null],["073345005006","Freisbach",null],["073345005017","Lingenfeld",null],["073345005018","Lustadt",null],["073345005028","Schwegenheim",null],["073345005032","Weingarten (Pfalz)",null],["073345005033","Westheim (Pfalz)",null],["073345006011","Hördt",null],["073345006015","Kuhardt",null],["073345006016","Leimersheim",null],["073345006025","Rülzheim",null],["073355001003","Bruchmühlbach-Miesau",null],["073355001011","Gerhardsbrunn",null],["073355001201","Lambsborn",null],["073355001202","Langwieden",null],["073355001203","Martinshöhe",null],["073355002004","Enkenbach-Alsenborn",null],["073355002007","Fischbach",null],["073355002010","Frankenstein",null],["073355002015","Hochspeyer",null],["073355002026","Mehlingen",null],["073355002028","Neuhemsbach",null],["073355002048","Waldleiningen",null],["073355002205","Sembach",null],["073355008016","Hütschenhausen",null],["073355008020","Kottweiler-Schwanden",null],["073355008030","Niedermohr",null],["073355008038","Ramstein-Miesenbach, Stadt",null],["073355008044","Steinwenden",null],["073355009005","Erzenhausen",null],["073355009006","Eulenbis",null],["073355009019","Kollweiler",null],["073355009024","Mackenbach",null],["073355009040","Rodenbach",null],["073355009043","Schwedelbach",null],["073355009049","Weilerbach",null],["073355009501","Reichenbach-Steegen",null],["073355010009","Frankelbach",null],["073355010013","Heiligenmoschel",null],["073355010014","Hirschhorn/ Pfalz",null],["073355010017","Katzweiler",null],["073355010025","Mehlbach",null],["073355010029","Niederkirchen",null],["073355010033","Olsbrücken",null],["073355010034","Otterbach",null],["073355010035","Otterberg, Stadt",null],["073355010041","Schallodenbach",null],["073355010042","Schneckenhausen",null],["073355010046","Sulzbachtal",null],["073355011002","Bann",null],["073355011012","Hauptstuhl",null],["073355011018","Kindsbach",null],["073355011021","Krickenbach",null],["073355011022","Landstuhl, Sickingenstadt, Stadt",null],["073355011023","Linden",null],["073355011027","Mittelbrunn",null],["073355011031","Oberarnbach",null],["073355011037","Queidersbach",null],["073355011045","Stelzenberg",null],["073355011047","Trippstadt",null],["073355011204","Schopp",null],["073365008001","Adenbach",null],["073365008005","Aschbach",null],["073365008012","Buborn",null],["073365008013","Cronenberg",null],["073365008014","Deimberg",null],["073365008019","Einöllen",null],["073365008023","Eßweiler",null],["073365008029","Ginsweiler",null],["073365008030","Glanbrücken",null],["073365008033","Grumbach",null],["073365008035","Hausweiler",null],["073365008036","Hefersweiler",null],["073365008038","Heinzenhausen",null],["073365008040","Herren-Sulzbach",null],["073365008042","Hinzweiler",null],["073365008043","Hohenöllen",null],["073365008044","Homberg",null],["073365008045","Hoppstädten",null],["073365008048","Jettenbach",null],["073365008049","Kappeln",null],["073365008050","Kirrweiler",null],["073365008053","Kreimbach-Kaulbach",null],["073365008057","Langweiler",null],["073365008058","Lauterecken, Stadt",null],["073365008060","Lohnweiler",null],["073365008061","Medard",null],["073365008062","Merzweiler",null],["073365008065","Nerzweiler",null],["073365008069","Nußbach",null],["073365008072","Oberweiler im Tal",null],["073365008073","Oberweiler-Tiefenbach",null],["073365008074","Odenbach",null],["073365008075","Offenbach-Hundheim",null],["073365008085","Reipoltskirchen",null],["073365008086","Relsberg",null],["073365008087","Rothselberg",null],["073365008090","Rutsweiler an der Lauter",null],["073365008095","Sankt Julian",null],["073365008100","Unterjeckenbach",null],["073365008104","Wiesweiler",null],["073365008105","Wolfstein, Stadt",null],["073365009004","Altenkirchen",null],["073365009008","Börsborn",null],["073365009010","Breitenbach",null],["073365009011","Brücken (Pfalz)",null],["073365009016","Dittweiler",null],["073365009017","Dunzweiler",null],["073365009027","Frohnhofen",null],["073365009031","Glan-Münchweiler",null],["073365009032","Gries",null],["073365009037","Henschtal",null],["073365009041","Herschweiler-Pettersheim",null],["073365009047","Hüffler",null],["073365009054","Krottelbach",null],["073365009056","Langenbach",null],["073365009064","Nanzdietschweiler",null],["073365009076","Ohmbach",null],["073365009082","Rehweiler",null],["073365009092","Schönenberg-Kübelberg",null],["073365009096","Steinbach am Glan",null],["073365009101","Wahnwegen",null],["073365009102","Waldmohr, Stadt",null],["073365009107","Matzenbach",null],["073365009501","Quirnbach/ Pfalz",null],["073365010002","Albessen",null],["073365010003","Altenglan",null],["073365010006","Blaubach",null],["073365010009","Bosenbach",null],["073365010015","Dennweiler-Frohnbach",null],["073365010018","Ehweiler",null],["073365010021","Elzweiler",null],["073365010022","Erdesbach",null],["073365010024","Etschberg",null],["073365010025","Föckelberg",null],["073365010034","Haschbach am Remigiusberg",null],["073365010039","Herchweiler",null],["073365010046","Horschbach",null],["073365010051","Körborn",null],["073365010052","Konken",null],["073365010055","Kusel, Stadt",null],["073365010066","Neunkirchen am Potzberg",null],["073365010067","Niederalben",null],["073365010068","Niederstaufenbach",null],["073365010070","Oberalben",null],["073365010071","Oberstaufenbach",null],["073365010077","Pfeffelbach",null],["073365010079","Rammelsbach",null],["073365010081","Rathsweiler",null],["073365010084","Reichweiler",null],["073365010088","Ruthweiler",null],["073365010089","Rutsweiler am Glan",null],["073365010091","Schellweiler",null],["073365010094","Selchenbach",null],["073365010097","Thallichtenberg",null],["073365010098","Theisbergstegen",null],["073365010099","Ulmet",null],["073365010103","Welchweiler",null],["073365010106","Bedesbach",null],["073375001001","Albersweiler",null],["073375001017","Dernbach",null],["073375001024","Eußerthal",null],["073375001033","Gossersweiler-Stein",null],["073375001054","Münchweiler am Klingbach",null],["073375001064","Ramberg",null],["073375001067","Rinnthal",null],["073375001074","Silz",null],["073375001078","Völkersweiler",null],["073375001080","Waldhambach",null],["073375001081","Waldrohrbach",null],["073375001083","Wernersberg",null],["073375001501","Annweiler am Trifels, Stadt",null],["073375002005","Bad Bergzabern, Stadt",null],["073375002006","Barbelroth",null],["073375002008","Birkenhördt",null],["073375002013","Böllenborn",null],["073375002018","Dierbach",null],["073375002019","Dörrenbach",null],["073375002029","Gleiszellen-Gleishorbach",null],["073375002037","Hergersweiler",null],["073375002045","Kapellen-Drusweiler",null],["073375002046","Kapsweyer",null],["073375002049","Klingenmünster",null],["073375002055","Niederhorbach",null],["073375002056","Niederotterbach",null],["073375002058","Oberhausen",null],["073375002059","Oberotterbach",null],["073375002060","Oberschlettenbach",null],["073375002062","Pleisweiler-Oberhofen",null],["073375002071","Schweigen-Rechtenbach",null],["073375002072","Schweighofen",null],["073375002076","Steinfeld",null],["073375002079","Vorderweidenthal",null],["073375003002","Altdorf",null],["073375003011","Böbingen",null],["073375003015","Burrweiler",null],["073375003020","Edenkoben, Stadt",null],["073375003021","Edesheim",null],["073375003025","Flemlingen",null],["073375003027","Freimersheim (Pfalz)",null],["073375003028","Gleisweiler",null],["073375003032","Gommersheim",null],["073375003035","Großfischlingen",null],["073375003036","Hainfeld",null],["073375003048","Kleinfischlingen",null],["073375003066","Rhodt unter Rietburg",null],["073375003069","Roschbach",null],["073375003077","Venningen",null],["073375003084","Weyher in der Pfalz",null],["073375004038","Herxheim bei Landau/ Pfalz",null],["073375004039","Herxheimweyher",null],["073375004044","Insheim",null],["073375004068","Rohrbach",null],["073375005007","Billigheim-Ingenheim",null],["073375005009","Birkweiler",null],["073375005012","Böchingen",null],["073375005022","Eschbach",null],["073375005026","Frankweiler",null],["073375005031","Göcklingen",null],["073375005040","Heuchelheim-Klingen",null],["073375005042","Ilbesheim bei Landau in der Pfalz",null],["073375005043","Impflingen",null],["073375005050","Knöringen",null],["073375005051","Leinsweiler",null],["073375005065","Ranschbach",null],["073375005073","Siebeldingen",null],["073375005082","Walsheim",null],["073375006047","Kirrweiler (Pfalz)",null],["073375006052","Maikammer",null],["073375006070","Sankt Martin",null],["073375007014","Bornheim",null],["073375007023","Essingen",null],["073375007041","Hochstadt (Pfalz)",null],["073375007061","Offenbach an der Queich",null],["073380004004","Bobenheim-Roxheim",null],["073380005005","Böhl-Iggelheim",null],["073380017017","Limburgerhof",null],["073380019019","Mutterstadt",null],["073380025025","Schifferstadt, Stadt",null],["073385001006","Dannstadt-Schauernheim",null],["073385001014","Hochdorf-Assenheim",null],["073385001022","Rödersheim-Gronau",null],["073385004003","Birkenheide",null],["073385004008","Fußgönheim",null],["073385004018","Maxdorf",null],["073385006002","Beindersheim",null],["073385006009","Großniedesheim",null],["073385006012","Heßheim",null],["073385006013","Heuchelheim bei Frankenthal",null],["073385006015","Kleinniedesheim",null],["073385006016","Lambsheim",null],["073385007007","Dudenhofen",null],["073385007010","Hanhofen",null],["073385007011","Harthausen",null],["073385007023","Römerberg",null],["073385008001","Altrip",null],["073385008020","Neuhofen",null],["073385008021","Otterstadt",null],["073385008026","Waldsee",null],["073390005005","Bingen am Rhein, Stadt",null],["073390009009","Budenheim",null],["073390030030","Ingelheim am Rhein, Stadt",null],["073395001003","Bacharach, Stadt",null],["073395001007","Breitscheid",null],["073395001036","Manubach",null],["073395001038","Münster-Sarmsheim",null],["073395001040","Niederheimbach",null],["073395001044","Oberdiebach",null],["073395001045","Oberheimbach",null],["073395001058","Trechtingshausen",null],["073395001062","Waldalgesheim",null],["073395001063","Weiler bei Bingen",null],["073395002006","Bodenheim",null],["073395002020","Gau-Bischofsheim",null],["073395002026","Harxheim",null],["073395002034","Lörzweiler",null],["073395002039","Nackenheim",null],["073395003001","Appenheim",null],["073395003008","Bubenheim",null],["073395003016","Engelstadt",null],["073395003019","Gau-Algesheim, Stadt",null],["073395003041","Nieder-Hilbersheim",null],["073395003046","Ober-Hilbersheim",null],["073395003048","Ockenheim",null],["073395003051","Schwabenheim an der Selz",null],["073395006017","Essenheim",null],["073395006031","Jugenheim in Rheinhessen",null],["073395006032","Klein-Winternheim",null],["073395006042","Nieder-Olm, Stadt",null],["073395006047","Ober-Olm",null],["073395006054","Sörgenloch",null],["073395006057","Stadecken-Elsheim",null],["073395006067","Zornheim",null],["073395007010","Dalheim",null],["073395007011","Dexheim",null],["073395007012","Dienheim",null],["073395007013","Dolgesheim",null],["073395007015","Eimsheim",null],["073395007018","Friesenheim",null],["073395007024","Guntersblum",null],["073395007025","Hahnheim",null],["073395007028","Hillesheim",null],["073395007033","Köngernheim",null],["073395007035","Ludwigshöhe",null],["073395007037","Mommenheim",null],["073395007043","Nierstein, Stadt",null],["073395007049","Oppenheim, Stadt",null],["073395007053","Selzen",null],["073395007059","Uelversheim",null],["073395007060","Undenheim",null],["073395007064","Weinolsheim",null],["073395007066","Wintersheim",null],["073395007201","Dorn-Dürkheim",null],["073395008002","Aspisheim",null],["073395008004","Badenheim",null],["073395008021","Gensingen",null],["073395008022","Grolsheim",null],["073395008029","Horrweiler",null],["073395008050","Sankt Johann",null],["073395008056","Sprendlingen",null],["073395008065","Welgesheim",null],["073395008068","Zotzenheim",null],["073395008202","Wolfsheim",null],["073405001001","Bobenthal",null],["073405001002","Busenberg",null],["073405001004","Dahn, Stadt",null],["073405001009","Erfweiler",null],["073405001010","Erlenbach bei Dahn",null],["073405001011","Fischbach bei Dahn",null],["073405001021","Hirschthal",null],["073405001029","Ludwigswinkel",null],["073405001033","Niederschlettenbach",null],["073405001034","Nothweiler",null],["073405001039","Rumbach",null],["073405001043","Schindhard",null],["073405001045","Schönau (Pfalz)",null],["073405001501","Bruchweiler-Bärenbach",null],["073405001502","Bundenthal",null],["073405002005","Darstein",null],["073405002006","Dimbach",null],["073405002014","Hauenstein",null],["073405002020","Hinterweidenthal",null],["073405002030","Lug",null],["073405002047","Schwanheim",null],["073405002049","Spirkelbach",null],["073405002057","Wilgartswiesen",null],["073405003008","Eppenbrunn",null],["073405003019","Hilst",null],["073405003026","Kröppen",null],["073405003028","Lemberg",null],["073405003036","Obersimten",null],["073405003040","Ruppertsweiler",null],["073405003048","Schweix",null],["073405003052","Trulben",null],["073405003053","Vinningen",null],["073405003205","Bottenbach",null],["073405004003","Clausen",null],["073405004007","Donsieders",null],["073405004027","Leimen",null],["073405004031","Merzalben",null],["073405004032","Münchweiler an der Rodalb",null],["073405004038","Rodalben, Stadt",null],["073405006012","Geiselberg",null],["073405006015","Heltersberg",null],["073405006016","Hermersberg",null],["073405006022","Höheinöd",null],["073405006025","Horbach",null],["073405006044","Schmalenberg",null],["073405006050","Steinalben",null],["073405006054","Waldfischbach-Burgalben",null],["073405008201","Althornbach",null],["073405008202","Battweiler",null],["073405008203","Bechhofen",null],["073405008206","Contwig",null],["073405008207","Dellfeld",null],["073405008208","Dietrichingen",null],["073405008209","Großbundenbach",null],["073405008210","Großsteinhausen",null],["073405008211","Hornbach, Stadt",null],["073405008212","Käshofen",null],["073405008213","Kleinbundenbach",null],["073405008214","Kleinsteinhausen",null],["073405008218","Mauschbach",null],["073405008221","Riedelberg",null],["073405008223","Rosenkopf",null],["073405008226","Walshausen",null],["073405008227","Wiesbach",null],["073405009017","Herschberg",null],["073405009018","Hettenhausen",null],["073405009023","Höheischweiler",null],["073405009024","Höhfröschen",null],["073405009035","Nünschweiler",null],["073405009037","Petersberg",null],["073405009041","Saalstadt",null],["073405009042","Schauerberg",null],["073405009051","Thaleischweiler-Fröschen",null],["073405009055","Weselberg",null],["073405009204","Biedershausen",null],["073405009215","Knopp-Labach",null],["073405009216","Krähenberg",null],["073405009217","Maßweiler",null],["073405009219","Obernheim-Kirchenarnbach",null],["073405009220","Reifenberg",null],["073405009222","Rieschweiler-Mühlbach",null],["073405009224","Schmitshausen",null],["073405009225","Wallhalben",null],["073405009228","Winterbach (Pfalz)",null],["081110000000","Stuttgart, Landeshauptstadt",null],["081150003003","Böblingen, Stadt",null],["081150028028","Leonberg, Stadt",null],["081150029029","Magstadt",null],["081150041041","Renningen, Stadt",null],["081150042042","Rutesheim, Stadt",null],["081150044044","Schönaich",null],["081150045045","Sindelfingen, Stadt",null],["081150050050","Weil der Stadt, Stadt",null],["081150051051","Weil im Schönbuch",null],["081150052052","Weissach",null],["081155001001","Aidlingen",null],["081155001054","Grafenau",null],["081155002013","Ehningen",null],["081155002015","Gärtringen",null],["081155003010","Deckenpfronn",null],["081155003021","Herrenberg, Stadt",null],["081155003037","Nufringen",null],["081155004002","Altdorf",null],["081155004022","Hildrizhausen",null],["081155004024","Holzgerlingen, Stadt",null],["081155005004","Bondorf",null],["081155005016","Gäufelden",null],["081155005034","Mötzingen",null],["081155005053","Jettingen",null],["081155006046","Steinenbronn",null],["081155006048","Waldenbuch, Stadt",null],["081160015015","Denkendorf",null],["081160019019","Esslingen am Neckar, Stadt",null],["081160047047","Neuhausen auf den Fildern",null],["081160072072","Wernau (Neckar), Stadt",null],["081160076076","Aichwald",null],["081160077077","Filderstadt, Stadt",null],["081160078078","Leinfelden-Echterdingen, Stadt",null],["081160080080","Ostfildern, Stadt",null],["081160081081","Aichtal, Stadt",null],["081165001016","Dettingen unter Teck",null],["081165001033","Kirchheim unter Teck, Stadt",null],["081165001048","Notzingen",null],["081165002018","Erkenbrechtsweiler",null],["081165002054","Owen, Stadt",null],["081165002079","Lenningen",null],["081165003005","Altdorf",null],["081165003006","Altenriet",null],["081165003008","Bempflingen",null],["081165003041","Neckartailfingen",null],["081165003042","Neckartenzlingen",null],["081165003063","Schlaitdorf",null],["081165004011","Beuren",null],["081165004036","Kohlberg",null],["081165004046","Neuffen, Stadt",null],["081165005020","Frickenhausen",null],["081165005022","Großbettlingen",null],["081165005049","Nürtingen, Stadt",null],["081165005050","Oberboihingen",null],["081165005068","Unterensingen",null],["081165005073","Wolfschlugen",null],["081165006004","Altbach",null],["081165006014","Deizisau",null],["081165006056","Plochingen, Stadt",null],["081165007007","Baltmannsweiler",null],["081165007027","Hochdorf",null],["081165007037","Lichtenwald",null],["081165007058","Reichenbach an der Fils",null],["081165008012","Bissingen an der Teck",null],["081165008029","Holzmaden",null],["081165008043","Neidlingen",null],["081165008053","Ohmden",null],["081165008070","Weilheim an der Teck, Stadt",null],["081165009035","Köngen",null],["081165009071","Wendlingen am Neckar, Stadt",null],["081170010010","Böhmenkirch",null],["081175001006","Bad Ditzenbach",null],["081175001014","Deggingen",null],["081175002018","Ebersbach an der Fils, Stadt",null],["081175002044","Schlierbach",null],["081175003019","Eislingen/Fils, Stadt",null],["081175003037","Ottenbach",null],["081175003042","Salach",null],["081175004007","Bad Überkingen",null],["081175004024","Geislingen an der Steige, Stadt",null],["081175004033","Kuchen",null],["081175005026","Göppingen, Stadt",null],["081175005043","Schlat",null],["081175005053","Wäschenbeuren",null],["081175005055","Wangen",null],["081175006015","Donzdorf, Stadt",null],["081175006025","Gingen an der Fils",null],["081175006049","Süßen, Stadt",null],["081175006061","Lauterstein, Stadt",null],["081175007016","Drackenstein",null],["081175007028","Gruibingen",null],["081175007031","Hohenstadt",null],["081175007035","Mühlhausen im Täle",null],["081175007058","Wiesensteig, Stadt",null],["081175008001","Adelberg",null],["081175008009","Birenbach",null],["081175008011","Börtlingen",null],["081175008038","Rechberghausen",null],["081175009002","Aichelberg",null],["081175009012","Bad Boll",null],["081175009017","Dürnau",null],["081175009023","Gammelshausen",null],["081175009029","Hattenhofen",null],["081175009060","Zell unter Aichelberg",null],["081175010003","Albershausen",null],["081175010051","Uhingen, Stadt",null],["081175011020","Eschenbach",null],["081175011030","Heiningen",null],["081180003003","Asperg, Stadt",null],["081180011011","Ditzingen, Stadt",null],["081180019019","Gerlingen, Stadt",null],["081180021021","Großbottwar, Stadt",null],["081180046046","Kornwestheim, Stadt",null],["081180048048","Ludwigsburg, Stadt",null],["081180050050","Markgröningen, Stadt",null],["081180051051","Möglingen",null],["081180060060","Oberstenfeld",null],["081180076076","Sachsenheim, Stadt",null],["081180080080","Korntal-Münchingen, Stadt",null],["081180081081","Remseck am Neckar, Stadt",null],["081185001007","Besigheim, Stadt",null],["081185001016","Freudental",null],["081185001018","Gemmrigheim",null],["081185001028","Hessigheim",null],["081185001047","Löchgau",null],["081185001053","Mundelsheim",null],["081185001074","Walheim",null],["081185002071","Tamm",null],["081185002077","Ingersheim",null],["081185002079","Bietigheim-Bissingen, Stadt",null],["081185003010","Bönnigheim, Stadt",null],["081185003015","Erligheim",null],["081185003040","Kirchheim am Neckar",null],["081185004063","Pleidelsheim",null],["081185004078","Freiberg am Neckar, Stadt",null],["081185005001","Affalterbach",null],["081185005006","Benningen am Neckar",null],["081185005014","Erdmannhausen",null],["081185005049","Marbach am Neckar, Stadt",null],["081185006027","Hemmingen",null],["081185006067","Schwieberdingen",null],["081185007054","Murr",null],["081185007070","Steinheim an der Murr, Stadt",null],["081185008012","Eberdingen",null],["081185008059","Oberriexingen, Stadt",null],["081185008068","Sersheim",null],["081185008073","Vaihingen an der Enz, Stadt",null],["081190001001","Alfdorf",null],["081190020020","Fellbach, Stadt",null],["081190041041","Korb",null],["081190044044","Murrhardt, Stadt",null],["081190061061","Rudersberg",null],["081190079079","Waiblingen, Stadt",null],["081190089089","Berglen",null],["081190090090","Remshalden",null],["081190091091","Weinstadt, Stadt",null],["081190093093","Kernen im Remstal",null],["081195001003","Allmersbach im Tal",null],["081195001004","Althütte",null],["081195001006","Auenwald",null],["081195001008","Backnang, Stadt",null],["081195001018","Burgstetten",null],["081195001038","Kirchberg an der Murr",null],["081195001053","Oppenweiler",null],["081195001083","Weissach im Tal",null],["081195001087","Aspach",null],["081195002055","Plüderhausen",null],["081195002076","Urbach",null],["081195003067","Schorndorf, Stadt",null],["081195003086","Winterbach",null],["081195004024","Großerlach",null],["081195004069","Spiegelberg",null],["081195004075","Sulzbach an der Murr",null],["081195005037","Kaisersbach",null],["081195005084","Welzheim, Stadt",null],["081195006042","Leutenbach",null],["081195006068","Schwaikheim",null],["081195006085","Winnenden, Stadt",null],["081210000000","Heilbronn, Universitätsstadt",null],["081250007007","Bad Wimpfen, Stadt",null],["081250039039","Gundelsheim, Stadt",null],["081250058058","Leingarten, Stadt",null],["081250068068","Neudenau, Stadt",null],["081250107107","Wüstenrot",null],["081255001005","Bad Friedrichshall, Stadt",null],["081255001078","Oedheim",null],["081255001079","Offenau",null],["081255002006","Bad Rappenau, Stadt",null],["081255002049","Kirchardt",null],["081255002087","Siegelsbach",null],["081255003013","Brackenheim, Stadt",null],["081255003017","Cleebronn",null],["081255004026","Eppingen, Stadt",null],["081255004034","Gemmingen",null],["081255004047","Ittlingen",null],["081255005030","Flein",null],["081255005094","Talheim",null],["081255006056","Lauffen am Neckar, Stadt",null],["081255006066","Neckarwestheim",null],["081255006074","Nordheim",null],["081255007048","Jagsthausen",null],["081255007063","Möckmühl, Stadt",null],["081255007084","Roigheim",null],["081255007103","Widdern, Stadt",null],["081255008027","Erlenbach",null],["081255008065","Neckarsulm, Stadt",null],["081255008096","Untereisesheim",null],["081255009069","Neuenstadt am Kocher, Stadt",null],["081255009111","Hardthausen am Kocher",null],["081255009113","Langenbrettach",null],["081255010038","Güglingen, Stadt",null],["081255010081","Pfaffenhofen",null],["081255010108","Zaberfeld",null],["081255011059","Löwenstein, Stadt",null],["081255011110","Obersulm",null],["081255012001","Abstatt",null],["081255012008","Beilstein, Stadt",null],["081255012046","Ilsfeld",null],["081255012098","Untergruppenbach",null],["081255013061","Massenbachhausen",null],["081255013086","Schwaigern, Stadt",null],["081255014021","Eberstadt",null],["081255014024","Ellhofen",null],["081255014057","Lehrensteinsfeld",null],["081255014102","Weinsberg, Stadt",null],["081260011011","Bretzfeld",null],["081260072072","Schöntal",null],["081265001047","Kupferzell",null],["081265001058","Neuenstein, Stadt",null],["081265001085","Waldenburg, Stadt",null],["081265002020","Dörzbach",null],["081265002045","Krautheim, Stadt",null],["081265002056","Mulfingen",null],["081265003039","Ingelfingen, Stadt",null],["081265003046","Künzelsau, Stadt",null],["081265004028","Forchtenberg, Stadt",null],["081265004060","Niedernhall, Stadt",null],["081265004086","Weißbach",null],["081265005066","Öhringen, Stadt",null],["081265005069","Pfedelbach",null],["081265005094","Zweiflingen",null],["081270008008","Blaufelden",null],["081270052052","Mainhardt",null],["081270075075","Schrozberg, Stadt",null],["081275001009","Braunsbach",null],["081275001086","Untermünkheim",null],["081275002014","Crailsheim, Stadt",null],["081275002073","Satteldorf",null],["081275002103","Frankenhardt",null],["081275002104","Stimpfach",null],["081275003101","Kreßberg",null],["081275003102","Fichtenau",null],["081275004032","Gerabronn, Stadt",null],["081275004047","Langenburg, Stadt",null],["081275005043","Ilshofen, Stadt",null],["081275005089","Vellberg, Stadt",null],["081275005099","Wolpertshausen",null],["081275006023","Fichtenberg",null],["081275006025","Gaildorf, Stadt",null],["081275006062","Oberrot",null],["081275006079","Sulzbach-Laufen",null],["081275007012","Bühlertann",null],["081275007013","Bühlerzell",null],["081275007063","Obersontheim",null],["081275008046","Kirchberg an der Jagst, Stadt",null],["081275008071","Rot am See",null],["081275008091","Wallhausen",null],["081275009056","Michelbach an der Bilz",null],["081275009059","Michelfeld",null],["081275009076","Schwäbisch Hall, Stadt",null],["081275009100","Rosengarten",null],["081280020020","Creglingen, Stadt",null],["081280039039","Freudenberg, Stadt",null],["081280064064","Külsheim, Stadt",null],["081280082082","Niederstetten, Stadt",null],["081280126126","Weikersheim, Stadt",null],["081280131131","Wertheim, Stadt",null],["081280139139","Lauda-Königshofen, Stadt",null],["081285001006","Assamstadt",null],["081285001007","Bad Mergentheim, Stadt",null],["081285001058","Igersheim",null],["081285002014","Boxberg, Stadt",null],["081285002138","Ahorn",null],["081285003047","Grünsfeld, Stadt",null],["081285003137","Wittighausen",null],["081285004045","Großrinderfeld",null],["081285004061","Königheim",null],["081285004115","Tauberbischofsheim, Stadt",null],["081285004128","Werbach",null],["081350010010","Dischingen",null],["081350015015","Gerstetten",null],["081350020020","Herbrechtingen, Stadt",null],["081350025025","Königsbronn",null],["081350032032","Steinheim am Albuch",null],["081355001016","Giengen an der Brenz, Stadt",null],["081355001021","Hermaringen",null],["081355002019","Heidenheim an der Brenz, Stadt",null],["081355002026","Nattheim",null],["081355003027","Niederstotzingen, Stadt",null],["081355003031","Sontheim an der Brenz",null],["081360002002","Abtsgmünd",null],["081360027027","Gschwend",null],["081360042042","Lorch, Stadt",null],["081360045045","Neresheim, Stadt",null],["081360050050","Oberkochen, Stadt",null],["081365001021","Essingen",null],["081365001033","Hüttlingen",null],["081365001088","Aalen, Stadt",null],["081365002010","Bopfingen, Stadt",null],["081365002037","Kirchheim am Ries",null],["081365002087","Riesbürg",null],["081365003003","Adelmannsfelden",null],["081365003018","Ellenberg",null],["081365003019","Ellwangen (Jagst), Stadt",null],["081365003035","Jagstzell",null],["081365003046","Neuler",null],["081365003060","Rosenberg",null],["081365003084","Wört",null],["081365003089","Rainau",null],["081365004038","Lauchheim, Stadt",null],["081365004082","Westhausen",null],["081365005020","Eschach",null],["081365005024","Göggingen",null],["081365005034","Iggingen",null],["081365005040","Leinzell",null],["081365005049","Obergröningen",null],["081365005062","Schechingen",null],["081365006007","Bartholomä",null],["081365006009","Böbingen an der Rems",null],["081365006028","Heubach, Stadt",null],["081365006029","Heuchlingen",null],["081365006043","Mögglingen",null],["081365007065","Schwäbisch Gmünd, Stadt",null],["081365007079","Waldstetten",null],["081365008015","Durlangen",null],["081365008044","Mutlangen",null],["081365008061","Ruppertshofen",null],["081365008066","Spraitbach",null],["081365008070","Täferrot",null],["081365009068","Stödtlen",null],["081365009071","Tannhausen",null],["081365009075","Unterschneidheim",null],["082110000000","Baden-Baden, Stadt",null],["082120000000","Karlsruhe, Stadt",null],["082150017017","Ettlingen, Stadt",null],["082150046046","Malsch",null],["082150047047","Marxzell",null],["082150064064","Östringen, Stadt",null],["082150084084","Ubstadt-Weiher",null],["082150089089","Walzbachtal",null],["082150090090","Weingarten (Baden)",null],["082150096096","Karlsbad",null],["082150097097","Kraichtal, Stadt",null],["082150101101","Pfinztal",null],["082150102102","Eggenstein-Leopoldshafen",null],["082150105105","Linkenheim-Hochstetten",null],["082150106106","Waghäusel, Stadt",null],["082150108108","Rheinstetten, Stadt",null],["082150109109","Stutensee, Stadt",null],["082150110110","Waldbronn",null],["082155001039","Kronau",null],["082155001100","Bad Schönborn",null],["082155002007","Bretten, Stadt",null],["082155002025","Gondelsheim",null],["082155003009","Bruchsal, Stadt",null],["082155003021","Forst",null],["082155003029","Hambrücken",null],["082155003103","Karlsdorf-Neuthard",null],["082155004099","Graben-Neudorf",null],["082155004111","Dettenheim",null],["082155005040","Kürnbach",null],["082155005059","Oberderdingen",null],["082155006066","Philippsburg, Stadt",null],["082155006107","Oberhausen-Rheinhausen",null],["082155007082","Sulzfeld",null],["082155007094","Zaisenhausen",null],["082160008008","Bühlertal",null],["082160013013","Forbach",null],["082160015015","Gaggenau, Stadt",null],["082165001006","Bischweier",null],["082165001024","Kuppenheim, Stadt",null],["082165002007","Bühl, Stadt",null],["082165002041","Ottersweier",null],["082165003002","Au am Rhein",null],["082165003005","Bietigheim",null],["082165003009","Durmersheim",null],["082165003012","Elchesheim-Illingen",null],["082165004017","Gernsbach, Stadt",null],["082165004029","Loffenau",null],["082165004059","Weisenbach",null],["082165005023","Iffezheim",null],["082165005033","Muggensturm",null],["082165005039","Ötigheim",null],["082165005043","Rastatt, Stadt",null],["082165005052","Steinmauern",null],["082165006028","Lichtenau, Stadt",null],["082165006063","Rheinmünster",null],["082165007022","Hügelsheim",null],["082165007049","Sinzheim",null],["082210000000","Heidelberg, Stadt",null],["082220000000","Mannheim, Universitätsstadt",null],["082250014014","Buchen (Odenwald), Stadt",null],["082250060060","Mudau",null],["082255001032","Hardheim",null],["082255001039","Höpfingen",null],["082255001109","Walldürn, Stadt",null],["082255002033","Haßmersheim",null],["082255002042","Hüffenhardt",null],["082255003002","Aglasterhausen",null],["082255003068","Neunkirchen",null],["082255003116","Schwarzach",null],["082255004024","Fahrenbach",null],["082255004052","Limbach",null],["082255005058","Mosbach, Stadt",null],["082255005067","Neckarzimmern",null],["082255005074","Obrigheim",null],["082255005117","Elztal",null],["082255006010","Binau",null],["082255006064","Neckargerach",null],["082255006113","Zwingenberg",null],["082255006118","Waldbrunn",null],["082255007075","Osterburken, Stadt",null],["082255007082","Rosenberg",null],["082255007114","Ravenstein, Stadt",null],["082255008009","Billigheim",null],["082255008115","Schefflenz",null],["082255009001","Adelsheim, Stadt",null],["082255009091","Seckach",null],["082260009009","Brühl",null],["082260012012","Dossenheim",null],["082260018018","Eppelheim, Stadt",null],["082260028028","Heddesheim",null],["082260036036","Ilvesheim",null],["082260037037","Ketsch",null],["082260038038","Ladenburg, Stadt",null],["082260041041","Leimen, Stadt",null],["082260060060","Nußloch",null],["082260062062","Oftersheim",null],["082260063063","Plankstadt",null],["082260076076","Sandhausen",null],["082260082082","Schriesheim, Stadt",null],["082260084084","Schwetzingen, Stadt",null],["082260095095","Walldorf, Stadt",null],["082260096096","Weinheim, Stadt",null],["082260103103","St. Leon-Rot",null],["082260105105","Edingen-Neckarhausen",null],["082260107107","Hirschberg an der Bergstraße",null],["082265001013","Eberbach, Stadt",null],["082265001081","Schönbrunn",null],["082265002020","Eschelbronn",null],["082265002048","Mauer",null],["082265002049","Meckesheim",null],["082265002086","Spechbach",null],["082265002104","Lobbach",null],["082265003031","Hemsbach, Stadt",null],["082265003040","Laudenbach",null],["082265004003","Altlußheim",null],["082265004032","Hockenheim, Stadt",null],["082265004059","Neulußheim",null],["082265004068","Reilingen",null],["082265005006","Bammental",null],["082265005022","Gaiberg",null],["082265005056","Neckargemünd, Stadt",null],["082265005097","Wiesenbach",null],["082265006046","Malsch",null],["082265006054","Mühlhausen",null],["082265006065","Rauenberg, Stadt",null],["082265007027","Heddesbach",null],["082265007029","Heiligkreuzsteinach",null],["082265007080","Schönau, Stadt",null],["082265007099","Wilhelmsfeld",null],["082265008085","Sinsheim, Stadt",null],["082265008101","Zuzenhausen",null],["082265008102","Angelbachtal",null],["082265009017","Epfenbach",null],["082265009055","Neckarbischofsheim, Stadt",null],["082265009058","Neidenstein",null],["082265009066","Reichartshausen",null],["082265009091","Waibstadt, Stadt",null],["082265009106","Helmstadt-Bargen",null],["082265010010","Dielheim",null],["082265010098","Wiesloch, Stadt",null],["082310000000","Pforzheim, Stadt",null],["082350065065","Schömberg",null],["082350080080","Wildberg, Stadt",null],["082355001006","Altensteig, Stadt",null],["082355001022","Egenhausen",null],["082355001066","Simmersfeld",null],["082355002007","Althengstett",null],["082355002029","Gechingen",null],["082355002057","Ostelsheim",null],["082355002067","Simmozheim",null],["082355003018","Dobel",null],["082355003033","Bad Herrenalb, Stadt",null],["082355004008","Bad Liebenzell, Stadt",null],["082355004073","Unterreichenbach",null],["082355005047","Neubulach, Stadt",null],["082355005050","Neuweiler",null],["082355005084","Bad Teinach-Zavelstein, Stadt",null],["082355006055","Oberreichenbach",null],["082355006085","Calw, Stadt",null],["082355007020","Ebhausen",null],["082355007032","Haiterbach, Stadt",null],["082355007046","Nagold, Stadt",null],["082355007060","Rohrdorf",null],["082355008025","Enzklösterle",null],["082355008035","Höfen an der Enz",null],["082355008079","Bad Wildbad, Stadt",null],["082360004004","Birkenfeld",null],["082360028028","Illingen",null],["082360030030","Ispringen",null],["082360033033","Knittlingen, Stadt",null],["082360046046","Niefern-Öschelbronn",null],["082360070070","Keltern",null],["082360071071","Remchingen",null],["082360072072","Straubenhardt",null],["082365001019","Friolzheim",null],["082365001025","Heimsheim, Stadt",null],["082365001039","Mönsheim",null],["082365001065","Wiernsheim",null],["082365001067","Wimsheim",null],["082365001068","Wurmberg",null],["082365002011","Eisingen",null],["082365002074","Kämpfelbach",null],["082365002076","Königsbach-Stein",null],["082365003038","Maulbronn, Stadt",null],["082365003061","Sternenfels",null],["082365004040","Mühlacker, Stadt",null],["082365004050","Ötisheim",null],["082365005013","Engelsbrand",null],["082365005043","Neuenbürg, Stadt",null],["082365006031","Kieselbronn",null],["082365006073","Neulingen",null],["082365006075","Ölbronn-Dürrn",null],["082365007044","Neuhausen",null],["082365007062","Tiefenbronn",null],["082370002002","Alpirsbach, Stadt",null],["082370004004","Baiersbronn",null],["082370045045","Loßburg",null],["082375001019","Dornstetten, Stadt",null],["082375001030","Glatten",null],["082375001061","Schopfloch",null],["082375001074","Waldachtal",null],["082375002028","Freudenstadt, Stadt",null],["082375002073","Seewald",null],["082375002075","Bad Rippoldsau-Schapbach",null],["082375003024","Empfingen",null],["082375003027","Eutingen im Gäu",null],["082375003040","Horb am Neckar, Stadt",null],["082375005032","Grömbach",null],["082375005054","Pfalzgrafenweiler",null],["082375005072","Wörnersberg",null],["083110000000","Freiburg im Breisgau, Stadt",null],["083150068068","Lenzkirch",null],["083150076076","Neuenburg am Rhein, Stadt",null],["083150133133","Vogtsburg im Kaiserstuhl, Stadt",null],["083155001006","Bad Krozingen, Stadt",null],["083155001048","Hartheim am Rhein",null],["083155002015","Breisach am Rhein, Stadt",null],["083155002059","Ihringen",null],["083155002072","Merdingen",null],["083155003020","Buchenbach",null],["083155003064","Kirchzarten",null],["083155003084","Oberried",null],["083155003109","Stegen",null],["083155004014","Bollschweil",null],["083155004131","Ehrenkirchen",null],["083155005047","Gundelfingen",null],["083155005051","Heuweiler",null],["083155006008","Ballrechten-Dottingen",null],["083155006033","Eschbach",null],["083155006050","Heitersheim, Stadt",null],["083155007003","Au",null],["083155007056","Horben",null],["083155007073","Merzhausen",null],["083155007107","Sölden",null],["083155007125","Wittnau",null],["083155008016","Breitnau",null],["083155008052","Hinterzarten",null],["083155009013","Bötzingen",null],["083155009030","Eichstetten am Kaiserstuhl",null],["083155009043","Gottenheim",null],["083155010039","Friedenweiler",null],["083155010070","Löffingen, Stadt",null],["083155011115","Umkirch",null],["083155011132","March",null],["083155012004","Auggen",null],["083155012007","Badenweiler",null],["083155012022","Buggingen",null],["083155012074","Müllheim, Stadt",null],["083155012111","Sulzburg, Stadt",null],["083155013041","Glottertal",null],["083155013094","St. Märgen",null],["083155013095","St. Peter",null],["083155014028","Ebringen",null],["083155014089","Pfaffenweiler",null],["083155014098","Schallstadt",null],["083155015037","Feldberg (Schwarzwald)",null],["083155015102","Schluchsee",null],["083155016108","Staufen im Breisgau, Stadt",null],["083155016130","Münstertal/Schwarzwald",null],["083155017031","Eisenbach (Hochschwarzwald)",null],["083155017113","Titisee-Neustadt, Stadt",null],["083165001009","Denzlingen",null],["083165001036","Reute",null],["083165001045","Vörstetten",null],["083165002003","Biederbach",null],["083165002010","Elzach, Stadt",null],["083165002055","Winden im Elztal",null],["083165003011","Emmendingen, Stadt",null],["083165003024","Malterdingen",null],["083165003039","Sexau",null],["083165003043","Teningen",null],["083165003054","Freiamt",null],["083165004017","Herbolzheim, Stadt",null],["083165004020","Kenzingen, Stadt",null],["083165004049","Weisweil",null],["083165004053","Rheinhausen",null],["083165005002","Bahlingen am Kaiserstuhl",null],["083165005012","Endingen am Kaiserstuhl, Stadt",null],["083165005013","Forchheim",null],["083165005037","Riegel am Kaiserstuhl",null],["083165005038","Sasbach am Kaiserstuhl",null],["083165005051","Wyhl am Kaiserstuhl",null],["083165006014","Gutach im Breisgau",null],["083165006042","Simonswald",null],["083165006056","Waldkirch, Stadt",null],["083170005005","Appenweier",null],["083170031031","Friesenheim",null],["083170051051","Hornberg, Stadt",null],["083170057057","Kehl, Stadt",null],["083170141141","Willstätt",null],["083170151151","Neuried",null],["083170153153","Rheinau, Stadt",null],["083175001001","Achern, Stadt",null],["083175001068","Lauf",null],["083175001116","Sasbach",null],["083175001118","Sasbachwalden",null],["083175002026","Ettenheim, Stadt",null],["083175002073","Mahlberg, Stadt",null],["083175002113","Ringsheim",null],["083175002114","Rust",null],["083175002152","Kappel-Grafenhausen",null],["083175003009","Berghaupten",null],["083175003034","Gengenbach, Stadt",null],["083175003097","Ohlsbach",null],["083175004029","Fischerbach",null],["083175004040","Haslach im Kinzigtal, Stadt",null],["083175004046","Hofstetten",null],["083175004078","Mühlenbach",null],["083175004129","Steinach",null],["083175005039","Gutach (Schwarzwaldbahn)",null],["083175005041","Hausach, Stadt",null],["083175006056","Kappelrodeck",null],["083175006102","Ottenhöfen im Schwarzwald",null],["083175006126","Seebach",null],["083175007059","Kippenheim",null],["083175007065","Lahr/Schwarzwald, Stadt",null],["083175008008","Bad Peterstal-Griesbach",null],["083175008098","Oppenau, Stadt",null],["083175009067","Lautenbach",null],["083175009089","Oberkirch, Stadt",null],["083175009110","Renchen, Stadt",null],["083175010021","Durbach",null],["083175010047","Hohberg",null],["083175010096","Offenburg, Stadt",null],["083175010100","Ortenberg",null],["083175010122","Schutterwald",null],["083175011121","Schuttertal",null],["083175011127","Seelbach",null],["083175012075","Meißenheim",null],["083175012150","Schwanau",null],["083175013093","Oberwolfach",null],["083175013145","Wolfach, Stadt",null],["083175014011","Biberach",null],["083175014085","Nordrach",null],["083175014088","Oberharmersbach",null],["083175014146","Zell am Harmersbach, Stadt",null],["083179971971","Rheinau, gemeindefreies Gebiet",null],["083250012012","Dornhan, Stadt",null],["083255001014","Dunningen",null],["083255001071","Eschbronn",null],["083255002015","Epfendorf",null],["083255002045","Oberndorf am Neckar, Stadt",null],["083255002070","Fluorn-Winzeln",null],["083255003011","Dietingen",null],["083255003049","Rottweil, Stadt",null],["083255003064","Wellendingen",null],["083255003069","Zimmern ob Rottweil",null],["083255003072","Deißlingen",null],["083255004050","Schenkenzell",null],["083255004051","Schiltach, Stadt",null],["083255005001","Aichhalden",null],["083255005024","Hardt",null],["083255005036","Lauterbach",null],["083255005053","Schramberg, Stadt",null],["083255006057","Sulz am Neckar, Stadt",null],["083255006061","Vöhringen",null],["083255007009","Bösingen",null],["083255007060","Villingendorf",null],["083260003003","Bad Dürrheim, Stadt",null],["083260005005","Blumberg, Stadt",null],["083260031031","Königsfeld im Schwarzwald",null],["083260052052","St. Georgen im Schwarzwald, Stadt",null],["083260068068","Vöhrenbach, Stadt",null],["083265001006","Bräunlingen, Stadt",null],["083265001012","Donaueschingen, Stadt",null],["083265001027","Hüfingen, Stadt",null],["083265002017","Furtwangen im Schwarzwald, Stadt",null],["083265002020","Gütenbach",null],["083265003054","Schönwald im Schwarzwald",null],["083265003055","Schonach im Schwarzwald",null],["083265003060","Triberg im Schwarzwald, Stadt",null],["083265004010","Dauchingen",null],["083265004037","Mönchweiler",null],["083265004041","Niedereschach",null],["083265004061","Tuningen",null],["083265004065","Unterkirnach",null],["083265004074","Villingen-Schwenningen, Stadt",null],["083265004075","Brigachtal",null],["083275001004","Bärenthal",null],["083275001008","Buchheim",null],["083275001016","Fridingen an der Donau, Stadt",null],["083275001027","Irndorf",null],["083275001030","Kolbingen",null],["083275001036","Mühlheim an der Donau, Stadt",null],["083275001041","Renquishausen",null],["083275002007","Bubsheim",null],["083275002009","Deilingen",null],["083275002013","Egesheim",null],["083275002019","Gosheim",null],["083275002029","Königsheim",null],["083275002040","Reichenbach am Heuberg",null],["083275002051","Wehingen",null],["083275003018","Geisingen, Stadt",null],["083275003025","Immendingen",null],["083275004002","Aldingen",null],["083275004005","Balgheim",null],["083275004006","Böttingen",null],["083275004010","Denkingen",null],["083275004011","Dürbheim",null],["083275004017","Frittlingen",null],["083275004023","Hausen ob Verena",null],["083275004033","Mahlstetten",null],["083275004046","Spaichingen, Stadt",null],["083275005012","Durchhausen",null],["083275005020","Gunningen",null],["083275005048","Talheim",null],["083275005049","Trossingen, Stadt",null],["083275006038","Neuhausen ob Eck",null],["083275006050","Tuttlingen, Stadt",null],["083275006054","Wurmlingen",null],["083275006055","Seitingen-Oberflacht",null],["083275006056","Rietheim-Weilheim",null],["083275006057","Emmingen-Liptingen",null],["083350035035","Hilzingen",null],["083350063063","Radolfzell am Bodensee, Stadt",null],["083350080080","Tengen, Stadt",null],["083355001001","Aach, Stadt",null],["083355001022","Engen, Stadt",null],["083355001097","Mühlhausen-Ehingen",null],["083355002015","Büsingen am Hochrhein",null],["083355002026","Gailingen am Hochrhein",null],["083355002028","Gottmadingen",null],["083355003025","Gaienhofen",null],["083355003055","Moos",null],["083355003061","Öhningen",null],["083355004002","Allensbach",null],["083355004043","Konstanz, Universitätsstadt",null],["083355004066","Reichenau",null],["083355005075","Singen (Hohentwiel), Stadt",null],["083355005077","Steißlingen",null],["083355005081","Volkertshausen",null],["083355005100","Rielasingen-Worblingen",null],["083355006021","Eigeltingen",null],["083355006057","Mühlingen",null],["083355006079","Stockach, Stadt",null],["083355006096","Hohenfels",null],["083355006098","Bodman-Ludwigshafen",null],["083355006099","Orsingen-Nenzingen",null],["083360014014","Efringen-Kirchen",null],["083360084084","Steinen",null],["083360087087","Todtnau, Stadt",null],["083360091091","Weil am Rhein, Stadt",null],["083360105105","Grenzach-Wyhlen",null],["083360107107","Kleines Wiesental",null],["083365001045","Kandern, Stadt",null],["083365001104","Malsburg-Marzell",null],["083365003043","Inzlingen",null],["083365003050","Lörrach, Stadt",null],["083365004069","Rheinfelden (Baden), Stadt",null],["083365004082","Schwörstadt",null],["083365005006","Bad Bellingen",null],["083365005078","Schliengen",null],["083365006004","Aitern",null],["083365006010","Böllen",null],["083365006025","Fröhnd",null],["083365006079","Schönau im Schwarzwald, Stadt",null],["083365006080","Schönenberg",null],["083365006089","Tunau",null],["083365006090","Utzenfeld",null],["083365006094","Wembach",null],["083365006096","Wieden",null],["083365007034","Hasel",null],["083365007036","Hausen im Wiesental",null],["083365007057","Maulburg",null],["083365007081","Schopfheim, Stadt",null],["083365008008","Binzen",null],["083365008019","Eimeldingen",null],["083365008024","Fischingen",null],["083365008073","Rümmingen",null],["083365008075","Schallbach",null],["083365008100","Wittlingen",null],["083365009103","Zell im Wiesental, Stadt",null],["083365009106","Häg-Ehrsberg",null],["083370002002","Albbruck",null],["083370038038","Görwihl",null],["083370062062","Klettgau",null],["083370066066","Laufenburg (Baden), Stadt",null],["083370106106","Stühlingen, Stadt",null],["083370116116","Wehr, Stadt",null],["083375001022","Bonndorf im Schwarzwald, Stadt",null],["083375001127","Wutach",null],["083375002030","Dettighofen",null],["083375002060","Jestetten",null],["083375002070","Lottstetten",null],["083375003053","Hohentengen am Hochrhein",null],["083375003125","Küssaberg",null],["083375004039","Grafenhausen",null],["083375004128","Ühlingen-Birkendorf",null],["083375005049","Herrischried",null],["083375005076","Murg",null],["083375005090","Rickenbach",null],["083375005096","Bad Säckingen, Stadt",null],["083375006013","Bernau im Schwarzwald",null],["083375006027","Dachsberg (Südschwarzwald)",null],["083375006045","Häusern",null],["083375006051","Höchenschwand",null],["083375006059","Ibach",null],["083375006097","St. Blasien, Stadt",null],["083375006108","Todtmoos",null],["083375007032","Dogern",null],["083375007065","Lauchringen",null],["083375007118","Weilheim",null],["083375007126","Waldshut-Tiengen, Stadt",null],["083375008123","Wutöschingen",null],["083375008124","Eggingen",null],["084150014014","Dettingen an der Erms",null],["084150019019","Eningen unter Achalm",null],["084150059059","Pfullingen, Stadt",null],["084150061061","Reutlingen, Stadt",null],["084150073073","Trochtelfingen, Stadt",null],["084150080080","Wannweil",null],["084150091091","Sonnenbühl",null],["084150092092","Lichtenstein",null],["084150093093","St. Johann",null],["084155001089","Engstingen",null],["084155001090","Hohenstein",null],["084155002029","Grafenberg",null],["084155002050","Metzingen, Stadt",null],["084155002062","Riederich",null],["084155003027","Gomadingen",null],["084155003048","Mehrstetten",null],["084155003053","Münsingen, Stadt",null],["084155004060","Pliezhausen",null],["084155004087","Walddorfhäslach",null],["084155005028","Grabenstetten",null],["084155005039","Hülben",null],["084155005078","Bad Urach, Stadt",null],["084155005088","Römerstein",null],["084155006034","Hayingen, Stadt",null],["084155006058","Pfronstetten",null],["084155006085","Zwiefalten",null],["084159971971","Gutsbezirk Münsingen, gemeindefreies Gebiet",null],["084160009009","Dettenhausen",null],["084160022022","Kirchentellinsfurt",null],["084160023023","Kusterdingen",null],["084160041041","Tübingen, Universitätsstadt",null],["084160048048","Ammerbuch",null],["084165001011","Dußlingen",null],["084165001015","Gomaringen",null],["084165001026","Nehren",null],["084165002006","Bodelshausen",null],["084165002025","Mössingen, Stadt",null],["084165002031","Ofterdingen",null],["084165003018","Hirrlingen",null],["084165003036","Rottenburg am Neckar, Stadt",null],["084165003049","Neustetten",null],["084165003050","Starzach",null],["084170013013","Burladingen, Stadt",null],["084170025025","Haigerloch, Stadt",null],["084170054054","Rosenfeld, Stadt",null],["084175001010","Bitz",null],["084175001079","Albstadt, Stadt",null],["084175002002","Balingen, Stadt",null],["084175002022","Geislingen, Stadt",null],["084175003008","Bisingen",null],["084175003023","Grosselfingen",null],["084175004031","Hechingen, Stadt",null],["084175004036","Jungingen",null],["084175004051","Rangendingen",null],["084175005044","Meßstetten, Stadt",null],["084175005045","Nusplingen",null],["084175005047","Obernheim",null],["084175006014","Dautmergen",null],["084175006015","Dormettingen",null],["084175006016","Dotternhausen",null],["084175006029","Hausen am Tann",null],["084175006052","Ratshausen",null],["084175006057","Schömberg, Stadt",null],["084175006071","Weilen unter den Rinnen",null],["084175006078","Zimmern unter der Burg",null],["084175007063","Straßberg",null],["084175007075","Winterlingen",null],["084210000000","Ulm, Universitätsstadt",null],["084250039039","Erbach, Stadt",null],["084250108108","Schelklingen, Stadt",null],["084250141141","Blaustein, Stadt",null],["084255001002","Allmendingen",null],["084255001004","Altheim",null],["084255002017","Berghülen",null],["084255002020","Blaubeuren, Stadt",null],["084255003028","Dietenheim, Stadt",null],["084255003066","Illerrieden",null],["084255003140","Balzheim",null],["084255004014","Beimerstetten",null],["084255004031","Dornstadt",null],["084255004135","Westerstetten",null],["084255005033","Ehingen (Donau), Stadt",null],["084255005050","Griesingen",null],["084255005088","Oberdischingen",null],["084255005093","Öpfingen",null],["084255006064","Hüttisheim",null],["084255006110","Schnürpflingen",null],["084255006137","Illerkirchberg",null],["084255006138","Staig",null],["084255007071","Laichingen, Stadt",null],["084255007079","Merklingen",null],["084255007084","Nellingen",null],["084255007134","Westerheim",null],["084255007139","Heroldstatt",null],["084255008005","Altheim (Alb)",null],["084255008011","Asselfingen",null],["084255008013","Ballendorf",null],["084255008019","Bernstadt",null],["084255008022","Börslingen",null],["084255008024","Breitingen",null],["084255008062","Holzkirch",null],["084255008072","Langenau, Stadt",null],["084255008083","Neenstetten",null],["084255008085","Nerenstetten",null],["084255008092","Öllingen",null],["084255008097","Rammingen",null],["084255008112","Setzingen",null],["084255008130","Weidenstetten",null],["084255009008","Amstetten",null],["084255009075","Lonsee",null],["084255010035","Emeringen",null],["084255010036","Emerkingen",null],["084255010052","Grundsheim",null],["084255010055","Hausen am Bussen",null],["084255010073","Lauterach",null],["084255010081","Munderkingen, Stadt",null],["084255010090","Obermarchtal",null],["084255010091","Oberstadion",null],["084255010098","Rechtenstein",null],["084255010104","Rottenacker",null],["084255010123","Untermarchtal",null],["084255010124","Unterstadion",null],["084255010125","Unterwachingen",null],["084260134134","Schemmerhofen",null],["084265001005","Alleshausen",null],["084265001006","Allmannsweiler",null],["084265001013","Bad Buchau, Stadt",null],["084265001020","Betzenweiler",null],["084265001036","Dürnau",null],["084265001064","Kanzach",null],["084265001078","Moosburg",null],["084265001090","Oggelshausen",null],["084265001109","Seekirch",null],["084265001118","Tiefenbach",null],["084265002014","Bad Schussenried, Stadt",null],["084265002062","Ingoldingen",null],["084265003011","Attenweiler",null],["084265003021","Biberach an der Riß, Stadt",null],["084265003038","Eberhardzell",null],["084265003058","Hochdorf",null],["084265003071","Maselheim",null],["084265003074","Mittelbiberach",null],["084265003120","Ummendorf",null],["084265003128","Warthausen",null],["084265004019","Berkheim",null],["084265004031","Dettingen an der Iller",null],["084265004044","Erolzheim",null],["084265004065","Kirchberg an der Iller",null],["084265004066","Kirchdorf an der Iller",null],["084265005001","Achstetten",null],["084265005028","Burgrieden",null],["084265005070","Laupheim, Stadt",null],["084265005073","Mietingen",null],["084265006043","Erlenmoos",null],["084265006087","Ochsenhausen, Stadt",null],["084265006113","Steinhausen an der Rottum",null],["084265006135","Gutenzell-Hürbel",null],["084265007008","Altheim",null],["084265007035","Dürmentingen",null],["084265007045","Ertingen",null],["084265007067","Langenenslingen",null],["084265007097","Riedlingen, Stadt",null],["084265007121","Unlingen",null],["084265007124","Uttenweiler",null],["084265008100","Rot an der Rot",null],["084265008117","Tannheim",null],["084265009108","Schwendi",null],["084265009125","Wain",null],["084350035035","Meckenbeuren",null],["084355001013","Eriskirch",null],["084355001029","Kressbronn am Bodensee",null],["084355001030","Langenargen",null],["084355002016","Friedrichshafen, Stadt",null],["084355002024","Immenstaad am Bodensee",null],["084355003005","Bermatingen",null],["084355003034","Markdorf, Stadt",null],["084355003045","Oberteuringen",null],["084355003067","Deggenhausertal",null],["084355004010","Daisendorf",null],["084355004018","Hagnau am Bodensee",null],["084355004036","Meersburg, Stadt",null],["084355004054","Stetten",null],["084355004066","Uhldingen-Mühlhofen",null],["084355005015","Frickingen",null],["084355005020","Heiligenberg",null],["084355005052","Salem",null],["084355006042","Neukirch",null],["084355006057","Tettnang, Stadt",null],["084355007047","Owingen",null],["084355007053","Sipplingen",null],["084355007059","Überlingen, Stadt",null],["084360008008","Aulendorf, Stadt",null],["084360010010","Bad Wurzach, Stadt",null],["084360049049","Isny im Allgäu, Stadt",null],["084360052052","Kißlegg",null],["084360094094","Argenbühl",null],["084365001005","Altshausen",null],["084365001019","Boms",null],["084365001024","Ebenweiler",null],["084365001027","Eichstegen",null],["084365001032","Fleischwangen",null],["084365001040","Guggenhausen",null],["084365001047","Hoßkirch",null],["084365001053","Königseggwald",null],["084365001067","Riedhausen",null],["084365001077","Unterwaldhausen",null],["084365001093","Ebersbach-Musbach",null],["084365002009","Bad Waldsee, Stadt",null],["084365002014","Bergatreute",null],["084365003018","Bodnegg",null],["084365003039","Grünkraut",null],["084365003069","Schlier",null],["084365003079","Waldburg",null],["084365004003","Aichstetten",null],["084365004004","Aitrach",null],["084365004055","Leutkirch im Allgäu, Stadt",null],["084365005011","Baienfurt",null],["084365005012","Baindt",null],["084365005013","Berg",null],["084365005064","Ravensburg, Stadt",null],["084365005082","Weingarten, Stadt",null],["084365006078","Vogt",null],["084365006085","Wolfegg",null],["084365007001","Achberg",null],["084365007006","Amtzell",null],["084365007081","Wangen im Allgäu, Stadt",null],["084365008083","Wilhelmsdorf",null],["084365008095","Horgenzell",null],["084365009087","Wolpertswende",null],["084365009096","Fronreute",null],["084370086086","Ostrach",null],["084375001031","Gammertingen, Stadt",null],["084375001047","Hettingen, Stadt",null],["084375001082","Neufra",null],["084375001114","Veringenstadt, Stadt",null],["084375002053","Hohentengen",null],["084375002076","Mengen, Stadt",null],["084375002101","Scheer, Stadt",null],["084375003072","Leibertingen",null],["084375003078","Meßkirch, Stadt",null],["084375003123","Sauldorf",null],["084375004056","Illmensee",null],["084375004088","Pfullendorf, Stadt",null],["084375004118","Wald",null],["084375004124","Herdwangen-Schönach",null],["084375005044","Herbertingen",null],["084375005100","Bad Saulgau, Stadt",null],["084375006005","Beuron",null],["084375006008","Bingen",null],["084375006059","Inzigkofen",null],["084375006065","Krauchenwies",null],["084375006104","Sigmaringen, Stadt",null],["084375006105","Sigmaringendorf",null],["084375007102","Schwenningen",null],["084375007107","Stetten am kalten Markt",null],["091610000000","Ingolstadt",null],["091620000000","München, Landeshauptstadt",null],["091630000000","Rosenheim",null],["091710111111","Altötting, St",null],["091710112112","Burghausen, St",null],["091710113113","Burgkirchen a.d.Alz",null],["091710117117","Garching a.d.Alz",null],["091710118118","Haiming",null],["091710125125","Neuötting, St",null],["091710127127","Pleiskirchen",null],["091710131131","Teising",null],["091710132132","Töging a.Inn, St",null],["091710133133","Tüßling, M",null],["091710137137","Winhöring",null],["091715101114","Emmerting",null],["091715101124","Mehring",null],["091715102116","Feichten a.d.Alz",null],["091715102119","Halsbach",null],["091715102122","Kirchweidach",null],["091715102134","Tyrlaching",null],["091715103123","Marktl, M",null],["091715103130","Stammham",null],["091715104115","Erlbach",null],["091715104126","Perach",null],["091715104129","Reischach",null],["091715106121","Kastl",null],["091715106135","Unterneukirchen",null],["091720111111","Ainring",null],["091720112112","Anger",null],["091720114114","Bad Reichenhall, GKSt",null],["091720115115","Bayerisch Gmain",null],["091720116116","Berchtesgaden, M",null],["091720117117","Bischofswiesen",null],["091720118118","Freilassing, St",null],["091720122122","Laufen, St",null],["091720124124","Marktschellenberg, M",null],["091720128128","Piding",null],["091720129129","Ramsau b.Berchtesgaden",null],["091720130130","Saaldorf-Surheim",null],["091720131131","Schneizlreuth",null],["091720132132","Schönau a.Königssee",null],["091720134134","Teisendorf, M",null],["091729452452","Eck",null],["091729454454","Schellenberger Forst",null],["091730111111","Bad Heilbrunn",null],["091730112112","Bad Tölz, St",null],["091730118118","Dietramszell",null],["091730120120","Egling",null],["091730123123","Eurasburg",null],["091730124124","Gaißach",null],["091730126126","Geretsried, St",null],["091730130130","Icking",null],["091730131131","Jachenau",null],["091730134134","Königsdorf",null],["091730135135","Lenggries",null],["091730137137","Münsing",null],["091730145145","Wackersberg",null],["091730147147","Wolfratshausen, St",null],["091735107113","Benediktbeuern",null],["091735107115","Bichl",null],["091735108133","Kochel a.See",null],["091735108142","Schlehdorf",null],["091735109127","Greiling",null],["091735109140","Reichersbeuern",null],["091735109141","Sachsenkam",null],["091739451451","Pupplinger Au",null],["091739452452","Wolfratshauser Forst",null],["091740111111","Altomünster, M",null],["091740113113","Bergkirchen",null],["091740115115","Dachau, GKSt",null],["091740118118","Erdweg",null],["091740121121","Haimhausen",null],["091740122122","Hebertshausen",null],["091740126126","Karlsfeld",null],["091740131131","Markt Indersdorf, M",null],["091740135135","Odelzhausen",null],["091740136136","Petershausen",null],["091740137137","Pfaffenhofen a.d.Glonn",null],["091740141141","Röhrmoos",null],["091740143143","Schwabhausen",null],["091740146146","Sulzemoos",null],["091740147147","Hilgertshausen-Tandern",null],["091740150150","Vierkirchen",null],["091740151151","Weichs",null],["091750111111","Anzing",null],["091750115115","Ebersberg, St",null],["091750118118","Forstinning",null],["091750122122","Grafing b.München, St",null],["091750123123","Hohenlinden",null],["091750124124","Kirchseeon, M",null],["091750127127","Markt Schwaben, M",null],["091750132132","Vaterstetten",null],["091750133133","Pliening",null],["091750135135","Poing",null],["091750137137","Steinhöring",null],["091750139139","Zorneding",null],["091755112112","Aßling",null],["091755112119","Frauenneuharting",null],["091755112136","Emmering",null],["091755114113","Baiern",null],["091755114114","Bruck",null],["091755114116","Egmating",null],["091755114121","Glonn, M",null],["091755114128","Moosach",null],["091755114131","Oberpframmern",null],["091759451451","Anzinger Forst",null],["091759452452","Ebersberger Forst",null],["091759453453","Eglhartinger Forst",null],["091760112112","Altmannstein, M",null],["091760114114","Beilngries, St",null],["091760118118","Buxheim",null],["091760120120","Denkendorf",null],["091760121121","Dollnstein, M",null],["091760123123","Eichstätt, GKSt",null],["091760126126","Gaimersheim, M",null],["091760129129","Großmehring",null],["091760131131","Hepberg",null],["091760132132","Hitzhofen",null],["091760137137","Kinding, M",null],["091760138138","Kipfenberg, M",null],["091760139139","Kösching, M",null],["091760143143","Lenting",null],["091760148148","Mörnsheim, M",null],["091760161161","Stammham",null],["091760164164","Titting, M",null],["091760166166","Wellheim, M",null],["091760167167","Wettstetten",null],["091765115155","Pollenfeld",null],["091765115160","Schernfeld",null],["091765115165","Walting",null],["091765116116","Böhmfeld",null],["091765116124","Eitensheim",null],["091765118111","Adelschlag",null],["091765118122","Egweil",null],["091765118149","Nassenfels, M",null],["091765119147","Mindelstetten",null],["091765119150","Oberdolling",null],["091765119153","Pförring, M",null],["091769451451","Haunstetter Forst",null],["091770113113","Bockhorn",null],["091770115115","Dorfen, St",null],["091770117117","Erding, GKSt",null],["091770118118","Finsing",null],["091770119119","Forstern",null],["091770120120","Fraunberg",null],["091770123123","Isen, M",null],["091770127127","Lengdorf",null],["091770130130","Moosinning",null],["091770137137","Sankt Wolfgang",null],["091770139139","Taufkirchen (Vils)",null],["091775120114","Buch a.Buchrain",null],["091775120135","Pastetten",null],["091775121142","Walpertskirchen",null],["091775121144","Wörth",null],["091775123116","Eitting",null],["091775123133","Oberding",null],["091775124131","Neuching",null],["091775124134","Ottenhofen",null],["091775125121","Hohenpolding",null],["091775125122","Inning a.Holz",null],["091775125124","Kirchberg",null],["091775125138","Steinkirchen",null],["091775126112","Berglern",null],["091775126126","Langenpreising",null],["091775126143","Wartenberg, M",null],["091780116116","Au i.d.Hallertau, M",null],["091780120120","Eching",null],["091780122122","Rudelzhausen",null],["091780123123","Fahrenzhausen",null],["091780124124","Freising, GKSt",null],["091780130130","Hallbergmoos",null],["091780133133","Hohenkammer",null],["091780136136","Kirchdorf a.d.Amper",null],["091780137137","Kranzberg",null],["091780138138","Langenbach",null],["091780140140","Marzling",null],["091780143143","Moosburg a.d.Isar, St",null],["091780144144","Nandlstadt, M",null],["091780145145","Neufahrn b.Freising",null],["091785127113","Allershausen",null],["091785127150","Paunzhausen",null],["091785129125","Gammelsdorf",null],["091785129132","Hörgertshausen",null],["091785129142","Mauern",null],["091785129155","Wang",null],["091785130115","Attenkirchen",null],["091785130129","Haag a.d.Amper",null],["091785130156","Wolfersdorf",null],["091785130157","Zolling",null],["091790113113","Alling",null],["091790117117","Egenhofen",null],["091790118118","Eichenau",null],["091790119119","Emmering",null],["091790121121","Fürstenfeldbruck, GKSt",null],["091790123123","Germering, GKSt",null],["091790126126","Gröbenzell",null],["091790134134","Maisach",null],["091790138138","Moorenweis",null],["091790142142","Olching, St",null],["091790145145","Puchheim, St",null],["091790149149","Türkenfeld",null],["091795131111","Adelshofen",null],["091795131114","Althegnenberg",null],["091795131128","Hattenhofen",null],["091795131130","Jesenwang",null],["091795131132","Landsberied",null],["091795131136","Mammendorf",null],["091795131137","Mittelstetten",null],["091795131140","Oberschweinbach",null],["091795132125","Grafrath",null],["091795132131","Kottgeisering",null],["091795132147","Schöngeising",null],["091800112112","Bad Kohlgrub",null],["091800116116","Farchant",null],["091800117117","Garmisch-Partenkirchen, M",null],["091800118118","Grainau",null],["091800122122","Krün",null],["091800123123","Mittenwald, M",null],["091800124124","Murnau a.Staffelsee, M",null],["091800125125","Oberammergau",null],["091800126126","Oberau",null],["091800134134","Uffing a.Staffelsee",null],["091800136136","Wallgau",null],["091805133113","Bad Bayersoien",null],["091805133129","Saulgrub",null],["091805135115","Ettal",null],["091805135135","Unterammergau",null],["091805136114","Eschenlohe",null],["091805136119","Großweil",null],["091805136127","Ohlstadt",null],["091805136131","Schwaigen",null],["091805137128","Riegsee",null],["091805137132","Seehausen a.Staffelsee",null],["091805137133","Spatzenhausen",null],["091809451451","Ettaler Forst",null],["091810113113","Denklingen",null],["091810114114","Dießen am Ammersee, M",null],["091810116116","Egling a.d.Paar",null],["091810122122","Geltendorf",null],["091810128128","Kaufering, M",null],["091810130130","Landsberg am Lech, GKSt",null],["091810132132","Penzing",null],["091810144144","Utting am Ammersee",null],["091810145145","Weil",null],["091815138121","Fuchstal",null],["091815138143","Unterdießen",null],["091815139126","Hurlach",null],["091815139127","Igling",null],["091815139131","Obermeitingen",null],["091815140134","Prittriching",null],["091815140138","Scheuring",null],["091815141124","Hofstetten",null],["091815141140","Schwifting",null],["091815141141","Pürgen",null],["091815142111","Apfeldorf",null],["091815142129","Kinsau",null],["091815142133","Vilgertshofen",null],["091815142135","Reichling",null],["091815142137","Rott",null],["091815142142","Thaining",null],["091815143115","Eching am Ammersee",null],["091815143123","Greifenberg",null],["091815143139","Schondorf am Ammersee",null],["091815144118","Eresing",null],["091815144120","Finning",null],["091815144146","Windach",null],["091819451451","Ammersee",null],["091820111111","Bad Wiessee",null],["091820112112","Bayrischzell",null],["091820114114","Fischbachau",null],["091820116116","Gmund a.Tegernsee",null],["091820119119","Hausham",null],["091820120120","Holzkirchen, M",null],["091820123123","Irschenberg",null],["091820124124","Kreuth",null],["091820125125","Miesbach, St",null],["091820127127","Otterfing",null],["091820129129","Rottach-Egern",null],["091820131131","Schliersee, M",null],["091820132132","Tegernsee, St",null],["091820133133","Valley",null],["091820134134","Waakirchen",null],["091820136136","Warngau",null],["091820137137","Weyarn",null],["091830112112","Ampfing",null],["091830113113","Aschau a.Inn",null],["091830114114","Buchbach, M",null],["091830119119","Haag i.OB, M",null],["091830127127","Mettenheim",null],["091830128128","Mühldorf a.Inn, St",null],["091830135135","Obertaufkirchen",null],["091830144144","Schwindegg",null],["091830148148","Waldkraiburg, St",null],["091835145120","Heldenstein",null],["091835145138","Rattenkirchen",null],["091835146118","Gars a.Inn, M",null],["091835146147","Unterreit",null],["091835147123","Kirchdorf",null],["091835147140","Reichertsheim",null],["091835148122","Jettenbach",null],["091835148124","Kraiburg a.Inn, M",null],["091835148145","Taufkirchen",null],["091835149115","Egglkofen",null],["091835149129","Neumarkt-Sankt Veit, St",null],["091835150125","Lohkirchen",null],["091835150132","Oberbergkirchen",null],["091835150143","Schönberg",null],["091835150151","Zangberg",null],["091835151134","Oberneukirchen",null],["091835151136","Polling",null],["091835152116","Erharting",null],["091835152130","Niederbergkirchen",null],["091835152131","Niedertaufkirchen",null],["091835183126","Maitenbeth",null],["091835183139","Rechtmehring",null],["091839451451","Mühldorfer Hart",null],["091840112112","Aschheim",null],["091840113113","Baierbrunn",null],["091840114114","Brunnthal",null],["091840118118","Feldkirchen",null],["091840119119","Garching b.München, St",null],["091840120120","Gräfelfing",null],["091840121121","Grasbrunn",null],["091840122122","Grünwald",null],["091840123123","Haar",null],["091840127127","Höhenkirchen-Siegertsbrunn",null],["091840129129","Hohenbrunn",null],["091840130130","Ismaning",null],["091840131131","Kirchheim b.München",null],["091840132132","Neuried",null],["091840134134","Oberhaching",null],["091840135135","Oberschleißheim",null],["091840136136","Ottobrunn",null],["091840137137","Aying",null],["091840138138","Planegg",null],["091840139139","Pullach i.Isartal",null],["091840140140","Putzbrunn",null],["091840141141","Sauerlach",null],["091840142142","Schäftlarn",null],["091840144144","Straßlach-Dingharting",null],["091840145145","Taufkirchen",null],["091840146146","Neubiberg",null],["091840147147","Unterföhring",null],["091840148148","Unterhaching",null],["091840149149","Unterschleißheim, St",null],["091849452452","Forstenrieder Park",null],["091849454454","Grünwalder Forst",null],["091849457457","Perlacher Forst",null],["091850113113","Aresing",null],["091850125125","Burgheim, M",null],["091850127127","Ehekirchen",null],["091850139139","Karlshuld",null],["091850140140","Karlskron",null],["091850149149","Neuburg a.d.Donau, GKSt",null],["091850150150","Oberhausen",null],["091850153153","Rennertshofen, M",null],["091850158158","Schrobenhausen, St",null],["091850163163","Königsmoos",null],["091850168168","Weichering",null],["091855154118","Bergheim",null],["091855154157","Rohrenfels",null],["091855155116","Berg im Gau",null],["091855155123","Brunnen",null],["091855155131","Gachenbach",null],["091855155143","Langenmosen",null],["091855155166","Waidhofen",null],["091860113113","Baar-Ebenhausen",null],["091860125125","Gerolsbach",null],["091860128128","Hohenwart, M",null],["091860132132","Jetzendorf",null],["091860137137","Manching, M",null],["091860139139","Münchsmünster",null],["091860143143","Pfaffenhofen a.d.Ilm, St",null],["091860146146","Reichertshausen",null],["091860149149","Rohrbach",null],["091860151151","Scheyern",null],["091860152152","Schweitenkirchen",null],["091860158158","Vohburg a.d.Donau, St",null],["091860162162","Wolnzach, M",null],["091865156116","Ernsgaden",null],["091865156122","Geisenfeld, St",null],["091865157126","Hettenshausen",null],["091865157130","Ilmmünster",null],["091865158144","Pörnbach",null],["091865158147","Reichertshofen, M",null],["091870113113","Amerang",null],["091870114114","Aschau i.Chiemgau",null],["091870116116","Babensham",null],["091870117117","Bad Aibling, St",null],["091870118118","Bernau a.Chiemsee",null],["091870120120","Brannenburg",null],["091870122122","Bruckmühl, M",null],["091870124124","Edling",null],["091870125125","Eggstätt",null],["091870126126","Eiselfing",null],["091870128128","Bad Endorf, M",null],["091870129129","Bad Feilnbach",null],["091870130130","Feldkirchen-Westerham",null],["091870131131","Flintsbach a.Inn",null],["091870132132","Frasdorf",null],["091870134134","Griesstätt",null],["091870137137","Großkarolinenfeld",null],["091870142142","Schechen",null],["091870148148","Kiefersfelden",null],["091870150150","Kolbermoor, St",null],["091870154154","Neubeuern, M",null],["091870156156","Nußdorf a.Inn",null],["091870157157","Oberaudorf",null],["091870162162","Prien a.Chiemsee, M",null],["091870163163","Prutting",null],["091870165165","Raubling",null],["091870167167","Riedering",null],["091870168168","Rimsting",null],["091870169169","Rohrdorf",null],["091870172172","Samerberg",null],["091870174174","Söchtenau",null],["091870176176","Soyen",null],["091870177177","Stephanskirchen",null],["091870179179","Tuntenhausen",null],["091870181181","Vogtareuth",null],["091870182182","Wasserburg a.Inn, St",null],["091875160121","Breitbrunn a.Chiemsee",null],["091875160123","Chiemsee",null],["091875160138","Gstadt a.Chiemsee",null],["091875162139","Halfing",null],["091875162145","Höslwang",null],["091875162173","Schonstett",null],["091875165164","Ramerberg",null],["091875165170","Rott a.Inn",null],["091875184159","Pfaffing",null],["091875184186","Albaching",null],["091879451451","Rotter Forst-Nord",null],["091879452452","Rotter Forst-Süd",null],["091880113113","Berg",null],["091880117117","Andechs",null],["091880118118","Feldafing",null],["091880120120","Gauting",null],["091880121121","Gilching",null],["091880124124","Herrsching a.Ammersee",null],["091880126126","Inning a.Ammersee",null],["091880127127","Krailling",null],["091880132132","Seefeld",null],["091880137137","Pöcking",null],["091880139139","Starnberg, St",null],["091880141141","Tutzing",null],["091880144144","Weßling",null],["091880145145","Wörthsee",null],["091889451451","Starnberger See",null],["091890111111","Altenmarkt a.d.Alz",null],["091890114114","Chieming",null],["091890115115","Engelsberg",null],["091890118118","Fridolfing",null],["091890119119","Grabenstätt",null],["091890120120","Grassau, M",null],["091890124124","Inzell",null],["091890127127","Kirchanschöring",null],["091890130130","Nußdorf",null],["091890134134","Palling",null],["091890135135","Petting",null],["091890139139","Reit im Winkl",null],["091890140140","Ruhpolding",null],["091890141141","Schleching",null],["091890142142","Schnaitsee",null],["091890143143","Seeon-Seebruck",null],["091890145145","Siegsdorf",null],["091890148148","Surberg",null],["091890149149","Tacherting",null],["091890152152","Tittmoning, St",null],["091890154154","Traunreut, St",null],["091890155155","Traunstein, GKSt",null],["091890157157","Trostberg, St",null],["091890159159","Übersee",null],["091890160160","Unterwössen",null],["091895166113","Bergen",null],["091895166161","Vachendorf",null],["091895169129","Marquartstein",null],["091895169146","Staudach-Egerndach",null],["091895170126","Kienberg",null],["091895170133","Obing",null],["091895170137","Pittenhart",null],["091895173150","Taching a.See",null],["091895173162","Waging a.See, M",null],["091895173165","Wonneberg",null],["091899451451","Chiemsee (See)",null],["091899452452","Waginger See",null],["091900115115","Bernried am Starnberger See",null],["091900130130","Hohenpeißenberg",null],["091900138138","Pähl",null],["091900139139","Peißenberg, M",null],["091900140140","Peiting, M",null],["091900141141","Penzberg, St",null],["091900142142","Polling",null],["091900144144","Raisting",null],["091900148148","Schongau, St",null],["091900157157","Weilheim i.OB, St",null],["091900158158","Wessobrunn",null],["091900159159","Wielenbach",null],["091905174111","Altenstadt",null],["091905174129","Hohenfurch",null],["091905174133","Ingenried",null],["091905174149","Schwabbruck",null],["091905174151","Schwabsoien",null],["091905175114","Bernbeuren",null],["091905175118","Burggen",null],["091905176113","Antdorf",null],["091905176126","Habach",null],["091905176136","Obersöchering",null],["091905176153","Sindelsdorf",null],["091905177120","Eberfing",null],["091905177121","Eglfing",null],["091905177131","Huglfing",null],["091905177135","Oberhausen",null],["091905178117","Böbing",null],["091905178145","Rottenbuch",null],["091905179132","Iffeldorf",null],["091905179152","Seeshaupt",null],["091905180143","Prem",null],["091905180154","Steingaden",null],["091905180160","Wildsteig",null],["092610000000","Landshut",null],["092620000000","Passau",null],["092630000000","Straubing",null],["092710111111","Aholming",null],["092710113113","Auerbach",null],["092710116116","Bernried",null],["092710119119","Deggendorf, GKSt",null],["092710122122","Grafling",null],["092710125125","Hengersberg, M",null],["092710127127","Iggensbach",null],["092710128128","Künzing",null],["092710132132","Metten, M",null],["092710138138","Niederalteich",null],["092710140140","Offenberg",null],["092710141141","Osterhofen, St",null],["092710146146","Plattling, St",null],["092710151151","Stephansposching",null],["092710153153","Winzer, M",null],["092715202123","Grattersdorf",null],["092715202126","Hunding",null],["092715202130","Lalling",null],["092715202148","Schaufling",null],["092715204139","Oberpöring",null],["092715204143","Otzing",null],["092715204152","Wallerfing",null],["092715205118","Buchhofen",null],["092715205135","Moos",null],["092715206114","Außernzell",null],["092715206149","Schöllnach, M",null],["092720118118","Freyung, St",null],["092720120120","Grafenau, St",null],["092720121121","Grainet",null],["092720122122","Haidmühle",null],["092720127127","Hohenau",null],["092720129129","Jandelsbrunn",null],["092720134134","Mauth",null],["092720136136","Neureichenau",null],["092720140140","Ringelai",null],["092720141141","Röhrnbach, M",null],["092720142142","Saldenburg",null],["092720143143","Sankt Oswald-Riedlhütte",null],["092720146146","Neuschönau",null],["092720149149","Spiegelau",null],["092720151151","Waldkirchen, St",null],["092725211116","Eppenschlag",null],["092725211128","Innernzell",null],["092725211145","Schöfweg",null],["092725211147","Schönberg, M",null],["092725212126","Hinterschmiding",null],["092725212139","Philippsreut",null],["092725213150","Thurmansbang",null],["092725213152","Zenting",null],["092725214119","Fürsteneck",null],["092725214138","Perlesreut, M",null],["092729451451","Annathaler Wald",null],["092729452452","Frauenberger u. Duschlberger Wald",null],["092729453453","Graineter Wald",null],["092729455455","Leopoldsreuter Wald",null],["092729456456","Mauther Forst",null],["092729457457","Philippsreuter Wald",null],["092729458458","Pleckensteiner Wald",null],["092729459459","Sankt Oswald",null],["092729460460","Schlichtenberger Wald",null],["092729461461","Schönbrunner Wald",null],["092729463463","Waldhäuserwald",null],["092730111111","Abensberg, St",null],["092730116116","Bad Abbach, M",null],["092730137137","Kelheim, St",null],["092730147147","Mainburg, St",null],["092730152152","Neustadt a.d.Donau, St",null],["092730159159","Painten, M",null],["092730164164","Riedenburg, St",null],["092730165165","Rohr i.NB, M",null],["092735215121","Essing, M",null],["092735215133","Ihrlerstein",null],["092735216166","Saal a.d.Donau",null],["092735216175","Teugn",null],["092735217125","Hausen",null],["092735217127","Herrngiersdorf",null],["092735217141","Langquaid, M",null],["092735218119","Biburg",null],["092735218139","Kirchdorf",null],["092735218172","Siegenburg, M",null],["092735218177","Train",null],["092735218181","Wildenberg",null],["092735219113","Aiglsbach",null],["092735219115","Attenhofen",null],["092735219163","Elsendorf",null],["092735219178","Volkenschwand",null],["092739451451","Dürnbucher Forst",null],["092739452452","Frauenforst",null],["092739453453","Hacklberg",null],["092739454454","Hienheimer Forst",null],["092740111111","Adlkofen",null],["092740113113","Altdorf, M",null],["092740120120","Bodenkirchen",null],["092740121121","Buch a.Erlbach",null],["092740124124","Eching",null],["092740126126","Ergolding, M",null],["092740128128","Essenbach, M",null],["092740134134","Geisenhausen, M",null],["092740141141","Hohenthann",null],["092740146146","Kumhausen",null],["092740153153","Neufahrn i.NB",null],["092740156156","Niederaichbach",null],["092740172172","Pfeffenhausen, M",null],["092740176176","Rottenburg a.d.Laaber, St",null],["092740182182","Tiefenbach",null],["092740184184","Vilsbiburg, St",null],["092740185185","Vilsheim",null],["092740194194","Bruckberg",null],["092745220119","Bayerbach b.Ergoldsbach",null],["092745220127","Ergoldsbach, M",null],["092745221132","Furth",null],["092745221165","Obersüßbach",null],["092745221187","Weihmichl",null],["092745222174","Postau",null],["092745222188","Weng",null],["092745222191","Wörth a.d.Isar",null],["092745223112","Aham",null],["092745223135","Gerzen",null],["092745223145","Kröning",null],["092745223179","Schalkham",null],["092745226114","Altfraunhofen",null],["092745226118","Baierbach",null],["092745227154","Neufraunhofen",null],["092745227183","Velden, M",null],["092745227193","Wurmsham",null],["092750111111","Aicha vorm Wald",null],["092750114114","Aldersbach",null],["092750116116","Bad Füssing",null],["092750118118","Breitenberg",null],["092750119119","Büchlberg",null],["092750120120","Eging a.See, M",null],["092750121121","Fürstenstein",null],["092750122122","Fürstenzell, M",null],["092750124124","Bad Griesbach i.Rottal, St",null],["092750125125","Haarbach",null],["092750126126","Hauzenberg, St",null],["092750127127","Hofkirchen, M",null],["092750128128","Hutthurm, M",null],["092750130130","Kirchham",null],["092750131131","Kößlarn, M",null],["092750133133","Neuburg a.Inn",null],["092750134134","Neuhaus a.Inn",null],["092750135135","Neukirchen vorm Wald",null],["092750137137","Obernzell, M",null],["092750138138","Ortenburg, M",null],["092750141141","Pocking, St",null],["092750144144","Ruderting",null],["092750145145","Ruhstorf a.d.Rott, M",null],["092750146146","Salzweg",null],["092750148148","Sonnen",null],["092750149149","Tettenweis",null],["092750150150","Thyrnau",null],["092750151151","Tiefenbach",null],["092750153153","Untergriesbach, M",null],["092750154154","Vilshofen an der Donau, St",null],["092750156156","Wegscheid, M",null],["092750159159","Windorf, M",null],["092755229152","Tittling, M",null],["092755229160","Witzmannsberg",null],["092755232112","Aidenbach, M",null],["092755232117","Beutelsbach",null],["092755234132","Malching",null],["092755234143","Rotthalmünster, M",null],["092760113113","Arnbruck",null],["092760115115","Bayerisch Eisenstein",null],["092760116116","Bischofsmais",null],["092760117117","Bodenmais, M",null],["092760118118","Böbrach",null],["092760120120","Drachselsried",null],["092760121121","Frauenau",null],["092760122122","Geiersthal",null],["092760126126","Kirchberg i.Wald",null],["092760127127","Kirchdorf i.Wald",null],["092760128128","Kollnburg",null],["092760129129","Langdorf",null],["092760130130","Lindberg",null],["092760134134","Patersdorf",null],["092760135135","Prackenbach",null],["092760138138","Regen, St",null],["092760139139","Rinchnach",null],["092760143143","Teisnach, M",null],["092760144144","Viechtach, St",null],["092760148148","Zwiesel, St",null],["092765238111","Achslach",null],["092765238123","Gotteszell",null],["092765238142","Ruhmannsfelden, M",null],["092765238146","Zachenberg",null],["092770111111","Arnstorf, M",null],["092770114114","Dietersburg",null],["092770116116","Eggenfelden, St",null],["092770117117","Egglham",null],["092770121121","Gangkofen, M",null],["092770124124","Hebertsfelden",null],["092770126126","Johanniskirchen",null],["092770127127","Julbach",null],["092770128128","Kirchdorf a.Inn",null],["092770134134","Mitterskirchen",null],["092770138138","Pfarrkirchen, St",null],["092770139139","Postmünster",null],["092770142142","Roßbach",null],["092770144144","Schönau",null],["092770145145","Simbach a.Inn, St",null],["092770149149","Triftern, M",null],["092770151151","Unterdietfurt",null],["092770152152","Wittibreut",null],["092770153153","Wurmannsquick, M",null],["092770154154","Zeilarn",null],["092775239119","Falkenberg",null],["092775239131","Malgersdorf",null],["092775239141","Rimbach",null],["092775240122","Geratskirchen",null],["092775240133","Massing, M",null],["092775241112","Bayerbach",null],["092775241113","Bad Birnbach, M",null],["092775243140","Reut",null],["092775243148","Tann, M",null],["092775244118","Ering",null],["092775244147","Stubenberg",null],["092780118118","Bogen, St",null],["092780121121","Feldkirchen",null],["092780123123","Geiselhöring, St",null],["092780129129","Haibach",null],["092780141141","Kirchroth",null],["092780143143","Konzell",null],["092780144144","Laberweinting",null],["092780146146","Leiblfing",null],["092780148148","Mallersdorf-Pfaffenberg, M",null],["092780167167","Oberschneiding",null],["092780170170","Parkstetten",null],["092780178178","Rattenberg",null],["092780184184","Sankt Englmar",null],["092780190190","Steinach",null],["092780197197","Wiesenfelden",null],["092785246147","Loitzendorf",null],["092785246179","Rattiszell",null],["092785246189","Stallwang",null],["092785248116","Ascha",null],["092785248120","Falkenfels",null],["092785248134","Haselbach",null],["092785248151","Mitterfels, M",null],["092785249139","Hunderdorf",null],["092785249154","Neukirchen",null],["092785249198","Windberg",null],["092785250112","Aholfing",null],["092785250117","Atting",null],["092785250172","Perkam",null],["092785250177","Rain",null],["092785252149","Mariaposching",null],["092785252159","Niederwinkling",null],["092785252171","Perasdorf",null],["092785252187","Schwarzach, M",null],["092785256113","Aiterhofen",null],["092785256182","Salching",null],["092785257140","Irlbach",null],["092785257192","Straßkirchen",null],["092790112112","Dingolfing, St",null],["092790113113","Eichendorf, M",null],["092790115115","Frontenhausen, M",null],["092790122122","Landau a.d.Isar, St",null],["092790124124","Loiching",null],["092790126126","Marklkofen",null],["092790127127","Mengkofen",null],["092790128128","Moosthenning",null],["092790130130","Niederviehbach",null],["092790132132","Pilsting, M",null],["092790134134","Reisbach, M",null],["092790135135","Simbach, M",null],["092790137137","Wallersdorf, M",null],["092795208116","Gottfrieding",null],["092795208125","Mamming",null],["093610000000","Amberg",null],["093620000000","Regensburg",null],["093630000000","Weiden i.d.OPf.",null],["093710111111","Ammerthal",null],["093710113113","Auerbach i.d.OPf., St",null],["093710118118","Ebermannsdorf",null],["093710119119","Edelsfeld",null],["093710120120","Ensdorf",null],["093710121121","Freihung, M",null],["093710122122","Freudenberg",null],["093710127127","Hirschau, St",null],["093710129129","Hohenburg, M",null],["093710132132","Kastl, M",null],["093710136136","Kümmersbruck",null],["093710144144","Poppenricht",null],["093710146146","Rieden, M",null],["093710148148","Schmidmühlen, M",null],["093710150150","Schnaittenbach, St",null],["093710151151","Sulzbach-Rosenberg, St",null],["093710154154","Ursensollen",null],["093710156156","Vilseck, St",null],["093715301123","Gebenbach",null],["093715301126","Hahnbach, M",null],["093715302128","Hirschbach",null],["093715302135","Königstein, M",null],["093715303140","Etzelwang",null],["093715303141","Neukirchen b.Sulzbach-Rosenberg",null],["093715303157","Weigendorf",null],["093715304116","Birgland",null],["093715304131","Illschwang",null],["093719452452","Eichen",null],["093720112112","Arnschwang",null],["093720113113","Arrach",null],["093720115115","Blaibach",null],["093720116116","Cham, St",null],["093720117117","Chamerau",null],["093720124124","Eschlkam, M",null],["093720126126","Furth im Wald, St",null],["093720130130","Grafenwiesen",null],["093720135135","Hohenwarth",null],["093720137137","Bad Kötzting, St",null],["093720138138","Lam, M",null],["093720143143","Miltach",null],["093720144144","Neukirchen b.Hl.Blut, M",null],["093720146146","Pemfling",null],["093720151151","Rimbach",null],["093720153153","Roding, St",null],["093720154154","Rötz, St",null],["093720155155","Runding",null],["093720157157","Schönthal",null],["093720158158","Schorndorf",null],["093720164164","Traitsching",null],["093720168168","Waffenbrunn",null],["093720171171","Waldmünchen, St",null],["093720175175","Willmering",null],["093720177177","Zandt",null],["093720178178","Lohberg",null],["093725308163","Tiefenbach",null],["093725308165","Treffelstein",null],["093725310147","Pösing",null],["093725310161","Stamsried, M",null],["093725312128","Gleißenberg",null],["093725312174","Weiding",null],["093725313149","Reichenbach",null],["093725313170","Walderbach",null],["093725317167","Zell",null],["093725317169","Wald",null],["093725318125","Falkenstein, M",null],["093725318142","Michelsneukirchen",null],["093725318150","Rettenbach",null],["093730112112","Berching, St",null],["093730113113","Berg b.Neumarkt i.d.OPf.",null],["093730115115","Breitenbrunn, M",null],["093730119119","Deining",null],["093730121121","Dietfurt a.d.Altmühl, St",null],["093730126126","Freystadt, St",null],["093730134134","Hohenfels, M",null],["093730140140","Lauterhofen, M",null],["093730143143","Lupburg, M",null],["093730146146","Mühlhausen",null],["093730147147","Neumarkt i.d.OPf., GKSt",null],["093730151151","Parsberg, St",null],["093730155155","Postbauer-Heng, M",null],["093730156156","Pyrbaum, M",null],["093730160160","Seubersdorf i.d.OPf.",null],["093730167167","Velburg, St",null],["093735321114","Berngau",null],["093735321153","Pilsach",null],["093735321159","Sengenthal",null],["093740111111","Altenstadt a.d.Waldnaab",null],["093740118118","Eslarn, M",null],["093740121121","Floß, M",null],["093740122122","Flossenbürg",null],["093740124124","Grafenwöhr, St",null],["093740133133","Luhe-Wildenau, M",null],["093740134134","Mantel, M",null],["093740137137","Moosbach, M",null],["093740139139","Neustadt a.d.Waldnaab, St",null],["093740162162","Vohenstrauß, St",null],["093740164164","Waidhaus, M",null],["093740165165","Waldthurn, M",null],["093740168168","Windischeschenbach, St",null],["093745323128","Kirchendemenreuth",null],["093745323144","Parkstein, M",null],["093745323150","Püchersreuth",null],["093745323158","Störnstein",null],["093745323160","Theisseil",null],["093745324148","Trabitz",null],["093745324149","Pressath, St",null],["093745324156","Schwarzenbach",null],["093745325119","Etzenricht",null],["093745325131","Kohlberg, M",null],["093745325166","Weiherhammer",null],["093745326129","Kirchenthumbach, M",null],["093745326155","Schlammersdorf",null],["093745326163","Vorbach",null],["093745327117","Eschenbach i.d.OPf., St",null],["093745327140","Neustadt am Kulm, St",null],["093745327157","Speinshart",null],["093745329127","Irchenrieth",null],["093745329146","Pirk",null],["093745329154","Schirmitz",null],["093745329170","Bechtsrieth",null],["093745330132","Leuchtenberg, M",null],["093745330159","Tännesberg, M",null],["093745331123","Georgenberg",null],["093745331147","Pleystein, St",null],["093749451451","Heinersreuther Forst",null],["093749452452","Manteler Forst",null],["093749458458","Speinsharter Forst",null],["093750117117","Barbing",null],["093750118118","Beratzhausen, M",null],["093750119119","Bernhardswald",null],["093750143143","Hagelstadt",null],["093750148148","Hemau, St",null],["093750161161","Köfering",null],["093750165165","Lappersdorf, M",null],["093750170170","Mintraching",null],["093750174174","Neutraubling, St",null],["093750175175","Nittendorf, M",null],["093750179179","Obertraubling",null],["093750180180","Pentling",null],["093750181181","Pettendorf",null],["093750183183","Pfatter",null],["093750190190","Regenstauf, M",null],["093750196196","Schierling, M",null],["093750199199","Sinzing",null],["093750204204","Tegernheim",null],["093750205205","Thalmassing",null],["093750208208","Wenzenbach",null],["093750209209","Wiesent",null],["093750213213","Zeitlarn",null],["093755332131","Duggendorf",null],["093755332153","Holzheim a.Forst",null],["093755332156","Kallmünz, M",null],["093755333122","Brunn",null],["093755333127","Deuerling",null],["093755333162","Laaber, M",null],["093755334184","Pielenhofen",null],["093755334211","Wolfsegg",null],["093755335114","Altenthann",null],["093755335116","Bach a.d.Donau",null],["093755335130","Donaustauf, M",null],["093755336120","Brennberg",null],["093755336210","Wörth a.d.Donau, St",null],["093755337113","Alteglofsheim",null],["093755337182","Pfakofen",null],["093755338115","Aufhausen",null],["093755338171","Mötzing",null],["093755338191","Riekofen",null],["093755338201","Sünching",null],["093759451451","Forstmühler Forst",null],["093759452452","Kreuther Forst",null],["093760116116","Bodenwöhr",null],["093760117117","Bruck i.d.OPf., M",null],["093760119119","Burglengenfeld, St",null],["093760125125","Fensterbach",null],["093760141141","Maxhütte-Haidhof, St",null],["093760147147","Neunburg vorm Wald, St",null],["093760149149","Nittenau, St",null],["093760150150","Wernberg-Köblitz, M",null],["093760151151","Oberviechtach, St",null],["093760159159","Schmidgaden",null],["093760161161","Schwandorf, GKSt",null],["093760170170","Teublitz, St",null],["093765339131","Gleiritsch",null],["093765339148","Niedermurach",null],["093765339171","Teunz",null],["093765339178","Winklarn, M",null],["093765341112","Altendorf",null],["093765341133","Guteneck",null],["093765341144","Nabburg, St",null],["093765342162","Schwarzach b.Nabburg",null],["093765342163","Schwarzenfeld, M",null],["093765342169","Stulln",null],["093765343153","Pfreimd, St",null],["093765343173","Trausnitz",null],["093765344160","Schönsee, St",null],["093765344167","Stadlern",null],["093765344176","Weiding",null],["093765345122","Dieterskirchen",null],["093765345146","Neukirchen-Balbini, M",null],["093765345164","Schwarzhofen, M",null],["093765345172","Thanstein",null],["093765346168","Steinberg am See",null],["093765346175","Wackersdorf",null],["093769455455","Wolferlohe",null],["093770112112","Bärnau, St",null],["093770116116","Erbendorf, St",null],["093770118118","Friedenfels",null],["093770119119","Fuchsmühl, M",null],["093770127127","Immenreuth",null],["093770131131","Konnersreuth, M",null],["093770133133","Kulmain",null],["093770139139","Mähring, M",null],["093770142142","Bad Neualbenreuth, M",null],["093770146146","Plößberg, M",null],["093770154154","Tirschenreuth, St",null],["093770157157","Waldershof, St",null],["093770158158","Waldsassen, St",null],["093775347137","Leonberg",null],["093775347141","Mitterteich, St",null],["093775347145","Pechbrunn",null],["093775348128","Kastl",null],["093775348129","Kemnath, St",null],["093775349113","Brand",null],["093775349115","Ebnath",null],["093775349143","Neusorg",null],["093775349148","Pullenreuth",null],["093775350132","Krummennaab",null],["093775350149","Reuth b.Erbendorf",null],["093775351117","Falkenberg, M",null],["093775351159","Wiesau, M",null],["094610000000","Bamberg",null],["094620000000","Bayreuth",null],["094630000000","Coburg",null],["094640000000","Hof",null],["094710111111","Altendorf",null],["094710117117","Bischberg",null],["094710119119","Breitengüßbach",null],["094710123123","Buttenheim, M",null],["094710131131","Frensdorf",null],["094710137137","Gundelsheim",null],["094710140140","Hallstadt, St",null],["094710142142","Heiligenstadt i.OFr., M",null],["094710145145","Hirschaid, M",null],["094710150150","Kemmern",null],["094710155155","Litzendorf",null],["094710159159","Memmelsdorf",null],["094710165165","Oberhaid",null],["094710169169","Pettstadt",null],["094710172172","Pommersfelden",null],["094710174174","Rattelsdorf, M",null],["094710185185","Scheßlitz, St",null],["094710191191","Stegaurach",null],["094710195195","Strullendorf",null],["094710207207","Viereth-Trunstadt",null],["094710208208","Walsdorf",null],["094710214214","Zapfendorf, M",null],["094710220220","Schlüsselfeld, St",null],["094715401115","Baunach, St",null],["094715401133","Gerach",null],["094715401152","Lauter",null],["094715401175","Reckendorf",null],["094715403151","Königsfeld",null],["094715403189","Stadelhofen",null],["094715403209","Wattendorf",null],["094715407122","Burgwindheim, M",null],["094715407128","Ebrach, M",null],["094715408120","Burgebrach, M",null],["094715408186","Schönbrunn i.Steigerwald",null],["094715445154","Lisberg",null],["094715445173","Priesendorf",null],["094719452452","Ebracher Forst",null],["094719453453","Eichwald",null],["094719454454","Geisberger Forst",null],["094719455455","Hauptsmoor",null],["094719456456","Koppenwinder Forst",null],["094719457457","Lindach",null],["094719459459","Semberg",null],["094719460460","Steinachsrangen",null],["094719461461","Winkelhofer Forst",null],["094719462462","Zückshuter Forst",null],["094720111111","Ahorntal",null],["094720116116","Bad Berneck i.Fichtelgebirge, St",null],["094720119119","Bindlach",null],["094720121121","Bischofsgrün",null],["094720131131","Eckersdorf",null],["094720138138","Fichtelberg",null],["094720139139","Gefrees, St",null],["094720143143","Goldkronach, St",null],["094720150150","Heinersreuth",null],["094720164164","Mehlmeisel",null],["094720175175","Pegnitz, St",null],["094720179179","Pottenstein, St",null],["094720190190","Speichersdorf",null],["094720197197","Waischenfeld, St",null],["094720198198","Warmensteinach",null],["094725412115","Aufseß",null],["094725412154","Hollfeld, St",null],["094725412176","Plankenfels",null],["094725413141","Glashütten",null],["094725413167","Mistelgau",null],["094725414140","Gesees",null],["094725414155","Hummeltal",null],["094725414166","Mistelbach",null],["094725415133","Emtmannsberg",null],["094725415156","Kirchenpingarten",null],["094725415188","Seybothenreuth",null],["094725415199","Weidenberg, M",null],["094725416127","Creußen, St",null],["094725416146","Haag",null],["094725416180","Prebitz",null],["094725416184","Schnabelwaid, M",null],["094725417118","Betzenstein, St",null],["094725417177","Plech, M",null],["094729451451","Bischofsgrüner Forst",null],["094729453453","Fichtelberg",null],["094729454454","Forst Neustädtlein a.Forst",null],["094729456456","Glashüttener Forst",null],["094729458458","Heinersreuther Forst",null],["094729463463","Neubauer Forst-Nord",null],["094729464464","Prüll",null],["094729468468","Veldensteinerforst",null],["094729469469","Waidacher Forst",null],["094729470470","Warmensteinacher Forst-Nord",null],["094730112112","Ahorn",null],["094730120120","Dörfles-Esbach",null],["094730121121","Ebersdorf b.Coburg",null],["094730132132","Großheirath",null],["094730138138","Itzgrund",null],["094730141141","Lautertal",null],["094730144144","Meeder",null],["094730151151","Neustadt b.Coburg, GKSt",null],["094730158158","Bad Rodach, St",null],["094730159159","Rödental, St",null],["094730165165","Seßlach, St",null],["094730166166","Sonnefeld",null],["094730170170","Untersiemau",null],["094730174174","Weidhausen b.Coburg",null],["094730175175","Weitramsdorf",null],["094735418134","Grub a.Forst",null],["094735418153","Niederfüllbach",null],["094739452452","Callenberger Forst-West",null],["094739453453","Gellnhausen",null],["094739454454","Köllnholz",null],["094740123123","Eggolsheim, M",null],["094740124124","Egloffstein, M",null],["094740126126","Forchheim, GKSt",null],["094740129129","Gößweinstein, M",null],["094740133133","Hallerndorf",null],["094740134134","Hausen",null],["094740135135","Heroldsbach",null],["094740140140","Igensdorf, M",null],["094740146146","Langensendelbach",null],["094740154154","Neunkirchen a.Brand, M",null],["094740156156","Obertrubach",null],["094740161161","Pretzfeld, M",null],["094740176176","Wiesenttal, M",null],["094745420121","Ebermannstadt, St",null],["094745420168","Unterleinleiter",null],["094745422145","Kunreuth",null],["094745422158","Pinzberg",null],["094745422175","Wiesenthau",null],["094745423143","Kirchehrenbach",null],["094745423147","Leutenbach",null],["094745423171","Weilersbach",null],["094745425122","Effeltrich",null],["094745425160","Poxdorf",null],["094745426119","Dormitz",null],["094745426137","Hetzles",null],["094745426144","Kleinsendelbach",null],["094745427132","Gräfenberg, St",null],["094745427138","Hiltpoltstein, M",null],["094745427173","Weißenohe",null],["094750112112","Bad Steben, M",null],["094750113113","Berg",null],["094750120120","Döhlau",null],["094750128128","Geroldsgrün",null],["094750136136","Helmbrechts, St",null],["094750141141","Köditz",null],["094750142142","Konradsreuth",null],["094750154154","Münchberg, St",null],["094750156156","Naila, St",null],["094750158158","Oberkotzau, M",null],["094750161161","Regnitzlosau",null],["094750162162","Rehau, St",null],["094750168168","Schwarzenbach a.d.Saale, St",null],["094750169169","Schwarzenbach a.Wald, St",null],["094750171171","Selbitz, St",null],["094750175175","Stammbach, M",null],["094750189189","Zell im Fichtelgebirge, M",null],["094755428137","Issigau",null],["094755428146","Lichtenberg, St",null],["094755430123","Feilitzsch",null],["094755430127","Gattendorf",null],["094755430181","Töpen",null],["094755430182","Trogen",null],["094755431145","Leupoldsgrün",null],["094755431165","Schauenstein, St",null],["094755432174","Sparneck, M",null],["094755432184","Weißdorf",null],["094759451451","Forst Schwarzenbach a.Wald",null],["094759452452","Gerlaser Forst",null],["094759453453","Geroldsgrüner Forst",null],["094759454454","Martinlamitzer Forst-Nord",null],["094760145145","Kronach, St",null],["094760146146","Küps, M",null],["094760152152","Ludwigsstadt, St",null],["094760159159","Nordhalben, M",null],["094760164164","Pressig, M",null],["094760175175","Steinbach a.Wald",null],["094760177177","Steinwiesen, M",null],["094760178178","Stockheim",null],["094760179179","Tettau, M",null],["094760183183","Marktrodach, M",null],["094760184184","Wallenfels, St",null],["094760185185","Weißenbrunn",null],["094760189189","Wilhelmsthal",null],["094765433166","Reichenbach",null],["094765433180","Teuschnitz, St",null],["094765433182","Tschirn",null],["094765434154","Mitwitz, M",null],["094765434171","Schneckenlohe",null],["094769451451","Birnbaum",null],["094769453453","Langenbacher Forst",null],["094770121121","Himmelkron",null],["094770128128","Kulmbach, GKSt",null],["094770136136","Mainleus, M",null],["094770139139","Marktschorgast, M",null],["094770142142","Neudrossenfeld",null],["094770143143","Neuenmarkt",null],["094770148148","Presseck, M",null],["094770157157","Thurnau, M",null],["094770163163","Wirsberg, M",null],["094775435151","Rugendorf",null],["094775435156","Stadtsteinach, St",null],["094775436117","Grafengehaig, M",null],["094775436138","Marktleugast, M",null],["094775437118","Guttenberg",null],["094775437129","Kupferberg, St",null],["094775437135","Ludwigschorgast, M",null],["094775437159","Untersteinach",null],["094775438124","Kasendorf, M",null],["094775438164","Wonsees, M",null],["094775439119","Harsdorf",null],["094775439127","Ködnitz",null],["094775439158","Trebgast",null],["094780111111","Altenkunstadt",null],["094780116116","Burgkunstadt, St",null],["094780120120","Ebensfeld, M",null],["094780139139","Lichtenfels, St",null],["094780145145","Michelau i.OFr.",null],["094780165165","Bad Staffelstein, St",null],["094780176176","Weismain, St",null],["094785441143","Marktgraitz, M",null],["094785441155","Redwitz a.d.Rodach",null],["094785446127","Hochstadt a.Main",null],["094785446144","Marktzeuln, M",null],["094789451451","Breitengüßbacher Forst",null],["094789453453","Neuensorger Forst",null],["094790112112","Arzberg, St",null],["094790129129","Kirchenlamitz, St",null],["094790135135","Marktleuthen, St",null],["094790136136","Marktredwitz, GKSt",null],["094790145145","Röslau",null],["094790150150","Schönwald, St",null],["094790152152","Selb, GKSt",null],["094790166166","Weißenstadt, St",null],["094790169169","Wunsiedel, St",null],["094795442126","Höchstädt i.Fichtelgebirge",null],["094795442158","Thiersheim, M",null],["094795442159","Thierstein, M",null],["094795443127","Hohenberg a.d.Eger, St",null],["094795443147","Schirnding, M",null],["094795444111","Bad Alexandersbad",null],["094795444138","Nagel",null],["094795444161","Tröstau",null],["094799453453","Kaiserhammer Forst-Ost",null],["094799455455","Martinlamitzer Forst-Süd",null],["094799456456","Meierhöfer Seite",null],["094799457457","Neubauer Forst-Süd",null],["094799459459","Tröstauer Forst-Ost",null],["094799460460","Tröstauer Forst-West",null],["094799461461","Vordorfer Forst",null],["094799462462","Weißenstadter Forst-Nord",null],["094799463463","Weißenstadter Forst-Süd",null],["095610000000","Ansbach",null],["095620000000","Erlangen",null],["095630000000","Fürth",null],["095640000000","Nürnberg",null],["095650000000","Schwabach",null],["095710113113","Arberg, M",null],["095710114114","Aurach",null],["095710115115","Bechhofen, M",null],["095710127127","Burgoberbach",null],["095710130130","Colmberg, M",null],["095710135135","Dietenhofen, M",null],["095710136136","Dinkelsbühl, GKSt",null],["095710139139","Dürrwangen, M",null],["095710145145","Feuchtwangen, St",null],["095710146146","Flachslanden, M",null],["095710165165","Heilsbronn, St",null],["095710166166","Herrieden, St",null],["095710170170","Langfurth",null],["095710171171","Lehrberg, M",null],["095710174174","Leutershausen, St",null],["095710175175","Lichtenau, M",null],["095710177177","Merkendorf, St",null],["095710180180","Neuendettelsau",null],["095710183183","Oberdachstetten",null],["095710190190","Petersaurach",null],["095710193193","Rothenburg ob der Tauber, GKSt",null],["095710196196","Sachsen b.Ansbach",null],["095710199199","Schnelldorf",null],["095710200200","Schopfloch, M",null],["095710214214","Wassertrüdingen, St",null],["095710226226","Windsbach, St",null],["095715501111","Adelshofen",null],["095715501152","Gebsattel",null],["095715501155","Geslau",null],["095715501169","Insingen",null],["095715501181","Neusitz",null],["095715501188","Ohrenbach",null],["095715501205","Steinsfeld",null],["095715501225","Windelsbach",null],["095715502125","Buch a.Wald",null],["095715502134","Diebach",null],["095715502137","Dombühl, M",null],["095715502198","Schillingsfürst, St",null],["095715502222","Wettringen",null],["095715502228","Wörnitz",null],["095715504122","Bruckberg",null],["095715504194","Rügland",null],["095715504217","Weihenzell",null],["095715506189","Ornbau, St",null],["095715506216","Weidenbach, M",null],["095715507128","Burk",null],["095715507132","Dentlein a.Forst, M",null],["095715507223","Wieseth",null],["095715508179","Mönchsroth",null],["095715508218","Weiltingen, M",null],["095715508224","Wilburgstetten",null],["095715509141","Ehingen",null],["095715509154","Gerolfingen",null],["095715509192","Röckingen",null],["095715509208","Unterschwaningen",null],["095715509227","Wittelshofen",null],["095715538178","Mitteleschenbach",null],["095715538229","Wolframs-Eschenbach, St",null],["095719451451","Unterer Wald",null],["095720111111","Adelsdorf",null],["095720115115","Baiersdorf, St",null],["095720119119","Bubenreuth",null],["095720121121","Eckental, M",null],["095720130130","Hemhofen",null],["095720131131","Heroldsberg, M",null],["095720132132","Herzogenaurach, St",null],["095720135135","Höchstadt a.d.Aisch, St",null],["095720137137","Kalchreuth",null],["095720142142","Möhrendorf",null],["095720149149","Röttenbach",null],["095720160160","Wachenroth, M",null],["095720164164","Weisendorf, M",null],["095725510126","Gremsdorf",null],["095725510139","Lonnerstadt, M",null],["095725510143","Mühlhausen, M",null],["095725510159","Vestenbergsgreuth, M",null],["095725512114","Aurachtal",null],["095725512147","Oberreichenbach",null],["095725514120","Buckenhof",null],["095725514141","Marloffstein",null],["095725514154","Spardorf",null],["095725514158","Uttenreuth",null],["095725539127","Großenseebach",null],["095725539133","Heßdorf",null],["095729451451","Birkach",null],["095729452452","Buckenhofer Forst",null],["095729453453","Dormitzer Forst",null],["095729454454","Erlenstegener Forst",null],["095729455455","Forst Tennenlohe",null],["095729456456","Geschaidt",null],["095729457457","Kalchreuther Forst",null],["095729458458","Kraftshofer Forst",null],["095729459459","Mark",null],["095729460460","Neunhofer Forst",null],["095730111111","Ammerndorf, M",null],["095730114114","Cadolzburg, M",null],["095730115115","Großhabersdorf",null],["095730120120","Langenzenn, St",null],["095730122122","Oberasbach, St",null],["095730124124","Puschendorf",null],["095730125125","Roßtal, M",null],["095730127127","Stein, St",null],["095730133133","Wilhermsdorf, M",null],["095730134134","Zirndorf, St",null],["095735517126","Seukendorf",null],["095735517130","Veitsbronn",null],["095735540123","Obermichelbach",null],["095735540129","Tuchenbach",null],["095740112112","Altdorf b.Nürnberg, St",null],["095740117117","Burgthann",null],["095740123123","Feucht, M",null],["095740132132","Hersbruck, St",null],["095740135135","Kirchensittenbach",null],["095740138138","Lauf a.d.Pegnitz, St",null],["095740139139","Leinburg",null],["095740140140","Neuhaus a.d.Pegnitz, M",null],["095740141141","Neunkirchen a.Sand",null],["095740146146","Ottensoos",null],["095740147147","Pommelsbrunn",null],["095740150150","Reichenschwand",null],["095740152152","Röthenbach a.d.Pegnitz, St",null],["095740154154","Rückersdorf",null],["095740155155","Schnaittach, M",null],["095740156156","Schwaig b.Nürnberg",null],["095740157157","Schwarzenbruck",null],["095740158158","Simmelsdorf",null],["095740164164","Winkelhaid",null],["095745527129","Hartenstein",null],["095745527160","Velden, St",null],["095745527161","Vorra",null],["095745528111","Alfeld",null],["095745528128","Happurg",null],["095745529120","Engelthal",null],["095745529131","Henfenfeld",null],["095745529145","Offenhausen",null],["095749451451","Behringersdorfer Forst",null],["095749452452","Brunn",null],["095749453453","Engelthaler Forst",null],["095749454454","Feuchter Forst",null],["095749455455","Fischbach",null],["095749456456","Forsthof",null],["095749457457","Günthersbühler Forst",null],["095749458458","Haimendorfer Forst",null],["095749460460","Laufamholzer Forst",null],["095749461461","Leinburg",null],["095749462462","Rückersdorfer Forst",null],["095749463463","Schönberg",null],["095749464464","Winkelhaid",null],["095749465465","Zerzabelshofer Forst",null],["095750112112","Bad Windsheim, St",null],["095750116116","Burghaslach, M",null],["095750119119","Dietersheim",null],["095750121121","Emskirchen, M",null],["095750135135","Ipsheim, M",null],["095750145145","Markt Erlbach, M",null],["095750153153","Neustadt a.d.Aisch, St",null],["095750156156","Obernzenn, M",null],["095755518138","Langenfeld",null],["095755518144","Markt Bibart, M",null],["095755518147","Markt Taschendorf, M",null],["095755518157","Oberscheinfeld, M",null],["095755518161","Scheinfeld, St",null],["095755518165","Sugenheim, M",null],["095755519122","Ergersheim",null],["095755519127","Gollhofen",null],["095755519130","Hemmersheim",null],["095755519134","Ippesheim, M",null],["095755519146","Markt Nordheim, M",null],["095755519155","Oberickelsheim",null],["095755519163","Simmershofen",null],["095755519168","Uffenheim, St",null],["095755519179","Weigenheim",null],["095755520129","Hagenbüchach",null],["095755520181","Wilhelmsdorf",null],["095755521113","Baudenbach, M",null],["095755521118","Diespeck",null],["095755521128","Gutenstetten",null],["095755521150","Münchsteinach",null],["095755522117","Dachsbach, M",null],["095755522125","Gerhardshofen",null],["095755522167","Uehlfeld, M",null],["095755524115","Burgbernheim, St",null],["095755524124","Gallmersgarten",null],["095755524133","Illesheim",null],["095755524143","Marktbergel, M",null],["095755525152","Neuhof a.d.Zenn, M",null],["095755525166","Trautskirchen",null],["095759451451","Osing",null],["095760111111","Abenberg, St",null],["095760113113","Allersberg, M",null],["095760117117","Büchenbach",null],["095760121121","Georgensgmünd",null],["095760122122","Greding, St",null],["095760126126","Heideck, St",null],["095760127127","Hilpoltstein, St",null],["095760128128","Kammerstein",null],["095760132132","Schwanstetten, M",null],["095760137137","Rednitzhembach",null],["095760141141","Röttenbach",null],["095760142142","Rohr",null],["095760143143","Roth, St",null],["095760147147","Spalt, St",null],["095760148148","Thalmässing, M",null],["095760151151","Wendelstein, M",null],["095769451451","Abenberger Wald",null],["095769452452","Dechenwald",null],["095769453453","Forst Kleinschwarzenlohe",null],["095769454454","Heidenberg",null],["095769455455","Soos",null],["095770114114","Muhr a.See",null],["095770136136","Gunzenhausen, St",null],["095770148148","Langenaltheim",null],["095770158158","Pappenheim, St",null],["095770161161","Pleinfeld, M",null],["095770162162","Polsingen",null],["095770168168","Solnhofen",null],["095770173173","Treuchtlingen, St",null],["095770177177","Weißenburg i.Bay., GKSt",null],["095775532111","Absberg, M",null],["095775532138","Haundorf",null],["095775532159","Pfofeld",null],["095775532172","Theilenhofen",null],["095775533113","Alesheim",null],["095775533122","Dittenheim",null],["095775533149","Markt Berolzheim, M",null],["095775533150","Meinheim",null],["095775534125","Ellingen, St",null],["095775534127","Ettenstatt",null],["095775534141","Höttingen",null],["095775535115","Bergen",null],["095775535120","Burgsalach",null],["095775535151","Nennslingen, M",null],["095775535163","Raitenbuch",null],["095775536133","Gnotzheim, M",null],["095775536140","Heidenheim, M",null],["095775536179","Westheim",null],["096610000000","Aschaffenburg",null],["096620000000","Schweinfurt",null],["096630000000","Würzburg",null],["096710111111","Alzenau, St",null],["096710112112","Bessenbach",null],["096710114114","Karlstein a.Main",null],["096710119119","Geiselbach",null],["096710120120","Glattbach",null],["096710121121","Goldbach, M",null],["096710122122","Großostheim, M",null],["096710124124","Haibach",null],["096710130130","Hösbach, M",null],["096710133133","Johannesberg",null],["096710134134","Kahl a.Main",null],["096710136136","Kleinostheim",null],["096710139139","Laufach",null],["096710140140","Mainaschaff",null],["096710143143","Mömbris, M",null],["096710148148","Rothenbuch",null],["096710150150","Sailauf",null],["096710155155","Stockstadt a.Main, M",null],["096710156156","Waldaschaff",null],["096710157157","Weibersbrunn",null],["096715602126","Heigenbrücken",null],["096715602128","Heinrichsthal",null],["096715603127","Heimbuchenthal",null],["096715603141","Mespelbrunn",null],["096715603160","Dammbach",null],["096715604113","Blankenbach",null],["096715604135","Kleinkahl",null],["096715604138","Krombach",null],["096715604152","Schöllkrippen, M",null],["096715604153","Sommerkahl",null],["096715604159","Westerngrund",null],["096715604162","Wiesen",null],["096719451451","Forst Hain i.Spessart",null],["096719453453","Heinrichsthaler Forst",null],["096719456456","Rohrbrunner Forst",null],["096719457457","Rothenbucher Forst",null],["096719458458","Sailaufer Forst",null],["096719459459","Schöllkrippener Forst",null],["096719460460","Waldaschaffer Forst",null],["096719461461","Wiesener Forst",null],["096720112112","Bad Bocklet, M",null],["096720113113","Bad Brückenau, St",null],["096720114114","Bad Kissingen, GKSt",null],["096720117117","Burkardroth, M",null],["096720127127","Hammelburg, St",null],["096720134134","Motten",null],["096720135135","Münnerstadt, St",null],["096720136136","Nüdlingen",null],["096720139139","Oberthulba, M",null],["096720140140","Oerlenbach",null],["096720161161","Wartmannsroth",null],["096720163163","Wildflecken, M",null],["096720166166","Zeitlofs, M",null],["096725606126","Geroda, M",null],["096725606138","Oberleichtersbach",null],["096725606145","Riedenberg",null],["096725606149","Schondra, M",null],["096725607121","Elfershausen, M",null],["096725607124","Fuchsstadt",null],["096725608111","Aura a.d.Saale",null],["096725608122","Euerdorf, M",null],["096725608142","Ramsthal",null],["096725608155","Sulzthal, M",null],["096725609131","Maßbach, M",null],["096725609143","Rannungen",null],["096725609157","Thundorf i.UFr.",null],["096729451451","Dreistelzer Forst",null],["096729454454","Forst Detter-Süd",null],["096729455455","Geiersnest-Ost",null],["096729456456","Geiersnest-West",null],["096729457457","Großer Auersberg",null],["096729458458","Kälberberg",null],["096729461461","Mottener Forst-Süd",null],["096729462462","Neuwirtshauser Forst",null],["096729463463","Omerz u. Roter Berg",null],["096729464464","Römershager Forst-Nord",null],["096729465465","Römershager Forst-Ost",null],["096729466466","Roßbacher Forst",null],["096729468468","Waldfensterer Forst",null],["096730114114","Bad Neustadt a.d.Saale, St",null],["096730116116","Bastheim",null],["096730117117","Bischofsheim i.d.Rhön, St",null],["096730141141","Bad Königshofen i.Grabfeld, St",null],["096730149149","Oberelsbach, M",null],["096730162162","Sandberg",null],["096735633130","Hendungen",null],["096735633142","Mellrichstadt, St",null],["096735633151","Oberstreu",null],["096735633170","Stockheim",null],["096735634113","Aubstadt",null],["096735634126","Großbardorf",null],["096735634131","Herbstadt",null],["096735634134","Höchheim",null],["096735634172","Sulzdorf a.d.Lederhecke",null],["096735634173","Sulzfeld",null],["096735634174","Trappstadt, M",null],["096735635135","Hohenroth",null],["096735635146","Niederlauer",null],["096735635156","Rödelmaier",null],["096735635161","Salz",null],["096735635163","Schönau a.d.Brend",null],["096735635171","Strahlungen",null],["096735635186","Burglauer",null],["096735637123","Fladungen, St",null],["096735637129","Hausen",null],["096735637147","Nordheim v.d.Rhön",null],["096735638133","Heustreu",null],["096735638136","Hollstadt",null],["096735638175","Unsleben",null],["096735638183","Wollbach",null],["096735639153","Ostheim v.d.Rhön, St",null],["096735639167","Sondheim v.d.Rhön",null],["096735639182","Willmars",null],["096735640127","Großeibstadt",null],["096735640160","Saal a.d.Saale, M",null],["096735640184","Wülfershausen a.d.Saale",null],["096739451451","Bundorfer Forst",null],["096739452452","Burgwallbacher Forst",null],["096739453453","Forst Schmalwasser-Nord",null],["096739454454","Forst Schmalwasser-Süd",null],["096739455455","Mellrichstadter Forst",null],["096739456456","Steinacher Forst r.d.Saale",null],["096739457457","Sulzfelder Forst",null],["096739458458","Weigler",null],["096740133133","Eltmann, St",null],["096740147147","Haßfurt, St",null],["096740159159","Oberaurach",null],["096740163163","Knetzgau",null],["096740164164","Königsberg i.Bay., St",null],["096740171171","Maroldsweisach, M",null],["096740187187","Rauhenebrach",null],["096740195195","Sand a.Main",null],["096740210210","Untermerzbach",null],["096740221221","Zeil a.Main, St",null],["096745610118","Breitbrunn",null],["096745610129","Ebelsbach",null],["096745610160","Kirchlauter",null],["096745610201","Stettfeld",null],["096745611130","Ebern, St",null],["096745611184","Pfarrweisach",null],["096745611190","Rentweinsdorf, M",null],["096745612111","Aidhausen",null],["096745612120","Bundorf",null],["096745612121","Burgpreppach, M",null],["096745612149","Hofheim i.UFr., St",null],["096745612153","Riedbach",null],["096745612223","Ermershausen",null],["096745613139","Gädheim",null],["096745613180","Theres",null],["096745613219","Wonfurt",null],["096750117117","Dettelbach, St",null],["096750127127","Geiselwind, M",null],["096750141141","Kitzingen, GKSt",null],["096750144144","Mainbernheim, St",null],["096750158158","Prichsenstadt, St",null],["096750165165","Schwarzach a.Main, M",null],["096755614111","Abtswind, M",null],["096755614116","Castell",null],["096755614162","Rüdenhausen, M",null],["096755614178","Wiesentheid, M",null],["096755615131","Großlangheim, M",null],["096755615142","Kleinlangheim, M",null],["096755615177","Wiesenbronn",null],["096755616139","Iphofen, St",null],["096755616148","Markt Einersheim, M",null],["096755616161","Rödelsee",null],["096755616179","Willanzheim, M",null],["096755617112","Albertshofen",null],["096755617113","Biebelried",null],["096755617114","Buchbrunn",null],["096755617146","Mainstockheim",null],["096755617170","Sulzfeld a.Main",null],["096755618147","Marktbreit, St",null],["096755618149","Marktsteft, St",null],["096755618150","Martinsheim",null],["096755618156","Obernbreit, M",null],["096755618166","Segnitz",null],["096755618167","Seinsheim, M",null],["096755619155","Nordheim a.Main",null],["096755619169","Sommerach",null],["096755619174","Volkach, St",null],["096760112112","Amorbach, St",null],["096760117117","Collenberg",null],["096760118118","Dorfprozelten",null],["096760119119","Eichenbühl",null],["096760121121","Elsenfeld, M",null],["096760122122","Erlenbach a.Main, St",null],["096760123123","Eschau, M",null],["096760124124","Faulbach",null],["096760125125","Großheubach, M",null],["096760126126","Großwallstadt",null],["096760131131","Kirchzell, M",null],["096760134134","Klingenberg a.Main, St",null],["096760136136","Leidersbach",null],["096760139139","Miltenberg, St",null],["096760140140","Mömlingen",null],["096760144144","Niedernberg",null],["096760145145","Obernburg a.Main, St",null],["096760156156","Schneeberg, M",null],["096760160160","Sulzbach a.Main, M",null],["096760165165","Weilbach, M",null],["096760169169","Wörth a.Main, St",null],["096765626116","Bürgstadt, M",null],["096765626143","Neunkirchen",null],["096765627132","Kleinheubach, M",null],["096765627135","Laudenbach",null],["096765627153","Rüdenau",null],["096765630128","Hausen",null],["096765630133","Kleinwallstadt, M",null],["096765631141","Mönchberg, M",null],["096765631151","Röllbach",null],["096765632111","Altenbuch",null],["096765632158","Stadtprozelten, St",null],["096769452452","Forstwald",null],["096769455455","Hohe Wart",null],["096770114114","Arnstein, St",null],["096770127127","Eußenheim",null],["096770129129","Frammersbach, M",null],["096770131131","Gemünden a.Main, St",null],["096770148148","Karlstadt, St",null],["096770154154","Triefenstein, M",null],["096770155155","Lohr a.Main, St",null],["096770157157","Marktheidenfeld, St",null],["096770177177","Rieneck, St",null],["096775620137","Hasloch",null],["096775620151","Kreuzwertheim, M",null],["096775620182","Schollbrunn",null],["096775621119","Birkenfeld",null],["096775621120","Bischbrunn",null],["096775621125","Erlenbach b.Marktheidenfeld",null],["096775621126","Esselbach",null],["096775621135","Hafenlohr",null],["096775621146","Karbach, M",null],["096775621178","Roden",null],["096775621181","Rothenfels, St",null],["096775621193","Urspringen",null],["096775622116","Aura i.Sinngrund",null],["096775622122","Burgsinn, M",null],["096775622128","Fellen",null],["096775622159","Mittelsinn",null],["096775622169","Obersinn, M",null],["096775623132","Gössenheim",null],["096775623133","Gräfendorf",null],["096775623149","Karsbach",null],["096775624164","Neuendorf",null],["096775624166","Neustadt a.Main",null],["096775624172","Rechtenbach",null],["096775624186","Steinfeld",null],["096775625142","Himmelstadt",null],["096775625175","Retzstadt",null],["096775625189","Thüngen, M",null],["096775625203","Zellingen, M",null],["096775656165","Neuhütten",null],["096775656170","Partenstein",null],["096775656200","Wiesthal",null],["096779452452","Burgjoß",null],["096779453453","Forst Aura",null],["096779454454","Forst Lohrerstraße",null],["096779455455","Frammersbacher Forst",null],["096779456456","Fürstl. Löwenstein'scher Park",null],["096779457457","Haurain",null],["096779458458","Herrnwald",null],["096779459459","Langenprozeltener Forst",null],["096779461461","Partensteiner Forst",null],["096779463463","Ruppertshüttener Forst",null],["096780115115","Bergrheinfeld",null],["096780123123","Dittelbrunn",null],["096780128128","Euerbach",null],["096780132132","Geldersheim",null],["096780135135","Gochsheim",null],["096780136136","Grafenrheinfeld",null],["096780138138","Grettstadt",null],["096780150150","Kolitzheim",null],["096780160160","Niederwerrn",null],["096780168168","Poppenhausen",null],["096780170170","Röthlein",null],["096780174174","Schonungen",null],["096780176176","Schwebheim",null],["096780178178","Sennfeld",null],["096780181181","Stadtlauringen, M",null],["096780186186","Üchtelhausen",null],["096780190190","Waigolshausen",null],["096780192192","Wasserlosen",null],["096780193193","Werneck, M",null],["096785642122","Dingolshausen",null],["096785642124","Donnersdorf",null],["096785642130","Frankenwinheim",null],["096785642134","Gerolzhofen, St",null],["096785642153","Lülsfeld",null],["096785642157","Michelau i.Steigerwald",null],["096785642164","Oberschwarzach, M",null],["096785642183","Sulzheim",null],["096785643175","Schwanfeld",null],["096785643196","Wipfeld",null],["096789451451","Bürgerwald",null],["096789452452","Geiersberg",null],["096789453453","Hundelshausen",null],["096789454454","Nonnenkloster",null],["096789455455","Stollbergerforst",null],["096789456456","Vollburg",null],["096789457457","Wustvieler Forst",null],["096790126126","Eisingen",null],["096790134134","Gaukönigshofen",null],["096790136136","Gerbrunn",null],["096790142142","Güntersleben",null],["096790143143","Hausen b.Würzburg",null],["096790147147","Höchberg, M",null],["096790155155","Kleinrinderfeld",null],["096790156156","Kürnach",null],["096790164164","Neubrunn, M",null],["096790170170","Ochsenfurt, St",null],["096790175175","Randersacker, M",null],["096790176176","Reichenberg, M",null],["096790180180","Rimpar, M",null],["096790185185","Rottendorf",null],["096790193193","Theilheim",null],["096790194194","Thüngersheim",null],["096790200200","Leinach",null],["096790201201","Unterpleichfeld",null],["096790202202","Veitshöchheim",null],["096790204204","Waldbrunn",null],["096790205205","Waldbüttelbrunn",null],["096790209209","Zell a.Main, M",null],["096795644114","Aub, St",null],["096795644135","Gelchsheim, M",null],["096795644188","Sonderhofen",null],["096795645117","Bergtheim",null],["096795645169","Oberpleichfeld",null],["096795646124","Eibelstadt, St",null],["096795646131","Frickenhausen a.Main, M",null],["096795646187","Sommerhausen, M",null],["096795646206","Winterhausen, M",null],["096795647130","Estenfeld",null],["096795647167","Eisenheim, M",null],["096795647174","Prosselsheim",null],["096795648122","Bütthard, M",null],["096795648138","Giebelstadt, M",null],["096795649144","Helmstadt, M",null],["096795649149","Holzkirchen",null],["096795649177","Remlingen, M",null],["096795649196","Uettingen",null],["096795650137","Geroldshausen",null],["096795650153","Kirchheim",null],["096795651154","Kist",null],["096795651165","Altertheim",null],["096795652128","Erlabrunn",null],["096795652161","Margetshöchheim",null],["096795654118","Bieberehren",null],["096795654179","Riedenheim",null],["096795654182","Röttingen, St",null],["096795654192","Tauberrettersheim",null],["096795655141","Greußenheim",null],["096795655146","Hettstadt",null],["096799451451","Gramschatzer Wald",null],["096799452452","Guttenberger Wald",null],["096799453453","Irtenberger Wald",null],["097610000000","Augsburg",null],["097620000000","Kaufbeuren",null],["097630000000","Kempten (Allgäu)",null],["097640000000","Memmingen",null],["097710112112","Affing",null],["097710113113","Aichach, St",null],["097710130130","Friedberg, St",null],["097710140140","Hollenbach",null],["097710141141","Inchenhofen, M",null],["097710142142","Kissing",null],["097710145145","Merching",null],["097710158158","Rehling",null],["097710160160","Ried",null],["097715701114","Aindling, M",null],["097715701155","Petersdorf",null],["097715701169","Todtenweis",null],["097715703144","Kühbach, M",null],["097715703162","Schiltberg",null],["097715704111","Adelzhausen",null],["097715704122","Dasing",null],["097715704129","Eurasburg",null],["097715704149","Obergriesbach",null],["097715704165","Sielenbach",null],["097715705146","Mering, M",null],["097715705163","Schmiechen",null],["097715705168","Steindorf",null],["097715771156","Pöttmes, M",null],["097715771176","Baar (Schwaben)",null],["097720111111","Adelsried",null],["097720115115","Altenmünster",null],["097720117117","Aystetten",null],["097720121121","Biberbach, M",null],["097720125125","Bobingen, St",null],["097720130130","Diedorf, M",null],["097720131131","Dinkelscherben, M",null],["097720141141","Fischach, M",null],["097720145145","Gablingen",null],["097720147147","Gersthofen, St",null],["097720149149","Graben",null],["097720159159","Horgau",null],["097720163163","Königsbrunn, St",null],["097720167167","Kutzenhausen",null],["097720171171","Langweid a.Lech",null],["097720177177","Meitingen, M",null],["097720184184","Neusäß, St",null],["097720200200","Schwabmünchen, St",null],["097720202202","Stadtbergen, St",null],["097720207207","Thierhaupten, M",null],["097720215215","Wehringen",null],["097720223223","Zusmarshausen, M",null],["097725706114","Allmannshofen",null],["097725706134","Ehingen",null],["097725706136","Ellgau",null],["097725706166","Kühlenthal",null],["097725706185","Nordendorf",null],["097725706217","Westendorf",null],["097725707126","Bonstetten",null],["097725707137","Emersacker",null],["097725707156","Heretsried",null],["097725707216","Welden, M",null],["097725708148","Gessertshausen",null],["097725708211","Ustersbach",null],["097725709168","Langenneufnach",null],["097725709178","Mickhausen",null],["097725709179","Mittelneufnach",null],["097725709197","Scherstetten",null],["097725709214","Walkertshofen",null],["097725710151","Großaitingen",null],["097725710160","Kleinaitingen",null],["097725710186","Oberottmarshausen",null],["097725711162","Klosterlechfeld",null],["097725711209","Untermeitingen",null],["097725712157","Hiltenfingen",null],["097725712170","Langerringen",null],["097729451451","Schmellerforst",null],["097730117117","Bissingen, M",null],["097730122122","Buttenwiesen",null],["097730125125","Dillingen a.d.Donau, GKSt",null],["097730144144","Lauingen (Donau), St",null],["097735713113","Bächingen a.d.Brenz",null],["097735713136","Gundelfingen a.d.Donau, St",null],["097735713137","Haunsheim",null],["097735713153","Medlingen",null],["097735714112","Bachhagel",null],["097735714170","Syrgenstein",null],["097735714187","Zöschingen",null],["097735715147","Mödingen",null],["097735715183","Wittislingen, M",null],["097735715186","Ziertheim",null],["097735716119","Blindheim",null],["097735716139","Höchstädt a.d.Donau, St",null],["097735716146","Lutzingen",null],["097735716150","Finningen",null],["097735716164","Schwenningen",null],["097735718116","Binswangen",null],["097735718143","Laugna",null],["097735718179","Villenbach",null],["097735718182","Wertingen, St",null],["097735718188","Zusamaltheim",null],["097735719111","Aislingen, M",null],["097735719133","Glött",null],["097735719140","Holzheim",null],["097740116116","Ursberg",null],["097740119119","Bibertal",null],["097740121121","Burgau, St",null],["097740122122","Burtenbach, M",null],["097740135135","Günzburg, GKSt",null],["097740144144","Jettingen-Scheppach, M",null],["097740145145","Kammeltal",null],["097740150150","Krumbach (Schwaben), St",null],["097740155155","Leipheim, St",null],["097740162162","Neuburg a.d.Kammel, M",null],["097745727136","Gundremmingen",null],["097745727171","Offingen, M",null],["097745727174","Rettenbach",null],["097745728127","Dürrlauingen",null],["097745728140","Haldenwang",null],["097745728151","Landensberg",null],["097745728178","Röfingen",null],["097745728196","Winterbach",null],["097745729118","Bubesheim",null],["097745729148","Kötz",null],["097745730133","Ellzee",null],["097745730143","Ichenhausen, St",null],["097745730191","Waldstetten, M",null],["097745731111","Aletshausen",null],["097745731117","Breitenthal",null],["097745731124","Deisenhausen",null],["097745731129","Ebershausen",null],["097745731189","Wiesenbach",null],["097745731192","Waltenhausen",null],["097745732115","Balzhausen",null],["097745732160","Münsterhausen, M",null],["097745732185","Thannhausen, St",null],["097745733166","Aichen",null],["097745733198","Ziemetshausen, M",null],["097749451451","Ebershauser-Nattenhauser Wald",null],["097749452452","Winzerwald",null],["097750115115","Bellenberg",null],["097750129129","Illertissen, St",null],["097750134134","Nersingen",null],["097750135135","Neu-Ulm, GKSt",null],["097750139139","Elchingen",null],["097750149149","Roggenburg",null],["097750152152","Senden, St",null],["097750162162","Vöhringen, St",null],["097750164164","Weißenhorn, St",null],["097755739126","Holzheim",null],["097755739143","Pfaffenhofen a.d.Roth, M",null],["097755740111","Altenstadt, M",null],["097755740132","Kellmünz a.d.Iller, M",null],["097755740142","Osterberg",null],["097755741118","Buch, M",null],["097755741141","Oberroth",null],["097755741161","Unterroth",null],["097759451451","Auwald",null],["097759452452","Oberroggenburger Wald",null],["097759454454","Stoffenrieder Forst",null],["097759455455","Unterroggenburger Wald",null],["097760111111","Bodolz",null],["097760114114","Heimenkirch, M",null],["097760116116","Lindau (Bodensee), GKSt",null],["097760117117","Lindenberg i.Allgäu, St",null],["097760120120","Nonnenhorn",null],["097760122122","Opfenbach",null],["097760125125","Scheidegg, M",null],["097760128128","Wasserburg (Bodensee)",null],["097760129129","Weiler-Simmerberg, M",null],["097760131131","Hergatz",null],["097765735115","Hergensweiler",null],["097765735126","Sigmarszell",null],["097765735130","Weißensberg",null],["097765737112","Gestratz",null],["097765737113","Grünenbach",null],["097765737118","Maierhöfen",null],["097765737124","Röthenbach (Allgäu)",null],["097765738121","Oberreute",null],["097765738127","Stiefenhofen",null],["097770129129","Füssen, St",null],["097770130130","Germaringen",null],["097770147147","Lechbruck am See",null],["097770151151","Marktoberdorf, St",null],["097770152152","Mauerstetten",null],["097770153153","Nesselwang, M",null],["097770159159","Pfronten",null],["097770165165","Ronsberg, M",null],["097770169169","Schwangau",null],["097770173173","Halblech",null],["097775748121","Buchloe, St",null],["097775748140","Jengen",null],["097775748145","Lamerdingen",null],["097775748177","Waal, M",null],["097775749139","Irsee, M",null],["097775749158","Pforzen",null],["097775749164","Rieden",null],["097775751141","Kaltental, M",null],["097775751155","Oberostendorf",null],["097775751157","Osterzell",null],["097775751172","Stöttwang",null],["097775751182","Westendorf",null],["097775752111","Aitrang",null],["097775752112","Biessenhofen",null],["097775752118","Bidingen",null],["097775752167","Ruderatshofen",null],["097775753114","Baisweil",null],["097775753124","Eggenthal",null],["097775753128","Friesenried",null],["097775754138","Günzach",null],["097775754154","Obergünzburg, M",null],["097775754176","Untrasried",null],["097775755131","Görisried",null],["097775755144","Kraftisried",null],["097775755175","Unterthingau, M",null],["097775756125","Eisenberg",null],["097775756135","Hopferau",null],["097775756149","Lengenwang",null],["097775756168","Rückholz",null],["097775756170","Seeg",null],["097775756179","Wald",null],["097775770163","Rieden am Forggensee",null],["097775770166","Roßhaupten",null],["097775772171","Stötten a.Auerberg",null],["097775772183","Rettenbach a.Auerberg",null],["097780116116","Bad Wörishofen, St",null],["097780123123","Buxheim",null],["097780137137","Ettringen",null],["097780168168","Markt Rettenbach, M",null],["097780169169","Markt Wald, M",null],["097780173173","Mindelheim, St",null],["097780196196","Sontheim",null],["097780204204","Tussenhausen, M",null],["097785757119","Böhen",null],["097785757149","Hawangen",null],["097785757186","Ottobeuren, M",null],["097785758115","Babenhausen, M",null],["097785758130","Egg a.d.Günz",null],["097785758157","Kirchhaslach",null],["097785758184","Oberschönegg",null],["097785758217","Winterrieden",null],["097785758221","Kettershausen",null],["097785759121","Breitenbrunn",null],["097785759183","Oberrieden",null],["097785759187","Pfaffenhausen, M",null],["097785759190","Salgen",null],["097785760134","Eppishausen",null],["097785760158","Kirchheim i.Schw., M",null],["097785761120","Boos",null],["097785761139","Fellheim",null],["097785761150","Heimertingen",null],["097785761177","Niederrieden",null],["097785761188","Pleß",null],["097785762136","Erkheim, M",null],["097785762163","Lauben",null],["097785762180","Kammlach",null],["097785762214","Westerheim",null],["097785764111","Amberg",null],["097785764203","Türkheim, M",null],["097785764209","Rammingen",null],["097785764216","Wiedergeltingen",null],["097785765118","Benningen",null],["097785765151","Holzgünz",null],["097785765162","Lachen",null],["097785765171","Memmingerberg",null],["097785765202","Trunkelsberg",null],["097785765205","Ungerhausen",null],["097785766113","Apfeltrach",null],["097785766127","Dirlewang, M",null],["097785766199","Stetten",null],["097785766207","Unteregg",null],["097785767161","Kronburg",null],["097785767164","Lautrach",null],["097785767165","Legau, M",null],["097785768144","Bad Grönenbach, M",null],["097785768218","Wolfertschwenden",null],["097785768219","Woringen",null],["097789451451","Ungerhauser Wald",null],["097790115115","Asbach-Bäumenheim",null],["097790131131","Donauwörth, GKSt",null],["097790147147","Fremdingen",null],["097790155155","Harburg (Schwaben), St",null],["097790169169","Kaisheim, M",null],["097790178178","Marxheim",null],["097790181181","Mertingen",null],["097790185185","Möttingen",null],["097790194194","Nördlingen, GKSt",null],["097790196196","Oberndorf a.Lech",null],["097790218218","Tapfheim",null],["097795720176","Maihingen",null],["097795720177","Marktoffingen",null],["097795720224","Wallerstein, M",null],["097795721117","Auhausen",null],["097795721138","Ehingen a.Ries",null],["097795721154","Hainsfarth",null],["097795721180","Megesheim",null],["097795721188","Munningen",null],["097795721197","Oettingen i.Bay., St",null],["097795722111","Alerheim",null],["097795722112","Amerdingen",null],["097795722130","Deiningen",null],["097795722136","Ederheim",null],["097795722146","Forheim",null],["097795722162","Hohenaltheim",null],["097795722184","Mönchsdeggingen",null],["097795722203","Reimlingen",null],["097795722226","Wechingen",null],["097795723148","Fünfstetten",null],["097795723167","Huisheim",null],["097795723198","Otting",null],["097795723228","Wemding, St",null],["097795723231","Wolferstadt",null],["097795724126","Buchdorf",null],["097795724129","Daiting",null],["097795724186","Monheim, St",null],["097795724206","Rögling",null],["097795724217","Tagmersheim",null],["097795725149","Genderkingen",null],["097795725163","Holzheim",null],["097795725187","Münster",null],["097795725192","Niederschönenfeld",null],["097795725201","Rain, St",null],["097799452452","Dornstadt-Linkersbaindt",null],["097799453453","Esterholz",null],["097800112112","Altusried, M",null],["097800114114","Betzigau",null],["097800115115","Blaichach",null],["097800117117","Buchenberg, M",null],["097800118118","Burgberg i.Allgäu",null],["097800119119","Dietmannsried, M",null],["097800120120","Durach",null],["097800122122","Haldenwang",null],["097800123123","Bad Hindelang, M",null],["097800124124","Immenstadt i.Allgäu, St",null],["097800125125","Lauben",null],["097800128128","Oy-Mittelberg",null],["097800132132","Oberstaufen, M",null],["097800133133","Oberstdorf, M",null],["097800137137","Rettenberg",null],["097800139139","Sonthofen, St",null],["097800140140","Sulzberg, M",null],["097800143143","Waltenhofen",null],["097800145145","Wertach, M",null],["097800146146","Wiggensbach, M",null],["097800147147","Wildpoldsried",null],["097805742113","Balderschwang",null],["097805742116","Bolsterlang",null],["097805742121","Fischen i.Allgäu",null],["097805742131","Obermaiselstein",null],["097805742134","Ofterschwang",null],["097805745127","Missen-Wilhams",null],["097805745144","Weitnau, M",null],["097809451451","Kempter Wald",null],["100410100100","Saarbrücken, Landeshauptstadt",null],["100410511511","Friedrichsthal, Stadt",null],["100410512512","Großrosseln",null],["100410513513","Heusweiler",null],["100410514514","Kleinblittersdorf",null],["100410515515","Püttlingen, Stadt",null],["100410516516","Quierschied",null],["100410517517","Riegelsberg",null],["100410518518","Sulzbach/ Saar, Stadt",null],["100410519519","Völklingen, Stadt",null],["100420111111","Beckingen",null],["100420112112","Losheim am See",null],["100420113113","Merzig, Kreisstadt",null],["100420114114","Mettlach",null],["100420115115","Perl",null],["100420116116","Wadern, Stadt",null],["100420117117","Weiskirchen",null],["100429999999","Deutsch-luxemburgisches Hoheitsgebiet",null],["100430111111","Eppelborn",null],["100430112112","Illingen",null],["100430113113","Merchweiler",null],["100430114114","Neunkirchen, Kreisstadt",null],["100430115115","Ottweiler, Stadt",null],["100430116116","Schiffweiler",null],["100430117117","Spiesen-Elversberg",null],["100440111111","Dillingen/ Saar, Stadt",null],["100440112112","Lebach, Stadt",null],["100440113113","Nalbach",null],["100440114114","Rehlingen-Siersburg",null],["100440115115","Saarlouis, Kreisstadt",null],["100440116116","Saarwellingen",null],["100440117117","Schmelz",null],["100440118118","Schwalbach",null],["100440119119","Überherrn",null],["100440120120","Wadgassen",null],["100440121121","Wallerfangen",null],["100440122122","Bous",null],["100440123123","Ensdorf",null],["100450111111","Bexbach, Stadt",null],["100450112112","Blieskastel, Stadt",null],["100450113113","Gersheim",null],["100450114114","Homburg, Kreisstadt",null],["100450115115","Kirkel",null],["100450116116","Mandelbachtal",null],["100450117117","St. Ingbert, Stadt",null],["100460111111","Freisen",null],["100460112112","Marpingen",null],["100460113113","Namborn",null],["100460114114","Nohfelden",null],["100460115115","Nonnweiler",null],["100460116116","Oberthal",null],["100460117117","St. Wendel, Kreisstadt",null],["100460118118","Tholey",null],["110000000000","Berlin, Stadt",null],["110010001001","Mitte","Stadt-/Ortsteil bzw. Stadtbezirk"],["110020002002","Friedrichshain-Kreuzberg","Stadt-/Ortsteil bzw. Stadtbezirk"],["110030003003","Pankow","Stadt-/Ortsteil bzw. Stadtbezirk"],["110040004004","Charlottenburg-Wilmersdorf","Stadt-/Ortsteil bzw. Stadtbezirk"],["110050005005","Spandau","Stadt-/Ortsteil bzw. Stadtbezirk"],["110060006006","Steglitz-Zehlendorf","Stadt-/Ortsteil bzw. Stadtbezirk"],["110070007007","Tempelhof-Schöneberg","Stadt-/Ortsteil bzw. Stadtbezirk"],["110080008008","Neukölln","Stadt-/Ortsteil bzw. Stadtbezirk"],["110090009009","Treptow-Köpenick","Stadt-/Ortsteil bzw. Stadtbezirk"],["110100010010","Marzahn-Hellersdorf","Stadt-/Ortsteil bzw. Stadtbezirk"],["110110011011","Lichtenberg","Stadt-/Ortsteil bzw. Stadtbezirk"],["110120012012","Reinickendorf","Stadt-/Ortsteil bzw. Stadtbezirk"],["120510000000","Brandenburg an der Havel, Stadt",null],["120520000000","Cottbus/Chóśebuz, Stadt",null],["120530000000","Frankfurt (Oder), Stadt",null],["120540000000","Potsdam, Stadt",null],["120600005005","Ahrensfelde",null],["120600020020","Bernau bei Berlin, Stadt",null],["120600052052","Eberswalde, Stadt",null],["120600181181","Panketal",null],["120600198198","Schorfheide",null],["120600269269","Wandlitz",null],["120600280280","Werneuchen, Stadt",null],["120605003024","Biesenthal, Stadt",null],["120605003034","Breydin",null],["120605003154","Marienwerder",null],["120605003161","Melchow",null],["120605003192","Rüdnitz",null],["120605003250","Sydower Fließ",null],["120605006012","Althüttendorf",null],["120605006068","Friedrichswalde",null],["120605006100","Joachimsthal, Stadt",null],["120605006296","Ziethen",null],["120605011036","Britz",null],["120605011045","Chorin",null],["120605011092","Hohenfinow",null],["120605011128","Liepe",null],["120605011149","Lunow-Stolzenhagen",null],["120605011172","Niederfinow",null],["120605011176","Oderberg, Stadt",null],["120605011185","Parsteinsee",null],["120610020020","Bestensee",null],["120610112112","Eichwalde",null],["120610217217","Heidesee",null],["120610219219","Heideblick",null],["120610260260","Königs Wusterhausen, Stadt",null],["120610316316","Lübben (Spreewald) / Lubin (Błota), Stadt",null],["120610320320","Luckau, Stadt",null],["120610329329","Märkische Heide/Markojska Góla",null],["120610332332","Mittenwalde, Stadt",null],["120610433433","Schönefeld",null],["120610444444","Schulzendorf",null],["120610540540","Wildau, Stadt",null],["120610572572","Zeuthen",null],["120615108192","Groß Köris",null],["120615108216","Halbe",null],["120615108328","Märkisch Buchholz, Stadt",null],["120615108344","Münchehofe",null],["120615108448","Schwerin",null],["120615108492","Teupitz, Stadt",null],["120615113005","Alt Zauche-Wußwerk/Stara Niwa-Wózwjerch",null],["120615113061","Byhleguhre-Byhlen/Beła Góra-Bělin",null],["120615113224","Jamlitz",null],["120615113308","Lieberose, Stadt",null],["120615113352","Neu Zauche/Nowa Niwa",null],["120615113450","Schwielochsee/Gójacki Jazor",null],["120615113470","Spreewaldheide/Błośańska Góla",null],["120615113476","Straupitz (Spreewald)/Tšupc (Błota)",null],["120615114017","Bersteland",null],["120615114097","Drahnsdorf",null],["120615114164","Golßen, Stadt",null],["120615114244","Kasel-Golzig",null],["120615114265","Krausnick-Groß Wasserburg",null],["120615114405","Rietzneuendorf-Staakow",null],["120615114428","Schlepzig/Słopišća",null],["120615114435","Schönwald",null],["120615114471","Steinreich",null],["120615114510","Unterspreewald",null],["120620092092","Doberlug-Kirchhain, Stadt",null],["120620124124","Elsterwerda, Stadt",null],["120620140140","Finsterwalde, Stadt",null],["120620224224","Herzberg (Elster), Stadt",null],["120620410410","Röderland",null],["120620461461","Schönewalde, Stadt",null],["120620469469","Sonnewalde, Stadt",null],["120625031024","Bad Liebenwerda, Stadt",null],["120625031128","Falkenberg/Elster, Stadt",null],["120625031341","Mühlberg/Elbe, Stadt",null],["120625031500","Uebigau-Wahrenbrück, Stadt",null],["120625202219","Heideland",null],["120625202417","Rückersdorf",null],["120625202440","Schilda",null],["120625202453","Schönborn",null],["120625202492","Tröbitz",null],["120625205088","Crinitz",null],["120625205293","Lichterfeld-Schacksdorf",null],["120625205333","Massen-Niederlausitz",null],["120625205425","Sallgast",null],["120625207177","Gorden-Staupitz",null],["120625207240","Hohenleipisch",null],["120625207372","Plessa",null],["120625207464","Schraden",null],["120625209134","Fichtwald",null],["120625209237","Hohenbucko",null],["120625209282","Kremitzaue",null],["120625209289","Lebusa",null],["120625209445","Schlieben, Stadt",null],["120625211196","Gröden",null],["120625211208","Großthiemig",null],["120625211232","Hirschfeld",null],["120625211336","Merzdorf",null],["120630036036","Brieselang",null],["120630056056","Dallgow-Döberitz",null],["120630080080","Falkensee, Stadt",null],["120630148148","Ketzin/Havel, Stadt",null],["120630189189","Milower Land",null],["120630208208","Nauen, Stadt",null],["120630244244","Premnitz, Stadt",null],["120630252252","Rathenow, Stadt",null],["120630273273","Schönwalde-Glien",null],["120630357357","Wustermark",null],["120635302088","Friesack, Stadt",null],["120635302142","Wiesenaue",null],["120635302202","Mühlenberge",null],["120635302228","Paulinenaue",null],["120635302240","Pessin",null],["120635302256","Retzow",null],["120635306165","Kotzen",null],["120635306186","Märkisch Luch",null],["120635306212","Nennhausen",null],["120635306293","Stechow-Ferchesar",null],["120635309094","Gollenberg",null],["120635309112","Großderschau",null],["120635309134","Havelaue",null],["120635309161","Kleßen-Görne",null],["120635309260","Rhinow, Stadt",null],["120635309274","Seeblick",null],["120640029029","Altlandsberg, Stadt",null],["120640044044","Bad Freienwalde (Oder), Stadt",null],["120640136136","Fredersdorf-Vogelsdorf",null],["120640227227","Hoppegarten",null],["120640274274","Letschin",null],["120640317317","Müncheberg, Stadt",null],["120640336336","Neuenhagen bei Berlin",null],["120640380380","Petershagen/Eggersdorf",null],["120640428428","Rüdersdorf bei Berlin",null],["120640448448","Seelow, Stadt",null],["120640472472","Strausberg, Stadt",null],["120640512512","Wriezen, Stadt",null],["120645403053","Beiersdorf-Freudenberg",null],["120645403125","Falkenberg",null],["120645403205","Heckelberg-Brunow",null],["120645403222","Höhenland",null],["120645404009","Alt Tucheband",null],["120645404057","Bleyen-Genschmar",null],["120645404172","Golzow",null],["120645404266","Küstriner Vorland",null],["120645404538","Zechin",null],["120645406268","Lebus, Stadt",null],["120645406388","Podelzig",null],["120645406420","Reitwein",null],["120645406480","Treplin",null],["120645406539","Zeschdorf",null],["120645408084","Buckow (Märkische Schweiz), Stadt",null],["120645408153","Garzau-Garzin",null],["120645408370","Oberbarnim",null],["120645408408","Rehfelde",null],["120645408484","Waldsieversdorf",null],["120645410190","Gusow-Platkow",null],["120645410303","Märkische Höhe",null],["120645410340","Neuhardenberg",null],["120645412128","Falkenhagen (Mark)",null],["120645412130","Fichtenhöhe",null],["120645412288","Lietzen",null],["120645412290","Lindendorf",null],["120645412482","Vierlinden",null],["120645414061","Bliesdorf",null],["120645414349","Neulewin",null],["120645414365","Neutrebbin",null],["120645414371","Oderaue",null],["120645414393","Prötzel",null],["120645414417","Reichenow-Möglin",null],["120650036036","Birkenwerder",null],["120650084084","Fürstenberg/Havel, Stadt",null],["120650096096","Glienicke/Nordbahn",null],["120650136136","Hennigsdorf, Stadt",null],["120650144144","Hohen Neuendorf, Stadt",null],["120650165165","Kremmen, Stadt",null],["120650180180","Leegebruch",null],["120650193193","Liebenwalde, Stadt",null],["120650198198","Löwenberger Land",null],["120650225225","Mühlenbecker Land",null],["120650251251","Oberkrämer",null],["120650256256","Oranienburg, Stadt",null],["120650332332","Velten, Stadt",null],["120650356356","Zehdenick, Stadt",null],["120655502100","Gransee, Stadt",null],["120655502117","Großwoltersdorf",null],["120655502276","Schönermark",null],["120655502301","Sonnenberg",null],["120655502310","Stechlin",null],["120660052052","Calau/Kalawa, Stadt",null],["120660112112","Großräschen/Rań, Stadt",null],["120660176176","Lauchhammer, Stadt",null],["120660196196","Lübbenau/Spreewald / Lubnjow/Błota, Stadt",null],["120660285285","Schipkau",null],["120660296296","Schwarzheide, Stadt",null],["120660304304","Senftenberg/Zły Komorow, Stadt",null],["120660320320","Vetschau/Spreewald / Wětošow/Błota, Stadt",null],["120665601008","Altdöbern",null],["120665601041","Bronkow",null],["120665601202","Luckaitztal",null],["120665601226","Neu-Seeland/Nowa Jazorina",null],["120665601228","Neupetershain/Nowe Wiki",null],["120665606064","Frauendorf",null],["120665606104","Großkmehlen",null],["120665606168","Kroppen",null],["120665606188","Lindenau",null],["120665606240","Ortrand, Stadt",null],["120665606316","Tettau",null],["120665607116","Grünewald",null],["120665607120","Guteborn",null],["120665607124","Hermsdorf",null],["120665607132","Hohenbocka",null],["120665607272","Ruhland, Stadt",null],["120665607292","Schwarzbach",null],["120670036036","Beeskow, Stadt",null],["120670120120","Eisenhüttenstadt, Stadt",null],["120670124124","Erkner, Stadt",null],["120670137137","Friedland, Stadt",null],["120670144144","Fürstenwalde/Spree, Stadt",null],["120670201201","Grünheide (Mark)",null],["120670426426","Rietz-Neuendorf",null],["120670440440","Schöneiche bei Berlin",null],["120670481481","Storkow (Mark), Stadt",null],["120670493493","Tauche",null],["120670544544","Woltersdorf",null],["120675701076","Brieskow-Finkenheerd",null],["120675701180","Groß Lindow",null],["120675701508","Vogelsang",null],["120675701528","Wiesenau",null],["120675701552","Ziltendorf",null],["120675705292","Lawitz",null],["120675705338","Neißemünde",null],["120675705357","Neuzelle",null],["120675706040","Berkenbrück",null],["120675706072","Briesen (Mark)",null],["120675706237","Jacobsdorf",null],["120675706473","Steinhöfel",null],["120675707024","Bad Saarow",null],["120675707112","Diensdorf-Radlow",null],["120675707288","Langewahl",null],["120675707413","Reichenwalde",null],["120675707520","Wendisch Rietz",null],["120675708205","Grunow-Dammendorf",null],["120675708324","Mixdorf",null],["120675708336","Müllrose, Stadt",null],["120675708397","Ragow-Merz",null],["120675708438","Schlaubetal",null],["120675708458","Siehdichum",null],["120675709173","Gosen-Neu Zittau",null],["120675709408","Rauen",null],["120675709469","Spreenhagen",null],["120680117117","Fehrbellin",null],["120680181181","Heiligengrabe",null],["120680264264","Kyritz, Stadt",null],["120680320320","Neuruppin, Stadt",null],["120680353353","Rheinsberg, Stadt",null],["120680468468","Wittstock/Dosse, Stadt",null],["120680477477","Wusterhausen/Dosse",null],["120685804188","Herzberg (Mark)",null],["120685804280","Lindow (Mark), Stadt",null],["120685804372","Rüthnick",null],["120685804437","Vielitzsee",null],["120685805052","Breddin",null],["120685805109","Dreetz",null],["120685805324","Neustadt (Dosse), Stadt",null],["120685805409","Sieversdorf-Hohenofen",null],["120685805417","Stüdenitz-Schönermark",null],["120685805501","Zernitz-Lohm",null],["120685807072","Dabergotz",null],["120685807306","Märkisch Linden",null],["120685807413","Storbeck-Frankendorf",null],["120685807425","Temnitzquell",null],["120685807426","Temnitztal",null],["120685807452","Walsleben",null],["120690017017","Beelitz, Stadt",null],["120690020020","Bad Belzig, Stadt",null],["120690249249","Groß Kreutz (Havel)",null],["120690304304","Kleinmachnow",null],["120690306306","Kloster Lehnin",null],["120690397397","Michendorf",null],["120690454454","Nuthetal",null],["120690590590","Schwielowsee",null],["120690596596","Seddiner See",null],["120690604604","Stahnsdorf",null],["120690616616","Teltow, Stadt",null],["120690632632","Treuenbrietzen, Stadt",null],["120690656656","Werder (Havel), Stadt",null],["120690665665","Wiesenburg/Mark",null],["120695902018","Beetzsee",null],["120695902019","Beetzseeheide",null],["120695902270","Havelsee, Stadt",null],["120695902460","Päwesin",null],["120695902541","Roskow",null],["120695904052","Borkheide",null],["120695904056","Borkwalde",null],["120695904076","Brück, Stadt",null],["120695904216","Golzow",null],["120695904345","Linthe",null],["120695904470","Planebruch",null],["120695910402","Mühlenfließ",null],["120695910448","Niemegk, Stadt",null],["120695910474","Planetal",null],["120695910485","Rabenstein/Fläming",null],["120695917028","Bensdorf",null],["120695917537","Rosenau",null],["120695917688","Wusterwitz",null],["120695918089","Buckautal",null],["120695918224","Görzke",null],["120695918232","Gräben",null],["120695918648","Wenzlow",null],["120695918680","Wollin",null],["120695918696","Ziesar, Stadt",null],["120700125125","Groß Pankow (Prignitz)",null],["120700149149","Gumtow",null],["120700173173","Karstädt",null],["120700296296","Perleberg, Stadt",null],["120700302302","Plattenburg",null],["120700316316","Pritzwalk, Stadt",null],["120700424424","Wittenberge, Stadt",null],["120705001008","Bad Wilsnack, Stadt",null],["120705001052","Breese",null],["120705001241","Legde/Quitzöbel",null],["120705001348","Rühstädt",null],["120705001416","Weisen",null],["120705005060","Cumlosen",null],["120705005236","Lanz",null],["120705005244","Lenzen (Elbe), Stadt",null],["120705005246","Lenzerwische",null],["120705006096","Gerdshagen",null],["120705006153","Halenbeck-Rohlsdorf",null],["120705006222","Kümmernitztal",null],["120705006266","Marienfließ",null],["120705006280","Meyenburg, Stadt",null],["120705009028","Berge",null],["120705009145","Gülitz-Reetz",null],["120705009300","Pirow",null],["120705009325","Putlitz, Stadt",null],["120705009393","Triglitz",null],["120710057057","Drebkau/Drjowk, Stadt",null],["120710076076","Forst (Lausitz)/Baršć (Łužyca), Stadt",null],["120710160160","Guben, Stadt",null],["120710244244","Kolkwitz/Gołkojce",null],["120710301301","Neuhausen/Spree / Kopańce/Sprjewja",null],["120710337337","Schenkendöbern/Derbno",null],["120710372372","Spremberg/Grodk, Stadt",null],["120710408408","Welzow/Wjelcej, Stadt",null],["120715101028","Briesen/Brjazyna",null],["120715101032","Burg (Spreewald)/Bórkowy (Błota)",null],["120715101041","Dissen-Striesow/Dešno-Strjažow",null],["120715101164","Guhrow/Góry",null],["120715101341","Schmogrow-Fehrow/Smogorjow-Prjawoz",null],["120715101412","Werben/Wjerbno",null],["120715102044","Döbern/Derbno, Stadt",null],["120715102074","Felixsee/Feliksowy Jazor",null],["120715102153","Groß Schacksdorf-Simmersdorf",null],["120715102189","Jämlitz-Klein Düben",null],["120715102294","Neiße-Malxetal/Dolina Nysa-Małksa",null],["120715102392","Tschernitz/Cersk",null],["120715102414","Wiesengrund/Łukojce",null],["120715107052","Drachhausen/Hochoza",null],["120715107060","Drehnow/Drjenow",null],["120715107176","Heinersbrück/Móst",null],["120715107193","Jänschwalde/Janšojce",null],["120715107304","Peitz/Picnjo, Stadt",null],["120715107384","Tauer/Turjej",null],["120715107386","Teichland/Gatojce",null],["120715107401","Turnow-Preilack/Turnow-Pśiłuk",null],["120720002002","Am Mellensee",null],["120720014014","Baruth/Mark, Stadt",null],["120720017017","Blankenfelde-Mahlow",null],["120720120120","Großbeeren",null],["120720169169","Jüterbog, Stadt",null],["120720232232","Luckenwalde, Stadt",null],["120720240240","Ludwigsfelde, Stadt",null],["120720297297","Niedergörsdorf",null],["120720312312","Nuthe-Urstromtal",null],["120720340340","Rangsdorf",null],["120720426426","Trebbin, Stadt",null],["120720477477","Zossen, Stadt",null],["120725204053","Dahme/Mark, Stadt",null],["120725204055","Dahmetal",null],["120725204157","Ihlow",null],["120725204298","Niederer Fläming",null],["120730008008","Angermünde, Stadt",null],["120730069069","Boitzenburger Land",null],["120730384384","Lychen, Stadt",null],["120730429429","Nordwestuckermark",null],["120730452452","Prenzlau, Stadt",null],["120730532532","Schwedt/Oder, Stadt",null],["120730572572","Templin, Stadt",null],["120730579579","Uckerland",null],["120735303085","Brüssow, Stadt",null],["120735303093","Carmzow-Wallmow",null],["120735303216","Göritz",null],["120735303490","Schenkenberg",null],["120735303520","Schönfeld",null],["120735304097","Casekow",null],["120735304189","Gartz (Oder), Stadt",null],["120735304309","Hohenselchow-Groß Pinnow",null],["120735304393","Mescherin",null],["120735304565","Tantow",null],["120735305157","Flieth-Stegelitz",null],["120735305201","Gerswalde",null],["120735305396","Milmersdorf",null],["120735305404","Mittenwalde",null],["120735305569","Temmen-Ringenwalde",null],["120735306225","Gramzow",null],["120735306261","Grünow",null],["120735306430","Oberuckersee",null],["120735306458","Randowtal",null],["120735306578","Uckerfelde",null],["120735306645","Zichow",null],["120735310032","Berkholz-Meyenburg",null],["120735310386","Mark Landin",null],["120735310440","Pinnow",null],["120735310603","Passow",null],["130009999999","Küstengewässer einschl. Anteil am Festlandsockel",null],["130030000000","Rostock, Hanse- und Universitätsstadt",null],["130040000000","Schwerin, Landeshauptstadt",null],["130710027027","Dargun, Stadt",null],["130710029029","Demmin, Hansestadt",null],["130710033033","Feldberger Seenlandschaft",null],["130710107107","Neubrandenburg, Vier-Tore-Stadt",null],["130710110110","Neustrelitz, Residenzstadt",null],["130710156156","Waren (Müritz), Stadt",null],["130715151008","Beggerow",null],["130715151014","Borrentin",null],["130715151064","Hohenbollentin",null],["130715151065","Hohenmocker",null],["130715151072","Kentzlin",null],["130715151076","Kletzin",null],["130715151089","Lindenberg",null],["130715151096","Meesiger",null],["130715151112","Nossendorf",null],["130715151128","Sarow",null],["130715151131","Schönfeld",null],["130715151136","Siedenbrünzow",null],["130715151139","Sommersdorf",null],["130715151148","Utzedel",null],["130715151150","Verchen",null],["130715151157","Warrenzin",null],["130715152028","Datzetal",null],["130715152035","Friedland, Stadt",null],["130715152037","Galenbeck",null],["130715153007","Basedow",null],["130715153032","Faulenrost",null],["130715153039","Gielow",null],["130715153084","Kummerow",null],["130715153092","Malchin, Stadt",null],["130715153109","Neukalen, Peenestadt",null],["130715154001","Alt Schwerin",null],["130715154036","Fünfseen",null],["130715154043","Göhren-Lebbin",null],["130715154093","Malchow, Inselstadt",null],["130715154113","Nossentiner Hütte",null],["130715154114","Penkow",null],["130715154138","Silz",null],["130715154155","Walow",null],["130715154171","Zislow",null],["130715155099","Mirow, Stadt",null],["130715155119","Priepert",null],["130715155159","Wesenberg, Stadt",null],["130715155167","Wustrow",null],["130715156011","Blankensee",null],["130715156012","Blumenholz",null],["130715156025","Carpin",null],["130715156042","Godendorf",null],["130715156058","Grünow",null],["130715156066","Hohenzieritz",null],["130715156075","Klein Vielen",null],["130715156080","Kratzeburg",null],["130715156100","Möllenbeck",null],["130715156147","Userin",null],["130715156162","Wokuhl-Dabelow",null],["130715157009","Beseritz",null],["130715157010","Blankenhof",null],["130715157019","Brunn",null],["130715157104","Neddemin",null],["130715157108","Neuenkirchen",null],["130715157111","Neverin",null],["130715157140","Sponholz",null],["130715157141","Staven",null],["130715157145","Trollenhagen",null],["130715157161","Woggersin",null],["130715157166","Wulkenzin",null],["130715157170","Zirzow",null],["130715158005","Ankershagen, Schliemanngemeinde",null],["130715158101","Möllenhagen",null],["130715158115","Penzlin, Stadt",null],["130715158173","Kuckssee",null],["130715159003","Altenhof",null],["130715159013","Bollewick",null],["130715159020","Buchholz",null],["130715159023","Bütow",null],["130715159034","Fincken",null],["130715159045","Gotthun",null],["130715159053","Groß Kelle",null],["130715159073","Kieve",null],["130715159087","Lärz",null],["130715159088","Leizen",null],["130715159097","Melz",null],["130715159118","Priborn",null],["130715159122","Rechlin",null],["130715159124","Röbel/Müritz, Stadt",null],["130715159133","Schwarz",null],["130715159137","Sietow",null],["130715159143","Stuer",null],["130715159175","Eldetal",null],["130715159176","Südmüritz",null],["130715160047","Grabowhöfe",null],["130715160056","Groß Plasten",null],["130715160063","Hohen Wangelin",null],["130715160069","Jabel",null],["130715160071","Kargow",null],["130715160077","Klink",null],["130715160078","Klocksin",null],["130715160103","Moltzow",null],["130715160144","Torgelow am See",null],["130715160154","Vollrathsruhe",null],["130715160172","Peenehagen",null],["130715160174","Schloen-Dratow",null],["130715161021","Burg Stargard, Stadt",null],["130715161026","Cölpin",null],["130715161055","Groß Nemerow",null],["130715161067","Holldorf",null],["130715161090","Lindetal",null],["130715161117","Pragsdorf",null],["130715162015","Bredenfelde",null],["130715162018","Briggow",null],["130715162048","Grammentin",null],["130715162060","Gülzow",null],["130715162068","Ivenack",null],["130715162070","Jürgenstorf",null],["130715162074","Kittendorf",null],["130715162079","Knorrendorf",null],["130715162102","Mölln",null],["130715162123","Ritzerow",null],["130715162127","Rosenow",null],["130715162142","Stavenhagen, Reuterstadt, Stadt",null],["130715162169","Zettemin",null],["130715163002","Altenhagen",null],["130715163004","Altentreptow, Stadt",null],["130715163006","Bartow",null],["130715163016","Breesen",null],["130715163017","Breest",null],["130715163022","Burow",null],["130715163041","Gnevkow",null],["130715163044","Golchen",null],["130715163049","Grapzow",null],["130715163050","Grischow",null],["130715163057","Groß Teetzleben",null],["130715163059","Gültz",null],["130715163081","Kriesow",null],["130715163120","Pripsleben",null],["130715163125","Röckwitz",null],["130715163135","Siedenbollentin",null],["130715163146","Tützpatz",null],["130715163158","Werder",null],["130715163160","Wildberg",null],["130715163163","Wolde",null],["130715164054","Groß Miltzow",null],["130715164083","Kublank",null],["130715164105","Neetzka",null],["130715164130","Schönbeck",null],["130715164132","Schönhausen",null],["130715164153","Voigtsdorf",null],["130715164164","Woldegk, Windmühlenstadt",null],["130720006006","Bad Doberan, Stadt",null],["130720029029","Dummerstorf",null],["130720036036","Graal-Müritz, Ostseeheilbad",null],["130720043043","Güstrow, Barlachstadt",null],["130720058058","Kröpelin, Stadt",null],["130720060060","Kühlungsborn, Ostseebad, Stadt",null],["130720074074","Neubukow, Stadt",null],["130720091091","Sanitz",null],["130720093093","Satow",null],["130720106106","Teterow, Bergringstadt",null],["130725251001","Admannshagen-Bargeshagen",null],["130725251007","Bartenshagen-Parkentin",null],["130725251017","Börgerende-Rethwisch",null],["130725251047","Hohenfelde",null],["130725251075","Nienhagen, Ostseebad",null],["130725251083","Reddelich",null],["130725251086","Retschow",null],["130725251099","Steffenshagen",null],["130725251117","Wittenbeck",null],["130725252009","Baumgarten",null],["130725252013","Bernitt",null],["130725252020","Bützow, Stadt",null],["130725252028","Dreetz",null],["130725252050","Jürgenshagen",null],["130725252053","Klein Belitz",null],["130725252078","Penzin",null],["130725252089","Rühn",null],["130725252101","Steinhagen",null],["130725252104","Tarnow",null],["130725252114","Warnow",null],["130725252120","Zepelin",null],["130725253019","Broderstorf",null],["130725253081","Poppendorf",null],["130725253087","Roggentin",null],["130725253108","Thulendorf",null],["130725254004","Altkalen",null],["130725254010","Behren-Lübchin",null],["130725254031","Finkenthal",null],["130725254035","Gnoien, Warbelstadt",null],["130725254111","Walkendorf",null],["130725255033","Glasewitz",null],["130725255039","Groß Schwiesow",null],["130725255042","Gülzow-Prüzen",null],["130725255044","Gutow",null],["130725255055","Klein Upahl",null],["130725255061","Kuhs",null],["130725255067","Lohmen",null],["130725255069","Lüssow",null],["130725255071","Mistorf",null],["130725255073","Mühl Rosin",null],["130725255079","Plaaz",null],["130725255084","Reimershagen",null],["130725255092","Sarmstorf",null],["130725255119","Zehna",null],["130725256026","Dobbin-Linstow",null],["130725256048","Hoppenrade",null],["130725256056","Krakow am See, Stadt",null],["130725256059","Kuchelmiß",null],["130725256063","Lalendorf",null],["130725257027","Dolgen am See",null],["130725257046","Hohen Sprenz",null],["130725257062","Laage, Stadt",null],["130725257112","Wardow",null],["130725258003","Alt Sührkow",null],["130725258023","Dahmen",null],["130725258024","Dalkendorf",null],["130725258038","Groß Roge",null],["130725258040","Groß Wokern",null],["130725258041","Groß Wüstenfelde",null],["130725258045","Hohen Demzin",null],["130725258049","Jördenstorf",null],["130725258066","Lelkendorf",null],["130725258082","Prebberede",null],["130725258094","Schorssow",null],["130725258096","Schwasdorf",null],["130725258103","Sukow-Levitzow",null],["130725258109","Thürkow",null],["130725258113","Warnkenhagen",null],["130725259002","Alt Bukow",null],["130725259005","Am Salzhaff",null],["130725259008","Bastorf",null],["130725259014","Biendorf",null],["130725259022","Carinerland",null],["130725259085","Rerik, Ostseebad, Stadt",null],["130725260012","Bentwisch",null],["130725260015","Blankenhagen",null],["130725260032","Gelbensande",null],["130725260072","Mönchhagen",null],["130725260088","Rövershagen",null],["130725261011","Benitz",null],["130725261018","Bröbberow",null],["130725261051","Kassow",null],["130725261090","Rukieten",null],["130725261095","Schwaan, Stadt",null],["130725261110","Vorbeck",null],["130725261116","Wiendorf",null],["130725262021","Cammin",null],["130725262034","Gnewitz",null],["130725262037","Grammow",null],["130725262076","Nustrow",null],["130725262097","Selpin",null],["130725262102","Stubbendorf",null],["130725262105","Tessin, Stadt",null],["130725262107","Thelkow",null],["130725262118","Zarnewanz",null],["130725263030","Elmenhorst/Lichtenhagen",null],["130725263057","Kritzmow",null],["130725263064","Lambrechtshagen",null],["130725263077","Papendorf",null],["130725263080","Pölchow",null],["130725263098","Stäbelow",null],["130725263121","Ziesendorf",null],["130730011011","Binz, Ostseebad",null],["130730035035","Grimmen, Stadt",null],["130730055055","Marlow, Stadt",null],["130730070070","Putbus, Stadt",null],["130730080080","Sassnitz, Stadt",null],["130730088088","Stralsund, Hansestadt",null],["130730089089","Süderholz",null],["130730105105","Zingst, Ostseeheilbad",null],["130735351005","Altenpleen",null],["130735351037","Groß Mohrdorf",null],["130735351044","Klausdorf",null],["130735351046","Kramerhof",null],["130735351066","Preetz",null],["130735351068","Prohn",null],["130735352009","Barth, Stadt",null],["130735352018","Divitz-Spoldershagen",null],["130735352025","Fuhlendorf",null],["130735352042","Karnin",null],["130735352043","Kenz-Küstrow",null],["130735352051","Löbnitz",null],["130735352053","Lüdershagen",null],["130735352069","Pruchten",null],["130735352077","Saal",null],["130735352094","Trinwillershagen",null],["130735353010","Bergen auf Rügen, Stadt",null],["130735353014","Buschvitz",null],["130735353027","Garz/Rügen, Stadt",null],["130735353038","Gustow",null],["130735353049","Lietzow",null],["130735353063","Parchtitz",null],["130735353064","Patzig",null],["130735353065","Poseritz",null],["130735353072","Ralswiek",null],["130735353074","Rappin",null],["130735353083","Sehlen",null],["130735354002","Ahrenshoop, Ostseebad",null],["130735354012","Born a. Darß",null],["130735354017","Dierhagen, Ostseebad",null],["130735354067","Prerow, Ostseebad",null],["130735354100","Wieck a. Darß",null],["130735354103","Wustrow, Ostseebad",null],["130735355024","Franzburg, Stadt",null],["130735355029","Glewitz",null],["130735355034","Gremersdorf-Buchholz",null],["130735355057","Millienhagen-Oebelitz",null],["130735355062","Papenhagen",null],["130735355076","Richtenberg, Stadt",null],["130735355086","Splietsdorf",null],["130735355096","Velgast",null],["130735355097","Weitenhagen",null],["130735355098","Wendisch Baggendorf",null],["130735356023","Elmenhorst",null],["130735356090","Sundhagen",null],["130735356102","Wittenhagen",null],["130735357006","Baabe, Ostseebad",null],["130735357031","Göhren, Ostseebad",null],["130735357048","Lancken-Granitz",null],["130735357084","Sellin, Ostseebad",null],["130735357106","Zirkow",null],["130735357107","Mönchgut, Ostseebad",null],["130735358036","Groß Kordshagen",null],["130735358041","Jakobsdorf",null],["130735358054","Lüssow",null],["130735358060","Niepars",null],["130735358061","Pantelitz",null],["130735358087","Steinhagen",null],["130735358099","Wendorf",null],["130735358104","Zarrendorf",null],["130735359004","Altenkirchen",null],["130735359013","Breege",null],["130735359019","Dranske",null],["130735359030","Glowe",null],["130735359052","Lohme",null],["130735359071","Putgarten",null],["130735359078","Sagard",null],["130735359101","Wiek",null],["130735360007","Bad Sülze, Stadt",null],["130735360015","Dettmannsdorf",null],["130735360016","Deyelsdorf",null],["130735360020","Drechow",null],["130735360022","Eixen",null],["130735360032","Grammendorf",null],["130735360033","Gransebieth",null],["130735360039","Hugoldsdorf",null],["130735360050","Lindholz",null],["130735360093","Tribsees, Stadt",null],["130735361001","Ahrenshagen-Daskow",null],["130735361075","Ribnitz-Damgarten, Bernsteinstadt",null],["130735361082","Schlemmin",null],["130735361085","Semlow",null],["130735362003","Altefähr",null],["130735362021","Dreschvitz",null],["130735362028","Gingst",null],["130735362040","Insel Hiddensee, Seebad",null],["130735362045","Kluis",null],["130735362059","Neuenkirchen",null],["130735362073","Rambin",null],["130735362079","Samtens",null],["130735362081","Schaprode",null],["130735362092","Trent",null],["130735362095","Ummanz",null],["130740026026","Grevesmühlen, Stadt",null],["130740035035","Insel Poel, Ostseebad",null],["130740087087","Wismar, Hansestadt",null],["130745451002","Bad Kleinen",null],["130745451003","Barnekow",null],["130745451008","Bobitz",null],["130745451019","Dorf Mecklenburg",null],["130745451030","Groß Stieten",null],["130745451031","Hohen Viecheln",null],["130745451047","Lübow",null],["130745451053","Metelsdorf",null],["130745451082","Ventschow",null],["130745452020","Dragun",null],["130745452021","Gadebusch, Stadt",null],["130745452040","Kneese",null],["130745452043","Krembz",null],["130745452054","Mühlen Eichsen",null],["130745452068","Roggendorf",null],["130745452070","Rögnitz",null],["130745452081","Veelböken",null],["130745453005","Bernstorf",null],["130745453022","Gägelow",null],["130745453069","Roggenstorf",null],["130745453071","Rüting",null],["130745453077","Testorf-Steinfort",null],["130745453079","Upahl",null],["130745453085","Warnow",null],["130745453093","Stepenitztal",null],["130745454010","Boltenhagen, Ostseebad",null],["130745454016","Damshagen",null],["130745454032","Hohenkirchen",null],["130745454037","Kalkhorst",null],["130745454039","Klütz, Stadt",null],["130745454089","Zierow",null],["130745455001","Alt Meteln",null],["130745455012","Brüsewitz",null],["130745455014","Cramonshagen",null],["130745455015","Dalberg-Wendelstorf",null],["130745455024","Gottesgabe",null],["130745455025","Grambow",null],["130745455038","Klein Trebbow",null],["130745455048","Lübstorf",null],["130745455050","Lützow",null],["130745455061","Perlin",null],["130745455062","Pingelshagen",null],["130745455064","Pokrent",null],["130745455072","Schildetal",null],["130745455075","Seehof",null],["130745455088","Zickhusen",null],["130745456004","Benz",null],["130745456007","Blowatz",null],["130745456009","Boiensdorf",null],["130745456034","Hornstorf",null],["130745456044","Krusenhagen",null],["130745456056","Neuburg",null],["130745457006","Bibow",null],["130745457023","Glasin",null],["130745457036","Jesendorf",null],["130745457046","Lübberstorf",null],["130745457057","Neukloster, Stadt",null],["130745457060","Passee",null],["130745457084","Warin, Stadt",null],["130745457090","Zurow",null],["130745457091","Züsow",null],["130745458013","Carlow",null],["130745458018","Dechow",null],["130745458028","Groß Molzahn",null],["130745458033","Holdorf",null],["130745458042","Königsfeld",null],["130745458065","Rehna, Stadt",null],["130745458066","Rieps",null],["130745458073","Schlagsdorf",null],["130745458078","Thandorf",null],["130745458080","Utecht",null],["130745458092","Wedendorfersee",null],["130745459017","Dassow, Stadt",null],["130745459027","Grieben",null],["130745459049","Lüdersdorf",null],["130745459052","Menzendorf",null],["130745459067","Roduchelstorf",null],["130745459074","Schönberg, Stadt",null],["130745459076","Selmsdorf",null],["130745459094","Siemz-Niendorf",null],["130750005005","Anklam, Hansestadt",null],["130750039039","Greifswald, Universitäts- und Hansestadt",null],["130750049049","Heringsdorf, Ostseebad",null],["130750105105","Pasewalk, Stadt",null],["130750130130","Strasburg (Uckermark), Stadt",null],["130750136136","Ueckermünde, Seebad , Stadt",null],["130755551021","Buggenhagen",null],["130755551072","Krummin",null],["130755551074","Lassan, Stadt",null],["130755551087","Lütow",null],["130755551124","Sauzin",null],["130755551144","Wolgast, Stadt",null],["130755551147","Zemitz",null],["130755552001","Ahlbeck",null],["130755552003","Altwarp",null],["130755552031","Eggesin, Stadt",null],["130755552037","Grambin",null],["130755552051","Hintersee",null],["130755552075","Leopoldshagen",null],["130755552078","Liepgarten",null],["130755552084","Lübs",null],["130755552085","Luckow",null],["130755552089","Meiersberg",null],["130755552093","Mönkebude",null],["130755552139","Vogelsang-Warsin",null],["130755553007","Bargischow",null],["130755553013","Blesewitz",null],["130755553015","Boldekow",null],["130755553020","Bugewitz",null],["130755553022","Butzow",null],["130755553029","Ducherow",null],["130755553053","Iven",null],["130755553068","Krien",null],["130755553073","Krusenfelde",null],["130755553088","Medow",null],["130755553098","Neu Kosenow",null],["130755553101","Neuenkirchen",null],["130755553110","Postlow",null],["130755553116","Rossin",null],["130755553122","Sarnow",null],["130755553127","Spantekow",null],["130755553128","Stolpe an der Peene",null],["130755553155","Neetzow-Liepen",null],["130755554002","Alt Tellin",null],["130755554009","Bentzin",null],["130755554023","Daberkow",null],["130755554054","Jarmen, Stadt",null],["130755554070","Kruckow",null],["130755554134","Tutow",null],["130755554140","Völschow",null],["130755555008","Behrenhoff",null],["130755555025","Dargelin",null],["130755555027","Dersekow",null],["130755555050","Hinrichshagen",null],["130755555076","Levenhagen",null],["130755555091","Mesekenhagen",null],["130755555102","Neuenkirchen",null],["130755555141","Wackerow",null],["130755555142","Weitenhagen",null],["130755556011","Bergholz",null],["130755556012","Blankensee",null],["130755556016","Boock",null],["130755556035","Glasow",null],["130755556038","Grambow",null],["130755556067","Krackow",null],["130755556079","Löcknitz",null],["130755556095","Nadrensee",null],["130755556107","Penkun, Stadt",null],["130755556108","Plöwen",null],["130755556113","Ramin",null],["130755556117","Rossow",null],["130755556119","Rothenklempenow",null],["130755557018","Brünzow",null],["130755557046","Hanshagen",null],["130755557059","Katzow",null],["130755557060","Kemnitz",null],["130755557069","Kröslin",null],["130755557081","Loissin",null],["130755557083","Lubmin, Seebad",null],["130755557097","Neu Boltenhagen",null],["130755557120","Rubenow",null],["130755557146","Wusterhusen",null],["130755558036","Görmin",null],["130755558082","Loitz, Stadt",null],["130755558123","Sassen-Trantow",null],["130755559004","Altwigshagen",null],["130755559033","Ferdinandshof",null],["130755559045","Hammer a.d. Uecker",null],["130755559048","Heinrichswalde",null],["130755559118","Rothemühl",null],["130755559131","Torgelow, Stadt",null],["130755559143","Wilhelmsburg",null],["130755560017","Brietzig",null],["130755560032","Fahrenwalde",null],["130755560042","Groß Luckow",null],["130755560055","Jatznick",null],["130755560063","Koblentz",null],["130755560071","Krugsdorf",null],["130755560103","Nieden",null],["130755560104","Papendorf",null],["130755560109","Polzow",null],["130755560115","Rollwitz",null],["130755560126","Schönwalde",null],["130755560138","Viereck",null],["130755560149","Zerrenthin",null],["130755561058","Karlshagen, Ostseebad",null],["130755561092","Mölschow",null],["130755561106","Peenemünde",null],["130755561133","Trassenheide, Ostseebad",null],["130755561151","Zinnowitz, Ostseebad",null],["130755562010","Benz",null],["130755562026","Dargen",null],["130755562034","Garz",null],["130755562056","Kamminke",null],["130755562065","Korswandt",null],["130755562066","Koserow, Ostseebad",null],["130755562080","Loddin, Seebad",null],["130755562090","Mellenthin",null],["130755562111","Pudagla",null],["130755562114","Rankwitz",null],["130755562129","Stolpe auf Usedom",null],["130755562135","Ückeritz, Seebad",null],["130755562137","Usedom, Stadt",null],["130755562148","Zempin, Seebad",null],["130755562152","Zirchow",null],["130755563006","Bandelin",null],["130755563040","Gribow",null],["130755563041","Groß Kiesow",null],["130755563043","Groß Polzin",null],["130755563044","Gützkow, Stadt",null],["130755563061","Klein Bünzow",null],["130755563094","Murchin",null],["130755563121","Rubkow",null],["130755563125","Schmatzin",null],["130755563145","Wrangelsburg",null],["130755563150","Ziethen",null],["130755563154","Züssow",null],["130755563156","Karlsburg",null],["130760014014","Boizenburg/ Elbe, Stadt",null],["130760060060","Hagenow, Stadt",null],["130760088088","Lübtheen, Stadt",null],["130760090090","Ludwigslust, Stadt",null],["130760108108","Parchim, Stadt",null],["130765652009","Bengerstorf",null],["130765652010","Besitz",null],["130765652016","Brahlstorf",null],["130765652030","Dersenow",null],["130765652054","Gresse",null],["130765652055","Greven",null],["130765652102","Neu Gülze",null],["130765652106","Nostorf",null],["130765652122","Schwanheide",null],["130765652136","Teldau",null],["130765652138","Tessin b. Boizenburg",null],["130765654034","Dömitz, Stadt",null],["130765654053","Grebs-Niendorf",null],["130765654067","Karenz",null],["130765654093","Malk Göhren",null],["130765654094","Malliß",null],["130765654103","Neu Kaliß",null],["130765654143","Vielank",null],["130765655040","Gallin-Kuppentin",null],["130765655051","Granzin",null],["130765655075","Kreien",null],["130765655077","Kritzow",null],["130765655089","Lübz, Stadt",null],["130765655109","Passow",null],["130765655125","Siggelkow",null],["130765655151","Werder",null],["130765655165","Gehlsbach",null],["130765655168","Ruhner Berge",null],["130765656032","Dobbertin",null],["130765656048","Goldberg, Stadt",null],["130765656096","Mestlin",null],["130765656104","Neu Poserin",null],["130765656135","Techentin",null],["130765657003","Balow",null],["130765657021","Brunow",null],["130765657027","Dambeck",null],["130765657037","Eldena",null],["130765657049","Gorlosen",null],["130765657050","Grabow, Stadt",null],["130765657069","Karstädt",null],["130765657076","Kremmin",null],["130765657097","Milow",null],["130765657098","Möllenbeck",null],["130765657100","Muchow",null],["130765657115","Prislich",null],["130765657161","Zierzow",null],["130765658002","Alt Zachun",null],["130765658004","Bandenitz",null],["130765658008","Belsch",null],["130765658013","Bobzin",null],["130765658019","Bresegard bei Picher",null],["130765658041","Gammelin",null],["130765658057","Groß Krams",null],["130765658064","Hoort",null],["130765658065","Hülseburg",null],["130765658070","Kirch Jesar",null],["130765658079","Kuhstorf",null],["130765658099","Moraas",null],["130765658110","Pätow-Steegen",null],["130765658111","Picher",null],["130765658116","Pritzier",null],["130765658119","Redefin",null],["130765658131","Strohkirchen",null],["130765658169","Toddin",null],["130765658145","Warlitz",null],["130765659001","Alt Krenzlin",null],["130765659018","Bresegard bei Eldena",null],["130765659046","Göhlen",null],["130765659058","Groß Laasch",null],["130765659086","Lübesse",null],["130765659087","Lüblow",null],["130765659118","Rastow",null],["130765659134","Sülstorf",null],["130765659141","Uelitz",null],["130765659146","Warlow",null],["130765659156","Wöbbelin",null],["130765660012","Blievenstorf",null],["130765660017","Brenz",null],["130765660105","Neustadt-Glewe, Stadt",null],["130765662035","Domsühl",null],["130765662056","Groß Godems",null],["130765662068","Karrenzin",null],["130765662085","Lewitzrand",null],["130765662120","Rom",null],["130765662126","Spornitz",null],["130765662129","Stolpe",null],["130765662160","Ziegendorf",null],["130765662162","Zölkow",null],["130765662164","Obere Warnow",null],["130765663006","Barkhagen",null],["130765663114","Plau am See, Stadt",null],["130765663166","Ganzlin",null],["130765664011","Blankenberg",null],["130765664015","Borkow",null],["130765664020","Brüel, Stadt",null],["130765664026","Dabel",null],["130765664062","Hohen Pritz",null],["130765664072","Kobrow",null],["130765664078","Kuhlen-Wendorf",null],["130765664101","Mustin",null],["130765664128","Sternberg, Stadt",null],["130765664148","Weitendorf",null],["130765664155","Witzin",null],["130765664167","Kloster Tempzin",null],["130765665036","Dümmer",null],["130765665063","Holthusen",null],["130765665071","Klein Rogahn",null],["130765665107","Pampow",null],["130765665121","Schossin",null],["130765665130","Stralendorf",null],["130765665147","Warsow",null],["130765665154","Wittenförden",null],["130765665163","Zülow",null],["130765666152","Wittenburg, Stadt",null],["130765666153","Wittendörp",null],["130765667039","Gallin",null],["130765667073","Kogel",null],["130765667092","Lüttow-Valluhn",null],["130765667142","Vellahn",null],["130765667159","Zarrentin am Schaalsee, Stadt",null],["130765668005","Banzkow",null],["130765668007","Barnin",null],["130765668023","Bülow",null],["130765668024","Cambs",null],["130765668025","Crivitz, Stadt",null],["130765668029","Demen",null],["130765668033","Dobin am See",null],["130765668038","Friedrichsruhe",null],["130765668044","Gneven",null],["130765668080","Langen Brütz",null],["130765668082","Leezen",null],["130765668112","Pinnow",null],["130765668113","Plate",null],["130765668117","Raben Steinfeld",null],["130765668133","Sukow",null],["130765668140","Tramm",null],["130765668158","Zapel",null],["145110000000","Chemnitz, Stadt",null],["145210010010","Amtsberg",null],["145210020020","Annaberg-Buchholz, Stadt",null],["145210035035","Aue-Bad Schlema, Stadt",null],["145210110110","Breitenbrunn/Erzgeb.",null],["145210130130","Crottendorf",null],["145210150150","Drebach",null],["145210160160","Ehrenfriedersdorf, Stadt",null],["145210170170","Eibenstock, Stadt",null],["145210200200","Gelenau/Erzgeb.",null],["145210240240","Großolbersdorf",null],["145210250250","Großrückerswalde",null],["145210260260","Grünhain-Beierfeld, Stadt",null],["145210290290","Hohndorf",null],["145210310310","Jahnsdorf/Erzgeb.",null],["145210320320","Johanngeorgenstadt, Stadt",null],["145210330330","Jöhstadt, Stadt",null],["145210355355","Lauter-Bernsbach, Stadt",null],["145210370370","Lößnitz, Stadt",null],["145210390390","Marienberg, Stadt",null],["145210400400","Mildenau",null],["145210410410","Neukirchen/Erzgeb.",null],["145210440440","Oberwiesenthal, Kurort, Stadt",null],["145210450450","Oelsnitz/Erzgeb., Stadt",null],["145210460460","Olbernhau, Stadt",null],["145210495495","Pockau-Lengefeld, Stadt",null],["145210500500","Raschau-Markersbach",null],["145210530530","Schneeberg, Stadt",null],["145210540540","Schönheide",null],["145210550550","Schwarzenberg/Erzgeb., Stadt",null],["145210560560","Sehmatal",null],["145210600600","Stützengrün",null],["145210620620","Thalheim/Erzgeb., Stadt",null],["145210630630","Thermalbad Wiesenbad",null],["145210640640","Thum, Stadt",null],["145210670670","Wolkenstein, Stadt",null],["145215101060","Bärenstein",null],["145215101340","Königswalde",null],["145215103040","Auerbach",null],["145215103120","Burkhardtsdorf",null],["145215103230","Gornsdorf",null],["145215110210","Geyer, Stadt",null],["145215110610","Tannenberg",null],["145215115380","Lugau/Erzgeb., Stadt",null],["145215115430","Niederwürschnitz",null],["145215130510","Scheibenberg, Stadt",null],["145215130520","Schlettau, Stadt",null],["145215132140","Deutschneudorf",null],["145215132280","Heidersdorf",null],["145215132570","Seiffen/Erzgeb., Kurort",null],["145215133420","Niederdorf",null],["145215133590","Stollberg/Erzgeb., Stadt",null],["145215138220","Gornau/Erzgeb.",null],["145215138690","Zschopau, Stadt",null],["145215139080","Bockau",null],["145215139700","Zschorlau",null],["145215140180","Elterlein, Stadt",null],["145215140710","Zwönitz, Stadt",null],["145215405090","Börnichen/Erzgeb.",null],["145215405270","Grünhainichen",null],["145220020020","Augustusburg, Stadt",null],["145220035035","Bobritzsch-Hilbersdorf",null],["145220050050","Brand-Erbisdorf, Stadt",null],["145220070070","Claußnitz",null],["145220080080","Döbeln, Stadt",null],["145220110110","Eppendorf",null],["145220120120","Erlau",null],["145220140140","Flöha, Stadt",null],["145220150150","Frankenberg/Sa., Stadt",null],["145220170170","Frauenstein, Stadt",null],["145220180180","Freiberg, Stadt, Universitätsstadt",null],["145220190190","Geringswalde, Stadt",null],["145220200200","Großhartmannsdorf",null],["145220210210","Großschirma, Stadt",null],["145220220220","Großweitzschen",null],["145220230230","Hainichen, Stadt",null],["145220240240","Halsbrücke",null],["145220250250","Hartha, Stadt",null],["145220260260","Hartmannsdorf",null],["145220290290","Königshain-Wiederau",null],["145220300300","Kriebstein",null],["145220310310","Leisnig, Stadt",null],["145220320320","Leubsdorf",null],["145220330330","Lichtenau",null],["145220350350","Lunzenau, Stadt",null],["145220390390","Mulda/Sa.",null],["145220400400","Neuhausen/Erzgeb.",null],["145220420420","Niederwiesa",null],["145220430430","Oberschöna",null],["145220440440","Oederan, Stadt",null],["145220460460","Penig, Stadt",null],["145220470470","Rechenberg-Bienenmühle",null],["145220480480","Reinsberg",null],["145220500500","Rossau",null],["145220510510","Roßwein, Stadt",null],["145220540540","Striegistal",null],["145220570570","Waldheim, Stadt",null],["145220580580","Wechselburg",null],["145225102060","Burgstädt, Stadt",null],["145225102380","Mühlau",null],["145225102550","Taura",null],["145225113340","Lichtenberg/Erzgeb.",null],["145225113590","Weißenborn/Erzgeb.",null],["145225119010","Altmittweida",null],["145225119360","Mittweida, Stadt, Hochschulstadt",null],["145225123450","Ostrau",null],["145225123620","Zschaitz-Ottewig",null],["145225126280","Königsfeld",null],["145225126490","Rochlitz, Stadt",null],["145225126530","Seelitz",null],["145225126600","Zettlitz",null],["145225129090","Dorfchemnitz",null],["145225129520","Sayda, Stadt",null],["145230010010","Adorf/Vogtl., Stadt",null],["145230020020","Auerbach/Vogtl., Stadt",null],["145230030030","Bad Brambach",null],["145230040040","Bad Elster, Stadt",null],["145230090090","Ellefeld",null],["145230100100","Elsterberg, Stadt",null],["145230160160","Klingenthal, Stadt",null],["145230170170","Lengenfeld, Stadt",null],["145230200200","Markneukirchen, Stadt",null],["145230245245","Muldenhammer",null],["145230280280","Neumark",null],["145230310310","Pausa-Mühltroff, Stadt",null],["145230320320","Plauen, Stadt",null],["145230330330","Pöhl",null],["145230360360","Rodewisch, Stadt",null],["145230365365","Rosenbach/Vogtl.",null],["145230380380","Steinberg",null],["145230450450","Weischlitz",null],["145235107120","Falkenstein/Vogtl., Stadt",null],["145235107130","Grünbach",null],["145235107290","Neustadt/Vogtl.",null],["145235120190","Limbach",null],["145235120260","Netzschkau, Stadt",null],["145235122060","Bösenbrunn",null],["145235122080","Eichigt",null],["145235122300","Oelsnitz/Vogtl., Stadt",null],["145235122440","Triebel/Vogtl.",null],["145235125150","Heinsdorfergrund",null],["145235125340","Reichenbach im Vogtland, Stadt",null],["145235131230","Mühlental",null],["145235131370","Schöneck/Vogtl., Stadt",null],["145235134270","Neuensalz",null],["145235134430","Treuen, Stadt",null],["145235402050","Bergen",null],["145235402410","Theuma",null],["145235402420","Tirpersdorf",null],["145235402460","Werda",null],["145240020020","Callenberg",null],["145240060060","Fraureuth",null],["145240070070","Gersdorf",null],["145240080080","Glauchau, Stadt",null],["145240090090","Hartenstein, Stadt",null],["145240120120","Hohenstein-Ernstthal, Stadt",null],["145240140140","Langenbernsdorf",null],["145240150150","Langenweißbach",null],["145240170170","Lichtentanne",null],["145240200200","Mülsen",null],["145240210210","Neukirchen/Pleiße",null],["145240230230","Oberlungwitz, Stadt",null],["145240250250","Reinsdorf",null],["145240300300","Werdau, Stadt",null],["145240310310","Wildenfels, Stadt",null],["145240320320","Wilkau-Haßlau, Stadt",null],["145240330330","Zwickau, Stadt",null],["145245104030","Crimmitschau, Stadt",null],["145245104050","Dennheritz",null],["145245111040","Crinitzberg",null],["145245111100","Hartmannsdorf b. Kirchberg",null],["145245111110","Hirschfeld",null],["145245111130","Kirchberg, Stadt",null],["145245114180","Limbach-Oberfrohna, Stadt",null],["145245114220","Niederfrohna",null],["145245118190","Meerane, Stadt",null],["145245118270","Schönberg",null],["145245128010","Bernsdorf",null],["145245128160","Lichtenstein/Sa., Stadt",null],["145245128280","St. Egidien",null],["145245135240","Oberwiera",null],["145245135260","Remse",null],["145245135290","Waldenburg, Stadt",null],["146120000000","Dresden, Stadt",null],["146250010010","Arnsdorf",null],["146250020020","Bautzen / Budyšin, Stadt",null],["146250030030","Bernsdorf, Stadt",null],["146250060060","Burkau",null],["146250090090","Cunewalde",null],["146250100100","Demitz-Thumitz",null],["146250110110","Doberschau-Gaußig / Dobruša-Huska",null],["146250120120","Elsterheide / Halštrowska Hola",null],["146250130130","Elstra, Stadt",null],["146250150150","Göda / Hodźij",null],["146250160160","Großdubrau / Wulka Dubrawa",null],["146250200200","Großröhrsdorf, Stadt",null],["146250220220","Haselbachtal",null],["146250230230","Hochkirch / Bukecy",null],["146250240240","Hoyerswerda / Wojerecy, Stadt",null],["146250250250","Kamenz / Kamjenc, Stadt",null],["146250280280","Königswartha / Rakecy",null],["146250290290","Kubschütz / Kubšicy",null],["146250310310","Lauta, Stadt",null],["146250330330","Lohsa / Łaz",null],["146250340340","Malschwitz / Malešecy",null],["146250380380","Neukirch/Lausitz",null],["146250420420","Oßling",null],["146250430430","Ottendorf-Okrilla",null],["146250480480","Radeberg, Stadt",null],["146250490490","Radibor / Radwor",null],["146250525525","Schirgiswalde-Kirschau, Stadt",null],["146250530530","Schmölln-Putzkau",null],["146250550550","Schwepnitz",null],["146250560560","Sohland a. d. Spree",null],["146250570570","Spreetal / Sprjewiny Doł",null],["146250590590","Steinigtwolmsdorf",null],["146250600600","Wachau",null],["146250610610","Weißenberg / Wóspork, Stadt",null],["146250630630","Wilthen, Stadt",null],["146250640640","Wittichenau / Kulow, Stadt",null],["146255207040","Bischofswerda, Stadt",null],["146255207510","Rammenau",null],["146255211140","Frankenthal",null],["146255211170","Großharthau",null],["146255212190","Großpostwitz/O.L. / Budestecy",null],["146255212390","Obergurig / Hornja Hórka",null],["146255218270","Königsbrück, Stadt",null],["146255218300","Laußnitz",null],["146255218370","Neukirch",null],["146255223360","Neschwitz / Njeswačidło",null],["146255223460","Puschwitz / Bóšicy",null],["146255231180","Großnaundorf",null],["146255231320","Lichtenberg",null],["146255231410","Ohorn",null],["146255231450","Pulsnitz, Stadt",null],["146255231580","Steina",null],["146255501080","Crostwitz / Chrósćicy",null],["146255501350","Nebelschütz / Njebjelčicy",null],["146255501440","Panschwitz-Kuckau / Pančicy-Kukow",null],["146255501470","Räckelwitz / Worklecy",null],["146255501500","Ralbitz-Rosenthal / Ralbicy-Róžant",null],["146260060060","Boxberg/O.L. / Hamor",null],["146260085085","Ebersbach-Neugersdorf, Stadt",null],["146260110110","Görlitz, Stadt",null],["146260180180","Herrnhut, Stadt",null],["146260245245","Kottmar",null],["146260250250","Krauschwitz i.d. O.L. / Krušwica",null],["146260280280","Leutersdorf",null],["146260300300","Markersdorf",null],["146260310310","Mittelherwigsdorf",null],["146260370370","Niesky, Stadt",null],["146260390390","Oderwitz",null],["146260420420","Ostritz, Stadt",null],["146260530530","Seifhennersdorf, Stadt",null],["146260610610","Zittau, Stadt",null],["146265203010","Bad Muskau / Mužakow, Stadt",null],["146265203100","Gablenz / Jabłońc",null],["146265206030","Bernstadt a. d. Eigen, Stadt",null],["146265206500","Schönau-Berzdorf a. d. Eigen",null],["146265214140","Großschönau",null],["146265214170","Hainewalde",null],["146265220150","Großschweidnitz",null],["146265220270","Lawalde",null],["146265220290","Löbau, Stadt",null],["146265220470","Rosenbach",null],["146265224070","Dürrhennersdorf",null],["146265224350","Neusalza-Spremberg, Stadt",null],["146265224510","Schönbach",null],["146265227050","Bertsdorf-Hörnitz",null],["146265227210","Jonsdorf, Kurort",null],["146265227400","Olbersdorf",null],["146265227430","Oybin",null],["146265228020","Beiersdorf",null],["146265228410","Oppach",null],["146265232240","Königshain",null],["146265232450","Reichenbach/O.L., Stadt",null],["146265232570","Vierkirchen",null],["146265233260","Kreba-Neudorf / Chrjebja-Nowa Wjes",null],["146265233460","Rietschen / Rěčicy",null],["146265235160","Hähnichen",null],["146265235480","Rothenburg/O.L., Stadt",null],["146265237120","Groß Düben / Dźěwin",null],["146265237490","Schleife / Slepo",null],["146265237560","Trebendorf / Trjebin",null],["146265242590","Weißkeißel / Wuskidź",null],["146265242600","Weißwasser/O.L., Stadt / Běła Woda",null],["146265502190","Hohendubrau / Wysoka Dubrawa",null],["146265502320","Mücka / Mikow",null],["146265502440","Quitzdorf am See",null],["146265502580","Waldhufen",null],["146265503200","Horka",null],["146265503230","Kodersdorf",null],["146265503330","Neißeaue",null],["146265503520","Schöpstal",null],["146270010010","Coswig, Stadt",null],["146270020020","Diera-Zehren",null],["146270030030","Ebersbach",null],["146270050050","Gröditz, Stadt",null],["146270060060","Großenhain, Stadt",null],["146270070070","Hirschstein",null],["146270080080","Käbschütztal",null],["146270100100","Klipphausen",null],["146270130130","Lommatzsch, Stadt",null],["146270140140","Meißen, Stadt",null],["146270150150","Moritzburg",null],["146270170170","Niederau",null],["146270180180","Nossen, Stadt",null],["146270200200","Priestewitz",null],["146270210210","Radebeul, Stadt",null],["146270220220","Radeburg, Stadt",null],["146270230230","Riesa, Stadt",null],["146270260260","Stauchitz",null],["146270270270","Strehla, Stadt",null],["146270290290","Thiendorf",null],["146270310310","Weinböhla",null],["146270360360","Zeithain",null],["146275225040","Glaubitz",null],["146275225190","Nünchritz",null],["146275234240","Röderaue",null],["146275234340","Wülknitz",null],["146275238110","Lampertswalde",null],["146275238250","Schönfeld",null],["146280050050","Bannewitz",null],["146280060060","Dippoldiswalde, Stadt",null],["146280100100","Dürrröhrsdorf-Dittersbach",null],["146280110110","Freital, Stadt",null],["146280130130","Glashütte, Stadt",null],["146280160160","Heidenau, Stadt",null],["146280190190","Hohnstein, Stadt",null],["146280220220","Kreischa",null],["146280260260","Neustadt in Sachsen, Stadt",null],["146280300300","Rabenau, Stadt",null],["146280360360","Sebnitz, Stadt",null],["146280380380","Stolpen, Stadt",null],["146280410410","Wilsdruff, Stadt",null],["146285201010","Altenberg, Stadt",null],["146285201170","Hermsdorf/Erzgeb.",null],["146285202020","Bad Gottleuba-Berggießhübel, Stadt",null],["146285202040","Bahretal",null],["146285202230","Liebstadt, Stadt",null],["146285204030","Bad Schandau, Stadt",null],["146285204320","Rathmannsdorf",null],["146285204330","Reinhardtsdorf-Schöna",null],["146285209080","Dohna, Stadt",null],["146285209250","Müglitztal",null],["146285219140","Gohrisch",null],["146285219210","Königstein/Sächs. Schw., Stadt",null],["146285219310","Rathen, Kurort",null],["146285219340","Rosenthal-Bielatal",null],["146285219390","Struppen",null],["146285221240","Lohmen",null],["146285221370","Stadt Wehlen, Stadt",null],["146285229070","Dohma",null],["146285229270","Pirna, Stadt",null],["146285230150","Hartmannsdorf-Reichenau",null],["146285230205","Klingenberg",null],["146285240090","Dorfhain",null],["146285240400","Tharandt, Stadt",null],["147130000000","Leipzig, Stadt",null],["147290030030","Bennewitz",null],["147290040040","Böhlen, Stadt",null],["147290050050","Borna, Stadt",null],["147290060060","Borsdorf",null],["147290070070","Brandis, Stadt",null],["147290080080","Colditz, Stadt",null],["147290140140","Frohburg, Stadt",null],["147290150150","Geithain, Stadt",null],["147290160160","Grimma, Stadt",null],["147290170170","Groitzsch, Stadt",null],["147290190190","Großpösna",null],["147290220220","Kitzscher, Stadt",null],["147290245245","Lossatal",null],["147290250250","Machern",null],["147290260260","Markkleeberg, Stadt",null],["147290270270","Markranstädt, Stadt",null],["147290320320","Neukieritzsch",null],["147290360360","Regis-Breitingen, Stadt",null],["147290370370","Rötha, Stadt",null],["147290380380","Thallwitz",null],["147290400400","Trebsen/Mulde, Stadt",null],["147290410410","Wurzen, Stadt",null],["147290430430","Zwenkau, Stadt",null],["147295301010","Bad Lausick, Stadt",null],["147295301330","Otterwisch",null],["147295307020","Belgershain",null],["147295307300","Naunhof, Stadt",null],["147295307340","Parthenstein",null],["147295308100","Elstertrebnitz",null],["147295308350","Pegau, Stadt",null],["147300020020","Bad Düben, Stadt",null],["147300045045","Belgern-Schildau, Stadt",null],["147300050050","Cavertitz",null],["147300060060","Dahlen, Stadt",null],["147300070070","Delitzsch, Stadt",null],["147300080080","Doberschütz",null],["147300110110","Eilenburg, Stadt",null],["147300160160","Laußig",null],["147300170170","Liebschützberg",null],["147300180180","Löbnitz",null],["147300190190","Mockrehna",null],["147300200200","Mügeln, Stadt",null],["147300210210","Naundorf",null],["147300230230","Oschatz, Stadt",null],["147300250250","Rackwitz",null],["147300270270","Schkeuditz, Stadt",null],["147300300300","Taucha, Stadt",null],["147300330330","Wermsdorf",null],["147300340340","Wiedemar",null],["147305302010","Arzberg",null],["147305302030","Beilrode",null],["147305303090","Dommitzsch, Stadt",null],["147305303120","Elsnig",null],["147305303320","Trossin",null],["147305306150","Krostitz",null],["147305306280","Schönwölkau",null],["147305311100","Dreiheide",null],["147305311310","Torgau, Stadt",null],["147305601140","Jesewitz",null],["147305601360","Zschepplin",null],["150010000000","Dessau-Roßlau, Stadt",null],["150020000000","Halle (Saale), Stadt",null],["150030000000","Magdeburg, Landeshauptstadt",null],["150810030030","Arendsee (Altmark), Stadt",null],["150810135135","Gardelegen, Hansestadt",null],["150810240240","Kalbe (Milde), Stadt",null],["150810280280","Klötze, Stadt",null],["150810455455","Salzwedel, Hansestadt",null],["150815051026","Apenburg-Winterfeld, Flecken",null],["150815051045","Beetzendorf",null],["150815051095","Dähre",null],["150815051105","Diesdorf, Flecken",null],["150815051225","Jübar",null],["150815051290","Kuhfelde",null],["150815051440","Rohrberg",null],["150815051545","Wallstawe",null],["150820005005","Aken (Elbe), Stadt",null],["150820015015","Bitterfeld-Wolfen, Stadt",null],["150820180180","Köthen (Anhalt), Stadt",null],["150820241241","Muldestausee",null],["150820256256","Osternienburger Land",null],["150820301301","Raguhn-Jeßnitz, Stadt",null],["150820340340","Sandersdorf-Brehna, Stadt",null],["150820377377","Südliches Anhalt, Stadt",null],["150820430430","Zerbst/Anhalt, Stadt",null],["150820440440","Zörbig, Stadt",null],["150830040040","Barleben",null],["150830270270","Haldensleben, Stadt",null],["150830298298","Hohe Börde",null],["150830390390","Niedere Börde",null],["150830411411","Oebisfelde-Weferlingen, Stadt",null],["150830415415","Oschersleben (Bode), Stadt",null],["150830490490","Sülzetal",null],["150830531531","Wanzleben-Börde, Stadt",null],["150830565565","Wolmirstedt, Stadt",null],["150835051030","Angern",null],["150835051120","Burgstall",null],["150835051130","Colbitz",null],["150835051361","Loitsche-Heinrichsberg",null],["150835051440","Rogätz",null],["150835051557","Westheide",null],["150835051580","Zielitz",null],["150835052020","Altenhausen",null],["150835052060","Beendorf",null],["150835052115","Bülstringen",null],["150835052125","Calvörde",null],["150835052205","Erxleben",null],["150835052230","Flechtingen",null],["150835052323","Ingersleben",null],["150835053190","Eilsleben",null],["150835053275","Harbke",null],["150835053320","Hötensleben",null],["150835053485","Sommersdorf",null],["150835053505","Ummendorf",null],["150835053515","Völpke",null],["150835053535","Wefensleben",null],["150835054025","Am Großen Bruch",null],["150835054035","Ausleben",null],["150835054245","Gröningen, Stadt",null],["150835054355","Kroppenstedt, Stadt",null],["150840130130","Elsteraue",null],["150840235235","Hohenmölsen, Stadt",null],["150840315315","Lützen, Stadt",null],["150840355355","Naumburg (Saale), Stadt",null],["150840490490","Teuchern, Stadt",null],["150840550550","Weißenfels, Stadt",null],["150840590590","Zeitz, Stadt",null],["150845051012","An der Poststraße",null],["150845051015","Bad Bibra, Stadt",null],["150845051125","Eckartsberga, Stadt",null],["150845051132","Finne",null],["150845051133","Finneland",null],["150845051246","Kaiserpfalz",null],["150845051282","Lanitz-Hassel-Tal",null],["150845052115","Droyßig",null],["150845052207","Gutenborn",null],["150845052275","Kretzschau",null],["150845052442","Schnaudertal",null],["150845052565","Wetterzeube",null],["150845053025","Balgstädt",null],["150845053135","Freyburg (Unstrut), Stadt",null],["150845053150","Gleina",null],["150845053170","Goseck",null],["150845053250","Karsdorf",null],["150845053285","Laucha an der Unstrut, Stadt",null],["150845053360","Nebra (Unstrut), Stadt",null],["150845054013","Meineweh",null],["150845054335","Mertendorf",null],["150845054341","Molauer Land",null],["150845054375","Osterfeld, Stadt",null],["150845054445","Schönburg",null],["150845054470","Stößen, Stadt",null],["150845054560","Wethau",null],["150850040040","Ballenstedt, Stadt",null],["150850055055","Blankenburg (Harz), Stadt",null],["150850110110","Falkenstein/Harz, Stadt",null],["150850135135","Halberstadt, Stadt",null],["150850145145","Harzgerode, Stadt",null],["150850185185","Huy",null],["150850190190","Ilsenburg (Harz), Stadt",null],["150850227227","Nordharz",null],["150850228228","Oberharz am Brocken, Stadt",null],["150850230230","Osterwieck, Stadt",null],["150850235235","Quedlinburg, Welterbestadt",null],["150850330330","Thale, Stadt",null],["150850370370","Wernigerode, Stadt",null],["150855051090","Ditfurt",null],["150855051125","Groß Quenstedt",null],["150855051140","Harsleben",null],["150855051160","Hedersleben",null],["150855051285","Schwanebeck, Stadt",null],["150855051287","Selke-Aue",null],["150855051365","Wegeleben, Stadt",null],["150860005005","Biederitz",null],["150860015015","Burg, Stadt",null],["150860035035","Elbe-Parey",null],["150860040040","Genthin, Stadt",null],["150860055055","Gommern, Stadt",null],["150860080080","Jerichow, Stadt",null],["150860140140","Möckern, Stadt",null],["150860145145","Möser",null],["150870015015","Allstedt, Stadt",null],["150870031031","Arnstein, Stadt",null],["150870130130","Eisleben, Lutherstadt",null],["150870165165","Gerbstedt, Stadt",null],["150870220220","Hettstedt, Stadt",null],["150870275275","Mansfeld, Stadt",null],["150870370370","Sangerhausen, Stadt",null],["150870386386","Seegebiet Mansfelder Land",null],["150870412412","Südharz",null],["150875051055","Berga",null],["150875051101","Brücken-Hackpfüffel",null],["150875051125","Edersleben",null],["150875051250","Kelbra (Kyffhäuser), Stadt",null],["150875051440","Wallhausen",null],["150875052010","Ahlsdorf",null],["150875052045","Benndorf",null],["150875052070","Blankenheim",null],["150875052075","Bornstedt",null],["150875052205","Helbra",null],["150875052210","Hergisdorf",null],["150875052260","Klostermansfeld",null],["150875052470","Wimmelburg",null],["150880020020","Bad Dürrenberg, Solestadt",null],["150880025025","Bad Lauchstädt, Goethestadt",null],["150880065065","Braunsbedra, Stadt",null],["150880150150","Kabelsketal",null],["150880195195","Landsberg, Stadt",null],["150880205205","Leuna, Stadt",null],["150880216216","Wettin-Löbejün, Stadt",null],["150880220220","Merseburg, Stadt",null],["150880235235","Mücheln (Geiseltal), Stadt",null],["150880295295","Petersberg",null],["150880305305","Querfurt, Stadt",null],["150880319319","Salzatal",null],["150880330330","Schkopau",null],["150880365365","Teutschenthal",null],["150885051030","Barnstädt",null],["150885051100","Farnstädt",null],["150885051250","Nemsdorf-Göhrendorf",null],["150885051265","Obhausen",null],["150885051340","Schraplau, Stadt",null],["150885051355","Steigra",null],["150890015015","Aschersleben, Stadt",null],["150890026026","Barby, Stadt",null],["150890030030","Bernburg (Saale), Stadt",null],["150890042042","Bördeland",null],["150890055055","Calbe (Saale), Stadt",null],["150890175175","Hecklingen, Stadt",null],["150890195195","Könnern, Stadt",null],["150890235235","Nienburg (Saale), Stadt",null],["150890305305","Schönebeck (Elbe), Stadt",null],["150890307307","Seeland, Stadt",null],["150890310310","Staßfurt, Stadt",null],["150895051041","Bördeaue",null],["150895051043","Börde-Hakel",null],["150895051045","Borne",null],["150895051075","Egeln, Stadt",null],["150895051365","Wolmirsleben",null],["150895052005","Alsleben (Saale), Stadt",null],["150895052130","Giersleben",null],["150895052165","Güsten, Stadt",null],["150895052185","Ilberstedt",null],["150895052245","Plötzkau",null],["150900070070","Bismark (Altmark), Stadt",null],["150900225225","Havelberg, Hansestadt",null],["150900415415","Osterburg (Altmark), Hansestadt",null],["150900535535","Stendal, Hansestadt",null],["150900546546","Tangerhütte, Stadt",null],["150900550550","Tangermünde, Stadt",null],["150905051010","Arneburg, Stadt",null],["150905051135","Eichstedt (Altmark)",null],["150905051180","Goldbeck",null],["150905051220","Hassel",null],["150905051245","Hohenberg-Krusemark",null],["150905051270","Iden",null],["150905051435","Rochau",null],["150905051610","Werben (Elbe), Hansestadt",null],["150905052285","Kamern",null],["150905052310","Klietz",null],["150905052445","Sandau (Elbe), Stadt",null],["150905052485","Schollene",null],["150905052500","Schönhausen (Elbe)",null],["150905052631","Wust-Fischbeck",null],["150905053003","Aland",null],["150905053007","Altmärkische Höhe",null],["150905053008","Altmärkische Wische",null],["150905053520","Seehausen (Altmark), Hansestadt",null],["150905053635","Zehrental",null],["150910010010","Annaburg, Stadt",null],["150910020020","Bad Schmiedeberg, Stadt",null],["150910060060","Coswig (Anhalt), Stadt",null],["150910110110","Gräfenhainichen, Stadt",null],["150910145145","Jessen (Elster), Stadt",null],["150910160160","Kemberg, Stadt",null],["150910241241","Oranienbaum-Wörlitz, Stadt",null],["150910375375","Wittenberg, Lutherstadt",null],["150910391391","Zahna-Elster, Stadt",null],["160510000000","Erfurt, Stadt",null],["160520000000","Gera, Stadt",null],["160530000000","Jena, Stadt",null],["160540000000","Suhl, Stadt",null],["160550000000","Weimar, Stadt",null],["160610045045","Heilbad Heiligenstadt, Stadt",null],["160610074074","Niederorschel",null],["160610115115","Leinefelde-Worbis, Stadt",null],["160610116116","Am Ohmberg",null],["160610117117","Sonnenstein",null],["160610118118","Dingelstädt, Stadt",null],["160615001003","Berlingerode",null],["160615001015","Brehme",null],["160615001026","Ecklingerode",null],["160615001031","Ferna",null],["160615001094","Tastungen",null],["160615001103","Wehnde",null],["160615001114","Teistungen",null],["160615006017","Breitenworbis",null],["160615006019","Buhla",null],["160615006037","Gernrode",null],["160615006044","Haynrode",null],["160615006058","Kirchworbis",null],["160615008001","Arenshausen",null],["160615008014","Bornhagen",null],["160615008021","Burgwalde",null],["160615008032","Freienhagen",null],["160615008033","Fretterode",null],["160615008036","Gerbershausen",null],["160615008048","Hohengandern",null],["160615008057","Kirchgandern",null],["160615008066","Lindewerra",null],["160615008069","Marth",null],["160615008078","Rohrberg",null],["160615008082","Rustenfelde",null],["160615008083","Schachtebich",null],["160615008102","Wahlhausen",null],["160615009012","Bodenrode-Westhausen",null],["160615009034","Geisleden",null],["160615009039","Glasehausen",null],["160615009047","Heuthen",null],["160615009049","Hohes Kreuz",null],["160615009076","Reinholterode",null],["160615009089","Steinbach",null],["160615009107","Wingerode",null],["160615012002","Asbach-Sickenberg",null],["160615012007","Birkenfelde",null],["160615012024","Dietzenrode/Vatterode",null],["160615012028","Eichstruth",null],["160615012065","Lenterode",null],["160615012067","Lutter",null],["160615012068","Mackenrode",null],["160615012077","Röhrig",null],["160615012084","Schönhagen",null],["160615012091","Steinheuterode",null],["160615012096","Thalwenden",null],["160615012097","Uder",null],["160615012111","Wüstheuterode",null],["160615013018","Büttstedt",null],["160615013027","Effelder",null],["160615013041","Großbartloff",null],["160615013063","Küllstedt",null],["160615013101","Wachstedt",null],["160615014023","Dieterode",null],["160615014035","Geismar",null],["160615014056","Kella",null],["160615014062","Krombach",null],["160615014075","Pfaffschwende",null],["160615014085","Schwobfeld",null],["160615014086","Sickerode",null],["160615014098","Volkerode",null],["160615014105","Wiesenfeld",null],["160615014113","Schimberg",null],["160620005005","Ellrich, Stadt",null],["160620041041","Nordhausen, Stadt",null],["160620049049","Sollstedt",null],["160620062062","Hohenstein",null],["160620063063","Werther",null],["160620065065","Harztor",null],["160625053008","Görsbach",null],["160625053054","Urbach",null],["160625053064","Heringen/Helme, Stadt",null],["160625054009","Großlohra",null],["160625054024","Kehmstedt",null],["160625054026","Kleinfurra",null],["160625054033","Lipprechterode",null],["160625054037","Niedergebra",null],["160625054066","Bleicherode, Stadt",null],["160630004004","Barchfeld-Immelborn",null],["160630076076","Treffurt, Stadt",null],["160630078078","Unterbreizbach",null],["160630082082","Vacha, Stadt",null],["160630092092","Wutha-Farnroda",null],["160630097097","Gerstungen",null],["160630098098","Hörselberg-Hainich",null],["160630099099","Bad Liebenstein, Stadt",null],["160630101101","Krayenberggemeinde",null],["160630103103","Werra-Suhl-Tal, Stadt",null],["160630105105","Eisenach, Stadt",null],["160635006006","Berka v. d. Hainich",null],["160635006008","Bischofroda",null],["160635006028","Frankenroda",null],["160635006037","Hallungen",null],["160635006046","Krauthausen",null],["160635006049","Lauterbach",null],["160635006058","Nazza",null],["160635006104","Amt Creuzburg, Stadt",null],["160635051003","Bad Salzungen, Stadt",null],["160635051051","Leimbach",null],["160635056011","Buttlar",null],["160635056032","Geisa, Stadt",null],["160635056033","Gerstengrund",null],["160635056068","Schleid",null],["160635057066","Ruhla, Stadt",null],["160635057071","Seebach",null],["160635059015","Dermbach",null],["160635059023","Empfertshausen",null],["160635059062","Oechsen",null],["160635059084","Weilar",null],["160635059086","Wiesenthal",null],["160640003003","Bad Langensalza, Stadt",null],["160640014014","Dünwald",null],["160640046046","Mühlhausen/Thüringen, Stadt",null],["160640071071","Unstruttal",null],["160640072072","Menteroda",null],["160640073073","Anrode",null],["160645001004","Bad Tennstedt, Stadt",null],["160645001005","Ballhausen",null],["160645001007","Blankenburg",null],["160645001009","Bruchstedt",null],["160645001021","Haussömmern",null],["160645001027","Hornsömmern",null],["160645001033","Kirchheilingen",null],["160645001038","Kutzleben",null],["160645001045","Mittelsömmern",null],["160645001061","Sundhausen",null],["160645001062","Tottleben",null],["160645001064","Urleben",null],["160645051019","Großvargula",null],["160645051022","Herbsleben",null],["160645052055","Rodeberg",null],["160645052074","Südeichsfeld",null],["160645053032","Kammerforst",null],["160645053053","Oppershausen",null],["160645053075","Vogtei",null],["160645054058","Schönstedt",null],["160645054076","Unstrut-Hainich",null],["160645055037","Körner",null],["160645055043","Marolterode",null],["160645055077","Nottertal-Heilinger Höhen, Stadt",null],["160650003003","Bad Frankenhausen/Kyffhäuser, Stadt",null],["160650032032","Helbedündorf",null],["160650067067","Sondershausen, Stadt",null],["160650085085","Kyffhäuserland",null],["160650087087","Roßleben-Wiehe, Stadt",null],["160650089089","Greußen, Stadt",null],["160655002012","Clingen, Stadt",null],["160655002048","Niederbösa",null],["160655002051","Oberbösa",null],["160655002074","Topfstedt",null],["160655002075","Trebra",null],["160655002077","Wasserthaleben",null],["160655002079","Westgreußen",null],["160655052001","Abtsbessingen",null],["160655052005","Bellstedt",null],["160655052014","Ebeleben, Stadt",null],["160655052018","Freienbessingen",null],["160655052038","Holzsußra",null],["160655052058","Rockstedt",null],["160655055008","Borxleben",null],["160655055019","Gehofen",null],["160655055042","Kalbsrieth",null],["160655055046","Mönchpfiffel-Nikolausrieth",null],["160655055056","Reinsdorf",null],["160655055086","Artern, Stadt",null],["160655056016","Etzleben",null],["160655056052","Oberheldrungen",null],["160655056088","An der Schmücke, Stadt",null],["160660023023","Floh-Seligenthal",null],["160660047047","Oberhof, Stadt",null],["160660063063","Schmalkalden, Kurort, Stadt",null],["160660069069","Steinbach-Hallenberg, Kurort, Stadt",null],["160660074074","Brotterode-Trusetal, Stadt",null],["160660092092","Zella-Mehlis, Stadt",null],["160660093093","Rhönblick",null],["160660094094","Grabfeld",null],["160665005012","Birx",null],["160665005019","Erbenhausen",null],["160665005024","Frankenheim/Rhön",null],["160665005052","Oberweid",null],["160665005095","Kaltennordheim, Stadt",null],["160665013025","Friedelshausen",null],["160665013041","Mehmels",null],["160665013064","Schwallungen",null],["160665013086","Wasungen, Stadt",null],["160665014005","Belrieth",null],["160665014015","Christes",null],["160665014016","Dillstädt",null],["160665014017","Einhausen",null],["160665014018","Ellingshausen",null],["160665014038","Kühndorf",null],["160665014039","Leutersdorf",null],["160665014045","Neubrunn",null],["160665014049","Obermaßfeld-Grimmenthal",null],["160665014057","Ritschenhausen",null],["160665014058","Rohr",null],["160665014065","Schwarza",null],["160665014079","Utendorf",null],["160665014081","Vachdorf",null],["160665050042","Meiningen, Stadt",null],["160665050056","Rippershausen",null],["160665050073","Sülzfeld",null],["160665050076","Untermaßfeld",null],["160665051013","Breitungen/Werra",null],["160665051022","Fambach",null],["160665051059","Rosa",null],["160665051061","Roßdorf",null],["160670019019","Friedrichroda, Stadt",null],["160670029029","Gotha, Stadt",null],["160670064064","Bad Tabarz",null],["160670065065","Tambach-Dietharz/Thür. Wald, Stadt",null],["160670072072","Waltershausen, Stadt",null],["160670087087","Nesse-Apfelstädt",null],["160670088088","Hörsel",null],["160675007004","Bienstädt",null],["160675007016","Eschenbergen",null],["160675007022","Friemar",null],["160675007047","Molschleben",null],["160675007052","Nottleben",null],["160675007055","Pferdingsleben",null],["160675007068","Tröchtelborn",null],["160675007071","Tüttleben",null],["160675007082","Zimmernsupra",null],["160675012009","Dachwig",null],["160675012011","Döllstädt",null],["160675012026","Gierstädt",null],["160675012033","Großfahner",null],["160675012067","Tonna",null],["160675050044","Luisenthal",null],["160675050053","Ohrdruf, Stadt",null],["160675052059","Schwabhausen",null],["160675052089","Drei Gleichen",null],["160675053063","Sonneborn",null],["160675053091","Nessetal",null],["160675054013","Emleben",null],["160675054036","Herrenhof",null],["160675054092","Georgenthal",null],["160680034034","Kölleda, Stadt",null],["160680051051","Sömmerda, Stadt",null],["160680058058","Weißensee, Stadt",null],["160680063063","Buttstädt",null],["160685002002","Andisleben",null],["160685002014","Gebesee, Stadt",null],["160685002045","Ringleben",null],["160685002057","Walschleben",null],["160685005005","Büchel",null],["160685005015","Griefstedt",null],["160685005022","Günstedt",null],["160685005043","Riethgen",null],["160685005064","Kindelbrück",null],["160685006019","Großneuhausen",null],["160685006033","Kleinneuhausen",null],["160685006041","Ostramondra",null],["160685006042","Rastenberg, Stadt",null],["160685009013","Gangloffsömmern",null],["160685009025","Haßleben",null],["160685009044","Riethnordhausen",null],["160685009049","Schwerstedt",null],["160685009053","Straußfurt",null],["160685009059","Werningshausen",null],["160685009062","Wundersleben",null],["160685012001","Alperstedt",null],["160685012007","Eckstedt",null],["160685012017","Großmölsen",null],["160685012021","Großrudestedt",null],["160685012032","Kleinmölsen",null],["160685012036","Markvippach",null],["160685012037","Nöda",null],["160685012039","Ollendorf",null],["160685012048","Schloßvippach",null],["160685012052","Sprötau",null],["160685012055","Udestedt",null],["160685012056","Vogelsberg",null],["160685050009","Elxleben",null],["160685050061","Witterda",null],["160690012012","Eisfeld, Stadt",null],["160690024024","Hildburghausen, Stadt",null],["160690042042","Schleusegrund",null],["160690043043","Schleusingen, Stadt",null],["160690053053","Veilsdorf",null],["160690061061","Masserberg",null],["160690062062","Römhild, Stadt",null],["160695002001","Ahlstädt",null],["160695002003","Beinerstadt",null],["160695002004","Bischofrod",null],["160695002008","Dingsleben",null],["160695002009","Ehrenberg",null],["160695002011","Eichenberg",null],["160695002016","Grimmelshausen",null],["160695002017","Grub",null],["160695002021","Henfstädt",null],["160695002025","Kloster Veßra",null],["160695002026","Lengfeld",null],["160695002028","Marisfeld",null],["160695002035","Oberstadt",null],["160695002037","Reurieth",null],["160695002044","Schmeheim",null],["160695002047","St.Bernhard",null],["160695002051","Themar, Stadt",null],["160695004041","Schlechtsart",null],["160695004046","Schweickershausen",null],["160695004049","Straufhain",null],["160695004052","Ummerstadt, Stadt",null],["160695004056","Westhausen",null],["160695004063","Heldburg, Stadt",null],["160695051006","Brünn/Thür.",null],["160695051058","Auengrund",null],["160700004004","Arnstadt, Stadt",null],["160700028028","Amt Wachsenburg",null],["160700029029","Ilmenau, Stadt",null],["160700048048","Stadtilm, Stadt",null],["160700057057","Geratal",null],["160700058058","Großbreitenbach, Stadt",null],["160705002011","Elgersburg",null],["160705002034","Martinroda",null],["160705002043","Plaue, Stadt",null],["160705009001","Alkersleben",null],["160705009006","Bösleben-Wüllersleben",null],["160705009008","Dornheim",null],["160705009012","Elleben",null],["160705009013","Elxleben",null],["160705009041","Osthausen-Wülfershausen",null],["160705009054","Witzleben",null],["160710001001","Apolda, Stadt",null],["160710003003","Bad Berka, Stadt",null],["160710008008","Blankenhain, Stadt",null],["160710101101","Ilmtal-Weinstraße",null],["160710103103","Grammetal",null],["160715007032","Hohenfelden",null],["160715007043","Klettbach",null],["160715007046","Kranichfeld, Stadt",null],["160715007059","Nauendorf",null],["160715007079","Rittersdorf",null],["160715007087","Tonndorf",null],["160715008009","Buchfart",null],["160715008013","Döbritschen",null],["160715008019","Frankendorf",null],["160715008025","Großschwabhausen",null],["160715008027","Hammerstedt",null],["160715008031","Hetschburg",null],["160715008037","Kapellendorf",null],["160715008038","Kiliansroda",null],["160715008042","Kleinschwabhausen",null],["160715008049","Lehnstedt",null],["160715008053","Magdala, Stadt",null],["160715008055","Mechelroda",null],["160715008056","Mellingen",null],["160715008071","Oettern",null],["160715008089","Umpferstedt",null],["160715008093","Vollersroda",null],["160715008095","Wiegendorf",null],["160715051004","Bad Sulza, Stadt",null],["160715051015","Eberstedt",null],["160715051022","Großheringen",null],["160715051064","Niedertrebra",null],["160715051069","Obertrebra",null],["160715051077","Rannstedt",null],["160715051083","Schmiedehausen",null],["160715053005","Ballstedt",null],["160715053017","Ettersburg",null],["160715053061","Neumark, Stadt",null],["160715053102","Am Ettersberg",null],["160720011011","Lauscha, Stadt",null],["160720015015","Schalkau, Stadt",null],["160720018018","Sonneberg, Stadt",null],["160720019019","Steinach, Stadt",null],["160720023023","Frankenblick",null],["160720024024","Föritztal",null],["160725051006","Goldisthal",null],["160725051013","Neuhaus am Rennweg, Stadt",null],["160730005005","Bad Blankenburg, Stadt",null],["160730076076","Rudolstadt, Stadt",null],["160730077077","Saalfeld/Saale, Stadt",null],["160730106106","Leutenberg, Stadt",null],["160730109109","Uhlstädt-Kirchhasel",null],["160730111111","Unterwellenborn",null],["160735005028","Gräfenthal, Stadt",null],["160735005046","Lehesten, Stadt",null],["160735005067","Probstzella",null],["160735012013","Cursdorf",null],["160735012014","Deesbach",null],["160735012017","Döschnitz",null],["160735012037","Katzhütte",null],["160735012055","Meura",null],["160735012074","Rohrbach",null],["160735012082","Schwarzburg",null],["160735012084","Sitzendorf",null],["160735012094","Unterweißbach",null],["160735012113","Schwarzatal, Stadt",null],["160735051002","Altenbeuthen",null],["160735051035","Hohenwarte",null],["160735051038","Kaulsdorf",null],["160735051107","Drognitz",null],["160735054001","Allendorf",null],["160735054006","Bechstedt",null],["160735054112","Königsee, Stadt",null],["160740044044","Kahla, Stadt",null],["160745005012","Crossen an der Elster",null],["160745005038","Hartmannsdorf",null],["160745005039","Heideland",null],["160745005072","Rauda",null],["160745005092","Silbitz",null],["160745005106","Walpernhain",null],["160745005116","Schkölen, Stadt",null],["160745007007","Bremsnitz",null],["160745007017","Eineborn",null],["160745007022","Geisenhain",null],["160745007024","Gneus",null],["160745007029","Großbockedra",null],["160745007045","Karlsdorf",null],["160745007046","Kleinbockedra",null],["160745007047","Kleinebersdorf",null],["160745007053","Lippersdorf-Erdmannsdorf",null],["160745007056","Meusebach",null],["160745007064","Oberbodnitz",null],["160745007066","Ottendorf",null],["160745007071","Rattelsdorf",null],["160745007074","Rausdorf",null],["160745007077","Renthendorf",null],["160745007097","Tautendorf",null],["160745007101","Tissa",null],["160745007102","Trockenborn-Wolfersdorf",null],["160745007103","Tröbnitz",null],["160745007104","Unterbodnitz",null],["160745007107","Waltersdorf",null],["160745007108","Weißbach",null],["160745011002","Altenberga",null],["160745011004","Bibra",null],["160745011008","Bucha",null],["160745011016","Eichenberg",null],["160745011021","Freienorla",null],["160745011031","Großeutersdorf",null],["160745011033","Großpürschütz",null],["160745011034","Gumperda",null],["160745011042","Hummelshain",null],["160745011048","Kleineutersdorf",null],["160745011049","Laasdorf",null],["160745011052","Lindig",null],["160745011057","Milda",null],["160745011065","Orlamünde, Stadt",null],["160745011076","Reinstädt",null],["160745011079","Rothenstein",null],["160745011087","Schöps",null],["160745011089","Seitenroda",null],["160745011095","Sulza",null],["160745011114","Zöllnitz",null],["160745014041","Hermsdorf, Stadt",null],["160745014059","Mörsdorf",null],["160745014075","Reichenbach",null],["160745014084","Schleifreisen",null],["160745014093","St.Gangloff",null],["160745015011","Dornburg-Camburg, Stadt",null],["160745015019","Frauenprießnitz",null],["160745015026","Golmsdorf",null],["160745015032","Großlöbichau",null],["160745015036","Hainichen",null],["160745015043","Jenalöbnitz",null],["160745015051","Lehesten",null],["160745015054","Löberschütz",null],["160745015063","Neuengönna",null],["160745015096","Tautenburg",null],["160745015099","Thierschneck",null],["160745015112","Wichmar",null],["160745015113","Zimmern",null],["160745050058","Möckern",null],["160745050081","Ruttersdorf-Lotschen",null],["160745050094","Stadtroda, Stadt",null],["160745051009","Bürgel, Stadt",null],["160745051028","Graitschen b. Bürgel",null],["160745051061","Nausnitz",null],["160745051068","Poxdorf",null],["160745052018","Eisenberg, Stadt",null],["160745052025","Gösen",null],["160745052037","Hainspitz",null],["160745052055","Mertendorf",null],["160745052067","Petersberg",null],["160745052073","Rauschwitz",null],["160745053001","Albersdorf",null],["160745053003","Bad Klosterlausnitz",null],["160745053005","Bobeck",null],["160745053082","Scheiditz",null],["160745053085","Schlöben",null],["160745053086","Schöngleina",null],["160745053091","Serba",null],["160745053098","Tautenhain",null],["160745053105","Waldeck",null],["160745053109","Weißenborn",null],["160750046046","Hirschberg, Stadt",null],["160750062062","Bad Lobenstein, Stadt",null],["160750085085","Pößneck, Stadt",null],["160750098098","Schleiz, Stadt",null],["160750131131","Gefell, Stadt",null],["160750132132","Tanna, Stadt",null],["160750133133","Wurzbach, Stadt",null],["160750134134","Remptendorf",null],["160750135135","Saalburg-Ebersdorf, Stadt",null],["160750136136","Rosenthal am Rennsteig",null],["160755004014","Dittersdorf",null],["160755004033","Görkwitz",null],["160755004034","Göschitz",null],["160755004048","Kirschkau",null],["160755004063","Löhma",null],["160755004068","Moßbach",null],["160755004072","Neundorf (bei Schleiz)",null],["160755004076","Oettersdorf",null],["160755004083","Plothen",null],["160755004084","Pörmitz",null],["160755004109","Tegau",null],["160755004119","Volkmannsdorf",null],["160755005006","Bodelwitz",null],["160755005016","Döbritz",null],["160755005031","Gertewitz",null],["160755005039","Grobengereuth",null],["160755005054","Langenorla",null],["160755005056","Lausnitz b. Neustadt an der Orla",null],["160755005074","Nimritz",null],["160755005075","Oberoppurg",null],["160755005077","Oppurg",null],["160755005087","Quaschwitz",null],["160755005105","Solkwitz",null],["160755005121","Weira",null],["160755005124","Wernburg",null],["160755011019","Dreitzsch",null],["160755011029","Geroda",null],["160755011057","Lemnitz",null],["160755011065","Miesitz",null],["160755011066","Mittelpöllnitz",null],["160755011093","Rosendorf",null],["160755011099","Schmieritz",null],["160755011114","Tömmelsdorf",null],["160755011116","Triptis, Stadt",null],["160755013023","Eßbach",null],["160755013035","Gössitz",null],["160755013047","Keila",null],["160755013069","Moxa",null],["160755013079","Paska",null],["160755013081","Peuschen",null],["160755013088","Ranis, Stadt",null],["160755013101","Schmorda",null],["160755013102","Schöndorf",null],["160755013103","Seisla",null],["160755013125","Wilhelmsdorf",null],["160755013127","Ziegenrück, Stadt",null],["160755013129","Krölpa",null],["160755050051","Kospoda",null],["160755050073","Neustadt an der Orla, Stadt",null],["160760004004","Berga/Elster, Stadt",null],["160760022022","Greiz, Stadt",null],["160760061061","Ronneburg, Stadt",null],["160760088088","Harth-Pöllnitz",null],["160760089089","Kraftsdorf",null],["160760092092","Auma-Weidatal, Stadt",null],["160760093093","Mohlsdorf-Teichwolframsdorf",null],["160765004009","Braunichswalde",null],["160765004017","Endschütz",null],["160765004019","Gauern",null],["160765004027","Hilbersdorf",null],["160765004034","Kauern",null],["160765004043","Linda b. Weida",null],["160765004055","Paitzdorf",null],["160765004062","Rückersdorf",null],["160765004069","Seelingstädt",null],["160765004074","Teichwitz",null],["160765004084","Wünschendorf/Elster",null],["160765006007","Bocka",null],["160765006033","Hundhaupten",null],["160765006042","Lederhose",null],["160765006044","Lindenkreuz",null],["160765006049","Münchenbernsdorf, Stadt",null],["160765006064","Saara",null],["160765006068","Schwarzbach",null],["160765006086","Zedlitz",null],["160765008006","Bethenhausen",null],["160765008008","Brahmenau",null],["160765008023","Großenstein",null],["160765008028","Hirschfeld",null],["160765008036","Korbußen",null],["160765008058","Pölzig",null],["160765008059","Reichstädt",null],["160765008067","Schwaara",null],["160765051003","Bad Köstritz, Stadt",null],["160765051012","Caaschwitz",null],["160765051026","Hartmannsdorf",null],["160765053014","Crimla",null],["160765053079","Weida, Stadt",null],["160765054041","Langenwolschendorf",null],["160765054081","Weißendorf",null],["160765054087","Zeulenroda-Triebes, Stadt",null],["160765056029","Hohenleuben, Stadt",null],["160765056038","Kühdorf",null],["160765056039","Langenwetzendorf",null],["160770001001","Altenburg, Stadt",null],["160770028028","Lucka, Stadt",null],["160770032032","Meuselwitz, Stadt",null],["160775004005","Fockendorf",null],["160775004007","Gerstenberg",null],["160775004015","Haselbach",null],["160775004048","Treben",null],["160775004052","Windischleuba",null],["160775005008","Göhren",null],["160775005009","Göllnitz",null],["160775005022","Kriebitzsch",null],["160775005027","Lödla",null],["160775005031","Mehna",null],["160775005034","Monstab",null],["160775005042","Rositz",null],["160775005044","Starkenberg",null],["160775009016","Heukewalde",null],["160775009018","Jonaswalde",null],["160775009026","Löbichau",null],["160775009041","Posterstein",null],["160775009047","Thonhausen",null],["160775009049","Vollmershain",null],["160775050012","Gößnitz, Stadt",null],["160775050017","Heyersdorf",null],["160775050039","Ponitz",null],["160775051011","Göpfersdorf",null],["160775051023","Langenleuba-Niederhain",null],["160775051036","Nobitz",null],["160775052003","Dobitschen",null],["160775052043","Schmölln, Stadt",null]]} \ No newline at end of file diff --git a/tests/components/nina/fixtures/sample_warnings.json b/tests/components/nina/fixtures/sample_warnings.json new file mode 100644 index 00000000000000..d53fecffa63208 --- /dev/null +++ b/tests/components/nina/fixtures/sample_warnings.json @@ -0,0 +1,44 @@ +[ + { + "id": "mow.DE-BW-S-SE018-20211102-18-001", + "payload": { + "version": 1, + "type": "ALERT", + "id": "mow.DE-BW-S-SE018-20211102-18-001", + "hash": "cae97b1c11bde900017305f681904ad5a6e8fd1c841241ced524b83eaa3522f4", + "data": { + "headline": "Corona-Verordnung des Landes: Warnstufe durch Landesgesundheitsamt ausgerufen", + "provider": "MOWAS", + "severity": "Minor", + "msgType": "Update", + "transKeys": {"event": "BBK-EVC-040"}, + "area": {"type": "ZGEM", "data": "9956+1102,100001"} + } + }, + "i18nTitle": { + "de": "Corona-Verordnung des Landes: Warnstufe durch Landesgesundheitsamt ausgerufen" + }, + "sent": "2021-11-02T20:07:16+01:00" + }, + { + "id": "mow.DE-NW-BN-SE030-20201014-30-000", + "payload": { + "version": 1, + "type": "ALERT", + "id": "mow.DE-NW-BN-SE030-20201014-30-000", + "hash": "551db820a43be7e4f39283e1dfb71b212cd520c3ee478d44f43519e9c48fde4c", + "data": { + "headline": "Ausfall Notruf 112", + "provider": "MOWAS", + "severity": "Minor", + "msgType": "Update", + "transKeys": {"event": "BBK-EVC-040"}, + "area": {"type": "ZGEM", "data": "1+11057,100001"} + } + }, + "i18nTitle": {"de": "Ausfall Notruf 112"}, + "start": "2021-11-01T05:20:00+01:00", + "sent": "2021-10-11T05:20:00+01:00", + "expires": "3021-11-22T05:19:00+01:00" + } +] \ No newline at end of file diff --git a/tests/components/nina/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py new file mode 100644 index 00000000000000..ebdd7ed4105ea4 --- /dev/null +++ b/tests/components/nina/test_binary_sensor.py @@ -0,0 +1,215 @@ +"""Test the Nina binary sensor.""" +import json +from typing import Any +from unittest.mock import patch + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.nina.const import ( + ATTR_EXPIRES, + ATTR_HEADLINE, + ATTR_ID, + ATTR_SENT, + ATTR_START, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, load_fixture + +ENTRY_DATA: dict[str, Any] = { + "slots": 5, + "corona_filter": True, + "regions": {"083350000000": "Aach, Stadt"}, +} + +ENTRY_DATA_NO_CORONA: dict[str, Any] = { + "slots": 5, + "corona_filter": False, + "regions": {"083350000000": "Aach, Stadt"}, +} + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test the creation and values of the NINA sensors.""" + + dummy_response: dict[str, Any] = json.loads( + load_fixture("sample_warnings.json", "nina") + ) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + return_value=dummy_response, + ): + + conf_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=ENTRY_DATA + ) + + entity_registry: er = er.async_get(hass) + conf_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(conf_entry.entry_id) + await hass.async_block_till_done() + + assert conf_entry.state == ConfigEntryState.LOADED + + state_w1 = hass.states.get("binary_sensor.warning_aach_stadt_1") + entry_w1 = entity_registry.async_get("binary_sensor.warning_aach_stadt_1") + + assert state_w1.state == STATE_ON + assert state_w1.attributes.get(ATTR_HEADLINE) == "Ausfall Notruf 112" + assert state_w1.attributes.get(ATTR_ID) == "mow.DE-NW-BN-SE030-20201014-30-000" + assert state_w1.attributes.get(ATTR_SENT) == "2021-10-11T04:20:00+00:00" + assert state_w1.attributes.get(ATTR_START) == "2021-11-01T04:20:00+00:00" + assert state_w1.attributes.get(ATTR_EXPIRES) == "3021-11-22T04:19:00+00:00" + + assert entry_w1.unique_id == "083350000000-1" + assert state_w1.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w2 = hass.states.get("binary_sensor.warning_aach_stadt_2") + entry_w2 = entity_registry.async_get("binary_sensor.warning_aach_stadt_2") + + assert state_w2.state == STATE_OFF + assert state_w2.attributes.get(ATTR_HEADLINE) is None + assert state_w2.attributes.get(ATTR_ID) is None + assert state_w2.attributes.get(ATTR_SENT) is None + assert state_w2.attributes.get(ATTR_START) is None + assert state_w2.attributes.get(ATTR_EXPIRES) is None + + assert entry_w2.unique_id == "083350000000-2" + assert state_w2.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w3 = hass.states.get("binary_sensor.warning_aach_stadt_3") + entry_w3 = entity_registry.async_get("binary_sensor.warning_aach_stadt_3") + + assert state_w3.state == STATE_OFF + assert state_w3.attributes.get(ATTR_HEADLINE) is None + assert state_w3.attributes.get(ATTR_ID) is None + assert state_w3.attributes.get(ATTR_SENT) is None + assert state_w3.attributes.get(ATTR_START) is None + assert state_w3.attributes.get(ATTR_EXPIRES) is None + + assert entry_w3.unique_id == "083350000000-3" + assert state_w3.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w4 = hass.states.get("binary_sensor.warning_aach_stadt_4") + entry_w4 = entity_registry.async_get("binary_sensor.warning_aach_stadt_4") + + assert state_w4.state == STATE_OFF + assert state_w4.attributes.get(ATTR_HEADLINE) is None + assert state_w4.attributes.get(ATTR_ID) is None + assert state_w4.attributes.get(ATTR_SENT) is None + assert state_w4.attributes.get(ATTR_START) is None + assert state_w4.attributes.get(ATTR_EXPIRES) is None + + assert entry_w4.unique_id == "083350000000-4" + assert state_w4.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w5 = hass.states.get("binary_sensor.warning_aach_stadt_5") + entry_w5 = entity_registry.async_get("binary_sensor.warning_aach_stadt_5") + + assert state_w5.state == STATE_OFF + assert state_w5.attributes.get(ATTR_HEADLINE) is None + assert state_w5.attributes.get(ATTR_ID) is None + assert state_w5.attributes.get(ATTR_SENT) is None + assert state_w5.attributes.get(ATTR_START) is None + assert state_w5.attributes.get(ATTR_EXPIRES) is None + + assert entry_w5.unique_id == "083350000000-5" + assert state_w5.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + +async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: + """Test the creation and values of the NINA sensors without the corona filter.""" + + dummy_response: dict[str, Any] = json.loads( + load_fixture("nina/sample_warnings.json") + ) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + return_value=dummy_response, + ): + + conf_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=ENTRY_DATA_NO_CORONA + ) + + entity_registry: er = er.async_get(hass) + conf_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(conf_entry.entry_id) + await hass.async_block_till_done() + + assert conf_entry.state == ConfigEntryState.LOADED + + state_w1 = hass.states.get("binary_sensor.warning_aach_stadt_1") + entry_w1 = entity_registry.async_get("binary_sensor.warning_aach_stadt_1") + + assert state_w1.state == STATE_ON + assert ( + state_w1.attributes.get(ATTR_HEADLINE) + == "Corona-Verordnung des Landes: Warnstufe durch Landesgesundheitsamt ausgerufen" + ) + assert state_w1.attributes.get(ATTR_ID) == "mow.DE-BW-S-SE018-20211102-18-001" + assert state_w1.attributes.get(ATTR_SENT) == "2021-11-02T19:07:16+00:00" + assert state_w1.attributes.get(ATTR_START) is None + assert state_w1.attributes.get(ATTR_EXPIRES) is None + + assert entry_w1.unique_id == "083350000000-1" + assert state_w1.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w2 = hass.states.get("binary_sensor.warning_aach_stadt_2") + entry_w2 = entity_registry.async_get("binary_sensor.warning_aach_stadt_2") + + assert state_w2.state == STATE_ON + assert state_w2.attributes.get(ATTR_HEADLINE) == "Ausfall Notruf 112" + assert state_w2.attributes.get(ATTR_ID) == "mow.DE-NW-BN-SE030-20201014-30-000" + assert state_w2.attributes.get(ATTR_SENT) == "2021-10-11T04:20:00+00:00" + assert state_w2.attributes.get(ATTR_START) == "2021-11-01T04:20:00+00:00" + assert state_w2.attributes.get(ATTR_EXPIRES) == "3021-11-22T04:19:00+00:00" + + assert entry_w2.unique_id == "083350000000-2" + assert state_w2.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w3 = hass.states.get("binary_sensor.warning_aach_stadt_3") + entry_w3 = entity_registry.async_get("binary_sensor.warning_aach_stadt_3") + + assert state_w3.state == STATE_OFF + assert state_w3.attributes.get(ATTR_HEADLINE) is None + assert state_w3.attributes.get(ATTR_ID) is None + assert state_w3.attributes.get(ATTR_SENT) is None + assert state_w3.attributes.get(ATTR_START) is None + assert state_w3.attributes.get(ATTR_EXPIRES) is None + + assert entry_w3.unique_id == "083350000000-3" + assert state_w3.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w4 = hass.states.get("binary_sensor.warning_aach_stadt_4") + entry_w4 = entity_registry.async_get("binary_sensor.warning_aach_stadt_4") + + assert state_w4.state == STATE_OFF + assert state_w4.attributes.get(ATTR_HEADLINE) is None + assert state_w4.attributes.get(ATTR_ID) is None + assert state_w4.attributes.get(ATTR_SENT) is None + assert state_w4.attributes.get(ATTR_START) is None + assert state_w4.attributes.get(ATTR_EXPIRES) is None + + assert entry_w4.unique_id == "083350000000-4" + assert state_w4.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w5 = hass.states.get("binary_sensor.warning_aach_stadt_5") + entry_w5 = entity_registry.async_get("binary_sensor.warning_aach_stadt_5") + + assert state_w5.state == STATE_OFF + assert state_w5.attributes.get(ATTR_HEADLINE) is None + assert state_w5.attributes.get(ATTR_ID) is None + assert state_w5.attributes.get(ATTR_SENT) is None + assert state_w5.attributes.get(ATTR_START) is None + assert state_w5.attributes.get(ATTR_EXPIRES) is None + + assert entry_w5.unique_id == "083350000000-5" + assert state_w5.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py new file mode 100644 index 00000000000000..a1aa97e0fbe08f --- /dev/null +++ b/tests/components/nina/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test the Nina config flow.""" +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import patch + +from pynina import ApiError + +from homeassistant import data_entry_flow +from homeassistant.components.nina.const import ( + CONF_FILTER_CORONA, + CONF_MESSAGE_SLOTS, + CONST_REGION_A_TO_D, + CONST_REGION_E_TO_H, + CONST_REGION_I_TO_L, + CONST_REGION_M_TO_Q, + CONST_REGION_R_TO_U, + CONST_REGION_V_TO_Z, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from tests.common import load_fixture + +DUMMY_DATA: dict[str, Any] = { + CONF_MESSAGE_SLOTS: 5, + CONST_REGION_A_TO_D: ["095760000000_0", "095760000000_1"], + CONST_REGION_E_TO_H: ["010610000000_0", "010610000000_1"], + CONST_REGION_I_TO_L: ["071320000000_0", "071320000000_1"], + CONST_REGION_M_TO_Q: ["071380000000_0", "071380000000_1"], + CONST_REGION_R_TO_U: ["072320000000_0", "072320000000_1"], + CONST_REGION_V_TO_Z: ["081270000000_0", "081270000000_1"], + CONF_FILTER_CORONA: True, +} + +DUMMY_RESPONSE: dict[str, Any] = json.loads(load_fixture("sample_regions.json", "nina")) + + +async def test_show_set_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + return_value=DUMMY_RESPONSE, + ): + + result: dict[str, Any] = 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_user_connection_error(hass: HomeAssistant) -> None: + """Test starting a flow by user but no connection.""" + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + side_effect=ApiError("Could not connect to Api"), + ): + + result: dict[str, Any] = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None: + """Test starting a flow by user but with an unexpected exception.""" + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + side_effect=Exception("DUMMY"), + ): + + result: dict[str, Any] = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_step_user(hass: HomeAssistant) -> None: + """Test starting a flow by user with valid values.""" + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + return_value=DUMMY_RESPONSE, + ), patch( + "homeassistant.components.nina.async_setup_entry", + return_value=True, + ): + + result: dict[str, Any] = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "NINA" + + +async def test_step_user_no_selection(hass: HomeAssistant) -> None: + """Test starting a flow by user with no selection.""" + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + return_value=DUMMY_RESPONSE, + ): + + result: dict[str, Any] = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_selection"} + + +async def test_step_user_already_configured(hass: HomeAssistant) -> None: + """Test starting a flow by user but it was already configured.""" + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + return_value=DUMMY_RESPONSE, + ): + result: dict[str, Any] = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/nina/test_init.py b/tests/components/nina/test_init.py new file mode 100644 index 00000000000000..455d7465a8776e --- /dev/null +++ b/tests/components/nina/test_init.py @@ -0,0 +1,66 @@ +"""Test the Nina init file.""" +import json +from typing import Any +from unittest.mock import patch + +from pynina import ApiError + +from homeassistant.components.nina.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + +ENTRY_DATA: dict[str, Any] = { + "slots": 5, + "corona_filter": True, + "regions": {"083350000000": "Aach, Stadt"}, +} + + +async def init_integration(hass) -> MockConfigEntry: + """Set up the NINA integration in Home Assistant.""" + + dummy_response: dict[str, Any] = json.loads( + load_fixture("sample_warnings.json", "nina") + ) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + return_value=dummy_response, + ): + + entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=ENTRY_DATA + ) + entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + return entry + + +async def test_config_entry_not_ready(hass: HomeAssistant) -> None: + """Test the configuration entry.""" + entry: MockConfigEntry = await init_integration(hass) + + assert entry.state == ConfigEntryState.LOADED + + +async def test_sensors_connection_error(hass: HomeAssistant) -> None: + """Test the creation and values of the NINA sensors with no connected.""" + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + side_effect=ApiError("Could not connect to Api"), + ): + conf_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=ENTRY_DATA + ) + + conf_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(conf_entry.entry_id) + await hass.async_block_till_done() + + assert conf_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index aae48b80d10285..3016727f7beccd 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -241,70 +241,3 @@ async def test_options_flow(hass: HomeAssistant, mock_get_source_ip) -> None: CONF_SCAN_INTERVAL: 10, } assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import(hass: HomeAssistant, mock_get_source_ip) -> None: - """Test we can import from yaml.""" - - with patch( - "homeassistant.components.nmap_tracker.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOSTS: "1.2.3.4/20", - CONF_HOME_INTERVAL: 3, - CONF_CONSIDER_HOME: 500, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", - CONF_SCAN_INTERVAL: 2000, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == "Nmap Tracker 1.2.3.4/20" - assert result["data"] == {} - assert result["options"] == { - CONF_HOSTS: "1.2.3.4/20", - CONF_HOME_INTERVAL: 3, - CONF_CONSIDER_HOME: 500, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4,6.4.3.2", - CONF_SCAN_INTERVAL: 2000, - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_aborts_if_matching( - hass: HomeAssistant, mock_get_source_ip -) -> None: - """Test we can import from yaml.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_HOSTS: "192.168.0.0/20", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", - }, - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOSTS: "192.168.0.0/20", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py new file mode 100644 index 00000000000000..77efdacf943151 --- /dev/null +++ b/tests/components/notion/conftest.py @@ -0,0 +1,73 @@ +"""Define fixtures for Notion tests.""" +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.notion import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="client") +def client_fixture(data_bridge, data_sensor, data_task): + """Define a fixture for an aionotion client.""" + client = AsyncMock() + client.bridge.async_all.return_value = data_bridge + client.sensor.async_all.return_value = data_sensor + client.task.async_all.return_value = data_task + return client + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config, unique_id): + """Define a config entry fixture.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_USERNAME: "user@host.com", + CONF_PASSWORD: "password123", + } + + +@pytest.fixture(name="data_bridge", scope="session") +def data_bridge_fixture(): + """Define bridge data.""" + return json.loads(load_fixture("bridge_data.json", "notion")) + + +@pytest.fixture(name="data_sensor", scope="session") +def data_sensor_fixture(): + """Define sensor data.""" + return json.loads(load_fixture("sensor_data.json", "notion")) + + +@pytest.fixture(name="data_task", scope="session") +def data_task_fixture(): + """Define task data.""" + return json.loads(load_fixture("task_data.json", "notion")) + + +@pytest.fixture(name="setup_notion") +async def setup_notion_fixture(hass, client, config): + """Define a fixture to set up Notion.""" + with patch("homeassistant.components.notion.config_flow.async_get_client"), patch( + "homeassistant.components.notion.PLATFORMS", [] + ), patch("homeassistant.components.notion.async_get_client", return_value=client): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="unique_id") +def unique_id_fixture(hass): + """Define a config entry unique ID fixture.""" + return "user@host.com" diff --git a/tests/components/notion/fixtures/bridge_data.json b/tests/components/notion/fixtures/bridge_data.json new file mode 100644 index 00000000000000..c865dd18bb3be6 --- /dev/null +++ b/tests/components/notion/fixtures/bridge_data.json @@ -0,0 +1,26 @@ +[ + { + "id": 12345, + "name": null, + "mode": "home", + "hardware_id": "0x1234567890abcdef", + "hardware_revision": 4, + "firmware_version": { + "wifi": "0.121.0", + "wifi_app": "3.3.0", + "silabs": "1.0.1" + }, + "missing_at": null, + "created_at": "2019-04-30T01:43:50.497Z", + "updated_at": "2019-04-30T01:44:43.749Z", + "system_id": 12345, + "firmware": { + "wifi": "0.121.0", + "wifi_app": "3.3.0", + "silabs": "1.0.1" + }, + "links": { + "system": 12345 + } + } +] diff --git a/tests/components/notion/fixtures/sensor_data.json b/tests/components/notion/fixtures/sensor_data.json new file mode 100644 index 00000000000000..e631f856207df9 --- /dev/null +++ b/tests/components/notion/fixtures/sensor_data.json @@ -0,0 +1,70 @@ +[ + { + "id": 123456, + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": { + "id": 12345, + "email": "user@email.com" + }, + "bridge": { + "id": 12345, + "hardware_id": "0x1234567890abcdef" + }, + "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "Bathroom Sensor", + "location_id": 123456, + "system_id": 12345, + "hardware_id": "0x1234567890abcdef", + "firmware_version": "1.1.2", + "hardware_revision": 5, + "device_key": "0x1234567890abcdef", + "encryption_key": true, + "installed_at": "2019-04-30T01:57:34.443Z", + "calibrated_at": "2019-04-30T01:57:35.651Z", + "last_reported_at": "2019-04-30T02:20:04.821Z", + "missing_at": null, + "updated_at": "2019-04-30T01:57:36.129Z", + "created_at": "2019-04-30T01:56:45.932Z", + "signal_strength": 5, + "links": { + "location": 123456 + }, + "lqi": 0, + "rssi": -46, + "surface_type": null + }, + { + "id": 132462, + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": { + "id": 12345, + "email": "user@email.com" + }, + "bridge": { + "id": 12345, + "hardware_id": "0x1234567890abcdef" + }, + "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "Living Room Sensor", + "location_id": 123456, + "system_id": 12345, + "hardware_id": "0x1234567890abcdef", + "firmware_version": "1.1.2", + "hardware_revision": 5, + "device_key": "0x1234567890abcdef", + "encryption_key": true, + "installed_at": "2019-04-30T01:45:56.169Z", + "calibrated_at": "2019-04-30T01:46:06.256Z", + "last_reported_at": "2019-04-30T02:20:04.829Z", + "missing_at": null, + "updated_at": "2019-04-30T01:46:07.717Z", + "created_at": "2019-04-30T01:45:14.148Z", + "signal_strength": 5, + "links": { + "location": 123456 + }, + "lqi": 0, + "rssi": -30, + "surface_type": null + } +] diff --git a/tests/components/notion/fixtures/task_data.json b/tests/components/notion/fixtures/task_data.json new file mode 100644 index 00000000000000..a56d734fb77f2a --- /dev/null +++ b/tests/components/notion/fixtures/task_data.json @@ -0,0 +1,86 @@ +[ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "task_type": "missing", + "sensor_data": [], + "status": { + "value": "not_missing", + "received_at": "2020-11-11T21:18:06.613Z" + }, + "created_at": "2020-11-11T21:18:06.613Z", + "updated_at": "2020-11-11T21:18:06.617Z", + "sensor_id": 525993, + "model_version": "2.0", + "configuration": {}, + "links": { + "sensor": 525993 + } + }, + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "task_type": "leak", + "sensor_data": [], + "status": { + "insights": { + "primary": { + "from_state": null, + "to_state": "no_leak", + "data_received_at": "2020-11-11T21:19:13.755Z", + "origin": {} + } + } + }, + "created_at": "2020-11-11T21:19:13.755Z", + "updated_at": "2020-11-11T21:19:13.764Z", + "sensor_id": 525993, + "model_version": "2.1", + "configuration": {}, + "links": { + "sensor": 525993 + } + }, + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "task_type": "temperature", + "sensor_data": [], + "status": { + "value": "20.991287231445312", + "received_at": "2021-01-27T15:18:49.996Z" + }, + "created_at": "2020-11-11T21:19:13.856Z", + "updated_at": "2020-11-11T21:19:13.865Z", + "sensor_id": 525993, + "model_version": "2.1", + "configuration": { + "lower": 15.56, + "upper": 29.44, + "offset": 0 + }, + "links": { + "sensor": 525993 + } + }, + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "task_type": "low_battery", + "sensor_data": [], + "status": { + "insights": { + "primary": { + "from_state": null, + "to_state": "high", + "data_received_at": "2020-11-17T18:40:27.024Z", + "origin": {} + } + } + }, + "created_at": "2020-11-17T18:40:27.024Z", + "updated_at": "2020-11-17T18:40:27.033Z", + "sensor_id": 525993, + "model_version": "4.1", + "configuration": {}, + "links": { + "sensor": 525993 + } + } +] diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index bda70bd6af9308..45bcedde15502d 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -1,5 +1,5 @@ """Define tests for the Notion config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from aionotion.errors import InvalidCredentialsError, NotionError import pytest @@ -9,79 +9,38 @@ from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from tests.common import MockConfigEntry - -@pytest.fixture(name="client") -def client_fixture(): - """Define a fixture for an aionotion client.""" - return AsyncMock(return_value=None) - - -@pytest.fixture(name="client_login") -def client_login_fixture(client): - """Define a fixture for patching the aiowatttime coroutine to get a client.""" - with patch( - "homeassistant.components.notion.config_flow.async_get_client" - ) as mock_client: - mock_client.side_effect = client - yield mock_client - - -async def test_duplicate_error(hass): +async def test_duplicate_error(hass, config, config_entry): """Test that errors are shown when duplicates are added.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - MockConfigEntry(domain=DOMAIN, unique_id="user@host.com", data=conf).add_to_hass( - hass - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) - await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -@pytest.mark.parametrize("client", [AsyncMock(side_effect=NotionError)]) -async def test_generic_notion_error(client_login, hass): - """Test that a generic aionotion error is handled correctly.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf - ) - - assert result["errors"] == {"base": "unknown"} - - -@pytest.mark.parametrize("client", [AsyncMock(side_effect=InvalidCredentialsError)]) -async def test_invalid_credentials(client_login, hass): - """Test that invalid credentials throw an error.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf - ) - await hass.async_block_till_done() - - assert result["errors"] == {"base": "invalid_auth"} +@pytest.mark.parametrize( + "exc,error", + [ + (NotionError, "unknown"), + (InvalidCredentialsError, "invalid_auth"), + ], +) +async def test_erros(hass, config, error, exc): + """Test that exceptions show the correct error.""" + with patch( + "homeassistant.components.notion.config_flow.async_get_client", side_effect=exc + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=config + ) + assert result["errors"] == {"base": error} -async def test_step_reauth(client_login, hass): +async def test_step_reauth(hass, config, config_entry, setup_notion): """Test that the reauth step works.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="user@email.com", - data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, - ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH}, - data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + DOMAIN, context={"source": SOURCE_REAUTH}, data=config ) assert result["step_id"] == "reauth_confirm" @@ -89,9 +48,7 @@ async def test_step_reauth(client_login, hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "reauth_confirm" - with patch( - "homeassistant.components.notion.async_setup_entry", return_value=True - ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): + with patch("homeassistant.components.notion.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password"} ) @@ -102,27 +59,20 @@ async def test_step_reauth(client_login, hass): assert len(hass.config_entries.async_entries()) == 1 -async def test_show_form(client_login, hass): +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} ) - await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" -async def test_step_user(client_login, hass): +async def test_step_user(hass, config, setup_notion): """Test that the user step works.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - with patch("homeassistant.components.notion.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf - ) - await hass.async_block_till_done() - + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=config + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "user@host.com" assert result["data"] == { diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py new file mode 100644 index 00000000000000..39d2777462f48a --- /dev/null +++ b/tests/components/notion/test_diagnostics.py @@ -0,0 +1,111 @@ +"""Test Notion diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_notion): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "bridges": { + "12345": { + "id": 12345, + "name": None, + "mode": "home", + "hardware_id": "0x1234567890abcdef", + "hardware_revision": 4, + "firmware_version": { + "wifi": "0.121.0", + "wifi_app": "3.3.0", + "silabs": "1.0.1", + }, + "missing_at": None, + "created_at": "2019-04-30T01:43:50.497Z", + "updated_at": "2019-04-30T01:44:43.749Z", + "system_id": 12345, + "firmware": { + "wifi": "0.121.0", + "wifi_app": "3.3.0", + "silabs": "1.0.1", + }, + "links": {"system": 12345}, + } + }, + "sensors": { + "123456": { + "id": 123456, + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": {"id": 12345, "email": REDACTED}, + "bridge": {"id": 12345, "hardware_id": "0x1234567890abcdef"}, + "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "Bathroom Sensor", + "location_id": 123456, + "system_id": 12345, + "hardware_id": "0x1234567890abcdef", + "firmware_version": "1.1.2", + "hardware_revision": 5, + "device_key": REDACTED, + "encryption_key": True, + "installed_at": "2019-04-30T01:57:34.443Z", + "calibrated_at": "2019-04-30T01:57:35.651Z", + "last_reported_at": "2019-04-30T02:20:04.821Z", + "missing_at": None, + "updated_at": "2019-04-30T01:57:36.129Z", + "created_at": "2019-04-30T01:56:45.932Z", + "signal_strength": 5, + "links": {"location": 123456}, + "lqi": 0, + "rssi": -46, + "surface_type": None, + }, + "132462": { + "id": 132462, + "uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "user": {"id": 12345, "email": REDACTED}, + "bridge": {"id": 12345, "hardware_id": "0x1234567890abcdef"}, + "last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "name": "Living Room Sensor", + "location_id": 123456, + "system_id": 12345, + "hardware_id": "0x1234567890abcdef", + "firmware_version": "1.1.2", + "hardware_revision": 5, + "device_key": REDACTED, + "encryption_key": True, + "installed_at": "2019-04-30T01:45:56.169Z", + "calibrated_at": "2019-04-30T01:46:06.256Z", + "last_reported_at": "2019-04-30T02:20:04.829Z", + "missing_at": None, + "updated_at": "2019-04-30T01:46:07.717Z", + "created_at": "2019-04-30T01:45:14.148Z", + "signal_strength": 5, + "links": {"location": 123456}, + "lqi": 0, + "rssi": -30, + "surface_type": None, + }, + }, + "tasks": { + "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "task_type": "low_battery", + "sensor_data": [], + "status": { + "insights": { + "primary": { + "from_state": None, + "to_state": "high", + "data_received_at": "2020-11-17T18:40:27.024Z", + "origin": {}, + } + } + }, + "created_at": "2020-11-17T18:40:27.024Z", + "updated_at": "2020-11-17T18:40:27.033Z", + "sensor_id": 525993, + "model_version": "4.1", + "configuration": {}, + "links": {"sensor": 525993}, + } + }, + } diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index 634902e054ecba..77966bd7e5ff3c 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -25,8 +25,6 @@ async def test_form(hass): "homeassistant.components.nuki.config_flow.NukiBridge.info", return_value=MOCK_INFO, ), patch( - "homeassistant.components.nuki.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.nuki.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -47,40 +45,9 @@ async def test_form(hass): "port": 8080, "token": "test-token", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 -async def test_import(hass): - """Test that the import works.""" - - with patch( - "homeassistant.components.nuki.config_flow.NukiBridge.info", - return_value=MOCK_INFO, - ), patch( - "homeassistant.components.nuki.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.nuki.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": "1.1.1.1", "port": 8080, "token": "test-token"}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "75BCD15" - assert result["data"] == { - "host": "1.1.1.1", - "port": 8080, - "token": "test-token", - } - - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_form_invalid_auth(hass): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -189,8 +156,6 @@ async def test_dhcp_flow(hass): "homeassistant.components.nuki.config_flow.NukiBridge.info", return_value=MOCK_INFO, ), patch( - "homeassistant.components.nuki.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.nuki.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -212,7 +177,6 @@ async def test_dhcp_flow(hass): } await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -242,7 +206,7 @@ async def test_reauth_success(hass): with patch( "homeassistant.components.nuki.config_flow.NukiBridge.info", return_value=MOCK_INFO, - ), patch("homeassistant.components.nuki.async_setup", return_value=True), patch( + ), patch( "homeassistant.components.nuki.async_setup_entry", return_value=True, ): diff --git a/tests/components/numato/test_binary_sensor.py b/tests/components/numato/test_binary_sensor.py index 5aa6aea2b8d687..41ddabc1a3cc3b 100644 --- a/tests/components/numato/test_binary_sensor.py +++ b/tests/components/numato/test_binary_sensor.py @@ -1,4 +1,5 @@ """Tests for the numato binary_sensor platform.""" +from homeassistant.const import Platform from homeassistant.helpers import discovery from homeassistant.setup import async_setup_component @@ -54,7 +55,9 @@ async def test_hass_binary_sensor_notification(hass, numato_fixture): async def test_binary_sensor_setup_without_discovery_info(hass, config, numato_fixture): """Test handling of empty discovery_info.""" numato_fixture.discover() - await discovery.async_load_platform(hass, "binary_sensor", "numato", None, config) + await discovery.async_load_platform( + hass, Platform.BINARY_SENSOR, "numato", None, config + ) for entity_id in MOCKUP_ENTITY_IDS: assert entity_id not in hass.states.async_entity_ids() await hass.async_block_till_done() # wait for numato platform to be loaded diff --git a/tests/components/numato/test_sensor.py b/tests/components/numato/test_sensor.py index c6d176dbc90725..45f3375c2e4f0f 100644 --- a/tests/components/numato/test_sensor.py +++ b/tests/components/numato/test_sensor.py @@ -1,5 +1,5 @@ """Tests for the numato sensor platform.""" -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.helpers import discovery from homeassistant.setup import async_setup_component @@ -30,7 +30,7 @@ async def test_failing_sensor_update(hass, numato_fixture, monkeypatch): async def test_sensor_setup_without_discovery_info(hass, config, numato_fixture): """Test handling of empty discovery_info.""" numato_fixture.discover() - await discovery.async_load_platform(hass, "sensor", "numato", None, config) + await discovery.async_load_platform(hass, Platform.SENSOR, "numato", None, config) for entity_id in MOCKUP_ENTITY_IDS: assert entity_id not in hass.states.async_entity_ids() await hass.async_block_till_done() # wait for numato platform to be loaded diff --git a/tests/components/numato/test_switch.py b/tests/components/numato/test_switch.py index 91cda5c2a3785f..15324a27e475ec 100644 --- a/tests/components/numato/test_switch.py +++ b/tests/components/numato/test_switch.py @@ -1,6 +1,11 @@ """Tests for the numato switch platform.""" from homeassistant.components import switch -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) from homeassistant.helpers import discovery from homeassistant.setup import async_setup_component @@ -106,7 +111,7 @@ async def test_failing_hass_operations(hass, numato_fixture, monkeypatch): async def test_switch_setup_without_discovery_info(hass, config, numato_fixture): """Test handling of empty discovery_info.""" numato_fixture.discover() - await discovery.async_load_platform(hass, "switch", "numato", None, config) + await discovery.async_load_platform(hass, Platform.SWITCH, "numato", None, config) for entity_id in MOCKUP_ENTITY_IDS: assert entity_id not in hass.states.async_entity_ids() await hass.async_block_till_done() # wait for numato platform to be loaded diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index a981a331e7ead1..806bd8771b2b47 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -3,6 +3,7 @@ import voluptuous_serialize import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.number import DOMAIN, device_action from homeassistant.helpers import config_validation as cv, device_registry from homeassistant.setup import async_setup_component @@ -48,7 +49,9 @@ async def test_get_actions(hass, device_reg, entity_reg): "entity_id": "number.test_5678", }, ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) @@ -69,7 +72,9 @@ async def test_get_action_no_state(hass, device_reg, entity_reg): "entity_id": "number.test_5678", }, ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) diff --git a/tests/components/nx584/test_binary_sensor.py b/tests/components/nx584/test_binary_sensor.py index fd2e5b30bac304..83f8a49c091b5b 100644 --- a/tests/components/nx584/test_binary_sensor.py +++ b/tests/components/nx584/test_binary_sensor.py @@ -8,6 +8,13 @@ from homeassistant.components.nx584 import binary_sensor as nx584 from homeassistant.setup import async_setup_component +DEFAULT_CONFIG = { + "host": nx584.DEFAULT_HOST, + "port": nx584.DEFAULT_PORT, + "exclude_zones": [], + "zone_types": {}, +} + class StopMe(Exception): """Stop helper.""" @@ -51,13 +58,8 @@ def client(fake_zones): def test_nx584_sensor_setup_defaults(mock_nx, mock_watcher, hass, fake_zones): """Test the setup with no configuration.""" add_entities = mock.MagicMock() - config = { - "host": nx584.DEFAULT_HOST, - "port": nx584.DEFAULT_PORT, - "exclude_zones": [], - "zone_types": {}, - } - assert nx584.setup_platform(hass, config, add_entities) + config = DEFAULT_CONFIG + nx584.setup_platform(hass, config, add_entities) mock_nx.assert_has_calls([mock.call(zone, "opening") for zone in fake_zones]) assert add_entities.called assert nx584_client.Client.call_count == 1 @@ -76,7 +78,7 @@ def test_nx584_sensor_setup_full_config(mock_nx, mock_watcher, hass, fake_zones) "zone_types": {3: "motion"}, } add_entities = mock.MagicMock() - assert nx584.setup_platform(hass, config, add_entities) + nx584.setup_platform(hass, config, add_entities) mock_nx.assert_has_calls( [ mock.call(fake_zones[0], "opening"), @@ -135,7 +137,11 @@ def test_nx584_sensor_setup_no_zones(hass): """Test the setup with no zones.""" nx584_client.Client.return_value.list_zones.return_value = [] add_entities = mock.MagicMock() - assert nx584.setup_platform(hass, {}, add_entities) + nx584.setup_platform( + hass, + DEFAULT_CONFIG, + add_entities, + ) assert not add_entities.called diff --git a/tests/components/nzbget/test_sensor.py b/tests/components/nzbget/test_sensor.py index d47e67c96c6722..290414ab0ffcd6 100644 --- a/tests/components/nzbget/test_sensor.py +++ b/tests/components/nzbget/test_sensor.py @@ -2,11 +2,11 @@ from datetime import timedelta from unittest.mock import patch +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, DATA_MEGABYTES, DATA_RATE_MEGABYTES_PER_SECOND, - DEVICE_CLASS_TIMESTAMP, ) from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -39,7 +39,7 @@ async def test_sensors(hass, nzbget_api) -> None: "post_processing_jobs": ("PostJobCount", "2", "Jobs", None), "post_processing_paused": ("PostPaused", "False", None, None), "queue_size": ("RemainingSizeMB", "512", DATA_MEGABYTES, None), - "uptime": ("UpTimeSec", uptime.isoformat(), None, DEVICE_CLASS_TIMESTAMP), + "uptime": ("UpTimeSec", uptime.isoformat(), None, SensorDeviceClass.TIMESTAMP), } for (sensor_id, data) in sensors.items(): diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index 57e89955d58ea2..422b47668aa46f 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -63,6 +63,7 @@ async def test_form(hass): "port": 81, "ssl": True, "path": "/", + "verify_ssl": True, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -107,6 +108,7 @@ async def test_form_cannot_connect(hass): "name": "Printer", "port": 81, "ssl": True, + "verify_ssl": True, "path": "/", "api_key": "test-key", }, @@ -157,6 +159,7 @@ async def test_form_unknown_exception(hass): "ssl": True, "path": "/", "api_key": "test-key", + "verify_ssl": True, }, ) diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index a7da0579c10959..5f8a0edd37324b 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -144,3 +144,46 @@ async def test_sensors_paused(hass): assert state.name == "OctoPrint Estimated Finish Time" entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" + + +async def test_sensors_printer_disconnected(hass): + """Test the underlying sensors.""" + job = { + "job": {}, + "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, + "state": "Paused", + } + with patch( + "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) + ): + await init_integration(hass, "sensor", printer=None, job=job) + + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.octoprint_job_percentage") + assert state is not None + assert state.state == "50" + assert state.name == "OctoPrint Job Percentage" + entry = entity_registry.async_get("sensor.octoprint_job_percentage") + assert entry.unique_id == "Job Percentage-uuid" + + state = hass.states.get("sensor.octoprint_current_state") + assert state is not None + assert state.state == "unavailable" + assert state.name == "OctoPrint Current State" + entry = entity_registry.async_get("sensor.octoprint_current_state") + assert entry.unique_id == "Current State-uuid" + + state = hass.states.get("sensor.octoprint_start_time") + assert state is not None + assert state.state == "unknown" + assert state.name == "OctoPrint Start Time" + entry = entity_registry.async_get("sensor.octoprint_start_time") + assert entry.unique_id == "Start Time-uuid" + + state = hass.states.get("sensor.octoprint_estimated_finish_time") + assert state is not None + assert state.state == "unknown" + assert state.name == "OctoPrint Estimated Finish Time" + entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") + assert entry.unique_id == "Estimated Finish Time-uuid" diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 45fe9a19546d3f..9605fb9e71cd4b 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -139,6 +139,7 @@ async def test_onboarding_user(hass, hass_storage, hass_client_no_auth): assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() + cur_users = len(await hass.auth.async_get_users()) client = await hass_client_no_auth() resp = await client.post( @@ -159,9 +160,9 @@ async def test_onboarding_user(hass, hass_storage, hass_client_no_auth): assert "auth_code" in data users = await hass.auth.async_get_users() - assert len(users) == 1 - user = users[0] - assert user.name == "Test Name" + assert len(await hass.auth.async_get_users()) == cur_users + 1 + user = next((user for user in users if user.name == "Test Name"), None) + assert user is not None assert len(user.credentials) == 1 assert user.credentials[0].data["username"] == "test-user" assert len(hass.data["person"][1].async_items()) == 1 @@ -287,8 +288,8 @@ async def test_onboarding_integration(hass, hass_storage, hass_client, hass_admi ) # Onboarding refresh token and new refresh token - for user in await hass.auth.async_get_users(): - assert len(user.refresh_tokens) == 2, user + user = await hass.auth.async_get_user(hass_admin_user.id) + assert len(user.refresh_tokens) == 2, user async def test_onboarding_integration_missing_credential( diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py new file mode 100644 index 00000000000000..48492a1993306f --- /dev/null +++ b/tests/components/oncue/__init__.py @@ -0,0 +1,281 @@ +"""Tests for the Oncue integration.""" +from contextlib import contextmanager +from unittest.mock import patch + +from aiooncue import OncueDevice, OncueSensor + +MOCK_ASYNC_FETCH_ALL = { + "123456": OncueDevice( + name="My Generator", + state="Off", + product_name="RDC 2.4", + hardware_version="319", + serial_number="SERIAL", + sensors={ + "Product": OncueSensor( + name="Product", + display_name="Controller Type", + value="RDC 2.4", + display_value="RDC 2.4", + unit=None, + ), + "FirmwareVersion": OncueSensor( + name="FirmwareVersion", + display_name="Current Firmware", + value="2.0.6", + display_value="2.0.6", + unit=None, + ), + "LatestFirmware": OncueSensor( + name="LatestFirmware", + display_name="Latest Firmware", + value="2.0.6", + display_value="2.0.6", + unit=None, + ), + "EngineSpeed": OncueSensor( + name="EngineSpeed", + display_name="Engine Speed", + value="0", + display_value="0 R/min", + unit="R/min", + ), + "EngineTargetSpeed": OncueSensor( + name="EngineTargetSpeed", + display_name="Engine Target Speed", + value="0", + display_value="0 R/min", + unit="R/min", + ), + "EngineOilPressure": OncueSensor( + name="EngineOilPressure", + display_name="Engine Oil Pressure", + value=0, + display_value="0 Psi", + unit="Psi", + ), + "EngineCoolantTemperature": OncueSensor( + name="EngineCoolantTemperature", + display_name="Engine Coolant Temperature", + value=32, + display_value="32 F", + unit="F", + ), + "BatteryVoltage": OncueSensor( + name="BatteryVoltage", + display_name="Battery Voltage", + value="13.4", + display_value="13.4 V", + unit="V", + ), + "LubeOilTemperature": OncueSensor( + name="LubeOilTemperature", + display_name="Lube Oil Temperature", + value=32, + display_value="32 F", + unit="F", + ), + "GensetControllerTemperature": OncueSensor( + name="GensetControllerTemperature", + display_name="Generator Controller Temperature", + value=84.2, + display_value="84.2 F", + unit="F", + ), + "EngineCompartmentTemperature": OncueSensor( + name="EngineCompartmentTemperature", + display_name="Engine Compartment Temperature", + value=62.6, + display_value="62.6 F", + unit="F", + ), + "GeneratorTrueTotalPower": OncueSensor( + name="GeneratorTrueTotalPower", + display_name="Generator True Total Power", + value="0.0", + display_value="0.0 W", + unit="W", + ), + "GeneratorTruePercentOfRatedPower": OncueSensor( + name="GeneratorTruePercentOfRatedPower", + display_name="Generator True Percent Of Rated Power", + value="0", + display_value="0 %", + unit="%", + ), + "GeneratorVoltageAB": OncueSensor( + name="GeneratorVoltageAB", + display_name="Generator Voltage AB", + value="0.0", + display_value="0.0 V", + unit="V", + ), + "GeneratorVoltageAverageLineToLine": OncueSensor( + name="GeneratorVoltageAverageLineToLine", + display_name="Generator Voltage Average Line To Line", + value="0.0", + display_value="0.0 V", + unit="V", + ), + "GeneratorCurrentAverage": OncueSensor( + name="GeneratorCurrentAverage", + display_name="Generator Current Average", + value="0.0", + display_value="0.0 A", + unit="A", + ), + "GeneratorFrequency": OncueSensor( + name="GeneratorFrequency", + display_name="Generator Frequency", + value="0.0", + display_value="0.0 Hz", + unit="Hz", + ), + "GensetSerialNumber": OncueSensor( + name="GensetSerialNumber", + display_name="Generator Serial Number", + value="33FDGMFR0026", + display_value="33FDGMFR0026", + unit=None, + ), + "GensetState": OncueSensor( + name="GensetState", + display_name="Generator State", + value="Off", + display_value="Off", + unit=None, + ), + "GensetControllerSerialNumber": OncueSensor( + name="GensetControllerSerialNumber", + display_name="Generator Controller Serial Number", + value="-1", + display_value="-1", + unit=None, + ), + "GensetModelNumberSelect": OncueSensor( + name="GensetModelNumberSelect", + display_name="Genset Model Number Select", + value="38 RCLB", + display_value="38 RCLB", + unit=None, + ), + "GensetControllerClockTime": OncueSensor( + name="GensetControllerClockTime", + display_name="Generator Controller Clock Time", + value="2022-01-13 18:08:13", + display_value="2022-01-13 18:08:13", + unit=None, + ), + "GensetControllerTotalOperationTime": OncueSensor( + name="GensetControllerTotalOperationTime", + display_name="Generator Controller Total Operation Time", + value="16770.8", + display_value="16770.8 h", + unit="h", + ), + "EngineTotalRunTime": OncueSensor( + name="EngineTotalRunTime", + display_name="Engine Total Run Time", + value="28.1", + display_value="28.1 h", + unit="h", + ), + "EngineTotalRunTimeLoaded": OncueSensor( + name="EngineTotalRunTimeLoaded", + display_name="Engine Total Run Time Loaded", + value="5.5", + display_value="5.5 h", + unit="h", + ), + "EngineTotalNumberOfStarts": OncueSensor( + name="EngineTotalNumberOfStarts", + display_name="Engine Total Number Of Starts", + value="101", + display_value="101", + unit=None, + ), + "GensetTotalEnergy": OncueSensor( + name="GensetTotalEnergy", + display_name="Genset Total Energy", + value="1.2022309E7", + display_value="1.2022309E7 kWh", + unit="kWh", + ), + "AtsContactorPosition": OncueSensor( + name="AtsContactorPosition", + display_name="Ats Contactor Position", + value="Source1", + display_value="Source1", + unit=None, + ), + "AtsSourcesAvailable": OncueSensor( + name="AtsSourcesAvailable", + display_name="Ats Sources Available", + value="Source1", + display_value="Source1", + unit=None, + ), + "Source1VoltageAverageLineToLine": OncueSensor( + name="Source1VoltageAverageLineToLine", + display_name="Source1 Voltage Average Line To Line", + value="253.5", + display_value="253.5 V", + unit="V", + ), + "Source2VoltageAverageLineToLine": OncueSensor( + name="Source2VoltageAverageLineToLine", + display_name="Source2 Voltage Average Line To Line", + value="0.0", + display_value="0.0 V", + unit="V", + ), + "IPAddress": OncueSensor( + name="IPAddress", + display_name="IP Address", + value="1.2.3.4:1026", + display_value="1.2.3.4:1026", + unit=None, + ), + "MacAddress": OncueSensor( + name="MacAddress", + display_name="Mac Address", + value="221157033710592", + display_value="221157033710592", + unit=None, + ), + "ConnectedServerIPAddress": OncueSensor( + name="ConnectedServerIPAddress", + display_name="Connected Server IP Address", + value="40.117.195.28", + display_value="40.117.195.28", + unit=None, + ), + "NetworkConnectionEstablished": OncueSensor( + name="NetworkConnectionEstablished", + display_name="Network Connection Established", + value="true", + display_value="True", + unit=None, + ), + "SerialNumber": OncueSensor( + name="SerialNumber", + display_name="Serial Number", + value="1073879692", + display_value="1073879692", + unit=None, + ), + }, + ) +} + + +def _patch_login_and_data(): + @contextmanager + def _patcher(): + with patch("homeassistant.components.oncue.Oncue.async_login",), patch( + "homeassistant.components.oncue.Oncue.async_fetch_all", + return_value=MOCK_ASYNC_FETCH_ALL, + ): + yield + + return _patcher() diff --git a/tests/components/oncue/test_binary_sensor.py b/tests/components/oncue/test_binary_sensor.py new file mode 100644 index 00000000000000..020b914c76bac0 --- /dev/null +++ b/tests/components/oncue/test_binary_sensor.py @@ -0,0 +1,35 @@ +"""Tests for the oncue binary_sensor.""" +from __future__ import annotations + +from homeassistant.components import oncue +from homeassistant.components.oncue.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import _patch_login_and_data + +from tests.common import MockConfigEntry + + +async def test_binary_sensors(hass: HomeAssistant) -> None: + """Test that the binary sensors are setup with the expected values.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with _patch_login_and_data(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert len(hass.states.async_all("binary_sensor")) == 1 + assert ( + hass.states.get( + "binary_sensor.my_generator_network_connection_established" + ).state + == STATE_ON + ) diff --git a/tests/components/oncue/test_config_flow.py b/tests/components/oncue/test_config_flow.py new file mode 100644 index 00000000000000..df9de02a6b3dc8 --- /dev/null +++ b/tests/components/oncue/test_config_flow.py @@ -0,0 +1,141 @@ +"""Test the Oncue config flow.""" +import asyncio +from unittest.mock import patch + +from aiooncue import LoginFailedException + +from homeassistant import config_entries +from homeassistant.components.oncue.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch("homeassistant.components.oncue.config_flow.Oncue.async_login"), patch( + "homeassistant.components.oncue.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "TEST-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "TEST-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.oncue.config_flow.Oncue.async_login", + side_effect=LoginFailedException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """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.oncue.config_flow.Oncue.async_login", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass: HomeAssistant) -> None: + """Test we handle unknown exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.oncue.config_flow.Oncue.async_login", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_already_configured(hass: HomeAssistant) -> None: + """Test already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "TEST-username", + "password": "test-password", + }, + unique_id="test-username", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("homeassistant.components.oncue.config_flow.Oncue.async_login"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/oncue/test_init.py b/tests/components/oncue/test_init.py new file mode 100644 index 00000000000000..ea733bb13b5501 --- /dev/null +++ b/tests/components/oncue/test_init.py @@ -0,0 +1,69 @@ +"""Tests for the oncue component.""" +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +from aiooncue import LoginFailedException + +from homeassistant.components import oncue +from homeassistant.components.oncue.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import _patch_login_and_data + +from tests.common import MockConfigEntry + + +async def test_config_entry_reload(hass: HomeAssistant) -> None: + """Test that a config entry can be reloaded.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with _patch_login_and_data(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_config_entry_login_error(hass: HomeAssistant) -> None: + """Test that a config entry is failed on login error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.oncue.Oncue.async_login", + side_effect=LoginFailedException, + ): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_config_entry_retry_later(hass: HomeAssistant) -> None: + """Test that a config entry retry on connection error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.oncue.Oncue.async_login", + side_effect=asyncio.TimeoutError, + ): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/oncue/test_sensor.py b/tests/components/oncue/test_sensor.py new file mode 100644 index 00000000000000..5fe8b807c1b4c1 --- /dev/null +++ b/tests/components/oncue/test_sensor.py @@ -0,0 +1,127 @@ +"""Tests for the oncue sensor.""" +from __future__ import annotations + +from homeassistant.components import oncue +from homeassistant.components.oncue.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import _patch_login_and_data + +from tests.common import MockConfigEntry + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test that the sensors are setup with the expected values.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with _patch_login_and_data(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert len(hass.states.async_all("sensor")) == 25 + assert hass.states.get("sensor.my_generator_latest_firmware").state == "2.0.6" + + assert hass.states.get("sensor.my_generator_engine_speed").state == "0" + + assert hass.states.get("sensor.my_generator_engine_oil_pressure").state == "0" + + assert ( + hass.states.get("sensor.my_generator_engine_coolant_temperature").state == "0" + ) + + assert hass.states.get("sensor.my_generator_battery_voltage").state == "13.4" + + assert hass.states.get("sensor.my_generator_lube_oil_temperature").state == "0" + + assert ( + hass.states.get("sensor.my_generator_generator_controller_temperature").state + == "29.0" + ) + + assert ( + hass.states.get("sensor.my_generator_engine_compartment_temperature").state + == "17.0" + ) + + assert ( + hass.states.get("sensor.my_generator_generator_true_total_power").state == "0.0" + ) + + assert ( + hass.states.get( + "sensor.my_generator_generator_true_percent_of_rated_power" + ).state + == "0" + ) + + assert ( + hass.states.get( + "sensor.my_generator_generator_voltage_average_line_to_line" + ).state + == "0.0" + ) + + assert hass.states.get("sensor.my_generator_generator_frequency").state == "0.0" + + assert hass.states.get("sensor.my_generator_generator_state").state == "Off" + + assert ( + hass.states.get( + "sensor.my_generator_generator_controller_total_operation_time" + ).state + == "16770.8" + ) + + assert hass.states.get("sensor.my_generator_engine_total_run_time").state == "28.1" + + assert ( + hass.states.get("sensor.my_generator_ats_contactor_position").state == "Source1" + ) + + assert hass.states.get("sensor.my_generator_ip_address").state == "1.2.3.4:1026" + + assert ( + hass.states.get("sensor.my_generator_connected_server_ip_address").state + == "40.117.195.28" + ) + + assert hass.states.get("sensor.my_generator_engine_target_speed").state == "0" + + assert ( + hass.states.get("sensor.my_generator_engine_total_run_time_loaded").state + == "5.5" + ) + + assert ( + hass.states.get( + "sensor.my_generator_source1_voltage_average_line_to_line" + ).state + == "253.5" + ) + + assert ( + hass.states.get( + "sensor.my_generator_source2_voltage_average_line_to_line" + ).state + == "0.0" + ) + + assert ( + hass.states.get("sensor.my_generator_genset_total_energy").state + == "1.2022309E7" + ) + assert ( + hass.states.get("sensor.my_generator_engine_total_number_of_starts").state + == "101" + ) + assert ( + hass.states.get("sensor.my_generator_generator_current_average").state == "0.0" + ) diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 3ae6dbab050569..0ab8b8f69170de 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -19,7 +19,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry -from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.helpers.entity_registry import EntityRegistry, RegistryEntryDisabler from .const import ( ATTR_DEFAULT_DISABLED, @@ -42,7 +42,7 @@ def check_and_enable_disabled_entities( entity_id = expected_entity[ATTR_ENTITY_ID] registry_entry = entity_registry.entities.get(entity_id) assert registry_entry.disabled - assert registry_entry.disabled_by == "integration" + assert registry_entry.disabled_by is RegistryEntryDisabler.INTEGRATION entity_registry.async_update_entity(entity_id, **{"disabled_by": None}) diff --git a/tests/components/onewire/conftest.py b/tests/components/onewire/conftest.py index d951ff17cdf5d1..189baa3e7da558 100644 --- a/tests/components/onewire/conftest.py +++ b/tests/components/onewire/conftest.py @@ -6,7 +6,6 @@ from homeassistant.components.onewire.const import ( CONF_MOUNT_DIR, - CONF_NAMES, CONF_TYPE_OWSERVER, CONF_TYPE_SYSBUS, DEFAULT_SYSBUS_MOUNT_DIR, @@ -37,9 +36,6 @@ def get_config_entry(hass: HomeAssistant) -> ConfigEntry: CONF_TYPE: CONF_TYPE_OWSERVER, CONF_HOST: "1.2.3.4", CONF_PORT: 1234, - CONF_NAMES: { - "10.111111111111": "My DS18B20", - }, }, options={}, entry_id="2", @@ -57,9 +53,6 @@ def get_sysbus_config_entry(hass: HomeAssistant) -> ConfigEntry: data={ CONF_TYPE: CONF_TYPE_SYSBUS, CONF_MOUNT_DIR: DEFAULT_SYSBUS_MOUNT_DIR, - CONF_NAMES: { - "10-111111111111": "My DS18B20", - }, }, unique_id=f"{CONF_TYPE_SYSBUS}:{DEFAULT_SYSBUS_MOUNT_DIR}", options={}, diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 2153e153961f83..8d3a82707529bf 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -2,6 +2,7 @@ from pi1wire import InvalidCRCException, UnsupportResponseException from pyownet.protocol import Error as ProtocolError +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.onewire.const import ( DOMAIN, MANUFACTURER_EDS, @@ -91,7 +92,7 @@ Platform.SENSOR: [ { ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.my_ds18b20_temperature", + ATTR_ENTITY_ID: "sensor.10_111111111111_temperature", ATTR_INJECT_READS: b" 25.123", ATTR_STATE: "25.1", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, @@ -617,6 +618,59 @@ }, ], }, + "30.111111111111": { + ATTR_INJECT_READS: [ + b"DS2760", # read device type + ], + ATTR_DEVICE_INFO: { + ATTR_IDENTIFIERS: {(DOMAIN, "30.111111111111")}, + ATTR_MANUFACTURER: MANUFACTURER_MAXIM, + ATTR_MODEL: "DS2760", + ATTR_NAME: "30.111111111111", + }, + Platform.SENSOR: [ + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_ENTITY_ID: "sensor.30_111111111111_temperature", + ATTR_INJECT_READS: b" 26.984", + ATTR_STATE: "27.0", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_UNIQUE_ID: "/30.111111111111/temperature", + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_DEVICE_FILE: "/30.111111111111/typeK/temperature", + ATTR_ENTITY_ID: "sensor.30_111111111111_thermocouple_temperature", + ATTR_INJECT_READS: b" 173.7563", + ATTR_STATE: "173.8", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_UNIQUE_ID: "/30.111111111111/typeX/temperature", + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, + ATTR_ENTITY_ID: "sensor.30_111111111111_voltage", + ATTR_INJECT_READS: b" 2.97", + ATTR_STATE: "3.0", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_UNIQUE_ID: "/30.111111111111/volt", + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, + ATTR_ENTITY_ID: "sensor.30_111111111111_vis", + ATTR_INJECT_READS: b" 0.12", + ATTR_STATE: "0.1", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_UNIQUE_ID: "/30.111111111111/vis", + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + }, + ], + }, "3A.111111111111": { ATTR_INJECT_READS: [ b"DS2413", # read device type @@ -796,6 +850,155 @@ ATTR_UNIT_OF_MEASUREMENT: PRESSURE_CBAR, }, ], + Platform.SWITCH: [ + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_0_enable", + ATTR_INJECT_READS: b"1", + ATTR_STATE: STATE_ON, + ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.0", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_1_enable", + ATTR_INJECT_READS: b"1", + ATTR_STATE: STATE_ON, + ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.1", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_2_enable", + ATTR_INJECT_READS: b"0", + ATTR_STATE: STATE_OFF, + ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.2", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_3_enable", + ATTR_INJECT_READS: b"0", + ATTR_STATE: STATE_OFF, + ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.3", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_0_enable", + ATTR_INJECT_READS: b"1", + ATTR_STATE: STATE_ON, + ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.0", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_1_enable", + ATTR_INJECT_READS: b"1", + ATTR_STATE: STATE_ON, + ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.1", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_2_enable", + ATTR_INJECT_READS: b"0", + ATTR_STATE: STATE_OFF, + ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.2", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_3_enable", + ATTR_INJECT_READS: b"0", + ATTR_STATE: STATE_OFF, + ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.3", + }, + ], + }, + "EF.111111111113": { + ATTR_INJECT_READS: [ + b"HB_HUB", # read type + ], + ATTR_DEVICE_INFO: { + ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111113")}, + ATTR_MANUFACTURER: MANUFACTURER_HOBBYBOARDS, + ATTR_MODEL: "HB_HUB", + ATTR_NAME: "EF.111111111113", + }, + Platform.BINARY_SENSOR: [ + { + ATTR_DEFAULT_DISABLED: True, + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, + ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, + ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_0", + ATTR_INJECT_READS: b"1", + ATTR_STATE: STATE_ON, + ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.0", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, + ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, + ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_1", + ATTR_INJECT_READS: b"0", + ATTR_STATE: STATE_OFF, + ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.1", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, + ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, + ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_2", + ATTR_INJECT_READS: b"1", + ATTR_STATE: STATE_ON, + ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.2", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, + ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, + ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_3", + ATTR_INJECT_READS: b"0", + ATTR_STATE: STATE_OFF, + ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.3", + }, + ], + Platform.SWITCH: [ + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_0_enable", + ATTR_INJECT_READS: b"1", + ATTR_STATE: STATE_ON, + ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.0", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_1_enable", + ATTR_INJECT_READS: b"0", + ATTR_STATE: STATE_OFF, + ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.1", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_2_enable", + ATTR_INJECT_READS: b"1", + ATTR_STATE: STATE_ON, + ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.2", + }, + { + ATTR_DEFAULT_DISABLED: True, + ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, + ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_3_enable", + ATTR_INJECT_READS: b"0", + ATTR_STATE: STATE_OFF, + ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.3", + }, + ], }, "7E.111111111111": { ATTR_INJECT_READS: [ @@ -895,7 +1098,7 @@ Platform.SENSOR: [ { ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.my_ds18b20_temperature", + ATTR_ENTITY_ID: "sensor.10_111111111111_temperature", ATTR_INJECT_READS: 25.123, ATTR_STATE: "25.1", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py new file mode 100644 index 00000000000000..bc164a9b1387d3 --- /dev/null +++ b/tests/components/onewire/test_diagnostics.py @@ -0,0 +1,61 @@ +"""Test 1-Wire diagnostics.""" +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_owproxy_mock_devices + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +@pytest.fixture(autouse=True) +def override_platforms(): + """Override PLATFORMS.""" + with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): + yield + + +DEVICE_DETAILS = { + "device_info": { + "identifiers": [["onewire", "EF.111111111113"]], + "manufacturer": "Hobby Boards", + "model": "HB_HUB", + "name": "EF.111111111113", + }, + "family": "EF", + "id": "EF.111111111113", + "path": "/EF.111111111113/", + "type": "HB_HUB", +} + + +@pytest.mark.parametrize("device_id", ["EF.111111111113"], indirect=True) +async def test_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, + hass_client, + owproxy: MagicMock, + device_id: str, +): + """Test config entry diagnostics.""" + setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "data": { + "host": REDACTED, + "port": 1234, + "type": "OWServer", + }, + "options": {}, + "title": "Mock Title", + }, + "devices": [DEVICE_DETAILS], + } diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index 433a6392f128a1..28413ae4d05fd5 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -1 +1,149 @@ """Tests for the ONVIF integration.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from zeep.exceptions import Fault + +from homeassistant import config_entries +from homeassistant.components.onvif import config_flow +from homeassistant.components.onvif.const import CONF_SNAPSHOT_AUTH +from homeassistant.components.onvif.models import DeviceInfo +from homeassistant.const import HTTP_DIGEST_AUTHENTICATION + +from tests.common import MockConfigEntry + +URN = "urn:uuid:123456789" +NAME = "TestCamera" +HOST = "1.2.3.4" +PORT = 80 +USERNAME = "admin" +PASSWORD = "12345" +MAC = "aa:bb:cc:dd:ee" +SERIAL_NUMBER = "ABCDEFGHIJK" +MANUFACTURER = "TestManufacturer" +MODEL = "TestModel" +FIRMWARE_VERSION = "TestFirmwareVersion" + + +def setup_mock_onvif_camera( + mock_onvif_camera, + with_h264=True, + two_profiles=False, + with_interfaces=True, + with_interfaces_not_implemented=False, + with_serial=True, +): + """Prepare mock onvif.ONVIFCamera.""" + devicemgmt = MagicMock() + + device_info = MagicMock() + device_info.SerialNumber = SERIAL_NUMBER if with_serial else None + devicemgmt.GetDeviceInformation = AsyncMock(return_value=device_info) + + interface = MagicMock() + interface.Enabled = True + interface.Info.HwAddress = MAC + + if with_interfaces_not_implemented: + devicemgmt.GetNetworkInterfaces = AsyncMock( + side_effect=Fault("not implemented") + ) + else: + devicemgmt.GetNetworkInterfaces = AsyncMock( + return_value=[interface] if with_interfaces else [] + ) + + media_service = MagicMock() + + profile1 = MagicMock() + profile1.VideoEncoderConfiguration.Encoding = "H264" if with_h264 else "MJPEG" + profile2 = MagicMock() + profile2.VideoEncoderConfiguration.Encoding = "H264" if two_profiles else "MJPEG" + + media_service.GetProfiles = AsyncMock(return_value=[profile1, profile2]) + + mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True) + mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt) + mock_onvif_camera.create_media_service = MagicMock(return_value=media_service) + mock_onvif_camera.close = AsyncMock(return_value=None) + + def mock_constructor( + host, + port, + user, + passwd, + wsdl_dir, + encrypt=True, + no_cache=False, + adjust_time=False, + transport=None, + ): + """Fake the controller constructor.""" + return mock_onvif_camera + + mock_onvif_camera.side_effect = mock_constructor + + +def setup_mock_device(mock_device): + """Prepare mock ONVIFDevice.""" + mock_device.async_setup = AsyncMock(return_value=True) + mock_device.available = True + mock_device.name = NAME + mock_device.info = DeviceInfo( + MANUFACTURER, + MODEL, + FIRMWARE_VERSION, + SERIAL_NUMBER, + MAC, + ) + + def mock_constructor(hass, config): + """Fake the controller constructor.""" + return mock_device + + mock_device.side_effect = mock_constructor + + +async def setup_onvif_integration( + hass, + config=None, + options=None, + unique_id=MAC, + entry_id="1", + source=config_entries.SOURCE_USER, +): + """Create an ONVIF config entry.""" + if not config: + config = { + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + CONF_SNAPSHOT_AUTH: HTTP_DIGEST_AUTHENTICATION, + } + + config_entry = MockConfigEntry( + domain=config_flow.DOMAIN, + source=source, + data={**config}, + options=options or {}, + entry_id=entry_id, + unique_id=unique_id, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.config_flow.wsdiscovery" + ) as mock_discovery, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True) + # no discovery + mock_discovery.return_value = [] + setup_mock_device(mock_device) + mock_device.device = mock_onvif_camera + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return config_entry, mock_onvif_camera, mock_device diff --git a/tests/components/onvif/test_button.py b/tests/components/onvif/test_button.py new file mode 100644 index 00000000000000..a8ac24da5248f4 --- /dev/null +++ b/tests/components/onvif/test_button.py @@ -0,0 +1,40 @@ +"""Test button of ONVIF integration.""" +from unittest.mock import AsyncMock + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.helpers import entity_registry as er + +from . import MAC, setup_onvif_integration + + +async def test_reboot_button(hass): + """Test states of the Reboot button.""" + await setup_onvif_integration(hass) + + state = hass.states.get("button.testcamera_reboot") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART + + registry = er.async_get(hass) + entry = registry.async_get("button.testcamera_reboot") + assert entry + assert entry.unique_id == f"{MAC}_reboot" + + +async def test_reboot_button_press(hass): + """Test Reboot button press.""" + _, camera, _ = await setup_onvif_integration(hass) + devicemgmt = camera.create_devicemgmt_service() + devicemgmt.SystemReboot = AsyncMock(return_value=True) + + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: "button.testcamera_reboot"}, + blocking=True, + ) + await hass.async_block_till_done() + + devicemgmt.SystemReboot.assert_called_once() diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index e4cb079515c34e..d4a90ce81c6e8b 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -1,5 +1,5 @@ """Test ONVIF config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from onvif.exceptions import ONVIFError from zeep.exceptions import Fault @@ -7,16 +7,19 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.onvif import config_flow -from tests.common import MockConfigEntry - -URN = "urn:uuid:123456789" -NAME = "TestCamera" -HOST = "1.2.3.4" -PORT = 80 -USERNAME = "admin" -PASSWORD = "12345" -MAC = "aa:bb:cc:dd:ee" -SERIAL_NUMBER = "ABCDEFGHIJK" +from . import ( + HOST, + MAC, + NAME, + PASSWORD, + PORT, + SERIAL_NUMBER, + URN, + USERNAME, + setup_mock_device, + setup_mock_onvif_camera, + setup_onvif_integration, +) DISCOVERY = [ { @@ -36,65 +39,6 @@ ] -def setup_mock_onvif_camera( - mock_onvif_camera, - with_h264=True, - two_profiles=False, - with_interfaces=True, - with_interfaces_not_implemented=False, - with_serial=True, -): - """Prepare mock onvif.ONVIFCamera.""" - devicemgmt = MagicMock() - - device_info = MagicMock() - device_info.SerialNumber = SERIAL_NUMBER if with_serial else None - devicemgmt.GetDeviceInformation = AsyncMock(return_value=device_info) - - interface = MagicMock() - interface.Enabled = True - interface.Info.HwAddress = MAC - - if with_interfaces_not_implemented: - devicemgmt.GetNetworkInterfaces = AsyncMock( - side_effect=Fault("not implemented") - ) - else: - devicemgmt.GetNetworkInterfaces = AsyncMock( - return_value=[interface] if with_interfaces else [] - ) - - media_service = MagicMock() - - profile1 = MagicMock() - profile1.VideoEncoderConfiguration.Encoding = "H264" if with_h264 else "MJPEG" - profile2 = MagicMock() - profile2.VideoEncoderConfiguration.Encoding = "H264" if two_profiles else "MJPEG" - - media_service.GetProfiles = AsyncMock(return_value=[profile1, profile2]) - - mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True) - mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt) - mock_onvif_camera.create_media_service = MagicMock(return_value=media_service) - mock_onvif_camera.close = AsyncMock(return_value=None) - - def mock_constructor( - host, - port, - user, - passwd, - wsdl_dir, - encrypt=True, - no_cache=False, - adjust_time=False, - transport=None, - ): - """Fake the controller constructor.""" - return mock_onvif_camera - - mock_onvif_camera.side_effect = mock_constructor - - def setup_mock_discovery( mock_discovery, with_name=False, with_mac=False, two_devices=False ): @@ -126,61 +70,6 @@ def setup_mock_discovery( mock_discovery.return_value = services -def setup_mock_device(mock_device): - """Prepare mock ONVIFDevice.""" - mock_device.async_setup = AsyncMock(return_value=True) - - def mock_constructor(hass, config): - """Fake the controller constructor.""" - return mock_device - - mock_device.side_effect = mock_constructor - - -async def setup_onvif_integration( - hass, - config=None, - options=None, - unique_id=MAC, - entry_id="1", - source=config_entries.SOURCE_USER, -): - """Create an ONVIF config entry.""" - if not config: - config = { - config_flow.CONF_NAME: NAME, - config_flow.CONF_HOST: HOST, - config_flow.CONF_PORT: PORT, - config_flow.CONF_USERNAME: USERNAME, - config_flow.CONF_PASSWORD: PASSWORD, - } - - config_entry = MockConfigEntry( - domain=config_flow.DOMAIN, - source=source, - data={**config}, - options=options or {}, - entry_id=entry_id, - unique_id=unique_id, - ) - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.onvif.config_flow.get_device" - ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" - ) as mock_discovery, patch( - "homeassistant.components.onvif.ONVIFDevice" - ) as mock_device: - setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True) - # no discovery - mock_discovery.return_value = [] - setup_mock_device(mock_device) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - return config_entry - - async def test_flow_discovered_devices(hass): """Test that config flow works for discovered devices.""" @@ -616,7 +505,7 @@ async def test_flow_import_onvif_auth_error(hass): async def test_option_flow(hass): """Test config flow options.""" - entry = await setup_onvif_integration(hass) + entry, _, _ = await setup_onvif_integration(hass) result = await hass.config_entries.options.async_init(entry.entry_id) diff --git a/tests/components/open_meteo/__init__.py b/tests/components/open_meteo/__init__.py new file mode 100644 index 00000000000000..11ac51700a8bcc --- /dev/null +++ b/tests/components/open_meteo/__init__.py @@ -0,0 +1 @@ +"""Tests for the Open-Meteo integration.""" diff --git a/tests/components/open_meteo/conftest.py b/tests/components/open_meteo/conftest.py new file mode 100644 index 00000000000000..cb950dcc442465 --- /dev/null +++ b/tests/components/open_meteo/conftest.py @@ -0,0 +1,49 @@ +"""Fixtures for the Open-Meteo integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from open_meteo import Forecast +import pytest + +from homeassistant.components.open_meteo.const import DOMAIN +from homeassistant.const import CONF_ZONE + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Home", + domain=DOMAIN, + data={CONF_ZONE: "zone.home"}, + unique_id="zone.home", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.open_meteo.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_open_meteo(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked Open-Meteo client.""" + fixture: str = "forecast.json" + if hasattr(request, "param") and request.param: + fixture = request.param + + forecast = Forecast.parse_raw(load_fixture(fixture, DOMAIN)) + with patch( + "homeassistant.components.open_meteo.OpenMeteo", autospec=True + ) as open_meteo_mock: + open_meteo = open_meteo_mock.return_value + open_meteo.forecast.return_value = forecast + yield open_meteo diff --git a/tests/components/open_meteo/fixtures/forecast.json b/tests/components/open_meteo/fixtures/forecast.json new file mode 100644 index 00000000000000..e9510cb2d2c927 --- /dev/null +++ b/tests/components/open_meteo/fixtures/forecast.json @@ -0,0 +1,6660 @@ +{ + "generationtime_ms": 2.886056900024414, + "latitude": 52.52, + "daily_units": { + "winddirection_10m_dominant": "°", + "temperature_2m_max": "°C", + "windspeed_10m_max": "km\/h", + "sunrise": "iso8601", + "precipitation_hours": "h", + "temperature_2m_min": "°C", + "apparent_temperature_min": "°C", + "sunset": "iso8601", + "apparent_temperature_max": "°C", + "weathercode": "wmo code", + "windgusts_10m_max": "km\/h", + "shortwave_radiation_sum": "MJ\/m²", + "time": "iso8601", + "precipitation_sum": "mm" + }, + "hourly": { + "soil_moisture_3_9cm": [ + 0.308, + 0.308, + 0.308, + 0.308, + 0.309, + 0.309, + 0.309, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.306, + 0.306, + 0.306, + 0.307, + 0.307, + 0.306, + 0.306, + 0.307, + 0.307, + 0.308, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.305, + 0.305, + 0.306, + 0.308, + 0.31, + 0.306, + 0.306, + 0.306, + 0.307, + 0.307, + 0.308, + 0.308, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.306, + 0.306, + 0.307, + 0.308, + 0.308, + 0.308, + 0.31, + 0.311, + 0.311, + 0.311, + 0.311, + 0.31, + 0.31, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.307, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.302, + 0.303, + 0.305, + 0.305, + 0.306, + 0.306, + 0.306, + 0.305, + 0.305, + 0.304, + 0.304, + 0.304, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.302, + 0.302, + 0.303, + 0.308 + ], + "soil_temperature_0cm": [ + 6.6, + 6.6, + 6.5, + 6.1, + 6.1, + 6, + 6.1, + 5.7, + 5.4, + 6.3, + 7, + 7.6, + 7.9, + 8.1, + 7.9, + 7.3, + 6.7, + 6.3, + 6.3, + 5.7, + 5.5, + 5.6, + 5.6, + 4.9, + 5.1, + 4.7, + 4.8, + 4.7, + 3.6, + 2.3, + 1.4, + 0.8, + 0.4, + 1.7, + 2.7, + 3.8, + 4.8, + 5.5, + 4.8, + 4.5, + 4.1, + 3.7, + 3.6, + 3.7, + 3.7, + 3.5, + 3.5, + 3.4, + 3.6, + 3.6, + 3.2, + 3.3, + 3.3, + 3.2, + 3, + 3.1, + 3.1, + 3.3, + 4.5, + 4.9, + 5.3, + 5.5, + 5.2, + 4.6, + 3.7, + 3.3, + 3.1, + 2.8, + 2.2, + 1.9, + 1.8, + 1.7, + 1.4, + 1.1, + 0.3, + -0, + -0, + -0, + -0.1, + -0.1, + -0.2, + -0.2, + 1.4, + 2.8, + 4.6, + 5.4, + 5, + 3.7, + 2.5, + 2.6, + 2.4, + 2.6, + 2.4, + 2.1, + 1.6, + 1.7, + 0.8, + -0, + -0.2, + -0.2, + -0.2, + -0.4, + -0.6, + -0.7, + -0.3, + 0.2, + 1, + 1.9, + 2.9, + 3.8, + 3.6, + 3.1, + 2.3, + 2, + 1.7, + 1.3, + 1, + 0.7, + 0.4, + 0.3, + 0.3, + 0.2, + 0.1, + 0.1, + -0.1, + -0.2, + -0.4, + -0.5, + -0.4, + -0.1, + 0.3, + 0.8, + 1.5, + 2.1, + 2.4, + 1.8, + 1.1, + 1.1, + 1.4, + 1.6, + 1.7, + 1.8, + 1.8, + 1.7, + 1.6, + 1.4, + 1.4, + 1.4, + 1.3, + 1.1, + 0.9, + 0.6, + 0.5, + 0.5, + 0.8, + 1.5, + 2.5, + 3.4, + 3.1, + 2.5, + 1.8, + 1.8, + 2.1, + 2.4, + 2.5, + 2.5, + 2.6, + 2.7 + ], + "weathercode": [ + 3, + 3, + 3, + 3, + 51, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 61, + 61, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 2, + 1, + 1, + 1, + 2, + 3, + 3, + 3, + 3, + 3, + 3, + 61, + 3, + 3, + 3, + 61, + 61, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 61, + 61, + 61, + 61, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 2, + 3, + 3, + 3, + 0, + 0, + 1, + 1, + 2, + 3, + 61, + 3, + 3, + 3, + 3, + 3, + 3, + 1, + 0, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 61, + 61, + 61, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 77, + 77, + 77, + 3, + 3, + 3, + 3, + 3, + 3, + 51, + 51, + 51, + 3, + 3, + 3, + 3, + 3, + 3, + 53, + 53, + 53, + 3, + 3, + 3, + 2, + 2, + 2, + 1, + 1, + 1, + 3, + 3, + 3, + 61, + 61, + 61, + 61, + 61, + 61, + 80 + ], + "windspeed_180m": [ + 26.8, + 26.6, + 27.5, + 24.6, + 27.8, + 28.5, + 27, + 24.9, + 26.6, + 22.8, + 21.1, + 18.6, + 14.3, + 14, + 12.3, + 13.3, + 10.8, + 8.6, + 10.7, + 12, + 14, + 16.4, + 17.1, + 17.9, + 20, + 22.4, + 20.9, + 19.5, + 16.9, + 16.5, + 14.5, + 13.8, + 16.2, + 20.7, + 16.3, + 12.2, + 13.8, + 14.6, + 22.7, + 26.5, + 22.9, + 30.1, + 34.3, + 35.4, + 31.3, + 30.7, + 33.5, + 34.4, + 32.8, + 31.4, + 29.8, + 30.4, + 30.5, + 27.5, + 26.1, + 27.1, + 25.9, + 27.8, + 25.4, + 22.1, + 20.1, + 16.7, + 17.1, + 17, + 20.1, + 16.7, + 20.3, + 23.3, + 32.4, + 34.8, + 36.2, + 35.8, + 34.2, + 33, + 36.1, + 38.7, + 38.8, + 37, + 35.5, + 35.7, + 35.9, + 36.9, + 37.6, + 35.1, + 30, + 25, + 21.1, + 24.5, + 29.8, + 30.4, + 29.5, + 29.8, + 31.9, + 32.2, + 24.2, + 25.7, + 27.5, + 25.7, + 26.5, + 27.4, + 28.5, + 28.8, + 28.9, + 28.6, + 28, + 27.1, + 25.8, + 24.6, + 23.2, + 21.7, + 21.5, + 21.6, + 20.9, + 18.8, + 16.6, + 16.5, + 18.7, + 21.9, + 24.6, + 24.1, + 22.4, + 20.4, + 20.1, + 20.3, + 20, + 19.2, + 18.2, + 17.1, + 16.1, + 15.2, + 15.1, + 16, + 17.6, + 19.5, + 21.8, + 25.4, + 30, + 33.1, + 36.4, + 40, + 41.9, + 43.2, + 43.6, + 41.9, + 39, + 35.9, + 35.1, + 34.9, + 34.7, + 34.5, + 34.3, + 33, + 30.8, + 28, + 24.4, + 21.9, + 19.5, + 17.8, + 18.4, + 20, + 23.4, + 28.5, + 36.2, + 44.9, + 47.4, + 47.8, + 47.9, + 48.8 + ], + "soil_moisture_9_27cm": [ + 0.315, + 0.315, + 0.314, + 0.314, + 0.315, + 0.315, + 0.315, + 0.315, + 0.315, + 0.315, + 0.315, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.314, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.313, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.308, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309, + 0.309 + ], + "shortwave_radiation": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.7, + 23, + 42.1, + 69.7, + 83, + 80.3, + 64.1, + 28, + 7.9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.7, + 32.4, + 77.2, + 109.4, + 124.7, + 120.1, + 93.1, + 26.9, + 14.6, + 0, + -0, + 0, + -0, + 0, + 0, + -0, + -0, + -0, + 0, + 0, + -0, + -0, + 0, + -0, + 0.3, + 17.4, + 58.5, + 92.6, + 107.1, + 114.2, + 92.2, + 46.9, + 12.3, + 0, + -0, + 0, + -0, + 0.1, + -0, + -0.1, + 0.1, + -0.1, + 0, + 0.1, + -0, + 0.1, + 0, + -0.2, + 0.3, + 27.8, + 71.4, + 115.2, + 117.8, + 83.3, + 87.2, + 54.4, + 11.1, + -0.2, + -0.1, + -0, + -0, + -0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 51.3, + 121.4, + 182, + 227.4, + 239.7, + 191.8, + 114.5, + 32.3, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 44.9, + 103.3, + 139.2, + 150.5, + 141.8, + 116.6, + 74.9, + 23.4, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 30.4, + 75.2, + 126.8, + 179.4, + 208.1, + 169.6, + 100.9, + 28.2, + 0, + 0, + 0, + -0, + -0, + 0, + 0 + ], + "cloudcover_mid": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 66, + 88, + 100, + 64, + 63, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 62, + 62, + 78, + 100, + 92, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 98, + 94, + 95, + 100, + 100, + 100, + 85, + 29, + 21, + 10, + 51, + 0, + 5, + 10, + 16, + 52, + 98, + 100, + 100, + 100, + 100, + 64, + 39, + 35, + 12, + 0, + 33, + 67, + 100, + 95, + 90, + 85, + 90, + 95, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 90, + 81, + 71, + 77, + 82, + 88, + 92, + 96, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 93, + 86, + 79, + 71, + 63, + 55, + 44, + 64, + 84, + 86, + 88, + 90, + 41, + 42, + 42, + 50, + 59, + 67, + 56, + 44, + 33, + 22, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 20, + 40, + 60, + 73, + 87, + 100, + 100, + 100, + 100, + 94 + ], + "cloudcover": [ + 100, + 100, + 98, + 100, + 100, + 98, + 96, + 88, + 88, + 100, + 94, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 83, + 87, + 100, + 100, + 100, + 79, + 34, + 7, + 0, + 49, + 100, + 99, + 100, + 100, + 95, + 100, + 100, + 100, + 100, + 96, + 95, + 100, + 100, + 100, + 96, + 97, + 100, + 91, + 93, + 100, + 91, + 98, + 100, + 99, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 74, + 100, + 100, + 100, + 0, + 5, + 13, + 18, + 52, + 99, + 100, + 100, + 100, + 100, + 94, + 86, + 88, + 43, + 0, + 33, + 67, + 100, + 99, + 98, + 97, + 98, + 99, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 94, + 88, + 82, + 87, + 92, + 97, + 98, + 99, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 98, + 97, + 95, + 97, + 98, + 100, + 99, + 98, + 98, + 98, + 98, + 98, + 94, + 97, + 99, + 98, + 98, + 97, + 96, + 95, + 95, + 94, + 93, + 92, + 86, + 81, + 75, + 61, + 47, + 33, + 54, + 75, + 96, + 97, + 99, + 100, + 100, + 100, + 100, + 100 + ], + "relativehumidity_2m": [ + 95, + 95, + 94, + 93, + 95, + 95, + 94, + 94, + 95, + 94, + 92, + 88, + 85, + 83, + 82, + 84, + 89, + 89, + 93, + 93, + 95, + 96, + 95, + 96, + 94, + 92, + 89, + 86, + 87, + 90, + 93, + 96, + 97, + 100, + 96, + 88, + 85, + 83, + 79, + 82, + 84, + 83, + 85, + 85, + 84, + 84, + 85, + 86, + 87, + 89, + 90, + 91, + 92, + 91, + 92, + 90, + 88, + 87, + 84, + 80, + 77, + 74, + 73, + 75, + 82, + 86, + 89, + 89, + 83, + 82, + 80, + 78, + 78, + 78, + 79, + 79, + 79, + 81, + 82, + 83, + 83, + 85, + 82, + 77, + 73, + 69, + 68, + 70, + 76, + 82, + 84, + 84, + 83, + 84, + 86, + 87, + 89, + 92, + 92, + 92, + 92, + 93, + 93, + 93, + 92, + 90, + 88, + 85, + 81, + 77, + 77, + 79, + 81, + 83, + 84, + 86, + 87, + 88, + 89, + 88, + 88, + 87, + 87, + 87, + 88, + 88, + 88, + 89, + 90, + 91, + 92, + 89, + 86, + 82, + 81, + 82, + 84, + 87, + 91, + 95, + 94, + 93, + 93, + 93, + 93, + 94, + 94, + 95, + 94, + 93, + 92, + 90, + 89, + 88, + 87, + 83, + 79, + 76, + 77, + 81, + 85, + 88, + 90, + 91, + 91, + 89, + 87, + 88 + ], + "cloudcover_low": [ + 100, + 100, + 98, + 100, + 100, + 98, + 96, + 88, + 88, + 100, + 94, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 83, + 87, + 100, + 100, + 100, + 79, + 34, + 7, + 0, + 49, + 100, + 99, + 100, + 100, + 95, + 100, + 100, + 100, + 91, + 92, + 92, + 91, + 85, + 90, + 96, + 97, + 100, + 91, + 93, + 100, + 86, + 95, + 100, + 97, + 100, + 96, + 93, + 64, + 81, + 62, + 80, + 83, + 89, + 86, + 79, + 20, + 20, + 48, + 71, + 64, + 49, + 19, + 0, + 0, + 0, + 31, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 70, + 80, + 89, + 100, + 64, + 88, + 75, + 76, + 34, + 0, + 3, + 6, + 9, + 11, + 12, + 14, + 25, + 36, + 47, + 38, + 29, + 19, + 40, + 61, + 81, + 79, + 77, + 75, + 75, + 75, + 74, + 69, + 64, + 59, + 59, + 58, + 58, + 65, + 72, + 79, + 81, + 84, + 86, + 91, + 95, + 100, + 99, + 98, + 97, + 93, + 90, + 87, + 88, + 93, + 98, + 97, + 96, + 95, + 93, + 91, + 89, + 90, + 91, + 92, + 86, + 81, + 75, + 61, + 47, + 33, + 52, + 71, + 90, + 93, + 96, + 99, + 89, + 80, + 70, + 80 + ], + "soil_moisture_27_81cm": [ + 0.332, + 0.332, + 0.332, + 0.332, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.331, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.33, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.329, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324, + 0.324 + ], + "temperature_2m": [ + 6.9, + 6.8, + 6.8, + 6.6, + 6.5, + 6.4, + 6.4, + 6.2, + 5.9, + 6, + 6.5, + 6.9, + 7.3, + 7.6, + 7.6, + 7.5, + 7.2, + 6.9, + 6.6, + 6.4, + 6, + 5.9, + 5.8, + 5.5, + 5.4, + 5.1, + 5, + 4.9, + 4.2, + 3.2, + 2.2, + 1.5, + 0.8, + 0.2, + 0.6, + 1.8, + 2.7, + 3.8, + 4.5, + 4.5, + 4.4, + 4.2, + 3.9, + 3.9, + 4, + 3.9, + 3.9, + 3.8, + 3.7, + 3.6, + 3.4, + 3.3, + 3.2, + 3.2, + 3.1, + 3.1, + 3.1, + 3.1, + 3.5, + 3.9, + 4.3, + 4.6, + 4.8, + 4.6, + 4, + 3.5, + 3.3, + 3, + 2.7, + 2.3, + 2, + 1.8, + 1.6, + 1.2, + 0.7, + 0.4, + 0.3, + 0.2, + 0.2, + 0.1, + -0, + -0.1, + 0.6, + 1.7, + 2.9, + 4, + 4.5, + 4.2, + 3.5, + 3, + 2.8, + 2.8, + 2.8, + 2.6, + 2.3, + 2, + 1.6, + 0.9, + 0.6, + 0.4, + 0.1, + -0, + -0.2, + -0.2, + -0.1, + 0, + 0.5, + 1.2, + 2.2, + 3.1, + 3.4, + 3.4, + 3.2, + 3, + 2.7, + 2.2, + 1.9, + 1.5, + 1.1, + 0.9, + 0.8, + 0.6, + 0.4, + 0.2, + -0.1, + -0.3, + -0.4, + -0.5, + -0.5, + -0.5, + -0.3, + 0.3, + 1.1, + 2, + 2.2, + 2, + 1.7, + 1.4, + 1, + 0.7, + 2, + 1.9, + 1.8, + 1.8, + 1.7, + 1.6, + 1.5, + 1.5, + 1.3, + 1.1, + 0.8, + 0.5, + 0.1, + -0.2, + -0.3, + 0.2, + 1, + 1.9, + 2.2, + 2.4, + 2.6, + 2.6, + 2.5, + 2.4, + 2.5, + 2.8, + 3, + 3 + ], + "direct_radiation": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.7, + 1.1, + 6.3, + 3.8, + 3.6, + 6.7, + 0.1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.1, + 2.1, + 5.7, + 14.6, + 14.3, + 15.2, + 12.3, + 1.6, + 0.3, + 0, + -0, + 0, + -0, + 0, + 0, + -0, + -0, + 0, + 0, + 0, + -0, + 0, + 0, + -0, + 0, + 0.1, + 1.8, + 2.5, + 1.9, + 1.7, + 0.9, + 0, + -0, + 0, + -0, + 0, + 0, + -0, + -0, + -0.1, + 0.1, + -0.2, + 0, + 0, + -0, + 0.2, + -0.1, + -0.1, + 0.1, + 1.2, + 5.2, + 22, + 27.9, + 15.4, + 26.4, + 3.6, + 0.1, + -0.2, + 0, + -0, + -0, + -0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 29.8, + 72.1, + 117.5, + 160.8, + 177.8, + 135.5, + 71.8, + 16.7, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 18.6, + 42.2, + 53.5, + 52.3, + 43.3, + 38.3, + 23.5, + 7, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 5.7, + 17.3, + 44.9, + 84.7, + 113.8, + 92.4, + 52, + 13.2, + 0, + 0, + 0, + -0, + -0, + 0, + 0 + ], + "dewpoint_2m": [ + 6.2, + 6.1, + 5.9, + 5.6, + 5.7, + 5.7, + 5.6, + 5.3, + 5.2, + 5.2, + 5.3, + 5.1, + 4.9, + 4.9, + 4.8, + 5, + 5.6, + 5.2, + 5.5, + 5.4, + 5.3, + 5.2, + 5.1, + 4.8, + 4.5, + 3.9, + 3.4, + 2.7, + 2.3, + 1.7, + 1.2, + 0.9, + 0.4, + 0.1, + -0, + 0, + 0.5, + 1.1, + 1.2, + 1.7, + 1.9, + 1.4, + 1.6, + 1.6, + 1.5, + 1.5, + 1.6, + 1.7, + 1.8, + 2, + 2, + 2, + 2, + 1.9, + 1.9, + 1.6, + 1.3, + 1.2, + 1.1, + 0.8, + 0.6, + 0.4, + 0.4, + 0.6, + 1.2, + 1.3, + 1.6, + 1.4, + 0.7, + 0.5, + 0.1, + -0.4, + -0.6, + -1.1, + -1.7, + -2.3, + -2.3, + -2.5, + -2.5, + -2.5, + -2.4, + -2.1, + -1.8, + -1.4, + -0.9, + -0.5, + -0.2, + 0.1, + 0.6, + 0.9, + 1.1, + 1, + 1, + 1, + 0.8, + 0.4, + -0.3, + -1, + -1.3, + -1.4, + -1.5, + -1.6, + -1.7, + -1.7, + -1.7, + -1.7, + -1.6, + -1.4, + -1.2, + -0.9, + -0.8, + -0.8, + -0.7, + -0.6, + -0.5, + -0.5, + -0.5, + -0.6, + -0.7, + -0.8, + -1, + -1.2, + -1.2, + -1.2, + -1.1, + -1.1, + -1.1, + -1, + -1, + -1, + -1, + -0.8, + -0.6, + -0.4, + -0.4, + -0.5, + -0.5, + -0.1, + 0.5, + 1, + 1.1, + 1, + 0.8, + 0.7, + 0.7, + 0.7, + 0.7, + 0.7, + 0.5, + 0.1, + -0.3, + -1, + -1.4, + -1.9, + -2.3, + -2.4, + -2.3, + -2, + -1.4, + -0.6, + 0.3, + 0.7, + 1, + 1.2, + 1.2, + 1.1, + 1.1, + 1.2 + ], + "freezinglevel_height": [ + 2432, + 2434, + 2512, + 2448, + 2512, + 2524, + 2608, + 2458, + 2446, + 2454, + 2454, + 2438, + 2544, + 2554, + 2544, + 2540, + 2550, + 2578, + 2516, + 2586, + 2540, + 2516, + 2508, + 2440, + 2464, + 2464, + 2412, + 2432, + 2260, + 2003, + 1897, + 1774, + 702, + 702, + 693, + 1511, + 635, + 634, + 522, + 668, + 624, + 697, + 674, + 586, + 910, + 856, + 846, + 697, + 665, + 623, + 611, + 633, + 562, + 566, + 620, + 438, + 399, + 398, + 412, + 466, + 501, + 537, + 548, + 513, + 492, + 488, + 448, + 462, + 564, + 569, + 544, + 555, + 540, + 514, + 565, + 471, + 464, + 530, + 507, + 562, + 603, + 566, + 574, + 489, + 462, + 472, + 569, + 562, + 567, + 579, + 597, + 605, + 604, + 598, + 589, + 585, + 582, + 573, + 562, + 548, + 524, + 498, + 468, + 434, + 416, + 402, + 394, + 399, + 412, + 438, + 469, + 508, + 548, + 559, + 562, + 551, + 529, + 497, + 456, + 431, + 407, + 379, + 366, + 356, + 331, + 291, + 241, + 176, + 125, + 72, + 50, + 105, + 199, + 299, + 326, + 308, + 298, + 329, + 376, + 420, + 424, + 411, + 381, + 344, + 297, + 245, + 227, + 219, + 202, + 172, + 135, + 93, + 67, + 44, + 41, + 68, + 113, + 191, + 258, + 335, + 455, + 565, + 687, + 845, + 950, + 1049, + 1187, + 1320 + ], + "soil_temperature_54cm": [ + 8.5, + 8.5, + 8.5, + 8.5, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.4, + 8.3, + 8.3, + 8.3, + 8.3, + 8.3, + 8.3, + 8.3, + 8.3, + 8.3, + 8.3, + 8.3, + 8.2, + 8.2, + 8.2, + 8.2, + 8.2, + 8.2, + 8.2, + 8.1, + 8.1, + 8.1, + 8.1, + 8.1, + 8.1, + 8.1, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 7.9, + 7.9, + 7.9, + 7.9, + 7.9, + 7.9, + 7.9, + 7.8, + 7.8, + 7.8, + 7.5, + 7.5, + 7.5, + 7.5, + 7.5, + 7.4, + 7.4, + 7.4, + 7.4, + 7.4, + 7.4, + 7.3, + 7.3, + 7.3, + 7.3, + 7.3, + 7.2, + 7.2, + 7.2, + 7.2, + 7.2, + 7.1, + 7.1, + 7.1, + 7.1, + 7.1, + 7.1, + 7, + 7, + 7, + 7, + 7, + 7, + 6.9, + 6.9, + 6.9, + 6.9, + 6.9, + 6.8, + 6.8, + 6.8, + 6.8, + 6.8, + 6.8, + 6.7, + 6.7, + 6.7, + 6.7, + 6.7, + 6.7, + 6.6, + 6.6, + 6.6, + 6.6, + 6.6, + 6.6, + 6.5, + 6.5, + 6.5, + 6.5, + 6.5, + 6.4, + 6.4, + 6.4, + 6.4, + 6.4, + 6.4, + 6.3, + 6.3, + 6.3, + 6.3, + 6.3, + 6.3, + 6.3, + 6.2, + 6.2, + 6.2, + 6.2, + 6.2, + 6.2, + 6.2, + 6.1, + 6.1, + 6.1, + 6.1, + 6.1, + 6.1, + 6.1, + 6.1, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 5.9, + 5.9 + ], + "soil_temperature_6cm": [ + 6.4, + 6.4, + 6.4, + 6.4, + 6.3, + 6.3, + 6.3, + 6.1, + 6, + 6, + 6.3, + 6.7, + 7.1, + 7.5, + 7.6, + 7.5, + 7.3, + 7, + 6.8, + 6.5, + 6.3, + 6.2, + 6.1, + 6, + 5.8, + 5.6, + 5.5, + 5.5, + 5.2, + 4.7, + 4, + 3.4, + 2.9, + 2.7, + 3.1, + 3.6, + 4.2, + 4.7, + 5, + 5, + 4.8, + 4.6, + 4.4, + 4.4, + 4.3, + 4.3, + 4.2, + 4.2, + 4.2, + 4.2, + 4.1, + 4, + 4, + 3.9, + 3.9, + 3.8, + 3.8, + 3.8, + 4, + 4.4, + 4.8, + 5.1, + 5.3, + 5.2, + 4.8, + 4.5, + 4.2, + 4, + 3.5, + 3.2, + 3, + 2.9, + 2.7, + 2.6, + 2.2, + 1.9, + 1.6, + 1.5, + 1.4, + 1.3, + 1.2, + 1.2, + 1.4, + 2, + 2.8, + 3.7, + 4.2, + 4.2, + 3.8, + 3.4, + 3.2, + 3.1, + 3.1, + 2.9, + 2.7, + 2.6, + 2.4, + 1.9, + 1.7, + 1.5, + 1.2, + 1.1, + 1, + 0.9, + 0.8, + 0.8, + 0.9, + 1.4, + 2, + 2.6, + 2.9, + 3.1, + 3.1, + 3, + 2.8, + 2.5, + 2.2, + 2, + 1.7, + 1.6, + 1.5, + 1.3, + 1.2, + 1.1, + 1, + 0.9, + 0.9, + 0.8, + 0.7, + 0.6, + 0.7, + 1, + 1.4, + 1.9, + 2.5, + 2.5, + 2.4, + 2.3, + 2.2, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2.1, + 2, + 1.9, + 1.7, + 1.5, + 1.3, + 1.2, + 1.6, + 2.1, + 2.7, + 2.8, + 2.8, + 2.8, + 2.7, + 2.7, + 2.6, + 2.6, + 2.7, + 2.8, + 2.8 + ], + "soil_moisture_1_3cm": [ + 0.305, + 0.305, + 0.305, + 0.305, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.306, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.303, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.306, + 0.306, + 0.305, + 0.305, + 0.305, + 0.305, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.303, + 0.303, + 0.303, + 0.303, + 0.302, + 0.303, + 0.308, + 0.31, + 0.312, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.303, + 0.303, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.303, + 0.304, + 0.304, + 0.299, + 0.299, + 0.298, + 0.298, + 0.299, + 0.299, + 0.299, + 0.299, + 0.299, + 0.299, + 0.3, + 0.301, + 0.303, + 0.305, + 0.307, + 0.307, + 0.306, + 0.304, + 0.304, + 0.303, + 0.303, + 0.302, + 0.302, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.3, + 0.299, + 0.302, + 0.312 + ], + "winddirection_80m": [ + 263, + 268, + 277, + 271, + 278, + 278, + 276, + 273, + 264, + 271, + 263, + 257, + 257, + 266, + 253, + 258, + 240, + 232, + 194, + 198, + 183, + 186, + 179, + 178, + 165, + 167, + 171, + 168, + 154, + 156, + 166, + 173, + 182, + 192, + 202, + 199, + 204, + 208, + 246, + 217, + 230, + 227, + 232, + 235, + 241, + 239, + 245, + 255, + 257, + 260, + 254, + 251, + 257, + 252, + 240, + 243, + 247, + 256, + 253, + 252, + 244, + 239, + 234, + 217, + 204, + 208, + 173, + 172, + 179, + 177, + 173, + 170, + 167, + 159, + 158, + 159, + 158, + 158, + 152, + 147, + 145, + 143, + 141, + 140, + 145, + 138, + 142, + 143, + 148, + 150, + 151, + 144, + 153, + 169, + 167, + 170, + 179, + 181, + 178, + 171, + 162, + 157, + 152, + 148, + 148, + 150, + 152, + 150, + 145, + 140, + 142, + 146, + 153, + 160, + 171, + 188, + 194, + 195, + 195, + 196, + 196, + 198, + 199, + 201, + 203, + 206, + 210, + 216, + 220, + 225, + 235, + 247, + 258, + 266, + 270, + 267, + 265, + 266, + 268, + 270, + 290, + 287, + 285, + 285, + 287, + 289, + 290, + 290, + 290, + 291, + 291, + 291, + 289, + 286, + 281, + 278, + 274, + 268, + 260, + 249, + 231, + 218, + 208, + 202, + 200, + 199, + 199, + 198 + ], + "winddirection_10m": [ + 261, + 265, + 274, + 268, + 275, + 272, + 271, + 269, + 258, + 268, + 261, + 255, + 256, + 263, + 251, + 256, + 233, + 218, + 185, + 188, + 173, + 175, + 168, + 168, + 158, + 160, + 165, + 162, + 149, + 151, + 159, + 158, + 170, + 190, + 202, + 197, + 202, + 205, + 245, + 214, + 223, + 224, + 229, + 231, + 238, + 236, + 243, + 253, + 255, + 258, + 251, + 248, + 254, + 249, + 238, + 240, + 245, + 254, + 252, + 251, + 243, + 237, + 233, + 216, + 201, + 207, + 167, + 167, + 167, + 165, + 162, + 163, + 160, + 152, + 149, + 149, + 148, + 148, + 140, + 136, + 134, + 133, + 135, + 136, + 142, + 135, + 139, + 141, + 144, + 142, + 137, + 130, + 144, + 157, + 146, + 154, + 164, + 160, + 156, + 147, + 136, + 130, + 125, + 123, + 126, + 132, + 137, + 135, + 131, + 127, + 128, + 132, + 137, + 141, + 146, + 156, + 166, + 174, + 181, + 182, + 182, + 182, + 186, + 189, + 194, + 196, + 198, + 203, + 206, + 211, + 222, + 237, + 253, + 262, + 268, + 264, + 260, + 261, + 263, + 265, + 286, + 284, + 282, + 283, + 285, + 287, + 287, + 287, + 288, + 289, + 290, + 290, + 288, + 284, + 280, + 277, + 273, + 267, + 259, + 244, + 212, + 202, + 199, + 198, + 197, + 195, + 194, + 194 + ], + "time": [ + "2021-11-24T00:00", + "2021-11-24T01:00", + "2021-11-24T02:00", + "2021-11-24T03:00", + "2021-11-24T04:00", + "2021-11-24T05:00", + "2021-11-24T06:00", + "2021-11-24T07:00", + "2021-11-24T08:00", + "2021-11-24T09:00", + "2021-11-24T10:00", + "2021-11-24T11:00", + "2021-11-24T12:00", + "2021-11-24T13:00", + "2021-11-24T14:00", + "2021-11-24T15:00", + "2021-11-24T16:00", + "2021-11-24T17:00", + "2021-11-24T18:00", + "2021-11-24T19:00", + "2021-11-24T20:00", + "2021-11-24T21:00", + "2021-11-24T22:00", + "2021-11-24T23:00", + "2021-11-25T00:00", + "2021-11-25T01:00", + "2021-11-25T02:00", + "2021-11-25T03:00", + "2021-11-25T04:00", + "2021-11-25T05:00", + "2021-11-25T06:00", + "2021-11-25T07:00", + "2021-11-25T08:00", + "2021-11-25T09:00", + "2021-11-25T10:00", + "2021-11-25T11:00", + "2021-11-25T12:00", + "2021-11-25T13:00", + "2021-11-25T14:00", + "2021-11-25T15:00", + "2021-11-25T16:00", + "2021-11-25T17:00", + "2021-11-25T18:00", + "2021-11-25T19:00", + "2021-11-25T20:00", + "2021-11-25T21:00", + "2021-11-25T22:00", + "2021-11-25T23:00", + "2021-11-26T00:00", + "2021-11-26T01:00", + "2021-11-26T02:00", + "2021-11-26T03:00", + "2021-11-26T04:00", + "2021-11-26T05:00", + "2021-11-26T06:00", + "2021-11-26T07:00", + "2021-11-26T08:00", + "2021-11-26T09:00", + "2021-11-26T10:00", + "2021-11-26T11:00", + "2021-11-26T12:00", + "2021-11-26T13:00", + "2021-11-26T14:00", + "2021-11-26T15:00", + "2021-11-26T16:00", + "2021-11-26T17:00", + "2021-11-26T18:00", + "2021-11-26T19:00", + "2021-11-26T20:00", + "2021-11-26T21:00", + "2021-11-26T22:00", + "2021-11-26T23:00", + "2021-11-27T00:00", + "2021-11-27T01:00", + "2021-11-27T02:00", + "2021-11-27T03:00", + "2021-11-27T04:00", + "2021-11-27T05:00", + "2021-11-27T06:00", + "2021-11-27T07:00", + "2021-11-27T08:00", + "2021-11-27T09:00", + "2021-11-27T10:00", + "2021-11-27T11:00", + "2021-11-27T12:00", + "2021-11-27T13:00", + "2021-11-27T14:00", + "2021-11-27T15:00", + "2021-11-27T16:00", + "2021-11-27T17:00", + "2021-11-27T18:00", + "2021-11-27T19:00", + "2021-11-27T20:00", + "2021-11-27T21:00", + "2021-11-27T22:00", + "2021-11-27T23:00", + "2021-11-28T00:00", + "2021-11-28T01:00", + "2021-11-28T02:00", + "2021-11-28T03:00", + "2021-11-28T04:00", + "2021-11-28T05:00", + "2021-11-28T06:00", + "2021-11-28T07:00", + "2021-11-28T08:00", + "2021-11-28T09:00", + "2021-11-28T10:00", + "2021-11-28T11:00", + "2021-11-28T12:00", + "2021-11-28T13:00", + "2021-11-28T14:00", + "2021-11-28T15:00", + "2021-11-28T16:00", + "2021-11-28T17:00", + "2021-11-28T18:00", + "2021-11-28T19:00", + "2021-11-28T20:00", + "2021-11-28T21:00", + "2021-11-28T22:00", + "2021-11-28T23:00", + "2021-11-29T00:00", + "2021-11-29T01:00", + "2021-11-29T02:00", + "2021-11-29T03:00", + "2021-11-29T04:00", + "2021-11-29T05:00", + "2021-11-29T06:00", + "2021-11-29T07:00", + "2021-11-29T08:00", + "2021-11-29T09:00", + "2021-11-29T10:00", + "2021-11-29T11:00", + "2021-11-29T12:00", + "2021-11-29T13:00", + "2021-11-29T14:00", + "2021-11-29T15:00", + "2021-11-29T16:00", + "2021-11-29T17:00", + "2021-11-29T18:00", + "2021-11-29T19:00", + "2021-11-29T20:00", + "2021-11-29T21:00", + "2021-11-29T22:00", + "2021-11-29T23:00", + "2021-11-30T00:00", + "2021-11-30T01:00", + "2021-11-30T02:00", + "2021-11-30T03:00", + "2021-11-30T04:00", + "2021-11-30T05:00", + "2021-11-30T06:00", + "2021-11-30T07:00", + "2021-11-30T08:00", + "2021-11-30T09:00", + "2021-11-30T10:00", + "2021-11-30T11:00", + "2021-11-30T12:00", + "2021-11-30T13:00", + "2021-11-30T14:00", + "2021-11-30T15:00", + "2021-11-30T16:00", + "2021-11-30T17:00", + "2021-11-30T18:00", + "2021-11-30T19:00", + "2021-11-30T20:00", + "2021-11-30T21:00", + "2021-11-30T22:00", + "2021-11-30T23:00" + ], + "soil_moisture_0_1cm": [ + 0.304, + 0.304, + 0.304, + 0.304, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.305, + 0.304, + 0.304, + 0.304, + 0.303, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.304, + 0.303, + 0.303, + 0.303, + 0.304, + 0.304, + 0.303, + 0.303, + 0.303, + 0.303, + 0.302, + 0.303, + 0.304, + 0.303, + 0.303, + 0.303, + 0.305, + 0.306, + 0.305, + 0.305, + 0.304, + 0.304, + 0.304, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.303, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.303, + 0.31, + 0.312, + 0.313, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.301, + 0.301, + 0.301, + 0.301, + 0.301, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.301, + 0.301, + 0.301, + 0.301, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.302, + 0.303, + 0.304, + 0.304, + 0.298, + 0.298, + 0.297, + 0.297, + 0.298, + 0.298, + 0.298, + 0.298, + 0.298, + 0.299, + 0.3, + 0.302, + 0.304, + 0.307, + 0.309, + 0.308, + 0.306, + 0.303, + 0.302, + 0.302, + 0.302, + 0.301, + 0.301, + 0.3, + 0.3, + 0.3, + 0.3, + 0.3, + 0.3, + 0.301, + 0.3, + 0.299, + 0.302, + 0.314 + ], + "direct_normal_irradiance": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.4, + 8.5, + 5.8, + 25.1, + 13.4, + 13, + 28, + 0.5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1.5, + 24.6, + 32.2, + 58.9, + 50.7, + 54.7, + 52.4, + 10.4, + 3.5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1.4, + 10.5, + 10.4, + 6.8, + 6, + 4, + 0.3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1.4, + 13.3, + 30.5, + 91.3, + 101.2, + 56.8, + 114.8, + 23.7, + 1.5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 341.5, + 430.3, + 493.5, + 589.6, + 660.9, + 596.3, + 477.7, + 191.5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 213.7, + 256.9, + 227.8, + 193.9, + 162.6, + 170.3, + 158.4, + 80.4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 65.4, + 107.1, + 193.9, + 317.5, + 431.7, + 415.9, + 356.1, + 151.7, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "soil_temperature_18cm": [ + 6.3, + 6.4, + 6.4, + 6.5, + 6.5, + 6.6, + 6.6, + 6.6, + 6.6, + 6.6, + 6.6, + 6.7, + 6.7, + 6.8, + 6.9, + 7, + 7.1, + 7.1, + 7.1, + 7.1, + 7.1, + 7.1, + 7, + 7, + 6.9, + 6.9, + 6.8, + 6.8, + 6.7, + 6.6, + 6.5, + 6.3, + 6.1, + 5.9, + 5.7, + 5.6, + 5.5, + 5.5, + 5.6, + 5.6, + 5.6, + 5.6, + 5.6, + 5.6, + 5.5, + 5.5, + 5.5, + 5.5, + 5.4, + 5.4, + 5.4, + 5.3, + 5.3, + 5.3, + 5.2, + 5.2, + 5.2, + 5.1, + 5.1, + 5.1, + 5.2, + 5.2, + 5.3, + 5.4, + 5.4, + 5.4, + 5.4, + 5.3, + 4.9, + 4.9, + 4.8, + 4.7, + 4.6, + 4.6, + 4.5, + 4.4, + 4.2, + 4.1, + 4, + 3.9, + 3.8, + 3.7, + 3.6, + 3.5, + 3.5, + 3.6, + 3.7, + 3.9, + 4, + 4, + 4.1, + 4.1, + 4.1, + 4.1, + 4.1, + 4, + 4, + 3.9, + 3.8, + 3.8, + 3.6, + 3.5, + 3.4, + 3.3, + 3.2, + 3.1, + 3.1, + 3, + 3, + 3.1, + 3.1, + 3.2, + 3.4, + 3.4, + 3.4, + 3.5, + 3.5, + 3.4, + 3.4, + 3.4, + 3.3, + 3.2, + 3.2, + 3.1, + 3, + 3, + 2.9, + 2.8, + 2.8, + 2.7, + 2.7, + 2.6, + 2.6, + 2.6, + 2.9, + 2.9, + 3, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3.1, + 3, + 2.9, + 2.9, + 2.9, + 3, + 3, + 3.1, + 3.2, + 3.2, + 3.2, + 3.3, + 3.3, + 3.3, + 3.3, + 3.4 + ], + "winddirection_180m": [ + 266, + 271, + 284, + 279, + 287, + 290, + 283, + 277, + 277, + 274, + 274, + 267, + 258, + 267, + 254, + 259, + 253, + 246, + 208, + 208, + 196, + 199, + 194, + 191, + 174, + 178, + 181, + 177, + 165, + 168, + 186, + 199, + 201, + 207, + 219, + 222, + 222, + 214, + 247, + 229, + 241, + 236, + 245, + 247, + 247, + 243, + 250, + 257, + 259, + 263, + 257, + 255, + 261, + 256, + 247, + 248, + 250, + 257, + 254, + 253, + 245, + 240, + 234, + 218, + 205, + 209, + 185, + 186, + 200, + 198, + 196, + 193, + 192, + 187, + 183, + 180, + 180, + 181, + 178, + 173, + 170, + 169, + 167, + 168, + 168, + 156, + 144, + 145, + 157, + 166, + 175, + 168, + 177, + 192, + 200, + 195, + 200, + 202, + 199, + 196, + 192, + 190, + 188, + 186, + 185, + 184, + 183, + 183, + 182, + 182, + 181, + 180, + 182, + 189, + 203, + 219, + 221, + 218, + 216, + 217, + 219, + 222, + 223, + 223, + 225, + 230, + 236, + 246, + 253, + 260, + 268, + 271, + 271, + 272, + 302, + 298, + 297, + 300, + 305, + 308, + 306, + 303, + 298, + 296, + 294, + 291, + 291, + 291, + 292, + 293, + 293, + 293, + 291, + 287, + 282, + 278, + 275, + 268, + 264, + 261, + 252, + 239, + 228, + 220, + 215, + 211, + 207, + 206 + ], + "apparent_temperature": [ + 4.4, + 4.2, + 4.3, + 4.4, + 4.1, + 4, + 3.9, + 3.7, + 3.6, + 3.4, + 4.1, + 4.6, + 4.9, + 5.2, + 5.3, + 5.4, + 5.5, + 5.3, + 4.8, + 4.7, + 4.2, + 4, + 3.9, + 3.4, + 3.2, + 2.8, + 2.5, + 2.3, + 1.6, + 0.5, + -0.5, + -1.2, + -2, + -2.9, + -2.6, + -1.3, + -0.3, + 0.7, + 0.9, + 1.1, + 1.4, + 0.8, + 0.4, + 0.5, + 0.5, + 0.3, + 0.2, + 0.1, + 0.1, + -0, + -0.1, + -0.3, + -0.5, + -0.2, + -0.2, + -0.3, + -0.4, + -1, + -0.2, + 0.2, + 0.7, + 1.3, + 1.4, + 1.3, + 0.7, + 0.4, + 0.5, + 0, + -0.6, + -1.1, + -1.5, + -1.9, + -2.2, + -2.6, + -3.2, + -3.5, + -3.6, + -3.6, + -3.7, + -3.8, + -3.9, + -4, + -3.4, + -2, + -0.9, + 0.2, + 0.6, + 0.5, + 0, + -0.3, + -0.3, + -0.4, + -0.4, + -0.6, + -0.8, + -1, + -1.4, + -2.1, + -2.4, + -2.7, + -3, + -3.2, + -3.4, + -3.5, + -3.5, + -3.3, + -2.8, + -2, + -1, + 0, + 0.3, + 0.4, + 0.3, + 0.2, + -0.1, + -0.5, + -0.9, + -1.4, + -1.9, + -2.1, + -2.3, + -2.5, + -2.8, + -3, + -3.3, + -3.5, + -3.6, + -3.6, + -3.6, + -3.6, + -3.3, + -2.7, + -1.9, + -1.2, + -1.4, + -1.4, + -1.6, + -1.9, + -2.4, + -2.9, + -1.6, + -1.8, + -2, + -2.1, + -2.3, + -2.4, + -2.4, + -2.4, + -2.5, + -2.9, + -3.3, + -3.9, + -4.2, + -4.5, + -4.6, + -4, + -3.1, + -2, + -1.3, + -0.7, + -0.3, + -0.5, + -1, + -1.4, + -1.5, + -1.4, + -1.3, + -1.2 + ], + "cloudcover_high": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 28, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 92, + 1, + 0, + 0, + 0, + 0, + 0, + 83, + 100, + 0, + 7, + 100, + 100, + 100, + 3, + 0, + 0, + 0, + 0, + 0, + 14, + 62, + 1, + 58, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 87, + 99, + 100, + 100, + 100, + 100, + 100, + 100, + 55, + 100, + 100, + 100, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 33, + 67, + 100, + 94, + 87, + 81, + 75, + 70, + 65, + 43, + 22, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 15, + 22, + 15, + 7, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 11, + 22, + 33, + 22, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 16, + 24, + 49, + 75, + 100, + 100, + 100, + 100, + 67 + ], + "pressure_msl": [ + 1025.2, + 1025.7, + 1025.7, + 1025.2, + 1024.7, + 1024.2, + 1024.2, + 1024.2, + 1024.2, + 1023.7, + 1024.2, + 1023.7, + 1023.2, + 1022.2, + 1021.2, + 1021.2, + 1020.7, + 1020.2, + 1020.2, + 1019.7, + 1018.7, + 1018.7, + 1018.2, + 1017.7, + 1016.7, + 1015.7, + 1015.2, + 1014.2, + 1013.7, + 1012.7, + 1012.2, + 1011.1, + 1011.1, + 1010.6, + 1010.1, + 1009.6, + 1008.6, + 1008.1, + 1007.6, + 1007.1, + 1007.1, + 1006.6, + 1006.1, + 1006.1, + 1005.6, + 1005.6, + 1005.1, + 1005.1, + 1005.1, + 1005.1, + 1004.6, + 1004.1, + 1004.1, + 1003.6, + 1003.6, + 1003.6, + 1003.1, + 1002.6, + 1002.6, + 1002.1, + 1001.6, + 1000.6, + 1000.1, + 999.6, + 999.6, + 999.1, + 998.6, + 998.1, + 997.2, + 996.7, + 996.2, + 995.7, + 995.2, + 994.7, + 994.2, + 993.7, + 993.2, + 992.7, + 992.7, + 992.2, + 992.7, + 992.7, + 992.7, + 992.7, + 992.2, + 991.7, + 991.7, + 991.7, + 992.2, + 992.2, + 992.7, + 992.7, + 992.7, + 993.2, + 993.2, + 993.2, + 993.7, + 993.2, + 993.7, + 993.7, + 993.7, + 993.7, + 994.2, + 994.2, + 994.7, + 994.7, + 995.2, + 995.2, + 995.2, + 995.2, + 995.2, + 995.2, + 995.7, + 995.7, + 996.2, + 996.2, + 996.7, + 996.7, + 996.7, + 996.7, + 997.2, + 997.2, + 997.2, + 997.7, + 997.7, + 997.7, + 998.2, + 998.7, + 999.3, + 999.3, + 999.8, + 1000.3, + 1000.3, + 1000.3, + 1000.6, + 1001.1, + 1001.1, + 1001.6, + 1002.1, + 1002.1, + 1006.6, + 1007.1, + 1007.1, + 1007.6, + 1007.6, + 1008.2, + 1008.2, + 1008.7, + 1008.7, + 1008.7, + 1008.7, + 1009.2, + 1009.2, + 1009.7, + 1009.7, + 1009.7, + 1009.2, + 1008.7, + 1008.2, + 1007.6, + 1006.6, + 1005.6, + 1004.6, + 1003.6, + 1002.1, + 1001.1, + 999.1, + 998.1 + ], + "diffuse_radiation": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.7, + 22.3, + 41, + 63.3, + 79.1, + 76.7, + 57.4, + 27.9, + 7.9, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.6, + 30.3, + 71.4, + 94.8, + 110.4, + 104.9, + 80.8, + 25.3, + 14.3, + -0, + 0, + 0, + -0, + 0, + -0, + 0, + 0, + -0, + 0, + 0, + 0, + -0, + 0, + -0, + 0.3, + 17.3, + 56.7, + 90.1, + 105.3, + 112.5, + 91.3, + 46.8, + 12.3, + 0, + -0, + 0, + -0.1, + 0.1, + -0, + -0, + -0, + 0, + -0, + 0.1, + -0, + -0.1, + 0.1, + -0, + 0.2, + 26.7, + 66.2, + 93.2, + 89.9, + 67.9, + 60.8, + 50.8, + 11, + 0, + -0.1, + -0, + -0, + -0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 21.5, + 49.3, + 64.5, + 66.6, + 62, + 56.3, + 42.7, + 15.6, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 26.2, + 61.1, + 85.6, + 98.2, + 98.6, + 78.3, + 51.4, + 16.4, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + -0, + -0, + 0, + 0, + 24.8, + 57.9, + 81.8, + 94.8, + 94.4, + 77.1, + 48.9, + 15, + 0, + 0, + 0, + -0, + -0, + 0, + 0 + ], + "snow_depth": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "windspeed_10m": [ + 10.4, + 10.9, + 10.1, + 7.9, + 8.8, + 8.9, + 9.3, + 9.4, + 8.1, + 9.6, + 8.5, + 7.6, + 7.9, + 7.4, + 7.1, + 6.4, + 4, + 3.1, + 5, + 4.3, + 4.6, + 4.9, + 5.3, + 5.3, + 5.8, + 6, + 6.3, + 5.9, + 5.9, + 6, + 5.2, + 4.7, + 5.1, + 7, + 6.9, + 6.6, + 6.6, + 7.8, + 11.5, + 10.5, + 8.1, + 10.1, + 10.9, + 10.9, + 11.2, + 11.8, + 12.3, + 12.9, + 12.7, + 12.5, + 12.1, + 12.3, + 12.6, + 10.9, + 10, + 10.8, + 10.8, + 14.8, + 12.1, + 11.5, + 10.5, + 8.8, + 9.2, + 8.7, + 9.2, + 7.8, + 6.3, + 7.5, + 7.9, + 7.9, + 8.7, + 9.5, + 9.2, + 9.4, + 9.6, + 9.4, + 9.6, + 9.1, + 9.2, + 9.4, + 9.2, + 9.5, + 10.6, + 9.2, + 9.9, + 10.2, + 10.7, + 10.4, + 8.9, + 8.2, + 7.2, + 7.6, + 7.7, + 7.4, + 6.5, + 6.6, + 6.2, + 5.8, + 5.7, + 5.6, + 5.9, + 6.2, + 6.6, + 7, + 7, + 6.9, + 6.8, + 6.6, + 6.2, + 5.9, + 5.8, + 5.8, + 5.6, + 5.1, + 4.5, + 4.1, + 4.3, + 4.9, + 5.6, + 5.7, + 5.5, + 5.3, + 5.4, + 5.7, + 5.9, + 5.6, + 5.2, + 4.7, + 4.5, + 4.4, + 4.3, + 4.6, + 5.4, + 6.5, + 8.7, + 7.7, + 6.8, + 7.5, + 8.6, + 9.8, + 11.1, + 11.7, + 12.5, + 13, + 13.4, + 13.6, + 13.4, + 12.9, + 12.5, + 12.8, + 13.5, + 14, + 13.8, + 13.3, + 12.4, + 11.9, + 11.3, + 10, + 8.2, + 6, + 5.5, + 7.4, + 10, + 13, + 14.3, + 15.2, + 15.8, + 16.1 + ], + "vapor_pressure_deficit": [ + 0.05, + 0.05, + 0.06, + 0.07, + 0.05, + 0.05, + 0.05, + 0.05, + 0.05, + 0.05, + 0.08, + 0.12, + 0.16, + 0.17, + 0.19, + 0.16, + 0.11, + 0.11, + 0.07, + 0.06, + 0.05, + 0.04, + 0.04, + 0.04, + 0.05, + 0.07, + 0.09, + 0.12, + 0.1, + 0.08, + 0.05, + 0.03, + 0.02, + 0, + 0.03, + 0.08, + 0.11, + 0.14, + 0.18, + 0.15, + 0.14, + 0.14, + 0.12, + 0.12, + 0.13, + 0.13, + 0.12, + 0.11, + 0.1, + 0.08, + 0.08, + 0.07, + 0.06, + 0.07, + 0.06, + 0.08, + 0.09, + 0.1, + 0.13, + 0.16, + 0.19, + 0.22, + 0.23, + 0.21, + 0.14, + 0.11, + 0.09, + 0.08, + 0.13, + 0.13, + 0.14, + 0.15, + 0.15, + 0.15, + 0.14, + 0.14, + 0.13, + 0.12, + 0.11, + 0.11, + 0.1, + 0.09, + 0.12, + 0.16, + 0.21, + 0.25, + 0.27, + 0.25, + 0.18, + 0.14, + 0.12, + 0.12, + 0.13, + 0.12, + 0.1, + 0.09, + 0.08, + 0.06, + 0.05, + 0.05, + 0.05, + 0.05, + 0.04, + 0.04, + 0.05, + 0.06, + 0.08, + 0.1, + 0.14, + 0.17, + 0.18, + 0.17, + 0.15, + 0.13, + 0.12, + 0.1, + 0.09, + 0.08, + 0.08, + 0.08, + 0.08, + 0.08, + 0.08, + 0.08, + 0.08, + 0.07, + 0.07, + 0.07, + 0.06, + 0.05, + 0.05, + 0.07, + 0.09, + 0.13, + 0.14, + 0.13, + 0.11, + 0.09, + 0.06, + 0.03, + 0.04, + 0.05, + 0.05, + 0.05, + 0.05, + 0.04, + 0.04, + 0.04, + 0.04, + 0.04, + 0.05, + 0.06, + 0.07, + 0.07, + 0.08, + 0.1, + 0.14, + 0.17, + 0.16, + 0.14, + 0.11, + 0.09, + 0.07, + 0.06, + 0.07, + 0.08, + 0.1, + 0.09 + ], + "windgusts_10m": [ + 22.1, + 23.8, + 23.5, + 21.5, + 18.4, + 19.6, + 19.9, + 20.3, + 19.6, + 20.8, + 25, + 19.1, + 17.9, + 17.6, + 16.8, + 15.7, + 14, + 8.5, + 10.4, + 11, + 9.5, + 10.2, + 11.1, + 11.8, + 12.2, + 13, + 14.4, + 13.3, + 13.2, + 12.5, + 12.7, + 10.6, + 10.4, + 15.6, + 16.8, + 18.8, + 15.9, + 17.8, + 25.3, + 24.6, + 22.5, + 21.4, + 23.4, + 23.3, + 24.7, + 25.3, + 26.2, + 29.2, + 28.2, + 27.9, + 26.9, + 26.8, + 28, + 27.2, + 23.5, + 24.9, + 24.6, + 31.8, + 32.6, + 27.5, + 25.1, + 23.1, + 20.4, + 20.2, + 24, + 20.1, + 16.8, + 16, + 20.5, + 20.1, + 21.1, + 22.2, + 23.6, + 24.4, + 25.7, + 26.4, + 26.3, + 27.2, + 27.1, + 27, + 26.4, + 26.4, + 27.1, + 28.7, + 28.1, + 27.2, + 26.5, + 25.5, + 23.6, + 23.6, + 22.2, + 21.4, + 20.4, + 19.4, + 18.4, + 18.1, + 17.7, + 17.4, + 17.6, + 17.9, + 18.1, + 18.4, + 18.6, + 18.9, + 19.2, + 19.5, + 19.9, + 21.5, + 23, + 24.6, + 24.1, + 23.6, + 23.1, + 21.4, + 19.8, + 18.1, + 15.8, + 13.5, + 11.2, + 10.9, + 10.6, + 10.3, + 8.9, + 7.5, + 6, + 5.1, + 4.2, + 3.3, + 5.5, + 7.8, + 10, + 14.4, + 18.8, + 23.2, + 24, + 25.6, + 27.2, + 26.1, + 25, + 24, + 25.6, + 27.1, + 28.7, + 29.6, + 30.5, + 31.4, + 30.5, + 29.7, + 28.8, + 30.3, + 31.8, + 33.3, + 32.3, + 31.3, + 30.4, + 28.3, + 26.2, + 24.2, + 21.1, + 18, + 14.9, + 19.9, + 24.9, + 29.9, + 32, + 34.1, + 36.3, + 36.9 + ], + "winddirection_120m": [ + 265, + 269, + 280, + 274, + 281, + 282, + 279, + 275, + 270, + 273, + 266, + 259, + 257, + 266, + 254, + 258, + 245, + 240, + 202, + 205, + 189, + 192, + 186, + 184, + 169, + 172, + 176, + 172, + 158, + 161, + 174, + 185, + 192, + 201, + 203, + 201, + 205, + 210, + 246, + 221, + 235, + 229, + 235, + 238, + 243, + 241, + 247, + 256, + 258, + 262, + 255, + 252, + 258, + 253, + 243, + 245, + 248, + 256, + 253, + 253, + 245, + 239, + 234, + 218, + 204, + 209, + 179, + 179, + 189, + 188, + 184, + 181, + 179, + 172, + 170, + 169, + 169, + 169, + 164, + 159, + 156, + 155, + 153, + 153, + 147, + 140, + 143, + 144, + 153, + 157, + 163, + 156, + 164, + 179, + 183, + 182, + 190, + 192, + 190, + 185, + 179, + 175, + 172, + 168, + 168, + 168, + 168, + 166, + 164, + 161, + 161, + 162, + 166, + 174, + 187, + 205, + 208, + 207, + 205, + 206, + 207, + 209, + 211, + 212, + 214, + 218, + 223, + 230, + 236, + 243, + 253, + 259, + 265, + 269, + 271, + 272, + 272, + 273, + 274, + 275, + 298, + 293, + 288, + 288, + 289, + 290, + 291, + 291, + 292, + 292, + 293, + 292, + 290, + 286, + 281, + 278, + 275, + 268, + 262, + 255, + 242, + 227, + 215, + 207, + 204, + 203, + 202, + 201 + ], + "windspeed_120m": [ + 24.7, + 24.5, + 23.8, + 20.2, + 21.5, + 22, + 23.5, + 22.3, + 22.2, + 21.2, + 18.6, + 14.6, + 13.8, + 13.2, + 12, + 12.7, + 9.1, + 8.1, + 11.3, + 12.5, + 13.6, + 15, + 15.3, + 16.1, + 17.6, + 18, + 17.9, + 16, + 14.8, + 15, + 14.5, + 12.9, + 14.8, + 18.2, + 10.1, + 9.9, + 9.7, + 13, + 21.6, + 22.1, + 19.3, + 23.4, + 26.6, + 25.5, + 26.5, + 27.7, + 29.1, + 30.2, + 29, + 27.8, + 27, + 26.9, + 27.4, + 23.8, + 22.1, + 23.9, + 23.4, + 28.4, + 23.9, + 21.3, + 19.3, + 15.9, + 16.5, + 16.4, + 19, + 15.9, + 16.9, + 19.5, + 27.9, + 29.4, + 30.7, + 31.2, + 29.3, + 28.6, + 31.6, + 34.1, + 35.2, + 33.5, + 32.8, + 33.5, + 33.4, + 34.1, + 33.3, + 27.9, + 19.2, + 18.6, + 20.4, + 23.3, + 27.3, + 27.1, + 26.5, + 27.4, + 29.3, + 28.8, + 22.7, + 23.9, + 25, + 24.2, + 24, + 23.5, + 23.2, + 23.4, + 23.7, + 24.1, + 24.4, + 24.6, + 24, + 22.1, + 19.5, + 17, + 17.2, + 18.2, + 18.5, + 16.5, + 14, + 13.5, + 15.8, + 19.2, + 22.4, + 22.1, + 20.5, + 18.9, + 19.1, + 19.8, + 20, + 19.1, + 17.7, + 15.9, + 14.8, + 13.8, + 13.4, + 14.1, + 15.5, + 17.2, + 18.2, + 20.8, + 23.9, + 26, + 28, + 30.1, + 34.7, + 33.6, + 32.6, + 32.7, + 32.9, + 33.2, + 33.3, + 33.4, + 33.2, + 33, + 32.6, + 31.4, + 29.6, + 27.3, + 24.3, + 22, + 19.6, + 17.3, + 16.7, + 17, + 18.5, + 21.8, + 27.2, + 34.1, + 37, + 38.7, + 39.8, + 40.2 + ], + "windspeed_80m": [ + 20.9, + 21.3, + 19.9, + 16.7, + 18.1, + 18.4, + 19.3, + 18.9, + 17.9, + 18.3, + 15.5, + 13, + 13, + 12.3, + 11.4, + 11.5, + 7.7, + 7.2, + 10.1, + 10.6, + 11.3, + 12, + 12.3, + 12.7, + 14.1, + 13.8, + 14, + 12.1, + 12.3, + 12.8, + 12.3, + 11.4, + 12.5, + 11.3, + 9.7, + 9.2, + 9.3, + 12, + 20, + 19.4, + 16.4, + 20.5, + 21.8, + 21.8, + 22.3, + 23.6, + 24.6, + 25.8, + 25.1, + 24.1, + 23.4, + 23.6, + 24, + 20.7, + 19.2, + 20.8, + 20.6, + 26.7, + 21.8, + 19.9, + 17.9, + 14.8, + 15.5, + 15.4, + 17.2, + 14.5, + 13.1, + 14.8, + 21.2, + 21.6, + 22.6, + 21.9, + 20.4, + 20.2, + 22.7, + 24.1, + 25.2, + 24.1, + 24.8, + 25, + 24.5, + 25.1, + 21.7, + 16.7, + 17.6, + 17.5, + 19.2, + 20.9, + 20.8, + 20.1, + 19.4, + 20.7, + 21.7, + 21.6, + 18.2, + 18.9, + 19, + 18.7, + 18.5, + 18.2, + 18.3, + 18.7, + 19.3, + 20, + 20.3, + 20.5, + 19.7, + 17.6, + 14.7, + 12.2, + 12.8, + 14.5, + 15.8, + 14.3, + 12.2, + 11.3, + 13, + 15.5, + 17.9, + 17.6, + 16.4, + 15.1, + 15, + 15.2, + 15.1, + 14.5, + 13.6, + 12.4, + 11.5, + 10.7, + 10.2, + 10.6, + 11.9, + 13.5, + 16.1, + 15.4, + 15.3, + 16.5, + 18.4, + 20.4, + 24.6, + 25.3, + 26.3, + 27.2, + 28, + 28.4, + 27.8, + 26.7, + 25.7, + 26.2, + 27.1, + 27.6, + 26.7, + 25.1, + 22.8, + 21, + 19, + 16.5, + 14.9, + 13.8, + 14.3, + 17, + 21.4, + 26.9, + 29.7, + 31.7, + 33.3, + 33.9 + ], + "evapotranspiration": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.01, + 0.01, + 0.02, + 0.02, + 0.02, + 0.02, + 0.01, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.01, + 0.01, + 0.01, + 0.01, + 0, + 0, + 0, + 0, + 0.01, + 0.02, + 0.02, + 0.02, + 0.03, + 0.02, + 0.02, + 0.01, + 0.01, + 0.01, + 0.02, + 0.02, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.02, + 0.02, + 0.03, + 0.02, + 0.02, + 0.02, + 0.02, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.02, + 0.02, + 0.02, + 0.02, + 0.02, + 0.02, + 0.02, + 0.01, + 0.01, + 0.02, + 0.02, + 0.03, + 0.03, + 0.03, + 0.02, + 0.02, + 0.02, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.01, + 0.01, + 0.02, + 0.02, + 0.03, + 0.03, + 0.02, + 0.02, + 0.01, + 0.01, + 0.01, + 0.01, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.02, + 0.02, + 0.02, + 0.02, + 0.02, + 0.03, + 0.03, + 0.03, + 0.02, + 0.02, + 0.02, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.01, + 0.02 + ], + "precipitation": [ + 0, + 0, + 0.01, + 0, + 0.03, + 0.01, + 0, + 0, + 0, + 0, + 0, + 0, + 0.01, + 0.01, + 0, + 0.06, + 0.06, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.03, + 0.07, + 0, + 0, + 0, + 0.09, + 0.09, + 0.01, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.01, + 0.1, + 0.3, + 0.2, + 0.15, + 0, + 0, + 0, + 0, + 0.01, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.1, + 0.01, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.05, + 0.05, + 0.05, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.01, + 0.01, + 0.01, + 0.03, + 0.03, + 0.03, + 0.02, + 0.02, + 0.02, + 0.13, + 0.13, + 0.13, + 0, + 0, + 0, + 0.07, + 0.07, + 0.07, + 0.16, + 0.16, + 0.16, + 0.01, + 0.01, + 0.01, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0.03, + 0.03, + 0.03, + 0.04, + 0.04, + 0.04, + 0.88 + ] + }, + "daily": { + "temperature_2m_max": [ + 7.6, + 5.4, + 4.8, + 4.5, + 3.4, + 2.2, + 3 + ], + "precipitation_hours": [ + 7, + 5, + 5, + 3, + 3, + 13, + 15 + ], + "shortwave_radiation_sum": [ + 1.44, + 2.16, + 1.95, + 2.05, + 4.18, + 2.86, + 3.31 + ], + "winddirection_10m_dominant": [ + 251, + 210, + 230, + 143, + 143, + 248, + 256 + ], + "windspeed_10m_max": [ + 10.9, + 12.9, + 14.8, + 10.7, + 7, + 13, + 16.1 + ], + "apparent_temperature_min": [ + 3.4, + -2.9, + -1.9, + -4, + -3.5, + -3.6, + -4.6 + ], + "sunset": [ + "2021-11-24T16:04", + "2021-11-25T16:03", + "2021-11-26T16:02", + "2021-11-27T16:01", + "2021-11-28T16:00", + "2021-11-29T15:59", + "2021-11-30T15:59" + ], + "weathercode": [ + 61, + 61, + 61, + 61, + 61, + 77, + 80 + ], + "sunrise": [ + "2021-11-24T07:41", + "2021-11-25T07:43", + "2021-11-26T07:45", + "2021-11-27T07:46", + "2021-11-28T07:48", + "2021-11-29T07:49", + "2021-11-30T07:51" + ], + "apparent_temperature_max": [ + 5.5, + 3.2, + 1.4, + 0.6, + 0.4, + -1.2, + -0.3 + ], + "temperature_2m_min": [ + 5.5, + 0.2, + 1.8, + -0.1, + -0.2, + -0.5, + -0.3 + ], + "windgusts_10m_max": [ + 8.5, + 10.4, + 16, + 18.1, + 10.9, + 3.3, + 14.9 + ], + "precipitation_sum": [ + 0.19, + 0.29, + 0.76, + 0.12, + 0.15, + 0.64, + 1.74 + ], + "time": [ + "2021-11-24", + "2021-11-25", + "2021-11-26", + "2021-11-27", + "2021-11-28", + "2021-11-29", + "2021-11-30" + ] + }, + "utc_offset_seconds": 3600, + "hourly_units": { + "precipitation": "mm", + "shortwave_radiation": "W\/m²", + "soil_moisture_0_1cm": "m³\/m³", + "pressure_msl": "hPa", + "soil_moisture_3_9cm": "m³\/m³", + "soil_temperature_54cm": "°C", + "soil_temperature_18cm": "°C", + "winddirection_120m": "°", + "vapor_pressure_deficit": "kPa", + "dewpoint_2m": "°C", + "winddirection_180m": "°", + "windspeed_10m": "km\/h", + "cloudcover_low": "%", + "cloudcover_mid": "%", + "cloudcover_high": "%", + "windgusts_10m": "km\/h", + "soil_moisture_9_27cm": "m³\/m³", + "windspeed_120m": "km\/h", + "winddirection_10m": "°", + "time": "iso8601", + "soil_temperature_6cm": "°C", + "apparent_temperature": "°C", + "windspeed_80m": "km\/h", + "soil_moisture_1_3cm": "m³\/m³", + "diffuse_radiation": "W\/m²", + "snow_depth": "m", + "windspeed_180m": "km\/h", + "weathercode": "wmo code", + "direct_normal_irradiance": "W\/m²", + "relativehumidity_2m": "%", + "soil_moisture_27_81cm": "m³\/m³", + "winddirection_80m": "°", + "freezinglevel_height": "m", + "evapotranspiration": "mm", + "cloudcover": "%", + "soil_temperature_0cm": "°C", + "direct_radiation": "W\/m²", + "temperature_2m": "°C" + }, + "longitude": 13.419998, + "elevation": 44.8125, + "current_weather": { + "temperature": 5.5, + "windspeed": 5.3, + "weathercode": 3, + "winddirection": 168, + "time": "2021-11-24T23:00" + } +} \ No newline at end of file diff --git a/tests/components/open_meteo/test_config_flow.py b/tests/components/open_meteo/test_config_flow.py new file mode 100644 index 00000000000000..f985e2a6193e15 --- /dev/null +++ b/tests/components/open_meteo/test_config_flow.py @@ -0,0 +1,33 @@ +"""Tests for the Open-Meteo config flow.""" + +from unittest.mock import MagicMock + +from homeassistant.components.open_meteo.const import DOMAIN +from homeassistant.components.zone import ENTITY_ID_HOME +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_setup_entry: MagicMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ZONE: ENTITY_ID_HOME}, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "test home" + assert result2.get("data") == {CONF_ZONE: ENTITY_ID_HOME} diff --git a/tests/components/open_meteo/test_init.py b/tests/components/open_meteo/test_init.py new file mode 100644 index 00000000000000..38619bc09db592 --- /dev/null +++ b/tests/components/open_meteo/test_init.py @@ -0,0 +1,68 @@ +"""Tests for the Open-Meteo integration.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from open_meteo import OpenMeteoConnectionError +from pytest import LogCaptureFixture + +from homeassistant.components.open_meteo.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ZONE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_open_meteo: AsyncMock, +) -> None: + """Test the Open-Meteo configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@patch( + "homeassistant.components.open_meteo.OpenMeteo.forecast", + side_effect=OpenMeteoConnectionError, +) +async def test_config_entry_not_ready( + mock_forecast: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Open-Meteo configuration entry not ready.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_forecast.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_zone_removed( + hass: HomeAssistant, + caplog: LogCaptureFixture, +) -> None: + """Test the Open-Meteo configuration entry not ready.""" + mock_config_entry = MockConfigEntry( + title="My Castle", + domain=DOMAIN, + data={CONF_ZONE: "zone.castle"}, + unique_id="zone.castle", + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Zone 'zone.castle' not found" in caplog.text diff --git a/tests/components/openalpr_cloud/test_image_processing.py b/tests/components/openalpr_cloud/test_image_processing.py index e1e9192e1de0c4..c1b88026f4d387 100644 --- a/tests/components/openalpr_cloud/test_image_processing.py +++ b/tests/components/openalpr_cloud/test_image_processing.py @@ -2,200 +2,188 @@ import asyncio from unittest.mock import PropertyMock, patch +import pytest + from homeassistant.components import camera, image_processing as ip from homeassistant.components.openalpr_cloud.image_processing import OPENALPR_API_URL -from homeassistant.core import callback -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component, get_test_home_assistant, load_fixture +from tests.common import assert_setup_component, async_capture_events, load_fixture from tests.components.image_processing import common -class TestOpenAlprCloudSetup: - """Test class for image processing.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_platform(self): - """Set up platform with one entity.""" - config = { - ip.DOMAIN: { - "platform": "openalpr_cloud", - "source": {"entity_id": "camera.demo_camera"}, - "region": "eu", - "api_key": "sk_abcxyz123456", - }, - "camera": {"platform": "demo"}, - } - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - assert self.hass.states.get("image_processing.openalpr_demo_camera") - - def test_setup_platform_name(self): - """Set up platform with one entity and set name.""" - config = { - ip.DOMAIN: { - "platform": "openalpr_cloud", - "source": {"entity_id": "camera.demo_camera", "name": "test local"}, - "region": "eu", - "api_key": "sk_abcxyz123456", - }, - "camera": {"platform": "demo"}, - } - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - assert self.hass.states.get("image_processing.test_local") - - def test_setup_platform_without_api_key(self): - """Set up platform with one entity without api_key.""" - config = { - ip.DOMAIN: { - "platform": "openalpr_cloud", - "source": {"entity_id": "camera.demo_camera"}, - "region": "eu", - }, - "camera": {"platform": "demo"}, - } - - with assert_setup_component(0, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - - def test_setup_platform_without_region(self): - """Set up platform with one entity without region.""" - config = { - ip.DOMAIN: { - "platform": "openalpr_cloud", - "source": {"entity_id": "camera.demo_camera"}, - "api_key": "sk_abcxyz123456", - }, - "camera": {"platform": "demo"}, - } - - with assert_setup_component(0, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - - -class TestOpenAlprCloud: - """Test class for image processing.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - config = { - ip.DOMAIN: { - "platform": "openalpr_cloud", - "source": {"entity_id": "camera.demo_camera", "name": "test local"}, - "region": "eu", - "api_key": "sk_abcxyz123456", - }, - "camera": {"platform": "demo"}, - } - - with patch( - "homeassistant.components.openalpr_cloud.image_processing." - "OpenAlprCloudEntity.should_poll", - new_callable=PropertyMock(return_value=False), - ): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - self.alpr_events = [] - - @callback - def mock_alpr_event(event): - """Mock event.""" - self.alpr_events.append(event) - - self.hass.bus.listen("image_processing.found_plate", mock_alpr_event) - - self.params = { - "secret_key": "sk_abcxyz123456", - "tasks": "plate", - "return_image": 0, - "country": "eu", - } - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_openalpr_process_image(self, aioclient_mock): - """Set up and scan a picture and test plates from event.""" - aioclient_mock.post( - OPENALPR_API_URL, - params=self.params, - text=load_fixture("alpr_cloud.json"), - status=200, - ) - - with patch( - "homeassistant.components.camera.async_get_image", - return_value=camera.Image("image/jpeg", b"image"), - ): - common.scan(self.hass, entity_id="image_processing.test_local") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.test_local") - - assert len(aioclient_mock.mock_calls) == 1 - assert len(self.alpr_events) == 5 - assert state.attributes.get("vehicles") == 1 - assert state.state == "H786P0J" - - event_data = [ - event.data - for event in self.alpr_events - if event.data.get("plate") == "H786P0J" - ] - assert len(event_data) == 1 - assert event_data[0]["plate"] == "H786P0J" - assert event_data[0]["confidence"] == float(90.436699) - assert event_data[0]["entity_id"] == "image_processing.test_local" - - def test_openalpr_process_image_api_error(self, aioclient_mock): - """Set up and scan a picture and test api error.""" - aioclient_mock.post( - OPENALPR_API_URL, - params=self.params, - text="{'error': 'error message'}", - status=400, - ) - - with patch( - "homeassistant.components.camera.async_get_image", - return_value=camera.Image("image/jpeg", b"image"), - ): - common.scan(self.hass, entity_id="image_processing.test_local") - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert len(self.alpr_events) == 0 - - def test_openalpr_process_image_api_timeout(self, aioclient_mock): - """Set up and scan a picture and test api error.""" - aioclient_mock.post( - OPENALPR_API_URL, params=self.params, exc=asyncio.TimeoutError() - ) - - with patch( - "homeassistant.components.camera.async_get_image", - return_value=camera.Image("image/jpeg", b"image"), - ): - common.scan(self.hass, entity_id="image_processing.test_local") - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert len(self.alpr_events) == 0 +@pytest.fixture +async def setup_openalpr_cloud(hass): + """Set up openalpr cloud.""" + config = { + ip.DOMAIN: { + "platform": "openalpr_cloud", + "source": {"entity_id": "camera.demo_camera", "name": "test local"}, + "region": "eu", + "api_key": "sk_abcxyz123456", + }, + "camera": {"platform": "demo"}, + } + + with patch( + "homeassistant.components.openalpr_cloud.image_processing." + "OpenAlprCloudEntity.should_poll", + new_callable=PropertyMock(return_value=False), + ): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + +@pytest.fixture +async def alpr_events(hass): + """Listen for events.""" + return async_capture_events(hass, "image_processing.found_plate") + + +PARAMS = { + "secret_key": "sk_abcxyz123456", + "tasks": "plate", + "return_image": 0, + "country": "eu", +} + + +async def test_setup_platform(hass): + """Set up platform with one entity.""" + config = { + ip.DOMAIN: { + "platform": "openalpr_cloud", + "source": {"entity_id": "camera.demo_camera"}, + "region": "eu", + "api_key": "sk_abcxyz123456", + }, + "camera": {"platform": "demo"}, + } + + with assert_setup_component(1, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + assert hass.states.get("image_processing.openalpr_demo_camera") + + +async def test_setup_platform_name(hass): + """Set up platform with one entity and set name.""" + config = { + ip.DOMAIN: { + "platform": "openalpr_cloud", + "source": {"entity_id": "camera.demo_camera", "name": "test local"}, + "region": "eu", + "api_key": "sk_abcxyz123456", + }, + "camera": {"platform": "demo"}, + } + + with assert_setup_component(1, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + assert hass.states.get("image_processing.test_local") + + +async def test_setup_platform_without_api_key(hass): + """Set up platform with one entity without api_key.""" + config = { + ip.DOMAIN: { + "platform": "openalpr_cloud", + "source": {"entity_id": "camera.demo_camera"}, + "region": "eu", + }, + "camera": {"platform": "demo"}, + } + + with assert_setup_component(0, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + + +async def test_setup_platform_without_region(hass): + """Set up platform with one entity without region.""" + config = { + ip.DOMAIN: { + "platform": "openalpr_cloud", + "source": {"entity_id": "camera.demo_camera"}, + "api_key": "sk_abcxyz123456", + }, + "camera": {"platform": "demo"}, + } + + with assert_setup_component(0, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + + +async def test_openalpr_process_image( + alpr_events, setup_openalpr_cloud, hass, aioclient_mock +): + """Set up and scan a picture and test plates from event.""" + aioclient_mock.post( + OPENALPR_API_URL, + params=PARAMS, + text=load_fixture("alpr_cloud.json"), + status=200, + ) + + with patch( + "homeassistant.components.camera.async_get_image", + return_value=camera.Image("image/jpeg", b"image"), + ): + common.async_scan(hass, entity_id="image_processing.test_local") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.test_local") + + assert len(aioclient_mock.mock_calls) == 1 + assert len(alpr_events) == 5 + assert state.attributes.get("vehicles") == 1 + assert state.state == "H786P0J" + + event_data = [ + event.data for event in alpr_events if event.data.get("plate") == "H786P0J" + ] + assert len(event_data) == 1 + assert event_data[0]["plate"] == "H786P0J" + assert event_data[0]["confidence"] == float(90.436699) + assert event_data[0]["entity_id"] == "image_processing.test_local" + + +async def test_openalpr_process_image_api_error( + alpr_events, setup_openalpr_cloud, hass, aioclient_mock +): + """Set up and scan a picture and test api error.""" + aioclient_mock.post( + OPENALPR_API_URL, + params=PARAMS, + text="{'error': 'error message'}", + status=400, + ) + + with patch( + "homeassistant.components.camera.async_get_image", + return_value=camera.Image("image/jpeg", b"image"), + ): + common.async_scan(hass, entity_id="image_processing.test_local") + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(alpr_events) == 0 + + +async def test_openalpr_process_image_api_timeout( + alpr_events, setup_openalpr_cloud, hass, aioclient_mock +): + """Set up and scan a picture and test api error.""" + aioclient_mock.post(OPENALPR_API_URL, params=PARAMS, exc=asyncio.TimeoutError()) + + with patch( + "homeassistant.components.camera.async_get_image", + return_value=camera.Image("image/jpeg", b"image"), + ): + common.async_scan(hass, entity_id="image_processing.test_local") + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(alpr_events) == 0 diff --git a/tests/components/openalpr_local/test_image_processing.py b/tests/components/openalpr_local/test_image_processing.py index 0cde6e0454bea3..fefc42fe2ab8e8 100644 --- a/tests/components/openalpr_local/test_image_processing.py +++ b/tests/components/openalpr_local/test_image_processing.py @@ -1,16 +1,52 @@ """The tests for the openalpr local platform.""" from unittest.mock import MagicMock, PropertyMock, patch +import pytest + import homeassistant.components.image_processing as ip from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.core import callback -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component, get_test_home_assistant, load_fixture +from tests.common import assert_setup_component, async_capture_events, load_fixture from tests.components.image_processing import common -def mock_async_subprocess(): +@pytest.fixture +async def setup_openalpr_local(hass): + """Set up openalpr local.""" + config = { + ip.DOMAIN: { + "platform": "openalpr_local", + "source": {"entity_id": "camera.demo_camera", "name": "test local"}, + "region": "eu", + }, + "camera": {"platform": "demo"}, + } + + with patch( + "homeassistant.components.openalpr_local.image_processing." + "OpenAlprLocalEntity.should_poll", + new_callable=PropertyMock(return_value=False), + ): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + +@pytest.fixture +def url(hass, setup_openalpr_local): + """Return the camera URL.""" + state = hass.states.get("camera.demo_camera") + return f"{hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" + + +@pytest.fixture +async def alpr_events(hass): + """Listen for events.""" + return async_capture_events(hass, "image_processing.found_plate") + + +@pytest.fixture +def popen_mock(): """Get a Popen mock back.""" async_popen = MagicMock() @@ -20,130 +56,87 @@ async def communicate(input=None): return (fixture, None) async_popen.communicate = communicate - return async_popen - - -class TestOpenAlprLocalSetup: - """Test class for image processing.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_platform(self): - """Set up platform with one entity.""" - config = { - ip.DOMAIN: { - "platform": "openalpr_local", - "source": {"entity_id": "camera.demo_camera"}, - "region": "eu", - }, - "camera": {"platform": "demo"}, - } - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - assert self.hass.states.get("image_processing.openalpr_demo_camera") - - def test_setup_platform_name(self): - """Set up platform with one entity and set name.""" - config = { - ip.DOMAIN: { - "platform": "openalpr_local", - "source": {"entity_id": "camera.demo_camera", "name": "test local"}, - "region": "eu", - }, - "camera": {"platform": "demo"}, - } - - with assert_setup_component(1, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - assert self.hass.states.get("image_processing.test_local") - - def test_setup_platform_without_region(self): - """Set up platform with one entity without region.""" - config = { - ip.DOMAIN: { - "platform": "openalpr_local", - "source": {"entity_id": "camera.demo_camera"}, - }, - "camera": {"platform": "demo"}, - } - - with assert_setup_component(0, ip.DOMAIN): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - -class TestOpenAlprLocal: - """Test class for image processing.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - config = { - ip.DOMAIN: { - "platform": "openalpr_local", - "source": {"entity_id": "camera.demo_camera", "name": "test local"}, - "region": "eu", - }, - "camera": {"platform": "demo"}, - } - - with patch( - "homeassistant.components.openalpr_local.image_processing." - "OpenAlprLocalEntity.should_poll", - new_callable=PropertyMock(return_value=False), - ): - setup_component(self.hass, ip.DOMAIN, config) - self.hass.block_till_done() - - state = self.hass.states.get("camera.demo_camera") - self.url = f"{self.hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" - - self.alpr_events = [] - - @callback - def mock_alpr_event(event): - """Mock event.""" - self.alpr_events.append(event) - - self.hass.bus.listen("image_processing.found_plate", mock_alpr_event) - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - @patch("asyncio.create_subprocess_exec", return_value=mock_async_subprocess()) - def test_openalpr_process_image(self, popen_mock, aioclient_mock): - """Set up and scan a picture and test plates from event.""" - aioclient_mock.get(self.url, content=b"image") - - common.scan(self.hass, entity_id="image_processing.test_local") - self.hass.block_till_done() - - state = self.hass.states.get("image_processing.test_local") - - assert popen_mock.called - assert len(self.alpr_events) == 5 - assert state.attributes.get("vehicles") == 1 - assert state.state == "PE3R2X" - - event_data = [ - event.data - for event in self.alpr_events - if event.data.get("plate") == "PE3R2X" - ] - assert len(event_data) == 1 - assert event_data[0]["plate"] == "PE3R2X" - assert event_data[0]["confidence"] == float(98.9371) - assert event_data[0]["entity_id"] == "image_processing.test_local" + + with patch("asyncio.create_subprocess_exec", return_value=async_popen) as mock: + yield mock + + +async def test_setup_platform(hass): + """Set up platform with one entity.""" + config = { + ip.DOMAIN: { + "platform": "openalpr_local", + "source": {"entity_id": "camera.demo_camera"}, + "region": "eu", + }, + "camera": {"platform": "demo"}, + } + + with assert_setup_component(1, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + assert hass.states.get("image_processing.openalpr_demo_camera") + + +async def test_setup_platform_name(hass): + """Set up platform with one entity and set name.""" + config = { + ip.DOMAIN: { + "platform": "openalpr_local", + "source": {"entity_id": "camera.demo_camera", "name": "test local"}, + "region": "eu", + }, + "camera": {"platform": "demo"}, + } + + with assert_setup_component(1, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + assert hass.states.get("image_processing.test_local") + + +async def test_setup_platform_without_region(hass): + """Set up platform with one entity without region.""" + config = { + ip.DOMAIN: { + "platform": "openalpr_local", + "source": {"entity_id": "camera.demo_camera"}, + }, + "camera": {"platform": "demo"}, + } + + with assert_setup_component(0, ip.DOMAIN): + await async_setup_component(hass, ip.DOMAIN, config) + await hass.async_block_till_done() + + +async def test_openalpr_process_image( + setup_openalpr_local, + url, + hass, + alpr_events, + popen_mock, + aioclient_mock, +): + """Set up and scan a picture and test plates from event.""" + aioclient_mock.get(url, content=b"image") + + common.async_scan(hass, entity_id="image_processing.test_local") + await hass.async_block_till_done() + + state = hass.states.get("image_processing.test_local") + + assert popen_mock.called + assert len(alpr_events) == 5 + assert state.attributes.get("vehicles") == 1 + assert state.state == "PE3R2X" + + event_data = [ + event.data for event in alpr_events if event.data.get("plate") == "PE3R2X" + ] + assert len(event_data) == 1 + assert event_data[0]["plate"] == "PE3R2X" + assert event_data[0]["confidence"] == float(98.9371) + assert event_data[0]["entity_id"] == "image_processing.test_local" diff --git a/tests/components/opengarage/test_config_flow.py b/tests/components/opengarage/test_config_flow.py index d421fb6a12984e..5406c40b3aa835 100644 --- a/tests/components/opengarage/test_config_flow.py +++ b/tests/components/opengarage/test_config_flow.py @@ -134,69 +134,3 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - - -async def test_step_import(hass: HomeAssistant) -> None: - """Test when import configuring from yaml.""" - with patch( - "opengarage.OpenGarage.update_state", - return_value={"name": "Name of the device", "mac": "unique"}, - ), patch( - "homeassistant.components.opengarage.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "host": "1.1.1.1", - "device_key": "AfsasdnfkjDD", - "port": 1234, - "verify_ssl": False, - "ssl": False, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Name of the device" - assert result["data"] == { - "host": "http://1.1.1.1", - "device_key": "AfsasdnfkjDD", - "port": 1234, - "verify_ssl": False, - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_step_import_ssl(hass: HomeAssistant) -> None: - """Test when import configuring from yaml.""" - with patch( - "opengarage.OpenGarage.update_state", - return_value={"name": "Name of the device", "mac": "unique"}, - ), patch( - "homeassistant.components.opengarage.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "host": "1.1.1.1", - "device_key": "AfsasdnfkjDD", - "port": 1234, - "verify_ssl": False, - "ssl": True, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Name of the device" - assert result["data"] == { - "host": "https://1.1.1.1", - "device_key": "AfsasdnfkjDD", - "port": 1234, - "verify_ssl": False, - } - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/openuv/conftest.py b/tests/components/openuv/conftest.py new file mode 100644 index 00000000000000..859769f2b587cc --- /dev/null +++ b/tests/components/openuv/conftest.py @@ -0,0 +1,74 @@ +"""Define test fixtures for OpenUV.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components.openuv import CONF_FROM_WINDOW, CONF_TO_WINDOW, DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LONGITUDE, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config, unique_id): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=unique_id, + data=config, + options={CONF_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 3.5}, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_API_KEY: "abcde12345", + CONF_ELEVATION: 0, + CONF_LATITUDE: 51.528308, + CONF_LONGITUDE: -0.3817765, + } + + +@pytest.fixture(name="data_protection_window", scope="session") +def data_protection_window_fixture(): + """Define a fixture to return UV protection window data.""" + return json.loads(load_fixture("protection_window_data.json", "openuv")) + + +@pytest.fixture(name="data_uv_index", scope="session") +def data_uv_index_fixture(): + """Define a fixture to return UV index data.""" + return json.loads(load_fixture("uv_index_data.json", "openuv")) + + +@pytest.fixture(name="setup_openuv") +async def setup_openuv_fixture(hass, config, data_protection_window, data_uv_index): + """Define a fixture to set up OpenUV.""" + with patch( + "homeassistant.components.openuv.Client.uv_index", return_value=data_uv_index + ), patch( + "homeassistant.components.openuv.Client.uv_protection_window", + return_value=data_protection_window, + ), patch( + "homeassistant.components.openuv.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="unique_id") +def unique_id_fixture(hass): + """Define a config entry unique ID fixture.""" + return "51.528308, -0.3817765" diff --git a/tests/components/openuv/fixtures/protection_window_data.json b/tests/components/openuv/fixtures/protection_window_data.json new file mode 100644 index 00000000000000..c27bd25d948523 --- /dev/null +++ b/tests/components/openuv/fixtures/protection_window_data.json @@ -0,0 +1,9 @@ +{ + "result": { + "from_time": "2018-07-30T15:17:49.750Z", + "from_uv": 3.2509, + "to_time": "2018-07-30T22:47:49.750Z", + "to_uv": 3.6483 + } +} + diff --git a/tests/components/openuv/fixtures/uv_index_data.json b/tests/components/openuv/fixtures/uv_index_data.json new file mode 100644 index 00000000000000..76c2b2ed988bf1 --- /dev/null +++ b/tests/components/openuv/fixtures/uv_index_data.json @@ -0,0 +1,41 @@ +{ + "result": { + "uv": 8.2342, + "uv_time": "2018-07-30T20:53:06.302Z", + "uv_max": 10.3335, + "uv_max_time": "2018-07-30T19:07:11.505Z", + "ozone": 300.7, + "ozone_time": "2018-07-30T18:07:04.466Z", + "safe_exposure_time": { + "st1": 20, + "st2": 24, + "st3": 32, + "st4": 40, + "st5": 65, + "st6": 121 + }, + "sun_info": { + "sun_times": { + "solarNoon": "2018-07-30T19:07:11.505Z", + "nadir": "2018-07-30T07:07:11.505Z", + "sunrise": "2018-07-30T11:57:49.750Z", + "sunset": "2018-07-31T02:16:33.259Z", + "sunriseEnd": "2018-07-30T12:00:53.253Z", + "sunsetStart": "2018-07-31T02:13:29.756Z", + "dawn": "2018-07-30T11:27:27.911Z", + "dusk": "2018-07-31T02:46:55.099Z", + "nauticalDawn": "2018-07-30T10:50:01.621Z", + "nauticalDusk": "2018-07-31T03:24:21.389Z", + "nightEnd": "2018-07-30T10:08:47.846Z", + "night": "2018-07-31T04:05:35.163Z", + "goldenHourEnd": "2018-07-30T12:36:14.026Z", + "goldenHour": "2018-07-31T01:38:08.983Z" + }, + "sun_position": { + "azimuth": 0.9567419441563509, + "altitude": 1.0235714275875594 + } + } + } +} + diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py index 8afd9803d7c367..8960d39a5b9e1d 100644 --- a/tests/components/openuv/test_config_flow.py +++ b/tests/components/openuv/test_config_flow.py @@ -13,107 +13,60 @@ CONF_LONGITUDE, ) -from tests.common import MockConfigEntry - -async def test_duplicate_error(hass): +async def test_duplicate_error(hass, config, config_entry): """Test that errors are shown when duplicates are added.""" - conf = { - CONF_API_KEY: "12345abcde", - CONF_ELEVATION: 59.1234, - CONF_LATITUDE: 39.128712, - CONF_LONGITUDE: -104.9812612, - } - - MockConfigEntry( - domain=DOMAIN, unique_id="39.128712, -104.9812612", data=conf - ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -async def test_invalid_api_key(hass): +async def test_invalid_api_key(hass, config): """Test that an invalid API key throws an error.""" - conf = { - CONF_API_KEY: "12345abcde", - CONF_ELEVATION: 59.1234, - CONF_LATITUDE: 39.128712, - CONF_LONGITUDE: -104.9812612, - } - with patch( - "pyopenuv.client.Client.uv_index", + "homeassistant.components.openuv.Client.uv_index", side_effect=InvalidApiKeyError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} -async def test_options_flow(hass): +async def test_options_flow(hass, config_entry): """Test config flow options.""" - conf = { - CONF_API_KEY: "12345abcde", - CONF_ELEVATION: 59.1234, - CONF_LATITUDE: 39.128712, - CONF_LONGITUDE: -104.9812612, - } - - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="abcde12345", - data=conf, - ) - config_entry.add_to_hass(hass) - with patch("homeassistant.components.openuv.async_setup_entry", return_value=True): await hass.config_entries.async_setup(config_entry.entry_id) 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_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 2.0} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == {CONF_FROM_WINDOW: 3.5, CONF_TO_WINDOW: 2.0} -async def test_step_user(hass): +async def test_step_user(hass, config, setup_openuv): """Test that the user step works.""" - conf = { - CONF_API_KEY: "12345abcde", - CONF_ELEVATION: 59.1234, - CONF_LATITUDE: 39.128712, - CONF_LONGITUDE: -104.9812612, - } - - with patch( - "homeassistant.components.openuv.async_setup_entry", return_value=True - ), patch("pyopenuv.client.Client.uv_index"): - 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" + 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" - 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_API_KEY: "12345abcde", - CONF_ELEVATION: 59.1234, - CONF_LATITUDE: 39.128712, - CONF_LONGITUDE: -104.9812612, - } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=config + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "51.528308, -0.3817765" + assert result["data"] == { + CONF_API_KEY: "abcde12345", + CONF_ELEVATION: 0, + CONF_LATITUDE: 51.528308, + CONF_LONGITUDE: -0.3817765, + } diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py new file mode 100644 index 00000000000000..4999e5d2132040 --- /dev/null +++ b/tests/components/openuv/test_diagnostics.py @@ -0,0 +1,69 @@ +"""Test OpenUV diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_openuv): + """Test config entry diagnostics.""" + await hass.services.async_call("openuv", "update_data") + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "data": { + "api_key": REDACTED, + "elevation": 0, + "latitude": REDACTED, + "longitude": REDACTED, + }, + "options": { + "from_window": 3.5, + "to_window": 3.5, + }, + }, + "data": { + "uv": { + "uv": 8.2342, + "uv_time": "2018-07-30T20:53:06.302Z", + "uv_max": 10.3335, + "uv_max_time": "2018-07-30T19:07:11.505Z", + "ozone": 300.7, + "ozone_time": "2018-07-30T18:07:04.466Z", + "safe_exposure_time": { + "st1": 20, + "st2": 24, + "st3": 32, + "st4": 40, + "st5": 65, + "st6": 121, + }, + "sun_info": { + "sun_times": { + "solarNoon": "2018-07-30T19:07:11.505Z", + "nadir": "2018-07-30T07:07:11.505Z", + "sunrise": "2018-07-30T11:57:49.750Z", + "sunset": "2018-07-31T02:16:33.259Z", + "sunriseEnd": "2018-07-30T12:00:53.253Z", + "sunsetStart": "2018-07-31T02:13:29.756Z", + "dawn": "2018-07-30T11:27:27.911Z", + "dusk": "2018-07-31T02:46:55.099Z", + "nauticalDawn": "2018-07-30T10:50:01.621Z", + "nauticalDusk": "2018-07-31T03:24:21.389Z", + "nightEnd": "2018-07-30T10:08:47.846Z", + "night": "2018-07-31T04:05:35.163Z", + "goldenHourEnd": "2018-07-30T12:36:14.026Z", + "goldenHour": "2018-07-31T01:38:08.983Z", + }, + "sun_position": { + "azimuth": 0.9567419441563509, + "altitude": 1.0235714275875594, + }, + }, + }, + "protection_window": { + "from_time": "2018-07-30T15:17:49.750Z", + "from_uv": 3.2509, + "to_time": "2018-07-30T22:47:49.750Z", + "to_uv": 3.6483, + }, + }, + } diff --git a/tests/components/overkiz/__init__.py b/tests/components/overkiz/__init__.py new file mode 100644 index 00000000000000..d827bcb8334184 --- /dev/null +++ b/tests/components/overkiz/__init__.py @@ -0,0 +1 @@ +"""Tests for the overkiz component.""" diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py new file mode 100644 index 00000000000000..e80add482b0d3f --- /dev/null +++ b/tests/components/overkiz/test_config_flow.py @@ -0,0 +1,354 @@ +"""Tests for Overkiz (by Somfy) config flow.""" +from __future__ import annotations + +from unittest.mock import Mock, patch + +from aiohttp import ClientError +from pyoverkiz.exceptions import ( + BadCredentialsException, + MaintenanceException, + TooManyRequestsException, +) +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import dhcp +from homeassistant.components.overkiz.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_EMAIL = "test@testdomain.com" +TEST_EMAIL2 = "test@testdomain.nl" +TEST_PASSWORD = "test-password" +TEST_PASSWORD2 = "test-password2" +TEST_HUB = "somfy_europe" +TEST_HUB2 = "hi_kumo_europe" +TEST_GATEWAY_ID = "1234-5678-9123" +TEST_GATEWAY_ID2 = "4321-5678-9123" + +MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] +MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID2)] + +FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( + host="192.168.0.51", + port=443, + hostname=f"gateway-{TEST_GATEWAY_ID}.local.", + type="_kizbox._tcp.local.", + name=f"gateway-{TEST_GATEWAY_ID}._kizbox._tcp.local.", + properties={ + "api_version": "1", + "gateway_pin": TEST_GATEWAY_ID, + "fw_version": "2021.5.4-29", + }, +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["errors"] == {} + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", return_value=None + ), patch( + "homeassistant.components.overkiz.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_EMAIL + assert result2["data"] == { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_HUB, + } + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "side_effect, error", + [ + (BadCredentialsException, "invalid_auth"), + (TooManyRequestsException, "too_many_requests"), + (TimeoutError, "cannot_connect"), + (ClientError, "cannot_connect"), + (MaintenanceException, "server_in_maintenance"), + (Exception, "unknown"), + ], +) +async def test_form_invalid_auth( + hass: HomeAssistant, side_effect: Exception, error: str +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + assert result["step_id"] == config_entries.SOURCE_USER + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": error} + + +async def test_abort_on_duplicate_entry(hass: HomeAssistant) -> None: + """Test config flow aborts Config Flow on duplicate entries.""" + MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ), patch("homeassistant.components.overkiz.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: + """Test config flow allows Config Flow unique entries.""" + MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID2, + data={"username": "test2@testdomain.com", "password": TEST_PASSWORD}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ), patch("homeassistant.components.overkiz.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_EMAIL + assert result2["data"] == { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_HUB, + } + + +async def test_dhcp_flow(hass: HomeAssistant) -> None: + """Test that DHCP discovery for new bridge works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=dhcp.DhcpServiceInfo( + hostname="gateway-1234-5678-9123", + ip="192.168.1.4", + macaddress="F8811A000000", + ), + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", return_value=None + ), patch( + "homeassistant.components.overkiz.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_EMAIL + assert result2["data"] == { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_HUB, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: + """Test that DHCP doesn't setup already configured gateways.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=dhcp.DhcpServiceInfo( + hostname="gateway-1234-5678-9123", + ip="192.168.1.4", + macaddress="F8811A000000", + ), + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow(hass): + """Test that zeroconf discovery for new bridge works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=FAKE_ZERO_CONF_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", return_value=None + ), patch( + "homeassistant.components.overkiz.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_EMAIL + assert result2["data"] == { + "username": TEST_EMAIL, + "password": TEST_PASSWORD, + "hub": TEST_HUB, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_flow_already_configured(hass): + """Test that zeroconf doesn't setup already configured gateways.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=FAKE_ZERO_CONF_INFO, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_success(hass): + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY_RESPONSE, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + "hub": TEST_HUB2, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert mock_entry.data["username"] == TEST_EMAIL + assert mock_entry.data["password"] == TEST_PASSWORD2 + + +async def test_reauth_wrong_account(hass): + """Test reauthentication flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( + "pyoverkiz.client.OverkizClient.get_gateways", + return_value=MOCK_GATEWAY2_RESPONSE, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + "hub": TEST_HUB2, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_wrong_account" diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py index c56770099efd86..31a3a327d1c840 100644 --- a/tests/components/owntracks/test_config_flow.py +++ b/tests/components/owntracks/test_config_flow.py @@ -76,21 +76,8 @@ async def test_user(hass, webhook_id, secret): assert result["description_placeholders"][CONF_WEBHOOK_URL] == WEBHOOK_URL -async def test_import(hass, webhook_id, secret): - """Test import step.""" - flow = await init_config_flow(hass) - - result = await flow.async_step_import({}) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "OwnTracks" - assert result["data"][CONF_WEBHOOK_ID] == WEBHOOK_ID - assert result["data"][CONF_SECRET] == SECRET - assert result["data"][CONF_CLOUDHOOK] == CLOUDHOOK - assert result["description_placeholders"] is None - - async def test_import_setup(hass): - """Test that we automatically create a config flow.""" + """Test that we don't automatically create a config entry.""" await async_process_ha_core_config( hass, {"external_url": "http://example.com"}, @@ -99,7 +86,7 @@ async def test_import_setup(hass): assert not hass.config_entries.async_entries(DOMAIN) assert await async_setup_component(hass, DOMAIN, {"owntracks": {}}) await hass.async_block_till_done() - assert hass.config_entries.async_entries(DOMAIN) + assert not hass.config_entries.async_entries(DOMAIN) async def test_abort_if_already_setup(hass): @@ -109,11 +96,6 @@ async def test_abort_if_already_setup(hass): MockConfigEntry(domain=DOMAIN, data={}).add_to_hass(hass) assert hass.config_entries.async_entries(DOMAIN) - # Should fail, already setup (import) - result = await flow.async_step_import({}) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "single_instance_allowed" - # Should fail, already setup (flow) result = await flow.async_step_user({}) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -143,7 +125,7 @@ async def test_unload(hass): "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" ) as mock_forward: result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={} + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={} ) assert len(mock_forward.mock_calls) == 1 @@ -167,20 +149,48 @@ async def test_unload(hass): async def test_with_cloud_sub(hass): """Test creating a config flow while subscribed.""" - hass.config.components.add("cloud") + assert await async_setup_component(hass, "cloud", {}) + with patch( "homeassistant.components.cloud.async_active_subscription", return_value=True ), patch( - "homeassistant.components.cloud.async_create_cloudhook", - return_value="https://hooks.nabu.casa/ABCD", + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "hass_nabucasa.cloudhooks.Cloudhooks.async_create", + return_value={"cloudhook_url": "https://hooks.nabu.casa/ABCD"}, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data={} ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY entry = result["result"] assert entry.data["cloudhook"] assert ( result["description_placeholders"]["webhook_url"] == "https://hooks.nabu.casa/ABCD" ) + + +async def test_with_cloud_sub_not_connected(hass): + """Test creating a config flow while subscribed.""" + assert await async_setup_component(hass, "cloud", {}) + + with patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=False + ), patch( + "hass_nabucasa.cloudhooks.Cloudhooks.async_create", + return_value={"cloudhook_url": "https://hooks.nabu.casa/ABCD"}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cloud_not_connected" diff --git a/tests/components/ozw/test_binary_sensor.py b/tests/components/ozw/test_binary_sensor.py index e6af71d41b4090..d0852a5caf0060 100644 --- a/tests/components/ozw/test_binary_sensor.py +++ b/tests/components/ozw/test_binary_sensor.py @@ -1,7 +1,7 @@ """Test Z-Wave Sensors.""" from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_MOTION, DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, ) from homeassistant.components.ozw.const import DOMAIN from homeassistant.const import ATTR_DEVICE_CLASS @@ -22,7 +22,7 @@ async def test_binary_sensor(hass, generic_data, binary_sensor_msg): entry = registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling legacy entity updated_entry = registry.async_update_entity( @@ -35,7 +35,7 @@ async def test_binary_sensor(hass, generic_data, binary_sensor_msg): state = hass.states.get("binary_sensor.trisensor_home_security_motion_detected") assert state assert state.state == "off" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MOTION + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOTION # Test incoming state change receive_msg(binary_sensor_msg) diff --git a/tests/components/ozw/test_sensor.py b/tests/components/ozw/test_sensor.py index e043d5eb58d8d1..2eddb9722e59d9 100644 --- a/tests/components/ozw/test_sensor.py +++ b/tests/components/ozw/test_sensor.py @@ -1,11 +1,6 @@ """Test Z-Wave Sensors.""" from homeassistant.components.ozw.const import DOMAIN -from homeassistant.components.sensor import ( - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_PRESSURE, - DOMAIN as SENSOR_DOMAIN, -) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.helpers import entity_registry as er @@ -24,15 +19,15 @@ async def test_sensor(hass, generic_data): # Test device classes state = hass.states.get("sensor.trisensor_relative_humidity") - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_HUMIDITY + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.HUMIDITY state = hass.states.get("sensor.trisensor_pressure") - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_PRESSURE + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.PRESSURE state = hass.states.get("sensor.trisensor_fake_power") - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER state = hass.states.get("sensor.trisensor_fake_energy") - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER state = hass.states.get("sensor.trisensor_fake_electric") - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER # Test ZWaveListSensor disabled by default registry = er.async_get(hass) @@ -43,7 +38,7 @@ async def test_sensor(hass, generic_data): entry = registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity updated_entry = registry.async_update_entity( diff --git a/tests/components/p1_monitor/test_diagnostics.py b/tests/components/p1_monitor/test_diagnostics.py new file mode 100644 index 00000000000000..6b97107c353be1 --- /dev/null +++ b/tests/components/p1_monitor/test_diagnostics.py @@ -0,0 +1,59 @@ +"""Tests for the diagnostics data provided by the P1 Monitor integration.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "entry": { + "title": "monitor", + "data": { + "host": REDACTED, + }, + }, + "data": { + "smartmeter": { + "gas_consumption": 2273.447, + "energy_tariff_period": "high", + "power_consumption": 877, + "energy_consumption_high": 2770.133, + "energy_consumption_low": 4988.071, + "power_production": 0, + "energy_production_high": 3971.604, + "energy_production_low": 1432.279, + }, + "phases": { + "voltage_phase_l1": "233.6", + "voltage_phase_l2": "0.0", + "voltage_phase_l3": "233.0", + "current_phase_l1": "1.6", + "current_phase_l2": "4.44", + "current_phase_l3": "3.51", + "power_consumed_phase_l1": 315, + "power_consumed_phase_l2": 0, + "power_consumed_phase_l3": 624, + "power_produced_phase_l1": 0, + "power_produced_phase_l2": 0, + "power_produced_phase_l3": 0, + }, + "settings": { + "gas_consumption_price": "0.64", + "energy_consumption_price_high": "0.20522", + "energy_consumption_price_low": "0.20522", + "energy_production_price_high": "0.20522", + "energy_production_price_low": "0.20522", + }, + }, + } diff --git a/tests/components/p1_monitor/test_init.py b/tests/components/p1_monitor/test_init.py index bddaff137e6885..1cf9cf219663fd 100644 --- a/tests/components/p1_monitor/test_init.py +++ b/tests/components/p1_monitor/test_init.py @@ -24,6 +24,7 @@ async def test_load_unload_config_entry( await hass.async_block_till_done() assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED @patch( diff --git a/tests/components/p1_monitor/test_sensor.py b/tests/components/p1_monitor/test_sensor.py index 61f3f027b6bde4..faaafca5ab8ba8 100644 --- a/tests/components/p1_monitor/test_sensor.py +++ b/tests/components/p1_monitor/test_sensor.py @@ -4,8 +4,8 @@ from homeassistant.components.p1_monitor.const import DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -13,10 +13,6 @@ ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, CURRENCY_EURO, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_VOLTAGE, ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, @@ -44,9 +40,9 @@ async def test_smartmeter( assert entry.unique_id == f"{entry_id}_smartmeter_power_consumption" assert state.state == "877" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumption" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ATTR_ICON not in state.attributes state = hass.states.get("sensor.monitor_energy_consumption_high") @@ -58,9 +54,9 @@ async def test_smartmeter( assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption - High Tariff" ) - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert ATTR_ICON not in state.attributes state = hass.states.get("sensor.monitor_energy_tariff_period") @@ -101,9 +97,9 @@ async def test_phases( assert entry.unique_id == f"{entry_id}_phases_voltage_phase_l1" assert state.state == "233.6" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Voltage Phase L1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_POTENTIAL_VOLT - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_VOLTAGE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE assert ATTR_ICON not in state.attributes state = hass.states.get("sensor.monitor_current_phase_l1") @@ -113,9 +109,9 @@ async def test_phases( assert entry.unique_id == f"{entry_id}_phases_current_phase_l1" assert state.state == "1.6" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Current Phase L1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_AMPERE - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT assert ATTR_ICON not in state.attributes state = hass.states.get("sensor.monitor_power_consumed_phase_l1") @@ -125,9 +121,9 @@ async def test_phases( assert entry.unique_id == f"{entry_id}_phases_power_consumed_phase_l1" assert state.state == "315" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumed Phase L1" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ATTR_ICON not in state.attributes assert entry.device_id @@ -157,7 +153,7 @@ async def test_settings( assert entry.unique_id == f"{entry_id}_settings_energy_consumption_price_low" assert state.state == "0.20522" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption Price - Low" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == f"{CURRENCY_EURO}/{ENERGY_KILO_WATT_HOUR}" @@ -170,7 +166,7 @@ async def test_settings( assert entry.unique_id == f"{entry_id}_settings_energy_production_price_low" assert state.state == "0.20522" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production Price - Low" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == f"{CURRENCY_EURO}/{ENERGY_KILO_WATT_HOUR}" @@ -203,4 +199,4 @@ async def test_smartmeter_disabled_by_default( entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/panasonic_viera/test_config_flow.py b/tests/components/panasonic_viera/test_config_flow.py index dd7f629c29bf4e..a968c513516d15 100644 --- a/tests/components/panasonic_viera/test_config_flow.py +++ b/tests/components/panasonic_viera/test_config_flow.py @@ -41,7 +41,7 @@ async def test_flow_non_encrypted(hass): ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_BASIC_DATA, + {**MOCK_BASIC_DATA}, ) assert result["type"] == "create_entry" @@ -65,7 +65,7 @@ async def test_flow_not_connected_error(hass): ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_BASIC_DATA, + {**MOCK_BASIC_DATA}, ) assert result["type"] == "form" @@ -89,7 +89,7 @@ async def test_flow_unknown_abort(hass): ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_BASIC_DATA, + {**MOCK_BASIC_DATA}, ) assert result["type"] == "abort" @@ -114,7 +114,7 @@ async def test_flow_encrypted_not_connected_pin_code_request(hass): ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_BASIC_DATA, + {**MOCK_BASIC_DATA}, ) assert result["type"] == "abort" @@ -139,7 +139,7 @@ async def test_flow_encrypted_unknown_pin_code_request(hass): ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_BASIC_DATA, + {**MOCK_BASIC_DATA}, ) assert result["type"] == "abort" @@ -168,7 +168,7 @@ async def test_flow_encrypted_valid_pin_code(hass): ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_BASIC_DATA, + {**MOCK_BASIC_DATA}, ) assert result["type"] == "form" @@ -206,7 +206,7 @@ async def test_flow_encrypted_invalid_pin_code_error(hass): ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_BASIC_DATA, + {**MOCK_BASIC_DATA}, ) assert result["type"] == "form" @@ -244,7 +244,7 @@ async def test_flow_encrypted_not_connected_abort(hass): ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_BASIC_DATA, + {**MOCK_BASIC_DATA}, ) assert result["type"] == "form" @@ -277,7 +277,7 @@ async def test_flow_encrypted_unknown_abort(hass): ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - MOCK_BASIC_DATA, + {**MOCK_BASIC_DATA}, ) assert result["type"] == "form" @@ -304,7 +304,7 @@ async def test_flow_non_encrypted_already_configured_abort(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=MOCK_BASIC_DATA, + data={**MOCK_BASIC_DATA}, ) assert result["type"] == "abort" @@ -323,7 +323,7 @@ async def test_flow_encrypted_already_configured_abort(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data=MOCK_BASIC_DATA, + data={**MOCK_BASIC_DATA}, ) assert result["type"] == "abort" @@ -342,7 +342,7 @@ async def test_imported_flow_non_encrypted(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_DATA, + data={**MOCK_CONFIG_DATA}, ) assert result["type"] == "create_entry" @@ -366,7 +366,7 @@ async def test_imported_flow_encrypted_valid_pin_code(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_DATA, + data={**MOCK_CONFIG_DATA}, ) assert result["type"] == "form" @@ -398,7 +398,7 @@ async def test_imported_flow_encrypted_invalid_pin_code_error(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_DATA, + data={**MOCK_CONFIG_DATA}, ) assert result["type"] == "form" @@ -430,7 +430,7 @@ async def test_imported_flow_encrypted_not_connected_abort(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_DATA, + data={**MOCK_CONFIG_DATA}, ) assert result["type"] == "form" @@ -457,7 +457,7 @@ async def test_imported_flow_encrypted_unknown_abort(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_DATA, + data={**MOCK_CONFIG_DATA}, ) assert result["type"] == "form" @@ -482,7 +482,7 @@ async def test_imported_flow_not_connected_error(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_DATA, + data={**MOCK_CONFIG_DATA}, ) assert result["type"] == "form" @@ -500,7 +500,7 @@ async def test_imported_flow_unknown_abort(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_DATA, + data={**MOCK_CONFIG_DATA}, ) assert result["type"] == "abort" @@ -519,7 +519,7 @@ async def test_imported_flow_non_encrypted_already_configured_abort(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_BASIC_DATA, + data={**MOCK_BASIC_DATA}, ) assert result["type"] == "abort" @@ -538,7 +538,7 @@ async def test_imported_flow_encrypted_already_configured_abort(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_BASIC_DATA, + data={**MOCK_BASIC_DATA}, ) assert result["type"] == "abort" diff --git a/tests/components/panasonic_viera/test_init.py b/tests/components/panasonic_viera/test_init.py index e3fc74133d3371..b4e220c42fdf2e 100644 --- a/tests/components/panasonic_viera/test_init.py +++ b/tests/components/panasonic_viera/test_init.py @@ -130,7 +130,7 @@ async def test_setup_entry_unencrypted_missing_device_info(hass, mock_remote): mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=MOCK_CONFIG_DATA[CONF_HOST], - data=MOCK_CONFIG_DATA, + data={**MOCK_CONFIG_DATA}, ) mock_entry.add_to_hass(hass) @@ -156,7 +156,7 @@ async def test_setup_entry_unencrypted_missing_device_info_none(hass): mock_entry = MockConfigEntry( domain=DOMAIN, unique_id=MOCK_CONFIG_DATA[CONF_HOST], - data=MOCK_CONFIG_DATA, + data={**MOCK_CONFIG_DATA}, ) mock_entry.add_to_hass(hass) @@ -207,7 +207,9 @@ async def test_setup_config_flow_initiated(hass): async def test_setup_unload_entry(hass, mock_remote): """Test if config entry is unloaded.""" mock_entry = MockConfigEntry( - domain=DOMAIN, unique_id=MOCK_DEVICE_INFO[ATTR_UDN], data=MOCK_CONFIG_DATA + domain=DOMAIN, + unique_id=MOCK_DEVICE_INFO[ATTR_UDN], + data={**MOCK_CONFIG_DATA}, ) mock_entry.add_to_hass(hass) diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index bc552b71770d05..a26e243df1104e 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -158,9 +158,7 @@ async def test_ws_get_notifications(hass, hass_ws_client): assert len(notifications) == 0 # Create - hass.components.persistent_notification.async_create( - "test", notification_id="Beer 2" - ) + pn.async_create(hass, "test", notification_id="Beer 2") await client.send_json({"id": 6, "type": "persistent_notification/get"}) msg = await client.receive_json() assert msg["id"] == 6 @@ -186,7 +184,7 @@ async def test_ws_get_notifications(hass, hass_ws_client): assert notifications[0]["status"] == pn.STATUS_READ # Dismiss - hass.components.persistent_notification.async_dismiss("Beer 2") + pn.async_dismiss(hass, "Beer 2") await client.send_json({"id": 8, "type": "persistent_notification/get"}) msg = await client.receive_json() notifications = msg["result"] diff --git a/tests/components/philips_js/__init__.py b/tests/components/philips_js/__init__.py index 9dea390a600615..f524a586fc82f8 100644 --- a/tests/components/philips_js/__init__.py +++ b/tests/components/philips_js/__init__.py @@ -58,8 +58,6 @@ "host": "1.1.1.1", } -MOCK_IMPORT = {"host": "1.1.1.1", "api_version": 6} - MOCK_CONFIG = { "host": "1.1.1.1", "api_version": 1, diff --git a/tests/components/philips_js/conftest.py b/tests/components/philips_js/conftest.py index fc7e142bf53144..e0069cf9b750d3 100644 --- a/tests/components/philips_js/conftest.py +++ b/tests/components/philips_js/conftest.py @@ -41,7 +41,9 @@ def mock_tv(): @fixture async def mock_config_entry(hass): """Get standard player.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, title=MOCK_NAME) + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, title=MOCK_NAME, unique_id="ABCDEFGHIJKLF" + ) config_entry.add_to_hass(hass) return config_entry diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index f3ab44844a2254..ace6219511564b 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -10,7 +10,6 @@ from . import ( MOCK_CONFIG, MOCK_CONFIG_PAIRED, - MOCK_IMPORT, MOCK_PASSWORD, MOCK_SYSTEM_UNPAIRED, MOCK_USERINPUT, @@ -45,32 +44,6 @@ async def mock_tv_pairable(mock_tv): return mock_tv -async def test_import(hass, mock_setup_entry): - """Test we get an item on import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "Philips TV (1234567890)" - assert result["data"] == MOCK_CONFIG - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_exist(hass, mock_config_entry): - """Test we get an item on import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT, - ) - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - async def test_form(hass, mock_setup_entry): """Test we get the form.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/philips_js/test_device_trigger.py b/tests/components/philips_js/test_device_trigger.py index 936dd65f1152dd..9980bf5627d357 100644 --- a/tests/components/philips_js/test_device_trigger.py +++ b/tests/components/philips_js/test_device_trigger.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.philips_js.const import DOMAIN from homeassistant.setup import async_setup_component @@ -29,7 +30,9 @@ async def test_get_triggers(hass, mock_device): "device_id": mock_device.id, }, ] - triggers = await async_get_device_automations(hass, "trigger", mock_device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, mock_device.id + ) assert_lists_same(triggers, expected_triggers) diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 83653945d8cb53..a4a52e50453eb8 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -1,7 +1,6 @@ """The tests for the Picnic sensor platform.""" import copy from datetime import timedelta -from typing import Dict import unittest from unittest.mock import patch @@ -11,10 +10,10 @@ from homeassistant import config_entries from homeassistant.components.picnic import const from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, SENSOR_TYPES +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONF_ACCESS_TOKEN, CURRENCY_EURO, - DEVICE_CLASS_TIMESTAMP, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -215,44 +214,49 @@ async def test_sensors_setup(self): self._assert_sensor( "sensor.picnic_selected_slot_start", "2021-03-03T13:45:00+00:00", - cls=DEVICE_CLASS_TIMESTAMP, + cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( "sensor.picnic_selected_slot_end", "2021-03-03T14:45:00+00:00", - cls=DEVICE_CLASS_TIMESTAMP, + cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( "sensor.picnic_selected_slot_max_order_time", "2021-03-02T21:00:00+00:00", - cls=DEVICE_CLASS_TIMESTAMP, + cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor("sensor.picnic_selected_slot_min_order_value", "35.0") self._assert_sensor( "sensor.picnic_last_order_slot_start", "2021-02-26T19:15:00+00:00", - cls=DEVICE_CLASS_TIMESTAMP, + cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( "sensor.picnic_last_order_slot_end", "2021-02-26T20:15:00+00:00", - cls=DEVICE_CLASS_TIMESTAMP, + cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor("sensor.picnic_last_order_status", "COMPLETED") self._assert_sensor( "sensor.picnic_last_order_eta_start", "2021-02-26T19:54:00+00:00", - cls=DEVICE_CLASS_TIMESTAMP, + cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( "sensor.picnic_last_order_eta_end", "2021-02-26T20:14:00+00:00", - cls=DEVICE_CLASS_TIMESTAMP, + cls=SensorDeviceClass.TIMESTAMP, + ) + self._assert_sensor( + "sensor.picnic_last_order_max_order_time", + "2021-02-25T21:00:00+00:00", + cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( "sensor.picnic_last_order_delivery_time", "2021-02-26T19:54:05+00:00", - cls=DEVICE_CLASS_TIMESTAMP, + cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( "sensor.picnic_last_order_total_price", "41.33", unit=CURRENCY_EURO @@ -319,7 +323,7 @@ async def test_sensors_eta_date_malformed(self): await self._setup_platform(use_default_responses=True) # Set non-datetime strings as eta - eta_dates: Dict[str, str] = { + eta_dates: dict[str, str] = { "start": "wrong-time", "end": "other-malformed-datetime", } @@ -385,6 +389,9 @@ async def test_sensors_no_data(self): ) self._assert_sensor("sensor.picnic_last_order_eta_start", STATE_UNAVAILABLE) self._assert_sensor("sensor.picnic_last_order_eta_end", STATE_UNAVAILABLE) + self._assert_sensor( + "sensor.picnic_last_order_max_order_time", STATE_UNAVAILABLE + ) self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNAVAILABLE) async def test_sensors_malformed_delivery_data(self): @@ -400,6 +407,7 @@ async def test_sensors_malformed_delivery_data(self): assert self._coordinator.last_update_success is True self._assert_sensor("sensor.picnic_last_order_eta_start", STATE_UNKNOWN) self._assert_sensor("sensor.picnic_last_order_eta_end", STATE_UNKNOWN) + self._assert_sensor("sensor.picnic_last_order_max_order_time", STATE_UNKNOWN) self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNKNOWN) async def test_sensors_malformed_response(self): diff --git a/tests/components/plaato/test_config_flow.py b/tests/components/plaato/test_config_flow.py index 887b2b2bb01f6c..ba24413846981d 100644 --- a/tests/components/plaato/test_config_flow.py +++ b/tests/components/plaato/test_config_flow.py @@ -12,7 +12,12 @@ DOMAIN, ) from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID -from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -95,12 +100,16 @@ async def test_show_config_form_validate_webhook(hass, webhook_id): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "api_method" - hass.config.components.add("cloud") + assert await async_setup_component(hass, "cloud", {}) with patch( "homeassistant.components.cloud.async_active_subscription", return_value=True ), patch( - "homeassistant.components.cloud.async_create_cloudhook", - return_value="https://hooks.nabu.casa/ABCD", + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "hass_nabucasa.cloudhooks.Cloudhooks.async_create", + return_value={"cloudhook_url": "https://hooks.nabu.casa/ABCD"}, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -114,6 +123,50 @@ async def test_show_config_form_validate_webhook(hass, webhook_id): assert result["step_id"] == "webhook" +async def test_show_config_form_validate_webhook_not_connected(hass, webhook_id): + """Test validating webhook when not connected aborts.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICE_TYPE: PlaatoDeviceType.Airlock, + CONF_DEVICE_NAME: "device_name", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "api_method" + + assert await async_setup_component(hass, "cloud", {}) + with patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=False + ), patch( + "hass_nabucasa.cloudhooks.Cloudhooks.async_create", + return_value={"cloudhook_url": "https://hooks.nabu.casa/ABCD"}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "", + CONF_USE_WEBHOOK: True, + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cloud_not_connected" + + async def test_show_config_form_validate_token(hass): """Test show configuration form.""" diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index cdd0d4dff3ec74..47e7d96d2feeae 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -278,20 +278,20 @@ def plextv_account_fixture(): return load_fixture("plex/plextv_account.xml") -@pytest.fixture(name="plextv_resources_base", scope="session") -def plextv_resources_base_fixture(): - """Load base payload for plex.tv resources and return it.""" - return load_fixture("plex/plextv_resources_base.xml") +@pytest.fixture(name="plextv_resources", scope="session") +def plextv_resources_fixture(): + """Load single-server payload for plex.tv resources and return it.""" + return load_fixture("plex/plextv_resources_one_server.xml") -@pytest.fixture(name="plextv_resources", scope="session") -def plextv_resources_fixture(plextv_resources_base): - """Load default payload for plex.tv resources and return it.""" - return plextv_resources_base.format(first_server_enabled=1, second_server_enabled=0) +@pytest.fixture(name="plextv_resources_two_servers", scope="session") +def plextv_resources_two_servers_fixture(): + """Load two-server payload for plex.tv resources and return it.""" + return load_fixture("plex/plextv_resources_two_servers.xml") @pytest.fixture(name="plextv_shared_users", scope="session") -def plextv_shared_users_fixture(plextv_resources_base): +def plextv_shared_users_fixture(): """Load payload for plex.tv shared users and return it.""" return load_fixture("plex/plextv_shared_users.xml") @@ -368,6 +368,18 @@ def sonos_resources_fixture(): return load_fixture("plex/sonos_resources.xml") +@pytest.fixture(name="hubs", scope="session") +def hubs_fixture(): + """Load hubs resource payload and return it.""" + return load_fixture("plex/hubs.xml") + + +@pytest.fixture(name="hubs_music_library", scope="session") +def hubs_music_library_fixture(): + """Load music library hubs resource payload and return it.""" + return load_fixture("plex/hubs_library_section.xml") + + @pytest.fixture(name="entry") def mock_config_entry(): """Return the default mocked config entry.""" diff --git a/tests/components/plex/const.py b/tests/components/plex/const.py index 9e376d19cacae1..ff9d3c0e9b3e90 100644 --- a/tests/components/plex/const.py +++ b/tests/components/plex/const.py @@ -1,5 +1,4 @@ """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_CLIENT_ID, @@ -8,6 +7,7 @@ CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, + Platform, ) MOCK_SERVERS = [ @@ -55,7 +55,7 @@ } DEFAULT_OPTIONS = { - MP_DOMAIN: { + Platform.MEDIA_PLAYER: { 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/fixtures/hubs.xml b/tests/components/plex/fixtures/hubs.xml new file mode 100644 index 00000000000000..6ed54c12f34c4d --- /dev/null +++ b/tests/components/plex/fixtures/hubs.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/components/plex/fixtures/hubs_library_section.xml b/tests/components/plex/fixtures/hubs_library_section.xml new file mode 100644 index 00000000000000..27e141c325fc9d --- /dev/null +++ b/tests/components/plex/fixtures/hubs_library_section.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/components/plex/fixtures/plextv_resources_one_server.xml b/tests/components/plex/fixtures/plextv_resources_one_server.xml new file mode 100644 index 00000000000000..ff2e458ff24edc --- /dev/null +++ b/tests/components/plex/fixtures/plextv_resources_one_server.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/components/plex/fixtures/plextv_resources_base.xml b/tests/components/plex/fixtures/plextv_resources_two_servers.xml similarity index 97% rename from tests/components/plex/fixtures/plextv_resources_base.xml rename to tests/components/plex/fixtures/plextv_resources_two_servers.xml index 5802c58d4d4eef..7da5df4c1df4d3 100644 --- a/tests/components/plex/fixtures/plextv_resources_base.xml +++ b/tests/components/plex/fixtures/plextv_resources_two_servers.xml @@ -1,8 +1,8 @@ - - + + - + diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py index 5bbd29f35c01e6..502084c2090215 100644 --- a/tests/components/plex/test_browse_media.py +++ b/tests/components/plex/test_browse_media.py @@ -6,7 +6,7 @@ ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ) -from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER +from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER, PLEX_URI_SCHEME from homeassistant.components.websocket_api.const import ERR_UNKNOWN_ERROR, TYPE_RESULT from .const import DEFAULT_DATA @@ -15,6 +15,8 @@ class MockPlexShow: """Mock a plexapi Season instance.""" + TAG = "Directory" + TYPE = "show" ratingKey = 30 title = "TV Show" type = "show" @@ -27,8 +29,11 @@ def __iter__(self): class MockPlexSeason: """Mock a plexapi Season instance.""" + TAG = "Directory" + TYPE = "season" ratingKey = 20 title = "Season 1" + parentTitle = "TV Show" type = "season" year = 2021 @@ -40,6 +45,7 @@ def __iter__(self): class MockPlexEpisode: """Mock a plexapi Episode instance.""" + TAG = "Video" ratingKey = 10 title = "Episode 1" grandparentTitle = "TV Show" @@ -50,6 +56,8 @@ class MockPlexEpisode: class MockPlexArtist: """Mock a plexapi Artist instance.""" + TAG = "Directory" + TYPE = "artist" ratingKey = 300 title = "Artist" type = "artist" @@ -58,10 +66,15 @@ def __iter__(self): """Iterate over albums.""" yield MockPlexAlbum() + def station(self): + """Mock the station artist method.""" + return MockPlexStation() + class MockPlexAlbum: """Mock a plexapi Album instance.""" + TAG = "Directory" ratingKey = 200 parentTitle = "Artist" title = "Album" @@ -76,19 +89,30 @@ def __iter__(self): class MockPlexTrack: """Mock a plexapi Track instance.""" + TAG = "Track" index = 1 ratingKey = 100 title = "Track 1" type = "track" +class MockPlexStation: + """Mock a plexapi radio station instance.""" + + TAG = "Playlist" + key = "/library/sections/3/stations/1" + title = "Radio Station" + radio = True + type = "playlist" + + async def test_browse_media( hass, hass_ws_client, mock_plex_server, requests_mock, - library_movies_filtertypes, - empty_payload, + hubs, + hubs_music_library, ): """Test getting Plex clients from plex.tv.""" websocket_client = await hass_ws_client(hass) @@ -96,23 +120,6 @@ async def test_browse_media( media_players = hass.states.async_entity_ids("media_player") msg_id = 1 - # Browse base of non-existent Plex server - await websocket_client.send_json( - { - "id": msg_id, - "type": "media_player/browse_media", - "entity_id": media_players[0], - ATTR_MEDIA_CONTENT_TYPE: "server", - ATTR_MEDIA_CONTENT_ID: "this server does not exist", - } - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == msg_id - assert msg["type"] == TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == ERR_UNKNOWN_ERROR - # Browse base of Plex server msg_id += 1 await websocket_client.send_json( @@ -129,14 +136,22 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" - assert result[ATTR_MEDIA_CONTENT_ID] == DEFAULT_DATA[CONF_SERVER_IDENTIFIER] - # Library Sections + On Deck + Recently Added + Playlists - assert len(result["children"]) == len(mock_plex_server.library.sections()) + 3 + assert ( + result[ATTR_MEDIA_CONTENT_ID] + == PLEX_URI_SCHEME + DEFAULT_DATA[CONF_SERVER_IDENTIFIER] + ) + # Library Sections + Recommended + Playlists + assert len(result["children"]) == len(mock_plex_server.library.sections()) + 2 music = next(iter(x for x in result["children"] if x["title"] == "Music")) tvshows = next(iter(x for x in result["children"] if x["title"] == "TV Shows")) playlists = next(iter(x for x in result["children"] if x["title"] == "Playlists")) - special_keys = ["On Deck", "Recently Added"] + special_keys = ["Recommended"] + + requests_mock.get( + f"{mock_plex_server.url_in_use}/hubs", + text=hubs, + ) # Browse into a special folder (server) msg_id += 1 @@ -146,7 +161,8 @@ async def test_browse_media( "type": "media_player/browse_media", "entity_id": media_players[0], ATTR_MEDIA_CONTENT_TYPE: "server", - ATTR_MEDIA_CONTENT_ID: f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}", + ATTR_MEDIA_CONTENT_ID: PLEX_URI_SCHEME + + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}", } ) @@ -158,29 +174,49 @@ async def test_browse_media( assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" assert ( result[ATTR_MEDIA_CONTENT_ID] - == f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}" + == PLEX_URI_SCHEME + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}" ) - assert len(result["children"]) == len(mock_plex_server.library.onDeck()) + assert len(result["children"]) == 4 # Hardcoded in fixture + assert result["children"][0]["media_content_type"] == "mixed" + assert result["children"][1]["media_content_type"] == "album" + assert result["children"][2]["media_content_type"] == "clip" + assert result["children"][3]["media_content_type"] == "playlist" - # Browse into a special folder (library) - requests_mock.get( - f"{mock_plex_server.url_in_use}/library/sections/1/all?includeMeta=1", - text=library_movies_filtertypes, + # Browse into a special folder (server): Continue Watching + msg_id += 1 + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: result["children"][0][ATTR_MEDIA_CONTENT_TYPE], + ATTR_MEDIA_CONTENT_ID: result["children"][0][ATTR_MEDIA_CONTENT_ID], + } ) + + msg = await websocket_client.receive_json() + assert msg["id"] == msg_id + assert msg["type"] == TYPE_RESULT + assert msg["success"] + result = msg["result"] + assert result[ATTR_MEDIA_CONTENT_TYPE] == "mixed" + requests_mock.get( - f"{mock_plex_server.url_in_use}/library/sections/1/collections?includeMeta=1", - text=empty_payload, + f"{mock_plex_server.url_in_use}/hubs/sections/3?includeStations=1", + text=hubs_music_library, ) + # Browse into a special folder (library) msg_id += 1 - library_section_id = next(iter(mock_plex_server.library.sections())).key + library_section_id = 3 await websocket_client.send_json( { "id": msg_id, "type": "media_player/browse_media", "entity_id": media_players[0], ATTR_MEDIA_CONTENT_TYPE: "library", - ATTR_MEDIA_CONTENT_ID: f"{library_section_id}:{special_keys[1]}", + ATTR_MEDIA_CONTENT_ID: PLEX_URI_SCHEME + + f"{library_section_id}:{special_keys[0]}", } ) @@ -190,11 +226,33 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" - assert result[ATTR_MEDIA_CONTENT_ID] == f"{library_section_id}:{special_keys[1]}" - assert len(result["children"]) == len( - mock_plex_server.library.sectionByID(library_section_id).recentlyAdded() + assert ( + result[ATTR_MEDIA_CONTENT_ID] + == PLEX_URI_SCHEME + f"{library_section_id}:{special_keys[0]}" + ) + assert len(result["children"]) == 1 + + # Browse into a library radio station hub + msg_id += 1 + await websocket_client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": media_players[0], + ATTR_MEDIA_CONTENT_TYPE: result["children"][0][ATTR_MEDIA_CONTENT_TYPE], + ATTR_MEDIA_CONTENT_ID: result["children"][0][ATTR_MEDIA_CONTENT_ID], + } ) + msg = await websocket_client.receive_json() + assert msg["id"] == msg_id + assert msg["type"] == TYPE_RESULT + assert msg["success"] + result = msg["result"] + assert result[ATTR_MEDIA_CONTENT_TYPE] == "station" + assert len(result["children"]) == 3 + assert result["children"][0]["title"] == "Library Radio" + # Browse into a Plex TV show library msg_id += 1 await websocket_client.send_json( @@ -213,11 +271,11 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) - # All items in section + On Deck + Recently Added + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) + # All items in section + Hubs assert ( len(result["children"]) - == len(mock_plex_server.library.sectionByID(result_id).all()) + 2 + == len(mock_plex_server.library.sectionByID(result_id).all()) + 1 ) # Browse into a Plex TV show @@ -247,7 +305,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "show" - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) assert result["title"] == mock_plex_server.fetch_item(result_id).title assert result["children"][0]["title"] == f"{mock_season.title} ({mock_season.year})" @@ -277,8 +335,11 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "season" - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) - assert result["title"] == f"{mock_season.title} ({mock_season.year})" + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) + assert ( + result["title"] + == f"{mock_season.parentTitle} - {mock_season.title} ({mock_season.year})" + ) assert ( result["children"][0]["title"] == f"{mock_episode.seasonEpisode.upper()} - {mock_episode.title}" @@ -299,7 +360,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" assert result["title"] == "Music" @@ -329,10 +390,11 @@ async def test_browse_media( assert mock_fetch.called assert msg["success"] result = msg["result"] - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) assert result[ATTR_MEDIA_CONTENT_TYPE] == "artist" assert result["title"] == mock_artist.title - assert result["children"][0]["title"] == f"{mock_album.title} ({mock_album.year})" + assert result["children"][0]["title"] == "Radio Station" + assert result["children"][1]["title"] == f"{mock_album.title} ({mock_album.year})" # Browse into a Plex album msg_id += 1 @@ -349,7 +411,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) assert result[ATTR_MEDIA_CONTENT_TYPE] == "album" assert ( result["title"] @@ -400,26 +462,3 @@ async def test_browse_media( result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "playlists" result_id = result[ATTR_MEDIA_CONTENT_ID] - - # Browse recently added items - msg_id += 1 - mock_items = [MockPlexAlbum(), MockPlexEpisode(), MockPlexSeason(), MockPlexTrack()] - with patch("plexapi.library.Library.search", return_value=mock_items) as mock_fetch: - await websocket_client.send_json( - { - "id": msg_id, - "type": "media_player/browse_media", - "entity_id": media_players[0], - ATTR_MEDIA_CONTENT_TYPE: "server", - ATTR_MEDIA_CONTENT_ID: f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[1]}", - } - ) - msg = await websocket_client.receive_json() - - assert msg["success"] - result = msg["result"] - assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" - result_id = result[ATTR_MEDIA_CONTENT_ID] - for child in result["children"]: - assert child["media_content_type"] in ["album", "episode"] - assert child["media_content_type"] not in ["season", "track"] diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index c18d38fdba1494..c22890ebef3e0d 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -8,7 +8,6 @@ import pytest import requests.exceptions -from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.plex import config_flow from homeassistant.components.plex.const import ( AUTOMATIC_SETUP_STRING, @@ -36,6 +35,7 @@ CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, + Platform, ) from .const import DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN, PLEX_DIRECT_URL @@ -198,7 +198,7 @@ async def test_multiple_servers_with_selection( hass, mock_plex_calls, requests_mock, - plextv_resources_base, + plextv_resources_two_servers, current_request_with_host, ): """Test creating an entry with multiple servers available.""" @@ -210,9 +210,7 @@ async def test_multiple_servers_with_selection( requests_mock.get( "https://plex.tv/api/resources", - text=plextv_resources_base.format( - first_server_enabled=1, second_server_enabled=1 - ), + text=plextv_resources_two_servers, ) with patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN @@ -231,7 +229,9 @@ async def test_multiple_servers_with_selection( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_SERVER: MOCK_SERVERS[0][CONF_SERVER]}, + user_input={ + CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][CONF_SERVER_IDENTIFIER] + }, ) assert result["type"] == "create_entry" @@ -250,47 +250,11 @@ async def test_multiple_servers_with_selection( assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN -async def test_only_non_present_servers( - hass, - mock_plex_calls, - requests_mock, - plextv_resources_base, - current_request_with_host, -): - """Test creating an entry with one server available.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] == "form" - assert result["step_id"] == "user" - - requests_mock.get( - "https://plex.tv/api/resources", - text=plextv_resources_base.format( - first_server_enabled=0, second_server_enabled=0 - ), - ) - 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"], user_input={} - ) - assert result["type"] == "external" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "form" - assert result["step_id"] == "select_server" - - async def test_adding_last_unconfigured_server( hass, mock_plex_calls, requests_mock, - plextv_resources_base, + plextv_resources_two_servers, current_request_with_host, ): """Test automatically adding last unconfigured server when multiple servers on account.""" @@ -310,9 +274,7 @@ async def test_adding_last_unconfigured_server( requests_mock.get( "https://plex.tv/api/resources", - text=plextv_resources_base.format( - first_server_enabled=1, second_server_enabled=1 - ), + text=plextv_resources_two_servers, ) with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -349,7 +311,7 @@ async def test_all_available_servers_configured( entry, requests_mock, plextv_account, - plextv_resources_base, + plextv_resources_two_servers, current_request_with_host, ): """Test when all available servers are already configured.""" @@ -372,9 +334,7 @@ async def test_all_available_servers_configured( requests_mock.get("https://plex.tv/users/account", text=plextv_account) requests_mock.get( "https://plex.tv/api/resources", - text=plextv_resources_base.format( - first_server_enabled=1, second_server_enabled=1 - ), + text=plextv_resources_two_servers, ) with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -414,7 +374,7 @@ async def test_option_flow(hass, entry, mock_plex_server): ) assert result["type"] == "create_entry" assert result["data"] == { - MP_DOMAIN: { + Platform.MEDIA_PLAYER: { CONF_USE_EPISODE_ART: True, CONF_IGNORE_NEW_SHARED_USERS: True, CONF_MONITORED_USERS: { @@ -446,7 +406,7 @@ async def test_missing_option_flow(hass, entry, mock_plex_server): ) assert result["type"] == "create_entry" assert result["data"] == { - MP_DOMAIN: { + Platform.MEDIA_PLAYER: { CONF_USE_EPISODE_ART: True, CONF_IGNORE_NEW_SHARED_USERS: True, CONF_MONITORED_USERS: { @@ -460,7 +420,9 @@ async def test_missing_option_flow(hass, entry, mock_plex_server): async def test_option_flow_new_users_available(hass, entry, setup_plex_server): """Test config options multiselect defaults when new Plex users are seen.""" OPTIONS_OWNER_ONLY = copy.deepcopy(DEFAULT_OPTIONS) - OPTIONS_OWNER_ONLY[MP_DOMAIN][CONF_MONITORED_USERS] = {"User 1": {"enabled": True}} + OPTIONS_OWNER_ONLY[Platform.MEDIA_PLAYER][CONF_MONITORED_USERS] = { + "User 1": {"enabled": True} + } entry.options = OPTIONS_OWNER_ONLY mock_plex_server = await setup_plex_server(config_entry=entry) diff --git a/tests/components/plex/test_device_handling.py b/tests/components/plex/test_device_handling.py index f36e5dc3641721..2c23ee7cb0992f 100644 --- a/tests/components/plex/test_device_handling.py +++ b/tests/components/plex/test_device_handling.py @@ -1,7 +1,7 @@ """Tests for handling the device registry.""" -from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN from homeassistant.components.plex.const import DOMAIN +from homeassistant.const import Platform async def test_cleanup_orphaned_devices(hass, entry, setup_plex_server): @@ -18,7 +18,7 @@ async def test_cleanup_orphaned_devices(hass, entry, setup_plex_server): assert test_device is not None test_entity = entity_registry.async_get_or_create( - MP_DOMAIN, DOMAIN, "entity_unique_id_123", device_id=test_device.id + Platform.MEDIA_PLAYER, DOMAIN, "entity_unique_id_123", device_id=test_device.id ) assert test_entity is not None @@ -53,9 +53,9 @@ async def test_migrate_transient_devices( identifiers=plexweb_device_id, model="Plex Web", ) - # plexweb_entity = entity_registry.async_get_or_create(MP_DOMAIN, DOMAIN, "unique_id_123:plexweb_id", suggested_object_id="plex_plex_web_chrome", device_id=plexweb_device.id) + entity_registry.async_get_or_create( - MP_DOMAIN, + Platform.MEDIA_PLAYER, DOMAIN, "unique_id_123:plexweb_id", suggested_object_id="plex_plex_web_chrome", @@ -68,7 +68,7 @@ async def test_migrate_transient_devices( model="Plex for Android (TV)", ) entity_registry.async_get_or_create( - MP_DOMAIN, + Platform.MEDIA_PLAYER, DOMAIN, "unique_id_123:1234567890123456-com-plexapp-android", suggested_object_id="plex_plex_for_android_tv_shield_android_tv", diff --git a/tests/components/plex/test_media_search.py b/tests/components/plex/test_media_search.py index 467ab3555f5d81..adfdff2d1dcbed 100644 --- a/tests/components/plex/test_media_search.py +++ b/tests/components/plex/test_media_search.py @@ -4,7 +4,6 @@ from plexapi.exceptions import BadRequest, NotFound import pytest -from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, @@ -16,7 +15,7 @@ SERVICE_PLAY_MEDIA, ) from homeassistant.components.plex.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.exceptions import HomeAssistantError @@ -30,7 +29,7 @@ async def test_media_lookups( requests_mock.get("/player/playback/playMedia", status_code=200) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -42,7 +41,7 @@ async def test_media_lookups( with pytest.raises(HomeAssistantError) as excinfo: with patch("plexapi.server.PlexServer.fetchItem", side_effect=NotFound): assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -57,7 +56,7 @@ async def test_media_lookups( with pytest.raises(HomeAssistantError) as excinfo: payload = '{"library_name": "Not a Library", "show_name": "TV Show"}' assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -70,7 +69,7 @@ async def test_media_lookups( with patch("plexapi.library.LibrarySection.search") as search: assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -82,7 +81,7 @@ async def test_media_lookups( search.assert_called_with(**{"show.title": "TV Show", "libtype": "show"}) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -96,7 +95,7 @@ async def test_media_lookups( ) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -110,7 +109,7 @@ async def test_media_lookups( ) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -129,7 +128,7 @@ async def test_media_lookups( ) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -141,7 +140,7 @@ async def test_media_lookups( search.assert_called_with(**{"artist.title": "Artist", "libtype": "artist"}) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -153,7 +152,7 @@ async def test_media_lookups( search.assert_called_with(**{"album.title": "Album", "libtype": "album"}) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -167,7 +166,7 @@ async def test_media_lookups( ) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -181,7 +180,7 @@ async def test_media_lookups( ) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -200,7 +199,7 @@ async def test_media_lookups( ) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -220,7 +219,7 @@ async def test_media_lookups( # Movie searches assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -232,7 +231,7 @@ async def test_media_lookups( search.assert_called_with(**{"movie.title": "Movie 1", "libtype": None}) assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -248,7 +247,7 @@ async def test_media_lookups( payload = '{"library_name": "Movies", "title": "Not a Movie"}' with patch("plexapi.library.LibrarySection.search", side_effect=BadRequest): assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -262,7 +261,7 @@ async def test_media_lookups( # Playlist searches assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -275,7 +274,7 @@ async def test_media_lookups( with pytest.raises(HomeAssistantError) as excinfo: payload = '{"playlist_name": "Not a Playlist"}' assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, @@ -290,7 +289,7 @@ async def test_media_lookups( with pytest.raises(HomeAssistantError) as excinfo: payload = "{}" assert await hass.services.async_call( - MP_DOMAIN, + Platform.MEDIA_PLAYER, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index c07693cf0736d6..83046b85be335c 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -33,6 +33,7 @@ class MockPlexMedia: class MockPlexClip(MockPlexMedia): """Minimal mock of plexapi clip object.""" + TAG = "Video" type = "clip" title = "Clip 1" @@ -40,6 +41,7 @@ class MockPlexClip(MockPlexMedia): class MockPlexMovie(MockPlexMedia): """Minimal mock of plexapi movie object.""" + TAG = "Video" type = "movie" title = "Movie 1" @@ -47,6 +49,7 @@ class MockPlexMovie(MockPlexMedia): class MockPlexMusic(MockPlexMedia): """Minimal mock of plexapi album object.""" + TAG = "Directory" listType = "audio" type = "album" title = "Album" @@ -56,6 +59,7 @@ class MockPlexMusic(MockPlexMedia): class MockPlexTVEpisode(MockPlexMedia): """Minimal mock of plexapi episode object.""" + TAG = "Video" type = "episode" title = "Episode 5" grandparentTitle = "TV Show" @@ -179,7 +183,8 @@ async def test_library_sensor_values( # Test movie library sensor entity_registry.async_update_entity( - entity_id="sensor.plex_server_1_library_tv_shows", disabled_by="user" + entity_id="sensor.plex_server_1_library_tv_shows", + disabled_by=er.RegistryEntryDisabler.USER, ) entity_registry.async_update_entity( entity_id="sensor.plex_server_1_library_movies", disabled_by=None @@ -214,7 +219,8 @@ async def test_library_sensor_values( # Test music library sensor entity_registry.async_update_entity( - entity_id="sensor.plex_server_1_library_movies", disabled_by="user" + entity_id="sensor.plex_server_1_library_movies", + disabled_by=er.RegistryEntryDisabler.USER, ) entity_registry.async_update_entity( entity_id="sensor.plex_server_1_library_music", disabled_by=None diff --git a/tests/components/plex/test_server.py b/tests/components/plex/test_server.py index 724b34cf729c34..68032868b1a57e 100644 --- a/tests/components/plex/test_server.py +++ b/tests/components/plex/test_server.py @@ -4,7 +4,6 @@ from requests.exceptions import ConnectionError, RequestException -from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.plex.const import ( CONF_IGNORE_NEW_SHARED_USERS, CONF_IGNORE_PLEX_WEB_CLIENTS, @@ -13,6 +12,7 @@ DOMAIN, SERVERS, ) +from homeassistant.const import Platform from .const import DEFAULT_DATA, DEFAULT_OPTIONS from .helpers import trigger_plex_update, wait_for_debouncer @@ -22,7 +22,7 @@ async def test_new_users_available(hass, entry, setup_plex_server): """Test setting up when new users available on Plex server.""" MONITORED_USERS = {"User 1": {"enabled": True}} OPTIONS_WITH_USERS = copy.deepcopy(DEFAULT_OPTIONS) - OPTIONS_WITH_USERS[MP_DOMAIN][CONF_MONITORED_USERS] = MONITORED_USERS + OPTIONS_WITH_USERS[Platform.MEDIA_PLAYER][CONF_MONITORED_USERS] = MONITORED_USERS entry.options = OPTIONS_WITH_USERS mock_plex_server = await setup_plex_server(config_entry=entry) @@ -48,8 +48,8 @@ async def test_new_ignored_users_available( """Test setting up when new users available on Plex server but are ignored.""" MONITORED_USERS = {"User 1": {"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 + OPTIONS_WITH_USERS[Platform.MEDIA_PLAYER][CONF_MONITORED_USERS] = MONITORED_USERS + OPTIONS_WITH_USERS[Platform.MEDIA_PLAYER][CONF_IGNORE_NEW_SHARED_USERS] = True entry.options = OPTIONS_WITH_USERS mock_plex_server = await setup_plex_server(config_entry=entry) @@ -151,7 +151,7 @@ async def test_mark_sessions_idle( async def test_ignore_plex_web_client(hass, entry, setup_plex_server): """Test option to ignore Plex Web clients.""" OPTIONS = copy.deepcopy(DEFAULT_OPTIONS) - OPTIONS[MP_DOMAIN][CONF_IGNORE_PLEX_WEB_CLIENTS] = True + OPTIONS[Platform.MEDIA_PLAYER][CONF_IGNORE_PLEX_WEB_CLIENTS] = True entry.options = OPTIONS mock_plex_server = await setup_plex_server( diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index 161755c97031b7..49fdd48f6624cc 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -2,7 +2,9 @@ from http import HTTPStatus from unittest.mock import patch +import plexapi.audio from plexapi.exceptions import NotFound +import plexapi.playqueue import pytest from homeassistant.components.media_player.const import MEDIA_TYPE_MUSIC @@ -14,7 +16,7 @@ SERVICE_REFRESH_LIBRARY, SERVICE_SCAN_CLIENTS, ) -from homeassistant.components.plex.services import play_on_sonos +from homeassistant.components.plex.services import lookup_plex_media from homeassistant.const import CONF_URL from homeassistant.exceptions import HomeAssistantError @@ -110,32 +112,28 @@ async def test_scan_clients(hass, mock_plex_server): ) -async def test_sonos_play_media( +async def test_lookup_media_for_other_integrations( hass, entry, setup_plex_server, requests_mock, - empty_payload, playqueue_1234, playqueue_created, - plextv_account, - sonos_resources, ): - """Test playback from a Sonos media_player.play_media call.""" - media_content_id = ( - '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}' - ) - sonos_speaker_name = "Zone A" - - requests_mock.get("https://plex.tv/users/account", text=plextv_account) - requests_mock.post("/playqueues", text=playqueue_created) - playback_mock = requests_mock.get( - "/player/playback/playMedia", status_code=HTTPStatus.OK + """Test media lookup for media_player.play_media calls from cast/sonos.""" + CONTENT_ID = '{"library_name": "Music", "artist_name": "Artist"}' + CONTENT_ID_KEY = "100" + CONTENT_ID_BAD_MEDIA = '{"library_name": "Music", "artist_name": "Not an Artist"}' + CONTENT_ID_PLAYQUEUE = '{"playqueue_id": 1234}' + CONTENT_ID_BAD_PLAYQUEUE = '{"playqueue_id": 1235}' + CONTENT_ID_SERVER = '{"plex_server": "Plex Server 1", "library_name": "Music", "artist_name": "Artist"}' + CONTENT_ID_SHUFFLE = ( + '{"library_name": "Music", "artist_name": "Artist", "shuffle": 1}' ) # Test with no Plex integration available with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name) + lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID) assert "Plex integration not configured" in str(excinfo.value) with patch( @@ -147,68 +145,45 @@ async def test_sonos_play_media( # Test with no Plex servers available with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name) + lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID) assert "No Plex servers available" in str(excinfo.value) # Complete setup of a Plex server await hass.config_entries.async_unload(entry.entry_id) - mock_plex_server = await setup_plex_server() - - # Test with unlinked Plex/Sonos accounts - requests_mock.get("https://sonos.plex.tv/resources", status_code=403) - with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name) - assert "Sonos speakers not linked to Plex account" in str(excinfo.value) - assert playback_mock.call_count == 0 + await setup_plex_server() - # Test with no speakers available - requests_mock.get("https://sonos.plex.tv/resources", text=empty_payload) - with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name) - assert f"Sonos speaker '{sonos_speaker_name}' is not associated with" in str( - excinfo.value - ) - assert playback_mock.call_count == 0 - - # Test with speakers available - requests_mock.get("https://sonos.plex.tv/resources", text=sonos_resources) - with patch.object(mock_plex_server.account, "_sonos_cache_timestamp", 0): - play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name) - assert playback_mock.call_count == 1 + # Test lookup success + result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID) + assert isinstance(result, plexapi.audio.Artist) - # Test with speakers available and media key payload - play_on_sonos(hass, MEDIA_TYPE_MUSIC, "100", sonos_speaker_name) - assert playback_mock.call_count == 2 + # Test media key payload + result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_KEY) + assert isinstance(result, plexapi.audio.Track) - # Test with speakers available and Plex server specified - content_id_with_server = '{"plex_server": "Plex Server 1", "library_name": "Music", "artist_name": "Artist", "album_name": "Album"}' - play_on_sonos(hass, MEDIA_TYPE_MUSIC, content_id_with_server, sonos_speaker_name) - assert playback_mock.call_count == 3 + # Test with specified server + result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_SERVER) + assert isinstance(result, plexapi.audio.Artist) - # Test with speakers available but media not found - content_id_bad_media = '{"library_name": "Music", "artist_name": "Not an Artist"}' + # Test with media not found with patch("plexapi.library.LibrarySection.search", return_value=None): with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos( - hass, MEDIA_TYPE_MUSIC, content_id_bad_media, sonos_speaker_name - ) + lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_BAD_MEDIA) assert "Plex media not found" in str(excinfo.value) - assert playback_mock.call_count == 3 - # Test with speakers available and playqueue + # Test with playqueue requests_mock.get("https://1.2.3.4:32400/playQueues/1234", text=playqueue_1234) - content_id_with_playqueue = '{"playqueue_id": 1234}' - play_on_sonos(hass, MEDIA_TYPE_MUSIC, content_id_with_playqueue, sonos_speaker_name) - assert playback_mock.call_count == 4 + result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_PLAYQUEUE) + assert isinstance(result, plexapi.playqueue.PlayQueue) - # Test with speakers available and invalid playqueue + # Test with invalid playqueue requests_mock.get( "https://1.2.3.4:32400/playQueues/1235", status_code=HTTPStatus.NOT_FOUND ) - content_id_with_playqueue = '{"playqueue_id": 1235}' with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos( - hass, MEDIA_TYPE_MUSIC, content_id_with_playqueue, sonos_speaker_name - ) + lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_BAD_PLAYQUEUE) assert "PlayQueue '1235' could not be found" in str(excinfo.value) - assert playback_mock.call_count == 4 + + # Test playqueue is created with shuffle + requests_mock.post("/playqueues", text=playqueue_created) + result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_SHUFFLE) + assert isinstance(result, plexapi.playqueue.PlayQueue) diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index f9f6ebbf0f5b18..d1b0c7c2130ce7 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -4,21 +4,27 @@ from http import HTTPStatus import unittest.mock as mock +import prometheus_client import pytest -from homeassistant.components import climate, humidifier, sensor +from homeassistant.components import climate, counter, humidifier, lock, sensor +from homeassistant.components.demo.binary_sensor import DemoBinarySensor +from homeassistant.components.demo.light import DemoLight from homeassistant.components.demo.number import DemoNumber from homeassistant.components.demo.sensor import DemoSensor import homeassistant.components.prometheus as prometheus +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONTENT_TYPE_TEXT_PLAIN, DEGREE, - DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, EVENT_STATE_CHANGED, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, ) from homeassistant.core import split_entity_id +from homeassistant.helpers import entity_registry from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -33,24 +39,115 @@ class FilterTest: should_pass: bool -async def prometheus_client(hass, hass_client, namespace): +async def setup_prometheus_client(hass, hass_client, namespace): """Initialize an hass_client with Prometheus component.""" + # Reset registry + prometheus_client.REGISTRY = prometheus_client.CollectorRegistry(auto_describe=True) + prometheus_client.ProcessCollector(registry=prometheus_client.REGISTRY) + prometheus_client.PlatformCollector(registry=prometheus_client.REGISTRY) + prometheus_client.GCCollector(registry=prometheus_client.REGISTRY) + config = {} if namespace is not None: config[prometheus.CONF_PROM_NAMESPACE] = namespace - await async_setup_component(hass, prometheus.DOMAIN, {prometheus.DOMAIN: config}) + assert await async_setup_component( + hass, prometheus.DOMAIN, {prometheus.DOMAIN: config} + ) + await hass.async_block_till_done() - await async_setup_component(hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]}) + return await hass_client() - await async_setup_component( - hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} + +async def generate_latest_metrics(client): + """Generate the latest metrics and transform the body.""" + resp = await client.get(prometheus.API_ENDPOINT) + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == CONTENT_TYPE_TEXT_PLAIN + body = await resp.text() + body = body.split("\n") + + assert len(body) > 3 + + return body + + +async def test_view_empty_namespace(hass, hass_client): + """Test prometheus metrics view.""" + client = await setup_prometheus_client(hass, hass_client, "") + + sensor2 = DemoSensor( + None, + "Radio Energy", + 14, + SensorDeviceClass.POWER, + None, + ENERGY_KILO_WATT_HOUR, + None, + ) + sensor2.hass = hass + sensor2.entity_id = "sensor.radio_energy" + with mock.patch( + "homeassistant.util.dt.utcnow", + return_value=datetime.datetime(1970, 1, 2, tzinfo=dt_util.UTC), + ): + await sensor2.async_update_ha_state() + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert "# HELP python_info Python platform information" in body + assert ( + "# HELP python_gc_objects_collected_total " + "Objects collected during gc" in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.radio_energy",' + 'friendly_name="Radio Energy"} 1.0' in body + ) + + assert ( + 'last_updated_time_seconds{domain="sensor",' + 'entity="sensor.radio_energy",' + 'friendly_name="Radio Energy"} 86400.0' in body + ) + + +async def test_view_default_namespace(hass, hass_client): + """Test prometheus metrics view.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + + client = await setup_prometheus_client(hass, hass_client, None) + + assert await async_setup_component( + hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]} ) await hass.async_block_till_done() - await async_setup_component( - hass, humidifier.DOMAIN, {"humidifier": [{"platform": "demo"}]} + body = await generate_latest_metrics(client) + + assert "# HELP python_info Python platform information" in body + assert ( + "# HELP python_gc_objects_collected_total " + "Objects collected during gc" in body + ) + + assert ( + 'homeassistant_sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body ) + +async def test_sensor_unit(hass, hass_client): + """Test prometheus metrics for sensors with a unit.""" + client = await setup_prometheus_client(hass, hass_client, "") + sensor1 = DemoSensor( None, "Television Energy", 74, None, None, ENERGY_KILO_WATT_HOUR, None ) @@ -59,7 +156,13 @@ async def prometheus_client(hass, hass_client, namespace): await sensor1.async_update_ha_state() sensor2 = DemoSensor( - None, "Radio Energy", 14, DEVICE_CLASS_POWER, None, ENERGY_KILO_WATT_HOUR, None + None, + "Radio Energy", + 14, + SensorDeviceClass.POWER, + None, + ENERGY_KILO_WATT_HOUR, + None, ) sensor2.hass = hass sensor2.entity_id = "sensor.radio_energy" @@ -100,6 +203,51 @@ async def prometheus_client(hass, hass_client, namespace): sensor5.entity_id = "sensor.sps30_pm_1um_weight_concentration" await sensor5.async_update_ha_state() + sensor6 = DemoSensor( + None, "Target temperature", 22.7, None, None, TEMP_CELSIUS, None + ) + sensor6.hass = hass + sensor6.entity_id = "input_number.target_temperature" + await sensor6.async_update_ha_state() + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'sensor_unit_kwh{domain="sensor",' + 'entity="sensor.television_energy",' + 'friendly_name="Television Energy"} 74.0' in body + ) + + assert ( + 'sensor_unit_sek_per_kwh{domain="sensor",' + 'entity="sensor.electricity_price",' + 'friendly_name="Electricity price"} 0.123' in body + ) + + assert ( + 'sensor_unit_u0xb0{domain="sensor",' + 'entity="sensor.wind_direction",' + 'friendly_name="Wind Direction"} 25.0' in body + ) + + assert ( + 'sensor_unit_u0xb5g_per_mu0xb3{domain="sensor",' + 'entity="sensor.sps30_pm_1um_weight_concentration",' + 'friendly_name="SPS30 PM <1µm Weight concentration"} 3.7069' in body + ) + + assert ( + 'input_number_state_celsius{domain="input_number",' + 'entity="input_number.target_temperature",' + 'friendly_name="Target temperature"} 22.7' in body + ) + + +async def test_sensor_without_unit(hass, hass_client): + """Test prometheus metrics for sensors without a unit.""" + client = await setup_prometheus_client(hass, hass_client, "") + sensor6 = DemoSensor(None, "Trend Gradient", 0.002, None, None, None, None) sensor6.hass = hass sensor6.entity_id = "sensor.trend_gradient" @@ -115,6 +263,102 @@ async def prometheus_client(hass, hass_client, namespace): sensor8.entity_id = "sensor.text_unit" await sensor8.async_update_ha_state() + body = await generate_latest_metrics(client) + + assert ( + 'sensor_state{domain="sensor",' + 'entity="sensor.trend_gradient",' + 'friendly_name="Trend Gradient"} 0.002' in body + ) + + assert ( + 'sensor_state{domain="sensor",' + 'entity="sensor.text",' + 'friendly_name="Text"} 0' not in body + ) + + assert ( + 'sensor_unit_text{domain="sensor",' + 'entity="sensor.text_unit",' + 'friendly_name="Text Unit"} 0' not in body + ) + + +async def test_sensor_device_class(hass, hass_client): + """Test prometheus metrics for sensor with a device_class.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + + client = await setup_prometheus_client(hass, hass_client, "") + + await async_setup_component(hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]}) + await hass.async_block_till_done() + + sensor1 = DemoSensor( + None, + "Fahrenheit", + 50, + SensorDeviceClass.TEMPERATURE, + None, + TEMP_FAHRENHEIT, + None, + ) + sensor1.hass = hass + sensor1.entity_id = "sensor.fahrenheit" + await sensor1.async_update_ha_state() + + sensor2 = DemoSensor( + None, + "Radio Energy", + 14, + SensorDeviceClass.POWER, + None, + ENERGY_KILO_WATT_HOUR, + None, + ) + sensor2.hass = hass + sensor2.entity_id = "sensor.radio_energy" + with mock.patch( + "homeassistant.util.dt.utcnow", + return_value=datetime.datetime(1970, 1, 2, tzinfo=dt_util.UTC), + ): + await sensor2.async_update_ha_state() + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.fahrenheit",' + 'friendly_name="Fahrenheit"} 10.0' in body + ) + + assert ( + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body + ) + + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'sensor_power_kwh{domain="sensor",' + 'entity="sensor.radio_energy",' + 'friendly_name="Radio Energy"} 14.0' in body + ) + + +async def test_input_number(hass, hass_client): + """Test prometheus metrics for input_number.""" + client = await setup_prometheus_client(hass, hass_client, "") + number1 = DemoNumber(None, "Threshold", 5.2, None, False, 0, 10, 0.1) number1.hass = hass number1.entity_id = "input_number.threshold" @@ -126,39 +370,72 @@ async def prometheus_client(hass, hass_client, namespace): number2._attr_name = None await number2.async_update_ha_state() - return await hass_client() - - -async def test_view_empty_namespace(hass, hass_client): - """Test prometheus metrics view.""" - client = await prometheus_client(hass, hass_client, "") - resp = await client.get(prometheus.API_ENDPOINT) + number3 = DemoSensor(None, "Retry count", 5, None, None, None, None) + number3.hass = hass + number3.entity_id = "input_number.retry_count" + await number3.async_update_ha_state() - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == CONTENT_TYPE_TEXT_PLAIN - body = await resp.text() - body = body.split("\n") + await hass.async_block_till_done() + body = await generate_latest_metrics(client) - assert len(body) > 3 + assert ( + 'input_number_state{domain="input_number",' + 'entity="input_number.threshold",' + 'friendly_name="Threshold"} 5.2' in body + ) - assert "# HELP python_info Python platform information" in body assert ( - "# HELP python_gc_objects_collected_total " - "Objects collected during gc" in body + 'input_number_state{domain="input_number",' + 'entity="input_number.brightness",' + 'friendly_name="None"} 60.0' in body ) assert ( - 'sensor_temperature_celsius{domain="sensor",' - 'entity="sensor.outside_temperature",' - 'friendly_name="Outside Temperature"} 15.6' in body + 'input_number_state{domain="input_number",' + 'entity="input_number.retry_count",' + 'friendly_name="Retry count"} 5.0' in body + ) + + +async def test_battery(hass, hass_client): + """Test prometheus metrics for battery.""" + assert await async_setup_component( + hass, + "conversation", + {}, ) + client = await setup_prometheus_client(hass, hass_client, "") + + await async_setup_component(hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]}) + await hass.async_block_till_done() + + body = await generate_latest_metrics(client) + assert ( 'battery_level_percent{domain="sensor",' 'entity="sensor.outside_temperature",' 'friendly_name="Outside Temperature"} 12.0' in body ) + +async def test_climate(hass, hass_client): + """Test prometheus metrics for climate.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + + client = await setup_prometheus_client(hass, hass_client, "") + + await async_setup_component( + hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + assert ( 'climate_current_temperature_celsius{domain="climate",' 'entity="climate.heatpump",' @@ -183,6 +460,38 @@ async def test_view_empty_namespace(hass, hass_client): 'friendly_name="Ecobee"} 24.0' in body ) + assert ( + 'climate_mode{domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump",' + 'mode="heat"} 1.0' in body + ) + + assert ( + 'climate_mode{domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump",' + 'mode="off"} 0.0' in body + ) + + +async def test_humidifier(hass, hass_client): + """Test prometheus metrics for battery.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + + client = await setup_prometheus_client(hass, hass_client, "") + + await async_setup_component( + hass, humidifier.DOMAIN, {"humidifier": [{"platform": "demo"}]} + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + assert ( 'humidifier_target_humidity_percent{domain="humidifier",' 'entity="humidifier.humidifier",' @@ -208,109 +517,601 @@ async def test_view_empty_namespace(hass, hass_client): 'mode="eco"} 0.0' in body ) - assert ( - 'sensor_humidity_percent{domain="sensor",' + +async def test_attributes(hass, hass_client): + """Test prometheus metrics for entity attributes.""" + client = await setup_prometheus_client(hass, hass_client, "") + + switch1 = DemoSensor(None, "Boolean", 74, None, None, None, None) + switch1.hass = hass + switch1.entity_id = "switch.boolean" + switch1._attr_extra_state_attributes = {"boolean": True} + await switch1.async_update_ha_state() + + switch2 = DemoSensor(None, "Number", 42, None, None, None, None) + switch2.hass = hass + switch2.entity_id = "switch.number" + switch2._attr_extra_state_attributes = {"Number": 10.2} + await switch2.async_update_ha_state() + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'switch_state{domain="switch",' + 'entity="switch.boolean",' + 'friendly_name="Boolean"} 74.0' in body + ) + + assert ( + 'switch_attr_boolean{domain="switch",' + 'entity="switch.boolean",' + 'friendly_name="Boolean"} 1.0' in body + ) + + assert ( + 'switch_state{domain="switch",' + 'entity="switch.number",' + 'friendly_name="Number"} 42.0' in body + ) + + assert ( + 'switch_attr_number{domain="switch",' + 'entity="switch.number",' + 'friendly_name="Number"} 10.2' in body + ) + + +async def test_binary_sensor(hass, hass_client): + """Test prometheus metrics for binary_sensor.""" + client = await setup_prometheus_client(hass, hass_client, "") + + binary_sensor1 = DemoBinarySensor(None, "Door", True, None) + binary_sensor1.hass = hass + binary_sensor1.entity_id = "binary_sensor.door" + await binary_sensor1.async_update_ha_state() + + binary_sensor1 = DemoBinarySensor(None, "Window", False, None) + binary_sensor1.hass = hass + binary_sensor1.entity_id = "binary_sensor.window" + await binary_sensor1.async_update_ha_state() + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'binary_sensor_state{domain="binary_sensor",' + 'entity="binary_sensor.door",' + 'friendly_name="Door"} 1.0' in body + ) + + assert ( + 'binary_sensor_state{domain="binary_sensor",' + 'entity="binary_sensor.window",' + 'friendly_name="Window"} 0.0' in body + ) + + +async def test_input_boolean(hass, hass_client): + """Test prometheus metrics for input_boolean.""" + client = await setup_prometheus_client(hass, hass_client, "") + + input_boolean1 = DemoSensor(None, "Test", 1, None, None, None, None) + input_boolean1.hass = hass + input_boolean1.entity_id = "input_boolean.test" + await input_boolean1.async_update_ha_state() + + input_boolean2 = DemoSensor(None, "Helper", 0, None, None, None, None) + input_boolean2.hass = hass + input_boolean2.entity_id = "input_boolean.helper" + await input_boolean2.async_update_ha_state() + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'input_boolean_state{domain="input_boolean",' + 'entity="input_boolean.test",' + 'friendly_name="Test"} 1.0' in body + ) + + assert ( + 'input_boolean_state{domain="input_boolean",' + 'entity="input_boolean.helper",' + 'friendly_name="Helper"} 0.0' in body + ) + + +async def test_light(hass, hass_client): + """Test prometheus metrics for lights.""" + client = await setup_prometheus_client(hass, hass_client, "") + + light1 = DemoSensor(None, "Desk", 1, None, None, None, None) + light1.hass = hass + light1.entity_id = "light.desk" + await light1.async_update_ha_state() + + light2 = DemoSensor(None, "Wall", 0, None, None, None, None) + light2.hass = hass + light2.entity_id = "light.wall" + await light2.async_update_ha_state() + + light3 = DemoLight(None, "TV", True, True, 255, None, None) + light3.hass = hass + light3.entity_id = "light.tv" + await light3.async_update_ha_state() + + light4 = DemoLight(None, "PC", True, True, 180, None, None) + light4.hass = hass + light4.entity_id = "light.pc" + await light4.async_update_ha_state() + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'light_brightness_percent{domain="light",' + 'entity="light.desk",' + 'friendly_name="Desk"} 100.0' in body + ) + + assert ( + 'light_brightness_percent{domain="light",' + 'entity="light.wall",' + 'friendly_name="Wall"} 0.0' in body + ) + + assert ( + 'light_brightness_percent{domain="light",' + 'entity="light.tv",' + 'friendly_name="TV"} 100.0' in body + ) + + assert ( + 'light_brightness_percent{domain="light",' + 'entity="light.pc",' + 'friendly_name="PC"} 70.58823529411765' in body + ) + + +async def test_lock(hass, hass_client): + """Test prometheus metrics for lock.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + + client = await setup_prometheus_client(hass, hass_client, "") + + await async_setup_component(hass, lock.DOMAIN, {"lock": [{"platform": "demo"}]}) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'lock_state{domain="lock",' + 'entity="lock.front_door",' + 'friendly_name="Front Door"} 1.0' in body + ) + + assert ( + 'lock_state{domain="lock",' + 'entity="lock.kitchen_door",' + 'friendly_name="Kitchen Door"} 0.0' in body + ) + + +async def test_counter(hass, hass_client): + """Test prometheus metrics for counter.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + + client = await setup_prometheus_client(hass, hass_client, "") + + await async_setup_component( + hass, counter.DOMAIN, {"counter": {"counter": {"initial": "2"}}} + ) + + await hass.async_block_till_done() + + body = await generate_latest_metrics(client) + + assert ( + 'counter_value{domain="counter",' + 'entity="counter.counter",' + 'friendly_name="None"} 2.0' in body + ) + + +async def test_renaming_entity_name(hass, hass_client): + """Test renaming entity name.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + client = await setup_prometheus_client(hass, hass_client, "") + + assert await async_setup_component( + hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} + ) + + assert await async_setup_component( + hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]} + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 1.0' in body + ) + + assert ( + 'sensor_humidity_percent{domain="sensor",' 'entity="sensor.outside_humidity",' 'friendly_name="Outside Humidity"} 54.0' in body ) assert ( - 'sensor_unit_kwh{domain="sensor",' - 'entity="sensor.television_energy",' - 'friendly_name="Television Energy"} 74.0' in body + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body ) assert ( - 'sensor_power_kwh{domain="sensor",' - 'entity="sensor.radio_energy",' - 'friendly_name="Radio Energy"} 14.0' in body + 'climate_action{action="heating",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 1.0' in body + ) + + assert ( + 'climate_action{action="cooling",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 0.0' in body + ) + + registry = entity_registry.async_get(hass) + assert "sensor.outside_temperature" in registry.entities + assert "climate.heatpump" in registry.entities + registry.async_update_entity( + entity_id="sensor.outside_temperature", + name="Outside Temperature Renamed", + ) + registry.async_update_entity( + entity_id="climate.heatpump", + name="HeatPump Renamed", + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + # Check if old metrics deleted + body_line = "\n".join(body) + assert 'friendly_name="Outside Temperature"' not in body_line + assert 'friendly_name="HeatPump"' not in body_line + + # Check if new metrics created + assert ( + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature Renamed"} 15.6' in body ) assert ( 'entity_available{domain="sensor",' - 'entity="sensor.radio_energy",' - 'friendly_name="Radio Energy"} 1.0' in body + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature Renamed"} 1.0' in body ) assert ( - 'last_updated_time_seconds{domain="sensor",' - 'entity="sensor.radio_energy",' - 'friendly_name="Radio Energy"} 86400.0' in body + 'climate_action{action="heating",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump Renamed"} 1.0' in body ) assert ( - 'sensor_unit_sek_per_kwh{domain="sensor",' - 'entity="sensor.electricity_price",' - 'friendly_name="Electricity price"} 0.123' in body + 'climate_action{action="cooling",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump Renamed"} 0.0' in body ) + # Keep other sensors assert ( - 'sensor_unit_u0xb0{domain="sensor",' - 'entity="sensor.wind_direction",' - 'friendly_name="Wind Direction"} 25.0' in body + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body ) assert ( - 'sensor_unit_u0xb5g_per_mu0xb3{domain="sensor",' - 'entity="sensor.sps30_pm_1um_weight_concentration",' - 'friendly_name="SPS30 PM <1µm Weight concentration"} 3.7069' in body + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + +async def test_renaming_entity_id(hass, hass_client): + """Test renaming entity id.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + client = await setup_prometheus_client(hass, hass_client, "") + + assert await async_setup_component( + hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]} ) + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + assert ( - 'sensor_state{domain="sensor",' - 'entity="sensor.trend_gradient",' - 'friendly_name="Trend Gradient"} 0.002' in body + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body ) assert ( - 'sensor_state{domain="sensor",' - 'entity="sensor.text",' - 'friendly_name="Text"} 0' not in body + 'entity_available{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 1.0' in body ) assert ( - 'sensor_unit_text{domain="sensor",' - 'entity="sensor.text_unit",' - 'friendly_name="Text Unit"} 0' not in body + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body ) assert ( - 'input_number_state{domain="input_number",' - 'entity="input_number.threshold",' - 'friendly_name="Threshold"} 5.2' in body + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body ) + registry = entity_registry.async_get(hass) + assert "sensor.outside_temperature" in registry.entities + registry.async_update_entity( + entity_id="sensor.outside_temperature", + new_entity_id="sensor.outside_temperature_renamed", + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + # Check if old metrics deleted + body_line = "\n".join(body) + assert 'entity="sensor.outside_temperature"' not in body_line + + # Check if new metrics created assert ( - 'input_number_state{domain="input_number",' - 'entity="input_number.brightness",' - 'friendly_name="None"} 60.0' in body + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature_renamed",' + 'friendly_name="Outside Temperature"} 15.6' in body ) + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_temperature_renamed",' + 'friendly_name="Outside Temperature"} 1.0' in body + ) -async def test_view_default_namespace(hass, hass_client): - """Test prometheus metrics view.""" - client = await prometheus_client(hass, hass_client, None) - resp = await client.get(prometheus.API_ENDPOINT) + # Keep other sensors + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == CONTENT_TYPE_TEXT_PLAIN - body = await resp.text() - body = body.split("\n") + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) - assert len(body) > 3 - assert "# HELP python_info Python platform information" in body +async def test_deleting_entity(hass, hass_client): + """Test deleting a entity.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + client = await setup_prometheus_client(hass, hass_client, "") + + await async_setup_component( + hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} + ) + + assert await async_setup_component( + hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]} + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + assert ( - "# HELP python_gc_objects_collected_total " - "Objects collected during gc" in body + 'sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body ) assert ( - 'homeassistant_sensor_temperature_celsius{domain="sensor",' + 'entity_available{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 1.0' in body + ) + + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + assert ( + 'climate_action{action="heating",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 1.0' in body + ) + + assert ( + 'climate_action{action="cooling",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 0.0' in body + ) + + registry = entity_registry.async_get(hass) + assert "sensor.outside_temperature" in registry.entities + assert "climate.heatpump" in registry.entities + registry.async_remove("sensor.outside_temperature") + registry.async_remove("climate.heatpump") + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + # Check if old metrics deleted + body_line = "\n".join(body) + assert 'entity="sensor.outside_temperature"' not in body_line + assert 'friendly_name="Outside Temperature"' not in body_line + assert 'entity="climate.heatpump"' not in body_line + assert 'friendly_name="HeatPump"' not in body_line + + # Keep other sensors + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + +async def test_disabling_entity(hass, hass_client): + """Test disabling a entity.""" + assert await async_setup_component( + hass, + "conversation", + {}, + ) + client = await setup_prometheus_client(hass, hass_client, "") + + await async_setup_component( + hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} + ) + + assert await async_setup_component( + hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]} + ) + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + assert ( + 'sensor_temperature_celsius{domain="sensor",' 'entity="sensor.outside_temperature",' 'friendly_name="Outside Temperature"} 15.6' in body ) + assert ( + 'state_change_total{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 1.0' in body + ) + + assert any( + 'state_change_created{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"}' in metric + for metric in body + ) + + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + + assert ( + 'climate_action{action="heating",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 1.0' in body + ) + + assert ( + 'climate_action{action="cooling",' + 'domain="climate",' + 'entity="climate.heatpump",' + 'friendly_name="HeatPump"} 0.0' in body + ) + + registry = entity_registry.async_get(hass) + assert "sensor.outside_temperature" in registry.entities + assert "climate.heatpump" in registry.entities + registry.async_update_entity( + entity_id="sensor.outside_temperature", + disabled_by="user", + ) + registry.async_update_entity(entity_id="climate.heatpump", disabled_by="user") + + await hass.async_block_till_done() + body = await generate_latest_metrics(client) + + # Check if old metrics deleted + body_line = "\n".join(body) + assert 'entity="sensor.outside_temperature"' not in body_line + assert 'friendly_name="Outside Temperature"' not in body_line + assert 'entity="climate.heatpump"' not in body_line + assert 'friendly_name="HeatPump"' not in body_line + + # Keep other sensors + assert ( + 'sensor_humidity_percent{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 54.0' in body + ) + + assert ( + 'entity_available{domain="sensor",' + 'entity="sensor.outside_humidity",' + 'friendly_name="Outside Humidity"} 1.0' in body + ) + @pytest.fixture(name="mock_client") def mock_client_fixture(): diff --git a/tests/components/pvoutput/__init__.py b/tests/components/pvoutput/__init__.py new file mode 100644 index 00000000000000..7b55b5c0471789 --- /dev/null +++ b/tests/components/pvoutput/__init__.py @@ -0,0 +1 @@ +"""Tests for the PVOutput integration.""" diff --git a/tests/components/pvoutput/conftest.py b/tests/components/pvoutput/conftest.py new file mode 100644 index 00000000000000..844bf1573424c7 --- /dev/null +++ b/tests/components/pvoutput/conftest.py @@ -0,0 +1,85 @@ +"""Fixtures for PVOutput integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from pvo import Status, System +import pytest + +from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="12345", + domain=DOMAIN, + data={CONF_API_KEY: "tskey-MOCK", CONF_SYSTEM_ID: 12345}, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.pvoutput.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_pvoutput_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked PVOutput client.""" + with patch( + "homeassistant.components.pvoutput.config_flow.PVOutput", autospec=True + ) as pvoutput_mock: + yield pvoutput_mock.return_value + + +@pytest.fixture +def mock_pvoutput() -> Generator[None, MagicMock, None]: + """Return a mocked PVOutput client.""" + status = Status( + reported_date="20211229", + reported_time="22:37", + energy_consumption=1000, + energy_generation=500, + normalized_output=0.5, + power_consumption=2500, + power_generation=1500, + temperature=20.2, + voltage=220.5, + ) + + system = System( + inverter_brand="Super Inverters Inc.", + system_name="Frenck's Solar Farm", + ) + + with patch( + "homeassistant.components.pvoutput.coordinator.PVOutput", autospec=True + ) as pvoutput_mock: + pvoutput = pvoutput_mock.return_value + pvoutput.status.return_value = status + pvoutput.system.return_value = system + yield pvoutput + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pvoutput: MagicMock +) -> MockConfigEntry: + """Set up the PVOutput integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/pvoutput/test_config_flow.py b/tests/components/pvoutput/test_config_flow.py new file mode 100644 index 00000000000000..0c060a75a9d412 --- /dev/null +++ b/tests/components/pvoutput/test_config_flow.py @@ -0,0 +1,309 @@ +"""Tests for the PVOutput config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pvo import PVOutputAuthenticationError, PVOutputConnectionError + +from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_pvoutput_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "12345" + assert result2.get("data") == { + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + +async def test_full_flow_with_authentication_error( + hass: HomeAssistant, + mock_pvoutput_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow with incorrect API key. + + This tests tests a full config flow, with a case the user enters an invalid + PVOutput API key, but recovers by entering the correct one. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_pvoutput_config_flow.status.side_effect = PVOutputAuthenticationError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "invalid", + }, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {"base": "invalid_auth"} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + mock_pvoutput_config_flow.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + }, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "12345" + assert result3.get("data") == { + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 2 + + +async def test_connection_error( + hass: HomeAssistant, mock_pvoutput_config_flow: MagicMock +) -> None: + """Test API connection error.""" + mock_pvoutput_config_flow.status.side_effect = PVOutputConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + }, + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {"base": "cannot_connect"} + + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pvoutput_config_flow: MagicMock, +) -> None: + """Test we abort if the PVOutput system is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "tadaaa", + }, + ) + + assert result2.get("type") == RESULT_TYPE_ABORT + assert result2.get("reason") == "already_configured" + + +async def test_import_flow( + hass: HomeAssistant, + mock_pvoutput_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_SYSTEM_ID: 1337, + CONF_API_KEY: "tadaaa", + CONF_NAME: "Test", + }, + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "Test" + assert result.get("data") == { + CONF_SYSTEM_ID: 1337, + CONF_API_KEY: "tadaaa", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pvoutput_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "some_new_key"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "some_new_key", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + +async def test_reauth_with_authentication_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pvoutput_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the reauthentication configuration flow with an authentication error. + + This tests tests a reauth flow, with a case the user enters an invalid + API key, but recover by entering the correct one. + """ + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + mock_pvoutput_config_flow.status.side_effect = PVOutputAuthenticationError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "invalid_key"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == {"base": "invalid_auth"} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 1 + + mock_pvoutput_config_flow.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_API_KEY: "valid_key"}, + ) + await hass.async_block_till_done() + + assert result3.get("type") == RESULT_TYPE_ABORT + assert result3.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "valid_key", + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_pvoutput_config_flow.status.mock_calls) == 2 + + +async def test_reauth_api_error( + hass: HomeAssistant, + mock_pvoutput_config_flow: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test API error during reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "reauth_confirm" + assert "flow_id" in result + + mock_pvoutput_config_flow.status.side_effect = PVOutputConnectionError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "some_new_key"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == {"base": "cannot_connect"} diff --git a/tests/components/pvoutput/test_diagnostics.py b/tests/components/pvoutput/test_diagnostics.py new file mode 100644 index 00000000000000..529e9b4e575b2c --- /dev/null +++ b/tests/components/pvoutput/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for the diagnostics data provided by the PVOutput integration.""" +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "energy_consumption": 1000, + "energy_generation": 500, + "normalized_output": 0.5, + "power_consumption": 2500, + "power_generation": 1500, + "reported_date": "2021-12-29", + "reported_time": "22:37:00", + "temperature": 20.2, + "voltage": 220.5, + } diff --git a/tests/components/pvoutput/test_init.py b/tests/components/pvoutput/test_init.py new file mode 100644 index 00000000000000..faaff3d4214f67 --- /dev/null +++ b/tests/components/pvoutput/test_init.py @@ -0,0 +1,104 @@ +"""Tests for the PVOutput integration.""" +from unittest.mock import MagicMock + +from pvo import PVOutputAuthenticationError, PVOutputConnectionError +import pytest + +from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pvoutput: MagicMock, +) -> None: + """Test the PVOutput configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_pvoutput.status.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pvoutput: MagicMock, +) -> None: + """Test the PVOutput configuration entry not ready.""" + mock_pvoutput.status.side_effect = PVOutputConnectionError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_pvoutput.status.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_authentication_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pvoutput: MagicMock, +) -> None: + """Test trigger reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + mock_pvoutput.status.side_effect = PVOutputAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id + + +async def test_import_config( + hass: HomeAssistant, + mock_pvoutput_config_flow: MagicMock, + mock_pvoutput: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test PVOutput being set up from config via import.""" + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "platform": DOMAIN, + CONF_SYSTEM_ID: 12345, + CONF_API_KEY: "abcdefghijklmnopqrstuvwxyz", + } + }, + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_pvoutput.status.mock_calls) == 1 + assert len(mock_pvoutput.system.mock_calls) == 1 + assert "the PVOutput platform in YAML is deprecated" in caplog.text diff --git a/tests/components/pvoutput/test_sensor.py b/tests/components/pvoutput/test_sensor.py new file mode 100644 index 00000000000000..a194326cd9d430 --- /dev/null +++ b/tests/components/pvoutput/test_sensor.py @@ -0,0 +1,138 @@ +"""Tests for the sensors provided by the PVOutput integration.""" +from homeassistant.components.pvoutput.const import DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, + POWER_KILO_WATT, + POWER_WATT, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the PVOutput sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.energy_consumed") + entry = entity_registry.async_get("sensor.energy_consumed") + assert entry + assert state + assert entry.unique_id == "12345_energy_consumption" + assert entry.entity_category is None + assert state.state == "1000" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumed" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_WATT_HOUR + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.energy_generated") + entry = entity_registry.async_get("sensor.energy_generated") + assert entry + assert state + assert entry.unique_id == "12345_energy_generation" + assert entry.entity_category is None + assert state.state == "500" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Generated" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL_INCREASING + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_WATT_HOUR + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.efficiency") + entry = entity_registry.async_get("sensor.efficiency") + assert entry + assert state + assert entry.unique_id == "12345_normalized_output" + assert entry.entity_category is None + assert state.state == "0.5" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Efficiency" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == f"{ENERGY_KILO_WATT_HOUR}/{POWER_KILO_WATT}" + ) + assert ATTR_DEVICE_CLASS not in state.attributes + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.power_consumed") + entry = entity_registry.async_get("sensor.power_consumed") + assert entry + assert state + assert entry.unique_id == "12345_power_consumption" + assert entry.entity_category is None + assert state.state == "2500" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumed" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.power_generated") + entry = entity_registry.async_get("sensor.power_generated") + assert entry + assert state + assert entry.unique_id == "12345_power_generation" + assert entry.entity_category is None + assert state.state == "1500" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Generated" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.temperature") + entry = entity_registry.async_get("sensor.temperature") + assert entry + assert state + assert entry.unique_id == "12345_temperature" + assert entry.entity_category is None + assert state.state == "20.2" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Temperature" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.voltage") + entry = entity_registry.async_get("sensor.voltage") + assert entry + assert state + assert entry.unique_id == "12345_voltage" + assert entry.entity_category is None + assert state.state == "220.5" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Voltage" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_POTENTIAL_VOLT + assert ATTR_ICON not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "12345")} + assert device_entry.manufacturer == "PVOutput" + assert device_entry.model == "Super Inverters Inc." + assert device_entry.name == "Frenck's Solar Farm" + assert device_entry.configuration_url == "https://pvoutput.org/list.jsp?sid=12345" + assert device_entry.entry_type is None + assert device_entry.sw_version is None + assert device_entry.hw_version is None diff --git a/tests/components/rainforest_eagle/__init__.py b/tests/components/rainforest_eagle/__init__.py index df4f1749d49046..3d0a51e84528f7 100644 --- a/tests/components/rainforest_eagle/__init__.py +++ b/tests/components/rainforest_eagle/__init__.py @@ -1 +1,36 @@ """Tests for the Rainforest Eagle integration.""" + + +MOCK_CLOUD_ID = "12345" +MOCK_200_RESPONSE_WITH_PRICE = { + "zigbee:InstantaneousDemand": { + "Name": "zigbee:InstantaneousDemand", + "Value": "1.152000", + }, + "zigbee:CurrentSummationDelivered": { + "Name": "zigbee:CurrentSummationDelivered", + "Value": "45251.285000", + }, + "zigbee:CurrentSummationReceived": { + "Name": "zigbee:CurrentSummationReceived", + "Value": "232.232000", + }, + "zigbee:Price": {"Name": "zigbee:Price", "Value": "0.053990"}, + "zigbee:PriceCurrency": {"Name": "zigbee:PriceCurrency", "Value": "USD"}, +} +MOCK_200_RESPONSE_WITHOUT_PRICE = { + "zigbee:InstantaneousDemand": { + "Name": "zigbee:InstantaneousDemand", + "Value": "1.152000", + }, + "zigbee:CurrentSummationDelivered": { + "Name": "zigbee:CurrentSummationDelivered", + "Value": "45251.285000", + }, + "zigbee:CurrentSummationReceived": { + "Name": "zigbee:CurrentSummationReceived", + "Value": "232.232000", + }, + "zigbee:Price": {"Name": "zigbee:Price", "Value": "invalid"}, + "zigbee:PriceCurrency": {"Name": "zigbee:PriceCurrency", "Value": "USD"}, +} diff --git a/tests/components/rainforest_eagle/conftest.py b/tests/components/rainforest_eagle/conftest.py new file mode 100644 index 00000000000000..d3a8687f724e51 --- /dev/null +++ b/tests/components/rainforest_eagle/conftest.py @@ -0,0 +1,85 @@ +"""Conftest for rainforest_eagle.""" +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.rainforest_eagle.const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_100, + TYPE_EAGLE_200, +) +from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.setup import async_setup_component + +from . import MOCK_200_RESPONSE_WITHOUT_PRICE, MOCK_CLOUD_ID + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry_200(hass): + """Return a config entry.""" + entry = MockConfigEntry( + domain="rainforest_eagle", + data={ + CONF_CLOUD_ID: MOCK_CLOUD_ID, + CONF_HOST: "192.168.1.55", + CONF_INSTALL_CODE: "abcdefgh", + CONF_HARDWARE_ADDRESS: "mock-hw-address", + CONF_TYPE: TYPE_EAGLE_200, + }, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def setup_rainforest_200(hass, config_entry_200): + """Set up rainforest.""" + with patch( + "aioeagle.ElectricMeter.create_instance", + return_value=Mock( + get_device_query=AsyncMock(return_value=MOCK_200_RESPONSE_WITHOUT_PRICE) + ), + ) as mock_update: + mock_update.return_value.is_connected = True + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update.return_value + + +@pytest.fixture +async def setup_rainforest_100(hass): + """Set up rainforest.""" + MockConfigEntry( + domain="rainforest_eagle", + data={ + CONF_CLOUD_ID: MOCK_CLOUD_ID, + CONF_HOST: "192.168.1.55", + CONF_INSTALL_CODE: "abcdefgh", + CONF_HARDWARE_ADDRESS: None, + CONF_TYPE: TYPE_EAGLE_100, + }, + ).add_to_hass(hass) + with patch( + "homeassistant.components.rainforest_eagle.data.Eagle100Reader", + return_value=Mock( + get_instantaneous_demand=Mock( + return_value={"InstantaneousDemand": {"Demand": "1.152000"}} + ), + get_current_summation=Mock( + return_value={ + "CurrentSummation": { + "SummationDelivered": "45251.285000", + "SummationReceived": "232.232000", + } + } + ), + ), + ) as mock_update: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield mock_update diff --git a/tests/components/rainforest_eagle/test_diagnostics.py b/tests/components/rainforest_eagle/test_diagnostics.py new file mode 100644 index 00000000000000..bffdccedcc0720 --- /dev/null +++ b/tests/components/rainforest_eagle/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Test the Rainforest Eagle diagnostics.""" +from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.rainforest_eagle.const import ( + CONF_CLOUD_ID, + CONF_INSTALL_CODE, +) + +from . import MOCK_200_RESPONSE_WITHOUT_PRICE + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics( + hass, hass_client, setup_rainforest_200, config_entry_200 +): + """Test config entry diagnostics.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry_200) + + config_entry_dict = config_entry_200.as_dict() + config_entry_dict["data"][CONF_INSTALL_CODE] = REDACTED + config_entry_dict["data"][CONF_CLOUD_ID] = REDACTED + + assert result == { + "config_entry": config_entry_dict, + "data": { + var["Name"]: var["Value"] + for var in MOCK_200_RESPONSE_WITHOUT_PRICE.values() + }, + } diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py index e895f2ac4fc10a..eaa392b93c3f8c 100644 --- a/tests/components/rainforest_eagle/test_sensor.py +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -1,113 +1,7 @@ """Tests for rainforest eagle sensors.""" -from unittest.mock import AsyncMock, Mock, patch +from homeassistant.components.rainforest_eagle.const import DOMAIN -import pytest - -from homeassistant.components.rainforest_eagle.const import ( - CONF_CLOUD_ID, - CONF_HARDWARE_ADDRESS, - CONF_INSTALL_CODE, - DOMAIN, - TYPE_EAGLE_100, - TYPE_EAGLE_200, -) -from homeassistant.const import CONF_HOST, CONF_TYPE -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry - -MOCK_CLOUD_ID = "12345" -MOCK_200_RESPONSE_WITH_PRICE = { - "zigbee:InstantaneousDemand": { - "Name": "zigbee:InstantaneousDemand", - "Value": "1.152000", - }, - "zigbee:CurrentSummationDelivered": { - "Name": "zigbee:CurrentSummationDelivered", - "Value": "45251.285000", - }, - "zigbee:CurrentSummationReceived": { - "Name": "zigbee:CurrentSummationReceived", - "Value": "232.232000", - }, - "zigbee:Price": {"Name": "zigbee:Price", "Value": "0.053990"}, - "zigbee:PriceCurrency": {"Name": "zigbee:PriceCurrency", "Value": "USD"}, -} -MOCK_200_RESPONSE_WITHOUT_PRICE = { - "zigbee:InstantaneousDemand": { - "Name": "zigbee:InstantaneousDemand", - "Value": "1.152000", - }, - "zigbee:CurrentSummationDelivered": { - "Name": "zigbee:CurrentSummationDelivered", - "Value": "45251.285000", - }, - "zigbee:CurrentSummationReceived": { - "Name": "zigbee:CurrentSummationReceived", - "Value": "232.232000", - }, - "zigbee:Price": {"Name": "zigbee:Price", "Value": "invalid"}, - "zigbee:PriceCurrency": {"Name": "zigbee:PriceCurrency", "Value": "USD"}, -} - - -@pytest.fixture -async def setup_rainforest_200(hass): - """Set up rainforest.""" - MockConfigEntry( - domain="rainforest_eagle", - data={ - CONF_CLOUD_ID: MOCK_CLOUD_ID, - CONF_HOST: "192.168.1.55", - CONF_INSTALL_CODE: "abcdefgh", - CONF_HARDWARE_ADDRESS: "mock-hw-address", - CONF_TYPE: TYPE_EAGLE_200, - }, - ).add_to_hass(hass) - with patch( - "aioeagle.ElectricMeter.create_instance", - return_value=Mock( - get_device_query=AsyncMock(return_value=MOCK_200_RESPONSE_WITHOUT_PRICE) - ), - ) as mock_update: - mock_update.return_value.is_connected = True - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_rainforest_100(hass): - """Set up rainforest.""" - MockConfigEntry( - domain="rainforest_eagle", - data={ - CONF_CLOUD_ID: MOCK_CLOUD_ID, - CONF_HOST: "192.168.1.55", - CONF_INSTALL_CODE: "abcdefgh", - CONF_HARDWARE_ADDRESS: None, - CONF_TYPE: TYPE_EAGLE_100, - }, - ).add_to_hass(hass) - with patch( - "homeassistant.components.rainforest_eagle.data.Eagle100Reader", - return_value=Mock( - get_instantaneous_demand=Mock( - return_value={"InstantaneousDemand": {"Demand": "1.152000"}} - ), - get_current_summation=Mock( - return_value={ - "CurrentSummation": { - "SummationDelivered": "45251.285000", - "SummationReceived": "232.232000", - } - } - ), - ), - ) as mock_update: - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update +from . import MOCK_200_RESPONSE_WITH_PRICE async def test_sensors_200(hass, setup_rainforest_200): diff --git a/tests/components/rainmachine/conftest.py b/tests/components/rainmachine/conftest.py new file mode 100644 index 00000000000000..3a32b3b7c9aaf1 --- /dev/null +++ b/tests/components/rainmachine/conftest.py @@ -0,0 +1,113 @@ +"""Define test fixtures for RainMachine.""" +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.rainmachine import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="client") +def client_fixture(controller, controller_mac): + """Define a regenmaschine client.""" + return AsyncMock(load_local=AsyncMock(), controllers={controller_mac: controller}) + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_PASSWORD: "password", + CONF_PORT: 8080, + CONF_SSL: True, + } + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config, controller_mac): + """Define a config entry fixture.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=controller_mac, data=config) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="controller") +def controller_fixture( + controller_mac, + data_programs, + data_provision_settings, + data_restrictions_current, + data_restrictions_universal, + data_zones, +): + """Define a regenmaschine controller.""" + controller = AsyncMock() + controller.api_version = "4.5.0" + controller.hardware_version = 3 + controller.name = "My RainMachine" + controller.mac = controller_mac + controller.software_version = "4.0.925" + + controller.programs.all.return_value = data_programs + controller.provisioning.settings.return_value = data_provision_settings + controller.restrictions.current.return_value = data_restrictions_current + controller.restrictions.universal.return_value = data_restrictions_universal + controller.zones.all.return_value = data_zones + + return controller + + +@pytest.fixture(name="controller_mac") +def controller_mac_fixture(): + """Define a controller MAC address.""" + return "aa:bb:cc:dd:ee:ff" + + +@pytest.fixture(name="data_programs", scope="session") +def data_programs_fixture(): + """Define program data.""" + return json.loads(load_fixture("programs_data.json", "rainmachine")) + + +@pytest.fixture(name="data_provision_settings", scope="session") +def data_provision_settings_fixture(): + """Define provisioning settings data.""" + return json.loads(load_fixture("provision_settings_data.json", "rainmachine")) + + +@pytest.fixture(name="data_restrictions_current", scope="session") +def data_restrictions_current_fixture(): + """Define current restrictions settings data.""" + return json.loads(load_fixture("restrictions_current_data.json", "rainmachine")) + + +@pytest.fixture(name="data_restrictions_universal", scope="session") +def data_restrictions_universal_fixture(): + """Define universal restrictions settings data.""" + return json.loads(load_fixture("restrictions_universal_data.json", "rainmachine")) + + +@pytest.fixture(name="data_zones", scope="session") +def data_zones_fixture(): + """Define zone data.""" + return json.loads(load_fixture("zones_data.json", "rainmachine")) + + +@pytest.fixture(name="setup_rainmachine") +async def setup_rainmachine_fixture(hass, client, config): + """Define a fixture to set up RainMachine.""" + with patch( + "homeassistant.components.rainmachine.Client", return_value=client + ), patch( + "homeassistant.components.rainmachine.config_flow.Client", return_value=client + ), patch( + "homeassistant.components.rainmachine.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield diff --git a/tests/components/rainmachine/fixtures/programs_data.json b/tests/components/rainmachine/fixtures/programs_data.json new file mode 100644 index 00000000000000..522ebd3badedb4 --- /dev/null +++ b/tests/components/rainmachine/fixtures/programs_data.json @@ -0,0 +1,284 @@ +[ + { + "uid": 1, + "name": "Morning", + "active": true, + "startTime": "06:00", + "cycles": 0, + "soak": 0, + "cs_on": false, + "delay": 0, + "delay_on": false, + "status": 0, + "startTimeParams": { + "offsetSign": 0, + "type": 0, + "offsetMinutes": 0 + }, + "frequency": { + "type": 0, + "param": "0" + }, + "coef": 0, + "ignoreInternetWeather": false, + "futureField1": 0, + "freq_modified": 0, + "useWaterSense": false, + "nextRun": "2018-06-04", + "startDate": "2018-04-28", + "endDate": null, + "yearlyRecurring": true, + "simulationExpired": false, + "wateringTimes": [ + { + "id": 1, + "order": -1, + "name": "Landscaping", + "duration": 0, + "active": true, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 2, + "order": -1, + "name": "Flower Box", + "duration": 0, + "active": true, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 3, + "order": -1, + "name": "TEST", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 4, + "order": -1, + "name": "Zone 4", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 5, + "order": -1, + "name": "Zone 5", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 6, + "order": -1, + "name": "Zone 6", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 7, + "order": -1, + "name": "Zone 7", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 8, + "order": -1, + "name": "Zone 8", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 9, + "order": -1, + "name": "Zone 9", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 10, + "order": -1, + "name": "Zone 10", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 11, + "order": -1, + "name": "Zone 11", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 12, + "order": -1, + "name": "Zone 12", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + } + ] + }, + { + "uid": 2, + "name": "Evening", + "active": false, + "startTime": "06:00", + "cycles": 0, + "soak": 0, + "cs_on": false, + "delay": 0, + "delay_on": false, + "status": 0, + "startTimeParams": { + "offsetSign": 0, + "type": 0, + "offsetMinutes": 0 + }, + "frequency": { + "type": 0, + "param": "0" + }, + "coef": 0, + "ignoreInternetWeather": false, + "futureField1": 0, + "freq_modified": 0, + "useWaterSense": false, + "nextRun": "2018-06-04", + "startDate": "2018-04-28", + "endDate": null, + "yearlyRecurring": true, + "simulationExpired": false, + "wateringTimes": [ + { + "id": 1, + "order": -1, + "name": "Landscaping", + "duration": 0, + "active": true, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 2, + "order": -1, + "name": "Flower Box", + "duration": 0, + "active": true, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 3, + "order": -1, + "name": "TEST", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 4, + "order": -1, + "name": "Zone 4", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 5, + "order": -1, + "name": "Zone 5", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 6, + "order": -1, + "name": "Zone 6", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 7, + "order": -1, + "name": "Zone 7", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 8, + "order": -1, + "name": "Zone 8", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 9, + "order": -1, + "name": "Zone 9", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 10, + "order": -1, + "name": "Zone 10", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 11, + "order": -1, + "name": "Zone 11", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + }, + { + "id": 12, + "order": -1, + "name": "Zone 12", + "duration": 0, + "active": false, + "userPercentage": 1, + "minRuntimeCoef": 1 + } + ] + } +] diff --git a/tests/components/rainmachine/fixtures/provision_settings_data.json b/tests/components/rainmachine/fixtures/provision_settings_data.json new file mode 100644 index 00000000000000..d62f9f4df1db6e --- /dev/null +++ b/tests/components/rainmachine/fixtures/provision_settings_data.json @@ -0,0 +1,84 @@ +{ + "system": { + "httpEnabled": true, + "rainSensorSnoozeDuration": 0, + "uiUnitsMetric": false, + "programZonesShowInactive": false, + "programSingleSchedule": false, + "standaloneMode": false, + "masterValveAfter": 0, + "touchSleepTimeout": 10, + "selfTest": false, + "useSoftwareRainSensor": false, + "defaultZoneWateringDuration": 300, + "maxLEDBrightness": 40, + "simulatorHistorySize": 0, + "vibration": false, + "masterValveBefore": 0, + "touchProgramToRun": null, + "useRainSensor": false, + "wizardHasRun": true, + "waterLogHistorySize": 365, + "netName": "Home", + "softwareRainSensorMinQPF": 5, + "touchAdvanced": false, + "useBonjourService": true, + "hardwareVersion": 3, + "touchLongPressTimeout": 3, + "showRestrictionsOnLed": false, + "parserDataSizeInDays": 6, + "programListShowInactive": true, + "parserHistorySize": 365, + "allowAlexaDiscovery": false, + "automaticUpdates": true, + "minLEDBrightness": 0, + "minWateringDurationThreshold": 0, + "localValveCount": 12, + "touchAuthAPSeconds": 60, + "useCommandLineArguments": false, + "databasePath": "/rainmachine-app/DB/Default", + "touchCyclePrograms": true, + "zoneListShowInactive": true, + "rainSensorRainStart": null, + "zoneDuration": [ + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300 + ], + "rainSensorIsNormallyClosed": true, + "useCorrectionForPast": true, + "useMasterValve": false, + "runParsersBeforePrograms": true, + "maxWateringCoef": 2, + "mixerHistorySize": 365 + }, + "location": { + "elevation": 1593.45141602, + "doyDownloaded": true, + "zip": null, + "windSensitivity": 0.5, + "krs": 0.16, + "stationID": 9172, + "stationSource": "station", + "et0Average": 6.578, + "latitude": 21.037234682342, + "state": "Default", + "stationName": "MY STATION", + "wsDays": 2, + "stationDownloaded": true, + "address": "Default", + "rainSensitivity": 0.8, + "timezone": "America/Los Angeles", + "longitude": -87.12872612, + "name": "Home" + } +} diff --git a/tests/components/rainmachine/fixtures/restrictions_current_data.json b/tests/components/rainmachine/fixtures/restrictions_current_data.json new file mode 100644 index 00000000000000..4bc93a9ddf42ae --- /dev/null +++ b/tests/components/rainmachine/fixtures/restrictions_current_data.json @@ -0,0 +1,10 @@ +{ + "hourly": false, + "freeze": false, + "month": false, + "weekDay": false, + "rainDelay": false, + "rainDelayCounter": -1, + "rainSensor": false +} + diff --git a/tests/components/rainmachine/fixtures/restrictions_universal_data.json b/tests/components/rainmachine/fixtures/restrictions_universal_data.json new file mode 100644 index 00000000000000..fa3cdbf4c47960 --- /dev/null +++ b/tests/components/rainmachine/fixtures/restrictions_universal_data.json @@ -0,0 +1,10 @@ +{ + "hotDaysExtraWatering": false, + "freezeProtectEnabled": true, + "freezeProtectTemp": 2, + "noWaterInWeekDays": "0000000", + "noWaterInMonths": "000000000000", + "rainDelayStartTime": 1524854551, + "rainDelayDuration": 0 +} + diff --git a/tests/components/rainmachine/fixtures/zones_data.json b/tests/components/rainmachine/fixtures/zones_data.json new file mode 100644 index 00000000000000..7df01f17ba6f58 --- /dev/null +++ b/tests/components/rainmachine/fixtures/zones_data.json @@ -0,0 +1,182 @@ +[ + { + "uid": 1, + "name": "Landscaping", + "state": 0, + "active": true, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 4, + "master": false, + "waterSense": false + }, + { + "uid": 2, + "name": "Flower Box", + "state": 0, + "active": true, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 5, + "master": false, + "waterSense": false + }, + { + "uid": 3, + "name": "TEST", + "state": 0, + "active": false, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 9, + "master": false, + "waterSense": false + }, + { + "uid": 4, + "name": "Zone 4", + "state": 0, + "active": false, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 2, + "master": false, + "waterSense": false + }, + { + "uid": 5, + "name": "Zone 5", + "state": 0, + "active": false, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 2, + "master": false, + "waterSense": false + }, + { + "uid": 6, + "name": "Zone 6", + "state": 0, + "active": false, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 2, + "master": false, + "waterSense": false + }, + { + "uid": 7, + "name": "Zone 7", + "state": 0, + "active": false, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 2, + "master": false, + "waterSense": false + }, + { + "uid": 8, + "name": "Zone 8", + "state": 0, + "active": false, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 2, + "master": false, + "waterSense": false + }, + { + "uid": 9, + "name": "Zone 9", + "state": 0, + "active": false, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 2, + "master": false, + "waterSense": false + }, + { + "uid": 10, + "name": "Zone 10", + "state": 0, + "active": false, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 2, + "master": false, + "waterSense": false + }, + { + "uid": 11, + "name": "Zone 11", + "state": 0, + "active": false, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 2, + "master": false, + "waterSense": false + }, + { + "uid": 12, + "name": "Zone 12", + "state": 0, + "active": false, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": false, + "type": 2, + "master": false, + "waterSense": false + } +] diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 35824296cc6e45..4514bbfb9d856d 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -1,5 +1,5 @@ """Define tests for the OpenUV config flow.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import patch import pytest from regenmaschine.errors import RainMachineError @@ -10,64 +10,22 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry - -def _get_mock_client(): - mock_controller = Mock() - mock_controller.name = "My Rain Machine" - mock_controller.mac = "aa:bb:cc:dd:ee:ff" - return Mock( - load_local=AsyncMock(), controllers={"aa:bb:cc:dd:ee:ff": mock_controller} - ) - - -async def test_duplicate_error(hass): +async def test_duplicate_error(hass, config, config_entry): """Test that errors are shown when duplicates are added.""" - conf = { - CONF_IP_ADDRESS: "192.168.1.100", - CONF_PASSWORD: "password", - CONF_PORT: 8080, - CONF_SSL: True, - } - - MockConfigEntry( - domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", data=conf - ).add_to_hass(hass) - - with patch( - "homeassistant.components.rainmachine.config_flow.Client", - return_value=_get_mock_client(), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=conf, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config + ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -async def test_invalid_password(hass): +async def test_invalid_password(hass, config): """Test that an invalid password throws an error.""" - conf = { - CONF_IP_ADDRESS: "192.168.1.100", - CONF_PASSWORD: "bad_password", - CONF_PORT: 8080, - CONF_SSL: True, - } - - with patch( - "regenmaschine.client.Client.load_local", - side_effect=RainMachineError, - ): + with patch("regenmaschine.client.Client.load_local", side_effect=RainMachineError): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=conf, + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config ) - await hass.async_block_till_done() - assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} @@ -91,19 +49,17 @@ async def test_invalid_password(hass): ], ) async def test_migrate_1_2( - hass, platform, entity_name, entity_id, old_unique_id, new_unique_id + hass, + client, + config, + config_entry, + entity_id, + entity_name, + old_unique_id, + new_unique_id, + platform, ): """Test migration from version 1 to 2 (consistent unique IDs).""" - conf = { - CONF_IP_ADDRESS: "192.168.1.100", - CONF_PASSWORD: "password", - CONF_PORT: 8080, - CONF_SSL: True, - } - - entry = MockConfigEntry(domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", data=conf) - entry.add_to_hass(hass) - ent_reg = er.async_get(hass) # Create entity RegistryEntry using old unique ID format: @@ -112,7 +68,7 @@ async def test_migrate_1_2( DOMAIN, old_unique_id, suggested_object_id=entity_name, - config_entry=entry, + config_entry=config_entry, original_name=entity_name, ) assert entity_entry.entity_id == entity_id @@ -121,8 +77,7 @@ async def test_migrate_1_2( with patch( "homeassistant.components.rainmachine.async_setup_entry", return_value=True ), patch( - "homeassistant.components.rainmachine.config_flow.Client", - return_value=_get_mock_client(), + "homeassistant.components.rainmachine.config_flow.Client", return_value=client ): await setup.async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -133,36 +88,19 @@ async def test_migrate_1_2( assert ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id) is None -async def test_options_flow(hass): +async def test_options_flow(hass, config, config_entry): """Test config flow options.""" - conf = { - CONF_IP_ADDRESS: "192.168.1.100", - CONF_PASSWORD: "password", - CONF_PORT: 8080, - CONF_SSL: True, - } - - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="abcde12345", - data=conf, - options={CONF_ZONE_RUN_TIME: 900}, - ) - config_entry.add_to_hass(hass) - with patch( "homeassistant.components.rainmachine.async_setup_entry", return_value=True ): await hass.config_entries.async_setup(config_entry.entry_id) 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_ZONE_RUN_TIME: 600} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == {CONF_ZONE_RUN_TIME: 600} @@ -174,34 +112,19 @@ async def test_show_form(hass): context={"source": config_entries.SOURCE_USER}, data=None, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" -async def test_step_user(hass): +async def test_step_user(hass, config, setup_rainmachine): """Test that the user step works.""" - conf = { - CONF_IP_ADDRESS: "192.168.1.100", - CONF_PASSWORD: "password", - CONF_PORT: 8080, - CONF_SSL: True, - } - - with patch( - "homeassistant.components.rainmachine.async_setup_entry", return_value=True - ) as mock_setup_entry, patch( - "homeassistant.components.rainmachine.config_flow.Client", - return_value=_get_mock_client(), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=conf, - ) - + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=config, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "My Rain Machine" + assert result["title"] == "My RainMachine" assert result["data"] == { CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "password", @@ -209,28 +132,17 @@ async def test_step_user(hass): CONF_SSL: True, CONF_ZONE_RUN_TIME: 600, } - assert mock_setup_entry.called @pytest.mark.parametrize( "source", [config_entries.SOURCE_ZEROCONF, config_entries.SOURCE_HOMEKIT] ) -async def test_step_homekit_zeroconf_ip_already_exists(hass, source): +async def test_step_homekit_zeroconf_ip_already_exists( + hass, client, config, config_entry, source +): """Test homekit and zeroconf with an ip that already exists.""" - conf = { - CONF_IP_ADDRESS: "192.168.1.100", - CONF_PASSWORD: "password", - CONF_PORT: 8080, - CONF_SSL: True, - } - - MockConfigEntry( - domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", data=conf - ).add_to_hass(hass) - with patch( - "homeassistant.components.rainmachine.config_flow.Client", - return_value=_get_mock_client(), + "homeassistant.components.rainmachine.config_flow.Client", return_value=client ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -252,21 +164,10 @@ async def test_step_homekit_zeroconf_ip_already_exists(hass, source): @pytest.mark.parametrize( "source", [config_entries.SOURCE_ZEROCONF, config_entries.SOURCE_HOMEKIT] ) -async def test_step_homekit_zeroconf_ip_change(hass, source): +async def test_step_homekit_zeroconf_ip_change(hass, client, config_entry, source): """Test zeroconf with an ip change.""" - conf = { - CONF_IP_ADDRESS: "192.168.1.100", - CONF_PASSWORD: "password", - CONF_PORT: 8080, - CONF_SSL: True, - } - - entry = MockConfigEntry(domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", data=conf) - entry.add_to_hass(hass) - with patch( - "homeassistant.components.rainmachine.config_flow.Client", - return_value=_get_mock_client(), + "homeassistant.components.rainmachine.config_flow.Client", return_value=client ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -283,28 +184,18 @@ async def test_step_homekit_zeroconf_ip_change(hass, source): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - assert entry.data[CONF_IP_ADDRESS] == "192.168.1.2" + assert config_entry.data[CONF_IP_ADDRESS] == "192.168.1.2" @pytest.mark.parametrize( "source", [config_entries.SOURCE_ZEROCONF, config_entries.SOURCE_HOMEKIT] ) -async def test_step_homekit_zeroconf_new_controller_when_some_exist(hass, source): +async def test_step_homekit_zeroconf_new_controller_when_some_exist( + hass, client, config, source +): """Test homekit and zeroconf for a new controller when one already exists.""" - existing_conf = { - CONF_IP_ADDRESS: "192.168.1.3", - CONF_PASSWORD: "password", - CONF_PORT: 8080, - CONF_SSL: True, - } - entry = MockConfigEntry( - domain=DOMAIN, unique_id="zz:bb:cc:dd:ee:ff", data=existing_conf - ) - entry.add_to_hass(hass) - with patch( - "homeassistant.components.rainmachine.config_flow.Client", - return_value=_get_mock_client(), + "homeassistant.components.rainmachine.config_flow.Client", return_value=client ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -324,9 +215,8 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist(hass, source with patch( "homeassistant.components.rainmachine.async_setup_entry", return_value=True - ) as mock_setup_entry, patch( - "homeassistant.components.rainmachine.config_flow.Client", - return_value=_get_mock_client(), + ), patch( + "homeassistant.components.rainmachine.config_flow.Client", return_value=client ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -339,7 +229,7 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist(hass, source await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "My Rain Machine" + assert result2["title"] == "My RainMachine" assert result2["data"] == { CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "password", @@ -347,15 +237,12 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist(hass, source CONF_SSL: True, CONF_ZONE_RUN_TIME: 600, } - assert mock_setup_entry.called -async def test_discovery_by_homekit_and_zeroconf_same_time(hass): +async def test_discovery_by_homekit_and_zeroconf_same_time(hass, client): """Test the same controller gets discovered by two different methods.""" - with patch( - "homeassistant.components.rainmachine.config_flow.Client", - return_value=_get_mock_client(), + "homeassistant.components.rainmachine.config_flow.Client", return_value=client ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -374,8 +261,7 @@ async def test_discovery_by_homekit_and_zeroconf_same_time(hass): assert result["step_id"] == "user" with patch( - "homeassistant.components.rainmachine.config_flow.Client", - return_value=_get_mock_client(), + "homeassistant.components.rainmachine.config_flow.Client", return_value=client ): result2 = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/rainmachine/test_diagnostics.py b/tests/components/rainmachine/test_diagnostics.py new file mode 100644 index 00000000000000..575e596abc2ead --- /dev/null +++ b/tests/components/rainmachine/test_diagnostics.py @@ -0,0 +1,592 @@ +"""Test RainMachine diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_rainmachine): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "title": "Mock Title", + "data": { + "ip_address": "192.168.1.100", + "password": REDACTED, + "port": 8080, + "ssl": True, + }, + "options": {}, + }, + "data": { + "coordinator": { + "programs": [ + { + "uid": 1, + "name": "Morning", + "active": True, + "startTime": "06:00", + "cycles": 0, + "soak": 0, + "cs_on": False, + "delay": 0, + "delay_on": False, + "status": 0, + "startTimeParams": { + "offsetSign": 0, + "type": 0, + "offsetMinutes": 0, + }, + "frequency": {"type": 0, "param": "0"}, + "coef": 0, + "ignoreInternetWeather": False, + "futureField1": 0, + "freq_modified": 0, + "useWaterSense": False, + "nextRun": "2018-06-04", + "startDate": "2018-04-28", + "endDate": None, + "yearlyRecurring": True, + "simulationExpired": False, + "wateringTimes": [ + { + "id": 1, + "order": -1, + "name": "Landscaping", + "duration": 0, + "active": True, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 2, + "order": -1, + "name": "Flower Box", + "duration": 0, + "active": True, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 3, + "order": -1, + "name": "TEST", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 4, + "order": -1, + "name": "Zone 4", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 5, + "order": -1, + "name": "Zone 5", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 6, + "order": -1, + "name": "Zone 6", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 7, + "order": -1, + "name": "Zone 7", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 8, + "order": -1, + "name": "Zone 8", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 9, + "order": -1, + "name": "Zone 9", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 10, + "order": -1, + "name": "Zone 10", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 11, + "order": -1, + "name": "Zone 11", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 12, + "order": -1, + "name": "Zone 12", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + ], + }, + { + "uid": 2, + "name": "Evening", + "active": False, + "startTime": "06:00", + "cycles": 0, + "soak": 0, + "cs_on": False, + "delay": 0, + "delay_on": False, + "status": 0, + "startTimeParams": { + "offsetSign": 0, + "type": 0, + "offsetMinutes": 0, + }, + "frequency": {"type": 0, "param": "0"}, + "coef": 0, + "ignoreInternetWeather": False, + "futureField1": 0, + "freq_modified": 0, + "useWaterSense": False, + "nextRun": "2018-06-04", + "startDate": "2018-04-28", + "endDate": None, + "yearlyRecurring": True, + "simulationExpired": False, + "wateringTimes": [ + { + "id": 1, + "order": -1, + "name": "Landscaping", + "duration": 0, + "active": True, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 2, + "order": -1, + "name": "Flower Box", + "duration": 0, + "active": True, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 3, + "order": -1, + "name": "TEST", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 4, + "order": -1, + "name": "Zone 4", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 5, + "order": -1, + "name": "Zone 5", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 6, + "order": -1, + "name": "Zone 6", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 7, + "order": -1, + "name": "Zone 7", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 8, + "order": -1, + "name": "Zone 8", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 9, + "order": -1, + "name": "Zone 9", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 10, + "order": -1, + "name": "Zone 10", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 11, + "order": -1, + "name": "Zone 11", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + { + "id": 12, + "order": -1, + "name": "Zone 12", + "duration": 0, + "active": False, + "userPercentage": 1, + "minRuntimeCoef": 1, + }, + ], + }, + ], + "provision.settings": { + "system": { + "httpEnabled": True, + "rainSensorSnoozeDuration": 0, + "uiUnitsMetric": False, + "programZonesShowInactive": False, + "programSingleSchedule": False, + "standaloneMode": False, + "masterValveAfter": 0, + "touchSleepTimeout": 10, + "selfTest": False, + "useSoftwareRainSensor": False, + "defaultZoneWateringDuration": 300, + "maxLEDBrightness": 40, + "simulatorHistorySize": 0, + "vibration": False, + "masterValveBefore": 0, + "touchProgramToRun": None, + "useRainSensor": False, + "wizardHasRun": True, + "waterLogHistorySize": 365, + "netName": "Home", + "softwareRainSensorMinQPF": 5, + "touchAdvanced": False, + "useBonjourService": True, + "hardwareVersion": 3, + "touchLongPressTimeout": 3, + "showRestrictionsOnLed": False, + "parserDataSizeInDays": 6, + "programListShowInactive": True, + "parserHistorySize": 365, + "allowAlexaDiscovery": False, + "automaticUpdates": True, + "minLEDBrightness": 0, + "minWateringDurationThreshold": 0, + "localValveCount": 12, + "touchAuthAPSeconds": 60, + "useCommandLineArguments": False, + "databasePath": "/rainmachine-app/DB/Default", + "touchCyclePrograms": True, + "zoneListShowInactive": True, + "rainSensorRainStart": None, + "zoneDuration": [ + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + ], + "rainSensorIsNormallyClosed": True, + "useCorrectionForPast": True, + "useMasterValve": False, + "runParsersBeforePrograms": True, + "maxWateringCoef": 2, + "mixerHistorySize": 365, + }, + "location": { + "elevation": 1593.45141602, + "doyDownloaded": True, + "zip": None, + "windSensitivity": 0.5, + "krs": 0.16, + "stationID": 9172, + "stationSource": "station", + "et0Average": 6.578, + "latitude": REDACTED, + "state": "Default", + "stationName": "MY STATION", + "wsDays": 2, + "stationDownloaded": True, + "address": "Default", + "rainSensitivity": 0.8, + "timezone": "America/Los Angeles", + "longitude": REDACTED, + "name": "Home", + }, + }, + "restrictions.current": { + "hourly": False, + "freeze": False, + "month": False, + "weekDay": False, + "rainDelay": False, + "rainDelayCounter": -1, + "rainSensor": False, + }, + "restrictions.universal": { + "hotDaysExtraWatering": False, + "freezeProtectEnabled": True, + "freezeProtectTemp": 2, + "noWaterInWeekDays": "0000000", + "noWaterInMonths": "000000000000", + "rainDelayStartTime": 1524854551, + "rainDelayDuration": 0, + }, + "zones": [ + { + "uid": 1, + "name": "Landscaping", + "state": 0, + "active": True, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 4, + "master": False, + "waterSense": False, + }, + { + "uid": 2, + "name": "Flower Box", + "state": 0, + "active": True, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 5, + "master": False, + "waterSense": False, + }, + { + "uid": 3, + "name": "TEST", + "state": 0, + "active": False, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 9, + "master": False, + "waterSense": False, + }, + { + "uid": 4, + "name": "Zone 4", + "state": 0, + "active": False, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 2, + "master": False, + "waterSense": False, + }, + { + "uid": 5, + "name": "Zone 5", + "state": 0, + "active": False, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 2, + "master": False, + "waterSense": False, + }, + { + "uid": 6, + "name": "Zone 6", + "state": 0, + "active": False, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 2, + "master": False, + "waterSense": False, + }, + { + "uid": 7, + "name": "Zone 7", + "state": 0, + "active": False, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 2, + "master": False, + "waterSense": False, + }, + { + "uid": 8, + "name": "Zone 8", + "state": 0, + "active": False, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 2, + "master": False, + "waterSense": False, + }, + { + "uid": 9, + "name": "Zone 9", + "state": 0, + "active": False, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 2, + "master": False, + "waterSense": False, + }, + { + "uid": 10, + "name": "Zone 10", + "state": 0, + "active": False, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 2, + "master": False, + "waterSense": False, + }, + { + "uid": 11, + "name": "Zone 11", + "state": 0, + "active": False, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 2, + "master": False, + "waterSense": False, + }, + { + "uid": 12, + "name": "Zone 12", + "state": 0, + "active": False, + "userDuration": 0, + "machineDuration": 0, + "remaining": 0, + "cycle": 0, + "noOfCycles": 0, + "restriction": False, + "type": 2, + "master": False, + "waterSense": False, + }, + ], + }, + "controller": { + "api_version": "4.5.0", + "hardware_version": 3, + "name": "My RainMachine", + "software_version": "4.0.925", + }, + }, + } diff --git a/tests/components/rdw/test_binary_sensor.py b/tests/components/rdw/test_binary_sensor.py index b3c4d5a9b3c27a..abf15d869cec9f 100644 --- a/tests/components/rdw/test_binary_sensor.py +++ b/tests/components/rdw/test_binary_sensor.py @@ -1,5 +1,5 @@ """Tests for the sensors provided by the RDW integration.""" -from homeassistant.components.binary_sensor import DEVICE_CLASS_PROBLEM +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.rdw.const import DOMAIN from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON from homeassistant.core import HomeAssistant @@ -33,7 +33,7 @@ async def test_vehicle_binary_sensors( assert entry.unique_id == "11ZKZ3_pending_recall" assert state.state == "off" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Pending Recall" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PROBLEM + assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PROBLEM assert ATTR_ICON not in state.attributes assert entry.device_id diff --git a/tests/components/rdw/test_diagnostics.py b/tests/components/rdw/test_diagnostics.py new file mode 100644 index 00000000000000..09c0ae62531c29 --- /dev/null +++ b/tests/components/rdw/test_diagnostics.py @@ -0,0 +1,45 @@ +"""Tests for the diagnostics data provided by the RDW integration.""" +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "apk_expiration": "2022-01-04", + "ascription_date": "2021-11-04", + "ascription_possible": True, + "brand": "Skoda", + "energy_label": "A", + "engine_capacity": 999, + "exported": False, + "interior": "hatchback", + "last_odometer_registration_year": 2021, + "liability_insured": False, + "license_plate": "11ZKZ3", + "list_price": 10697, + "first_admission": "2013-01-04", + "first_admission_netherlands": "2013-01-04", + "mass_empty": 840, + "mass_driveable": 940, + "model": "Citigo", + "number_of_cylinders": 3, + "number_of_doors": 0, + "number_of_seats": 4, + "number_of_wheelchair_seats": 0, + "number_of_wheels": 4, + "odometer_judgement": "Logisch", + "pending_recall": False, + "taxi": None, + "vehicle_type": "Personenauto", + } diff --git a/tests/components/rdw/test_sensor.py b/tests/components/rdw/test_sensor.py index 5eeea579194ecf..32d4c368d733d0 100644 --- a/tests/components/rdw/test_sensor.py +++ b/tests/components/rdw/test_sensor.py @@ -1,12 +1,11 @@ """Tests for the sensors provided by the RDW integration.""" from homeassistant.components.rdw.const import DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_DATE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -29,7 +28,7 @@ async def test_vehicle_sensors( assert entry.unique_id == "11ZKZ3_apk_expiration" assert state.state == "2022-01-04" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "APK Expiration" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_DATE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE assert ATTR_ICON not in state.attributes assert ATTR_STATE_CLASS not in state.attributes assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes @@ -41,7 +40,7 @@ async def test_vehicle_sensors( assert entry.unique_id == "11ZKZ3_ascription_date" assert state.state == "2021-11-04" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Ascription Date" - assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_DATE + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE assert ATTR_ICON not in state.attributes assert ATTR_STATE_CLASS not in state.attributes assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes diff --git a/tests/components/recollect_waste/conftest.py b/tests/components/recollect_waste/conftest.py new file mode 100644 index 00000000000000..a0d002e9d9a435 --- /dev/null +++ b/tests/components/recollect_waste/conftest.py @@ -0,0 +1,59 @@ +"""Define test fixtures for ReCollect Waste.""" +from datetime import date +from unittest.mock import patch + +from aiorecollect.client import PickupEvent, PickupType +import pytest + +from homeassistant.components.recollect_waste.const import ( + CONF_PLACE_ID, + CONF_SERVICE_ID, + DOMAIN, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config, unique_id): + """Define a config entry fixture.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_PLACE_ID: "12345", + CONF_SERVICE_ID: "12345", + } + + +@pytest.fixture(name="setup_recollect_waste") +async def setup_recollect_waste_fixture(hass, config): + """Define a fixture to set up ReCollect Waste.""" + pickup_event = PickupEvent( + date(2022, 1, 23), [PickupType("garbage", "Trash Collection")], "The Sun" + ) + + with patch( + "homeassistant.components.recollect_waste.Client.async_get_pickup_events", + return_value=[pickup_event], + ), patch( + "homeassistant.components.recollect_waste.config_flow.Client.async_get_pickup_events", + return_value=[pickup_event], + ), patch( + "homeassistant.components.recollect_waste.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="unique_id") +def unique_id_fixture(hass): + """Define a config entry unique ID fixture.""" + return "12345, 12345" diff --git a/tests/components/recollect_waste/test_config_flow.py b/tests/components/recollect_waste/test_config_flow.py index b55202d93b3ef8..4295f3777d5566 100644 --- a/tests/components/recollect_waste/test_config_flow.py +++ b/tests/components/recollect_waste/test_config_flow.py @@ -12,61 +12,42 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_FRIENDLY_NAME -from tests.common import MockConfigEntry - -async def test_duplicate_error(hass): +async def test_duplicate_error(hass, config, config_entry): """Test that errors are shown when duplicates are added.""" - conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} - - MockConfigEntry(domain=DOMAIN, unique_id="12345, 12345", data=conf).add_to_hass( - hass - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -async def test_invalid_place_or_service_id(hass): +async def test_invalid_place_or_service_id(hass, config): """Test that an invalid Place or Service ID throws an error.""" - conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} - with patch( - "aiorecollect.client.Client.async_get_pickup_events", + "homeassistant.components.recollect_waste.config_flow.Client.async_get_pickup_events", side_effect=RecollectError, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "invalid_place_or_service_id"} -async def test_options_flow(hass): +async def test_options_flow(hass, config, config_entry): """Test config flow options.""" - conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} - - config_entry = MockConfigEntry(domain=DOMAIN, unique_id="12345, 12345", data=conf) - config_entry.add_to_hass(hass) - with patch( "homeassistant.components.recollect_waste.async_setup_entry", return_value=True ): await hass.config_entries.async_setup(config_entry.entry_id) 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_FRIENDLY_NAME: True} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options == {CONF_FRIENDLY_NAME: True} @@ -76,22 +57,16 @@ async def test_show_form(hass): 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_user(hass): +async def test_step_user(hass, config, setup_recollect_waste): """Test that the user step works.""" - conf = {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} - - with patch( - "homeassistant.components.recollect_waste.async_setup_entry", return_value=True - ), patch("aiorecollect.client.Client.async_get_pickup_events", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf - ) - await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345, 12345" - assert result["data"] == {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=config + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "12345, 12345" + assert result["data"] == {CONF_PLACE_ID: "12345", CONF_SERVICE_ID: "12345"} diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py new file mode 100644 index 00000000000000..c9c9ba5a93f5b7 --- /dev/null +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Test ReCollect Waste diagnostics.""" +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics( + hass, config_entry, hass_client, setup_recollect_waste +): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": config_entry.as_dict(), + "data": [ + { + "date": { + "__type": "", + "isoformat": "2022-01-23", + }, + "pickup_types": [ + {"name": "garbage", "friendly_name": "Trash Collection"} + ], + "area_name": "The Sun", + } + ], + } diff --git a/tests/components/recorder/conftest.py b/tests/components/recorder/conftest.py index e7786307b698d0..b23bfee48d3ad1 100644 --- a/tests/components/recorder/conftest.py +++ b/tests/components/recorder/conftest.py @@ -1,8 +1,8 @@ """Common test tools.""" from __future__ import annotations -from collections.abc import AsyncGenerator -from typing import Awaitable, Callable, cast +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import cast from unittest.mock import patch import pytest diff --git a/tests/components/recorder/models_schema_23.py b/tests/components/recorder/models_schema_23.py new file mode 100644 index 00000000000000..50839f41906057 --- /dev/null +++ b/tests/components/recorder/models_schema_23.py @@ -0,0 +1,582 @@ +"""Models for SQLAlchemy. + +This file contains the model definitions for schema version 23, +used by Home Assistant Core 2021.11.0, which adds the name column +to statistics_meta. +It is used to test the schema migration logic. +""" +from __future__ import annotations + +from datetime import datetime, timedelta +import json +import logging +from typing import TypedDict, overload + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Identity, + Index, + Integer, + String, + Text, + distinct, +) +from sqlalchemy.dialects import mysql, oracle, postgresql +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import declarative_base, relationship +from sqlalchemy.orm.session import Session + +from homeassistant.const import ( + MAX_LENGTH_EVENT_CONTEXT_ID, + MAX_LENGTH_EVENT_EVENT_TYPE, + MAX_LENGTH_EVENT_ORIGIN, + MAX_LENGTH_STATE_DOMAIN, + MAX_LENGTH_STATE_ENTITY_ID, + MAX_LENGTH_STATE_STATE, +) +from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.helpers.json import JSONEncoder +import homeassistant.util.dt as dt_util + +# SQLAlchemy Schema +# pylint: disable=invalid-name +Base = declarative_base() + +SCHEMA_VERSION = 23 + +_LOGGER = logging.getLogger(__name__) + +DB_TIMEZONE = "+00:00" + +TABLE_EVENTS = "events" +TABLE_STATES = "states" +TABLE_RECORDER_RUNS = "recorder_runs" +TABLE_SCHEMA_CHANGES = "schema_changes" +TABLE_STATISTICS = "statistics" +TABLE_STATISTICS_META = "statistics_meta" +TABLE_STATISTICS_RUNS = "statistics_runs" +TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" + +ALL_TABLES = [ + TABLE_STATES, + TABLE_EVENTS, + TABLE_RECORDER_RUNS, + TABLE_SCHEMA_CHANGES, + TABLE_STATISTICS, + TABLE_STATISTICS_META, + TABLE_STATISTICS_RUNS, + TABLE_STATISTICS_SHORT_TERM, +] + +DATETIME_TYPE = DateTime(timezone=True).with_variant( + mysql.DATETIME(timezone=True, fsp=6), "mysql" +) +DOUBLE_TYPE = ( + Float() + .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") + .with_variant(oracle.DOUBLE_PRECISION(), "oracle") + .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") +) + + +class Events(Base): # type: ignore + """Event history data.""" + + __table_args__ = ( + # Used for fetching events at a specific time + # see logbook + Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENTS + event_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + event_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) + time_fired = Column(DATETIME_TYPE, index=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event, event_data=None): + """Create an event database object from a native event.""" + return Events( + event_type=event.event_type, + event_data=event_data + or json.dumps(event.data, cls=JSONEncoder, separators=(",", ":")), + origin=str(event.origin.value), + time_fired=event.time_fired, + context_id=event.context.id, + context_user_id=event.context.user_id, + context_parent_id=event.context.parent_id, + ) + + def to_native(self, validate_entity_id=True): + """Convert to a native HA Event.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id, + parent_id=self.context_parent_id, + ) + try: + return Event( + self.event_type, + json.loads(self.event_data), + EventOrigin(self.origin), + process_timestamp(self.time_fired), + context=context, + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting to event: %s", self) + return None + + +class States(Base): # type: ignore + """State change history.""" + + __table_args__ = ( + # Used for fetching the state of entities at a specific time + # (get_states in history.py) + Index("ix_states_entity_id_last_updated", "entity_id", "last_updated"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES + state_id = Column(Integer, Identity(), primary_key=True) + domain = Column(String(MAX_LENGTH_STATE_DOMAIN)) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + state = Column(String(MAX_LENGTH_STATE_STATE)) + attributes = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + event_id = Column( + Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True + ) + last_changed = Column(DATETIME_TYPE, default=dt_util.utcnow) + last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) + event = relationship("Events", uselist=False) + old_state = relationship("States", remote_side=[state_id]) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event): + """Create object from a state_changed event.""" + entity_id = event.data["entity_id"] + state = event.data.get("new_state") + + dbstate = States(entity_id=entity_id) + + # State got deleted + if state is None: + dbstate.state = "" + dbstate.domain = split_entity_id(entity_id)[0] + dbstate.attributes = "{}" + dbstate.last_changed = event.time_fired + dbstate.last_updated = event.time_fired + else: + dbstate.domain = state.domain + dbstate.state = state.state + dbstate.attributes = json.dumps( + dict(state.attributes), cls=JSONEncoder, separators=(",", ":") + ) + dbstate.last_changed = state.last_changed + dbstate.last_updated = state.last_updated + + return dbstate + + def to_native(self, validate_entity_id=True): + """Convert to an HA state object.""" + try: + return State( + self.entity_id, + self.state, + json.loads(self.attributes), + process_timestamp(self.last_changed), + process_timestamp(self.last_updated), + # Join the events table on event_id to get the context instead + # as it will always be there for state_changed events + context=Context(id=None), + validate_entity_id=validate_entity_id, + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state: %s", self) + return None + + +class StatisticResult(TypedDict): + """Statistic result data class. + + Allows multiple datapoints for the same statistic_id. + """ + + meta: StatisticMetaData + stat: StatisticData + + +class StatisticDataBase(TypedDict): + """Mandatory fields for statistic data class.""" + + start: datetime + + +class StatisticData(StatisticDataBase, total=False): + """Statistic data class.""" + + mean: float + min: float + max: float + last_reset: datetime | None + state: float + sum: float + + +class StatisticsBase: + """Statistics base class.""" + + id = Column(Integer, Identity(), primary_key=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + + @declared_attr + def metadata_id(self): + """Define the metadata_id column for sub classes.""" + return Column( + Integer, + ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), + index=True, + ) + + start = Column(DATETIME_TYPE, index=True) + mean = Column(DOUBLE_TYPE) + min = Column(DOUBLE_TYPE) + max = Column(DOUBLE_TYPE) + last_reset = Column(DATETIME_TYPE) + state = Column(DOUBLE_TYPE) + sum = Column(DOUBLE_TYPE) + + @classmethod + def from_stats(cls, metadata_id: int, stats: StatisticData): + """Create object from a statistics.""" + return cls( # type: ignore + metadata_id=metadata_id, + **stats, + ) + + +class Statistics(Base, StatisticsBase): # type: ignore + """Long term statistics.""" + + duration = timedelta(hours=1) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_statistic_id_start", "metadata_id", "start"), + ) + __tablename__ = TABLE_STATISTICS + + +class StatisticsShortTerm(Base, StatisticsBase): # type: ignore + """Short term statistics.""" + + duration = timedelta(minutes=5) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_short_term_statistic_id_start", "metadata_id", "start"), + ) + __tablename__ = TABLE_STATISTICS_SHORT_TERM + + +class StatisticMetaData(TypedDict): + """Statistic meta data class.""" + + has_mean: bool + has_sum: bool + name: str | None + source: str + statistic_id: str + unit_of_measurement: str | None + + +class StatisticsMeta(Base): # type: ignore + """Statistics meta data.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATISTICS_META + id = Column(Integer, Identity(), primary_key=True) + statistic_id = Column(String(255), index=True) + source = Column(String(32)) + unit_of_measurement = Column(String(255)) + has_mean = Column(Boolean) + has_sum = Column(Boolean) + name = Column(String(255)) + + @staticmethod + def from_meta(meta: StatisticMetaData) -> StatisticsMeta: + """Create object from meta data.""" + return StatisticsMeta(**meta) + + +class RecorderRuns(Base): # type: ignore + """Representation of recorder run.""" + + __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) + __tablename__ = TABLE_RECORDER_RUNS + run_id = Column(Integer, Identity(), primary_key=True) + start = Column(DateTime(timezone=True), default=dt_util.utcnow) + end = Column(DateTime(timezone=True)) + closed_incorrect = Column(Boolean, default=False) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + end = ( + f"'{self.end.isoformat(sep=' ', timespec='seconds')}'" if self.end else None + ) + return ( + f"" + ) + + def entity_ids(self, point_in_time=None): + """Return the entity ids that existed in this run. + + Specify point_in_time if you want to know which existed at that point + in time inside the run. + """ + session = Session.object_session(self) + + assert session is not None, "RecorderRuns need to be persisted" + + query = session.query(distinct(States.entity_id)).filter( + States.last_updated >= self.start + ) + + if point_in_time is not None: + query = query.filter(States.last_updated < point_in_time) + elif self.end is not None: + query = query.filter(States.last_updated < self.end) + + return [row[0] for row in query] + + def to_native(self, validate_entity_id=True): + """Return self, native format is this model.""" + return self + + +class SchemaChanges(Base): # type: ignore + """Representation of schema version changes.""" + + __tablename__ = TABLE_SCHEMA_CHANGES + change_id = Column(Integer, Identity(), primary_key=True) + schema_version = Column(Integer) + changed = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + +class StatisticsRuns(Base): # type: ignore + """Representation of statistics run.""" + + __tablename__ = TABLE_STATISTICS_RUNS + run_id = Column(Integer, Identity(), primary_key=True) + start = Column(DateTime(timezone=True)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + +@overload +def process_timestamp(ts: None) -> None: + ... + + +@overload +def process_timestamp(ts: datetime) -> datetime: + ... + + +def process_timestamp(ts: datetime | None) -> datetime | None: + """Process a timestamp into datetime object.""" + if ts is None: + return None + if ts.tzinfo is None: + return ts.replace(tzinfo=dt_util.UTC) + + return dt_util.as_utc(ts) + + +@overload +def process_timestamp_to_utc_isoformat(ts: None) -> None: + ... + + +@overload +def process_timestamp_to_utc_isoformat(ts: datetime) -> str: + ... + + +def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: + """Process a timestamp into UTC isotime.""" + if ts is None: + return None + if ts.tzinfo == dt_util.UTC: + return ts.isoformat() + if ts.tzinfo is None: + return f"{ts.isoformat()}{DB_TIMEZONE}" + return ts.astimezone(dt_util.UTC).isoformat() + + +class LazyState(State): + """A lazy version of core State.""" + + __slots__ = [ + "_row", + "entity_id", + "state", + "_attributes", + "_last_changed", + "_last_updated", + "_context", + ] + + def __init__(self, row): # pylint: disable=super-init-not-called + """Init the lazy state.""" + self._row = row + self.entity_id = self._row.entity_id + self.state = self._row.state or "" + self._attributes = None + self._last_changed = None + self._last_updated = None + self._context = None + + @property # type: ignore + def attributes(self): + """State attributes.""" + if not self._attributes: + try: + self._attributes = json.loads(self._row.attributes) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state: %s", self._row) + self._attributes = {} + return self._attributes + + @attributes.setter + def attributes(self, value): + """Set attributes.""" + self._attributes = value + + @property # type: ignore + def context(self): + """State context.""" + if not self._context: + self._context = Context(id=None) + return self._context + + @context.setter + def context(self, value): + """Set context.""" + self._context = value + + @property # type: ignore + def last_changed(self): + """Last changed datetime.""" + if not self._last_changed: + self._last_changed = process_timestamp(self._row.last_changed) + return self._last_changed + + @last_changed.setter + def last_changed(self, value): + """Set last changed datetime.""" + self._last_changed = value + + @property # type: ignore + def last_updated(self): + """Last updated datetime.""" + if not self._last_updated: + self._last_updated = process_timestamp(self._row.last_updated) + return self._last_updated + + @last_updated.setter + def last_updated(self, value): + """Set last updated datetime.""" + self._last_updated = value + + def as_dict(self): + """Return a dict representation of the LazyState. + + Async friendly. + + To be used for JSON serialization. + """ + if self._last_changed: + last_changed_isoformat = self._last_changed.isoformat() + else: + last_changed_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_changed + ) + if self._last_updated: + last_updated_isoformat = self._last_updated.isoformat() + else: + last_updated_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_updated + ) + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + def __eq__(self, other): + """Return the comparison.""" + return ( + other.__class__ in [self.__class__, State] + and self.entity_id == other.entity_id + and self.state == other.state + and self.attributes == other.attributes + ) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 7d7c3f27fb6b27..2bc0109e1b5adc 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -3,6 +3,7 @@ import asyncio from datetime import datetime, timedelta import sqlite3 +import threading from unittest.mock import patch import pytest @@ -261,7 +262,7 @@ def _throw_if_state_in_session(*args, **kwargs): hass.states.set(entity_id, "fail", attributes) wait_recording_done(hass) - assert "SQLAlchemyError error processing event" in caplog.text + assert "SQLAlchemyError error processing task" in caplog.text caplog.clear() hass.states.set(entity_id, state, attributes) @@ -273,7 +274,7 @@ def _throw_if_state_in_session(*args, **kwargs): assert "Error executing query" not in caplog.text assert "Error saving events" not in caplog.text - assert "SQLAlchemyError error processing event" not in caplog.text + assert "SQLAlchemyError error processing task" not in caplog.text async def test_force_shutdown_with_queue_of_writes_that_generate_exceptions( @@ -531,7 +532,7 @@ def test_saving_state_and_removing_entity(hass, hass_recorder): entity_id = "lock.mine" hass.states.set(entity_id, STATE_LOCKED) hass.states.set(entity_id, STATE_UNLOCKED) - hass.states.async_remove(entity_id) + hass.states.remove(entity_id) wait_recording_done(hass) @@ -1204,12 +1205,34 @@ async def test_database_lock_timeout(hass): """Test locking database timeout when recorder stopped.""" await async_init_recorder_component(hass) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() instance: Recorder = hass.data[DATA_INSTANCE] + + class BlockQueue(recorder.RecorderTask): + event: threading.Event = threading.Event() + + def run(self, instance: Recorder) -> None: + self.event.wait() + + block_task = BlockQueue() + instance.queue.put(block_task) with patch.object(recorder, "DB_LOCK_TIMEOUT", 0.1): try: with pytest.raises(TimeoutError): await instance.lock_database() finally: instance.unlock_database() + block_task.event.set() + + +async def test_database_lock_without_instance(hass): + """Test database lock doesn't fail if instance is not initialized.""" + await async_init_recorder_component(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + + instance: Recorder = hass.data[DATA_INSTANCE] + with patch.object(instance, "engine", None): + try: + assert await instance.lock_database() + finally: + assert instance.unlock_database() diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index c4dd33ce840158..296409d984fbf1 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1,12 +1,18 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name from datetime import timedelta +import importlib +import json +import sys from unittest.mock import patch, sentinel import pytest from pytest import approx +from sqlalchemy import create_engine +from sqlalchemy.orm import Session -from homeassistant.components.recorder import history +from homeassistant.components import recorder +from homeassistant.components.recorder import SQLITE_URL_PREFIX, history, statistics from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import ( StatisticsShortTerm, @@ -14,18 +20,21 @@ ) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, + delete_duplicates, get_last_short_term_statistics, get_last_statistics, get_metadata, list_statistic_ids, statistics_during_period, ) +from homeassistant.components.recorder.util import session_scope from homeassistant.const import TEMP_CELSIUS +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util -from tests.common import mock_registry +from tests.common import get_test_home_assistant, mock_registry from tests.components.recorder.common import wait_recording_done @@ -222,13 +231,19 @@ def test_rename_entity(hass_recorder): setup_component(hass, "sensor", {}) entity_reg = mock_registry(hass) - reg_entry = entity_reg.async_get_or_create( - "sensor", - "test", - "unique_0000", - suggested_object_id="test1", - ) - assert reg_entry.entity_id == "sensor.test1" + + @callback + def add_entry(): + reg_entry = entity_reg.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + + hass.add_job(add_entry) + hass.block_till_done() zero, four, states = record_states(hass) hist = history.get_significant_states(hass, zero, four) @@ -266,7 +281,11 @@ def test_rename_entity(hass_recorder): stats = statistics_during_period(hass, zero, period="5minute") assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} - entity_reg.async_update_entity(reg_entry.entity_id, new_entity_id="sensor.test99") + @callback + def rename_entry(): + entity_reg.async_update_entity("sensor.test1", new_entity_id="sensor.test99") + + hass.add_job(rename_entry) hass.block_till_done() stats = statistics_during_period(hass, zero, period="5minute") @@ -650,6 +669,638 @@ def test_monthly_statistics(hass_recorder, caplog, timezone): dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) +def _create_engine_test(*args, **kwargs): + """Test version of create_engine that initializes with old schema. + + This simulates an existing db with the old schema. + """ + module = "tests.components.recorder.models_schema_23" + importlib.import_module(module) + old_models = sys.modules[module] + engine = create_engine(*args, **kwargs) + old_models.Base.metadata.create_all(engine) + with Session(engine) as session: + session.add(recorder.models.StatisticsRuns(start=statistics.get_start_time())) + session.add( + recorder.models.SchemaChanges(schema_version=old_models.SCHEMA_VERSION) + ) + session.commit() + return engine + + +def test_delete_duplicates(caplog, tmpdir): + """Test removal of duplicated statistics.""" + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + module = "tests.components.recorder.models_schema_23" + importlib.import_module(module) + old_models = sys.modules[module] + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + + external_energy_statistics_1 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + ) + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_2 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 20, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 30, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 40, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 50, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 50, + }, + ) + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_2", + "unit_of_measurement": "kWh", + } + external_co2_statistics = ( + { + "start": period1, + "last_reset": None, + "mean": 10, + }, + { + "start": period2, + "last_reset": None, + "mean": 30, + }, + { + "start": period3, + "last_reset": None, + "mean": 60, + }, + { + "start": period4, + "last_reset": None, + "mean": 90, + }, + ) + external_co2_metadata = { + "has_mean": True, + "has_sum": False, + "name": "Fossil percentage", + "source": "test", + "statistic_id": "test:fossil_percentage", + "unit_of_measurement": "%", + } + + # Create some duplicated statistics with schema version 23 + with patch.object(recorder, "models", old_models), patch.object( + recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + ), patch( + "homeassistant.components.recorder.create_engine", new=_create_engine_test + ): + hass = get_test_home_assistant() + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + session.add( + recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + ) + session.add( + recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) + with session_scope(hass=hass) as session: + for stat in external_energy_statistics_1: + session.add(recorder.models.Statistics.from_stats(1, stat)) + for stat in external_energy_statistics_2: + session.add(recorder.models.Statistics.from_stats(2, stat)) + for stat in external_co2_statistics: + session.add(recorder.models.Statistics.from_stats(3, stat)) + + hass.stop() + + # Test that the duplicates are removed during migration from schema 23 + hass = get_test_home_assistant() + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() + + assert "Deleted 2 duplicated statistics rows" in caplog.text + assert "Found non identical" not in caplog.text + assert "Found more than" not in caplog.text + assert "Found duplicated" not in caplog.text + + +@pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") +def test_delete_duplicates_non_identical(caplog, tmpdir): + """Test removal of duplicated statistics.""" + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + module = "tests.components.recorder.models_schema_23" + importlib.import_module(module) + old_models = sys.modules[module] + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + + external_energy_statistics_1 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 6, + }, + ) + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_2 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 20, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 30, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 40, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 50, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 50, + }, + ) + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_2", + "unit_of_measurement": "kWh", + } + + # Create some duplicated statistics with schema version 23 + with patch.object(recorder, "models", old_models), patch.object( + recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + ), patch( + "homeassistant.components.recorder.create_engine", new=_create_engine_test + ): + hass = get_test_home_assistant() + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + session.add( + recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + ) + session.add( + recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + with session_scope(hass=hass) as session: + for stat in external_energy_statistics_1: + session.add(recorder.models.Statistics.from_stats(1, stat)) + for stat in external_energy_statistics_2: + session.add(recorder.models.Statistics.from_stats(2, stat)) + + hass.stop() + + # Test that the duplicates are removed during migration from schema 23 + hass = get_test_home_assistant() + hass.config.config_dir = tmpdir + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() + + assert "Deleted 2 duplicated statistics rows" in caplog.text + assert "Deleted 1 non identical" in caplog.text + assert "Found more than" not in caplog.text + assert "Found duplicated" not in caplog.text + + isotime = dt_util.utcnow().isoformat() + backup_file_name = f".storage/deleted_statistics.{isotime}.json" + + with open(hass.config.path(backup_file_name)) as backup_file: + backup = json.load(backup_file) + + assert backup == [ + { + "duplicate": { + "created": "2021-08-01T00:00:00", + "id": 4, + "last_reset": None, + "max": None, + "mean": None, + "metadata_id": 1, + "min": None, + "start": "2021-10-31T23:00:00", + "state": 3.0, + "sum": 5.0, + }, + "original": { + "created": "2021-08-01T00:00:00", + "id": 5, + "last_reset": None, + "max": None, + "mean": None, + "metadata_id": 1, + "min": None, + "start": "2021-10-31T23:00:00", + "state": 3.0, + "sum": 6.0, + }, + } + ] + + +@patch.object(statistics, "MAX_DUPLICATES", 2) +def test_delete_duplicates_too_many(caplog, tmpdir): + """Test removal of duplicated statistics.""" + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + module = "tests.components.recorder.models_schema_23" + importlib.import_module(module) + old_models = sys.modules[module] + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + + external_energy_statistics_1 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + ) + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_2 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 20, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 30, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 40, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 50, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 50, + }, + ) + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_2", + "unit_of_measurement": "kWh", + } + + # Create some duplicated statistics with schema version 23 + with patch.object(recorder, "models", old_models), patch.object( + recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + ), patch( + "homeassistant.components.recorder.create_engine", new=_create_engine_test + ): + hass = get_test_home_assistant() + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + session.add( + recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + ) + session.add( + recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + with session_scope(hass=hass) as session: + for stat in external_energy_statistics_1: + session.add(recorder.models.Statistics.from_stats(1, stat)) + for stat in external_energy_statistics_2: + session.add(recorder.models.Statistics.from_stats(2, stat)) + + hass.stop() + + # Test that the duplicates are removed during migration from schema 23 + hass = get_test_home_assistant() + hass.config.config_dir = tmpdir + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() + + assert "Deleted 2 duplicated statistics rows" in caplog.text + assert "Found non identical" not in caplog.text + assert "Found more than 1 duplicated statistic rows" in caplog.text + assert "Found duplicated" not in caplog.text + + +@patch.object(statistics, "MAX_DUPLICATES", 2) +def test_delete_duplicates_short_term(caplog, tmpdir): + """Test removal of duplicated statistics.""" + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + module = "tests.components.recorder.models_schema_23" + importlib.import_module(module) + old_models = sys.modules[module] + + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + statistic_row = { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + } + + # Create some duplicated statistics with schema version 23 + with patch.object(recorder, "models", old_models), patch.object( + recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + ), patch( + "homeassistant.components.recorder.create_engine", new=_create_engine_test + ): + hass = get_test_home_assistant() + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + session.add( + recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + ) + with session_scope(hass=hass) as session: + session.add( + recorder.models.StatisticsShortTerm.from_stats(1, statistic_row) + ) + session.add( + recorder.models.StatisticsShortTerm.from_stats(1, statistic_row) + ) + + hass.stop() + + # Test that the duplicates are removed during migration from schema 23 + hass = get_test_home_assistant() + hass.config.config_dir = tmpdir + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + hass.stop() + + assert "duplicated statistics rows" not in caplog.text + assert "Found non identical" not in caplog.text + assert "Found more than" not in caplog.text + assert "Deleted duplicated short term statistic" in caplog.text + + +def test_delete_duplicates_no_duplicates(hass_recorder, caplog): + """Test removal of duplicated statistics.""" + hass = hass_recorder() + wait_recording_done(hass) + with session_scope(hass=hass) as session: + delete_duplicates(hass.data[DATA_INSTANCE], session) + assert "duplicated statistics rows" not in caplog.text + assert "Found non identical" not in caplog.text + assert "Found more than" not in caplog.text + assert "Found duplicated" not in caplog.text + + +def test_duplicate_statistics_handle_integrity_error(hass_recorder, caplog): + """Test the recorder does not blow up if statistics is duplicated.""" + hass = hass_recorder() + wait_recording_done(hass) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_1 = [ + { + "start": period1, + "last_reset": None, + "state": 3, + "sum": 5, + }, + ] + external_energy_statistics_2 = [ + { + "start": period2, + "last_reset": None, + "state": 3, + "sum": 6, + } + ] + + with patch.object( + statistics, "_statistics_exists", return_value=False + ), patch.object( + statistics, "_insert_statistics", wraps=statistics._insert_statistics + ) as insert_statistics_mock: + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_2 + ) + wait_recording_done(hass) + assert insert_statistics_mock.call_count == 3 + + with session_scope(hass=hass) as session: + tmp = session.query(recorder.models.Statistics).all() + assert len(tmp) == 2 + + assert "Blocked attempt to insert duplicated statistic rows" in caplog.text + + def record_states(hass): """Record some test states. diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index fa449aefefc24d..ec74ea73975a26 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -570,7 +570,7 @@ async def test_write_lock_db(hass, tmp_path): instance = hass.data[DATA_INSTANCE] - with util.write_lock_db(instance): + with util.write_lock_db_sqlite(instance): # Database should be locked now, try writing SQL command with instance.engine.connect() as connection: with pytest.raises(OperationalError): diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 48e741a12a48e8..9c113c26578db8 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.remote import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -64,7 +65,9 @@ async def test_get_actions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert actions == expected_actions diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index 6f3c0e1c0a245d..e3ed3059d8b1e6 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -5,6 +5,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.remote import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -65,7 +66,9 @@ async def test_get_conditions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert conditions == expected_conditions @@ -83,10 +86,12 @@ async def test_get_condition_capabilities(hass, device_reg, entity_reg): {"name": "for", "optional": True, "type": "positive_time_period_dict"} ] } - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) for condition in conditions: capabilities = await async_get_device_automation_capabilities( - hass, "condition", condition + hass, DeviceAutomationType.CONDITION, condition ) assert capabilities == expected_capabilities diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index 3afa731cf596a8..0db45318bfaa1e 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.remote import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -50,6 +51,13 @@ async def test_get_triggers(hass, device_reg, entity_reg): ) entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "changed_states", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, { "platform": "device", "domain": DOMAIN, @@ -65,7 +73,9 @@ async def test_get_triggers(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert triggers == expected_triggers @@ -83,10 +93,12 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): {"name": "for", "optional": True, "type": "positive_time_period_dict"} ] } - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == expected_capabilities @@ -154,6 +166,30 @@ async def test_if_fires_on_state_change(hass, calls, enable_custom_integrations) }, }, }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "changed_states", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on_or_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, ] }, ) @@ -163,17 +199,19 @@ async def test_if_fires_on_state_change(hass, calls, enable_custom_integrations) hass.states.async_set(ent1.entity_id, STATE_OFF) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "turn_off device - {} - on - off - None".format( - ent1.entity_id - ) + assert len(calls) == 2 + assert {calls[0].data["some"], calls[1].data["some"]} == { + f"turn_off device - {ent1.entity_id} - on - off - None", + f"turn_on_or_off device - {ent1.entity_id} - on - off - None", + } hass.states.async_set(ent1.entity_id, STATE_ON) await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "turn_on device - {} - off - on - None".format( - ent1.entity_id - ) + assert len(calls) == 4 + assert {calls[2].data["some"], calls[3].data["some"]} == { + f"turn_on device - {ent1.entity_id} - off - on - None", + f"turn_on_or_off device - {ent1.entity_id} - off - on - None", + } async def test_if_fires_on_state_change_with_for( diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 2c2005a7fee4c8..024990ee000156 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -139,13 +139,3 @@ async def test_delete_command(hass): assert call.domain == remote.DOMAIN assert call.service == SERVICE_DELETE_COMMAND assert call.data[ATTR_ENTITY_ID] == ENTITY_ID - - -async def test_deprecated_base_class(caplog): - """Test deprecated base class.""" - - class CustomRemote(remote.RemoteDevice): - pass - - CustomRemote() - assert "RemoteDevice is deprecated, modify CustomRemote" in caplog.text diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index e1d7a3fc28c474..91704a59b5178f 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -228,7 +228,7 @@ }, "endpoints_available": [ True, # cockpit - False, # hvac-status + True, # hvac-status True, # location True, # battery-status True, # charge-mode @@ -237,6 +237,7 @@ "battery_status": "battery_status_not_charging.json", "charge_mode": "charge_mode_schedule.json", "cockpit": "cockpit_ev.json", + "hvac_status": "hvac_status.json", "location": "location.json", }, Platform.BINARY_SENSOR: [ @@ -356,6 +357,14 @@ ATTR_UNIQUE_ID: "vf1aaaaa555777999_mileage", ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, }, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_ENTITY_ID: "sensor.reg_number_outside_temperature", + ATTR_STATE: "8.0", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_outside_temperature", + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + }, { ATTR_DEVICE_CLASS: DEVICE_CLASS_PLUG_STATE, ATTR_ENTITY_ID: "sensor.reg_number_plug_state", diff --git a/tests/components/renault/test_diagnostics.py b/tests/components/renault/test_diagnostics.py new file mode 100644 index 00000000000000..1a61859ac93a53 --- /dev/null +++ b/tests/components/renault/test_diagnostics.py @@ -0,0 +1,202 @@ +"""Test Renault diagnostics.""" +import pytest + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.renault import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from tests.common import mock_device_registry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) + +pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") + +VEHICLE_DETAILS = { + "vin": REDACTED, + "registrationDate": "2017-08-01", + "firstRegistrationDate": "2017-08-01", + "engineType": "5AQ", + "engineRatio": "601", + "modelSCR": "ZOE", + "deliveryCountry": {"code": "FR", "label": "FRANCE"}, + "family": {"code": "X10", "label": "FAMILLE X10", "group": "007"}, + "tcu": { + "code": "TCU0G2", + "label": "TCU VER 0 GEN 2", + "group": "E70", + }, + "navigationAssistanceLevel": { + "code": "NAV3G5", + "label": "LEVEL 3 TYPE 5 NAVIGATION", + "group": "408", + }, + "battery": { + "code": "BT4AR1", + "label": "BATTERIE BT4AR1", + "group": "968", + }, + "radioType": { + "code": "RAD37A", + "label": "RADIO 37A", + "group": "425", + }, + "registrationCountry": {"code": "FR"}, + "brand": {"label": "RENAULT"}, + "model": {"code": "X101VE", "label": "ZOE", "group": "971"}, + "gearbox": { + "code": "BVEL", + "label": "BOITE A VARIATEUR ELECTRIQUE", + "group": "427", + }, + "version": {"code": "INT MB 10R"}, + "energy": {"code": "ELEC", "label": "ELECTRIQUE", "group": "019"}, + "registrationNumber": REDACTED, + "vcd": "SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE", + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2", + }, + ], + }, + { + "assetType": "PDF", + "assetRole": "GUIDE", + "title": "PDF Guide", + "description": "", + "renditions": [ + { + "url": "https://cdn.group.renault.com/ren/gb/myr/assets/x101ve/manual.pdf.asset.pdf/1558704861676.pdf" + } + ], + }, + { + "assetType": "URL", + "assetRole": "GUIDE", + "title": "e-guide", + "description": "", + "renditions": [{"url": "http://gb.e-guide.renault.com/eng/Zoe"}], + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "10 Fundamentals about getting the best out of your electric vehicle", + "description": "", + "renditions": [{"url": "39r6QEKcOM4"}], + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Automatic Climate Control", + "description": "", + "renditions": [{"url": "Va2FnZFo_GE"}], + }, + { + "assetType": "URL", + "assetRole": "CAR", + "title": "More videos", + "description": "", + "renditions": [{"url": "https://www.youtube.com/watch?v=wfpCMkK1rKI"}], + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Charging the battery", + "description": "", + "renditions": [{"url": "RaEad8DjUJs"}], + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Charging the battery at a station with a flap", + "description": "", + "renditions": [{"url": "zJfd7fJWtr0"}], + }, + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "RLINK1", + "easyConnectStore": False, + "electrical": True, + "rlinkStore": False, + "deliveryDate": "2017-08-11", + "retrievedFromDhs": False, + "engineEnergyType": "ELEC", + "radioCode": REDACTED, +} + +VEHICLE_DATA = { + "battery": { + "batteryAutonomy": 141, + "batteryAvailableEnergy": 31, + "batteryCapacity": 0, + "batteryLevel": 60, + "batteryTemperature": 20, + "chargingInstantaneousPower": 27, + "chargingRemainingTime": 145, + "chargingStatus": 1.0, + "plugStatus": 1, + "timestamp": "2020-01-12T21:40:16Z", + }, + "charge_mode": { + "chargeMode": "always", + }, + "cockpit": { + "totalMileage": 49114.27, + }, + "hvac_status": { + "externalTemperature": 8.0, + "hvacStatus": "off", + }, +} + + +@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry, hass_client +): + """Test config entry diagnostics.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "data": { + "kamereon_account_id": REDACTED, + "locale": "fr_FR", + "password": REDACTED, + "username": REDACTED, + }, + "title": "Mock Title", + }, + "vehicles": [{"details": VEHICLE_DETAILS, "data": VEHICLE_DATA}], + } + + +@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_device_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry, hass_client +): + """Test config entry diagnostics.""" + device_registry = mock_device_registry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device({(DOMAIN, "VF1AAAAA555777999")}) + assert device is not None + + assert await get_diagnostics_for_device( + hass, hass_client, config_entry, device + ) == {"details": VEHICLE_DETAILS, "data": VEHICLE_DATA} diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 2e584326e89675..c04bf8c0280514 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.helpers.entity_registry import EntityRegistry, RegistryEntryDisabler from . import ( check_device_registry, @@ -38,7 +38,7 @@ def _check_and_enable_disabled_entities( entity_id = expected_entity[ATTR_ENTITY_ID] registry_entry = entity_registry.entities.get(entity_id) assert registry_entry.disabled - assert registry_entry.disabled_by == "integration" + assert registry_entry.disabled_by is RegistryEntryDisabler.INTEGRATION entity_registry.async_update_entity(entity_id, **{"disabled_by": None}) diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 6daffcb2a5e759..8383d53b51f404 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -8,7 +8,7 @@ import respx from homeassistant import config as hass_config -import homeassistant.components.binary_sensor as binary_sensor +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -17,6 +17,7 @@ STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.setup import async_setup_component @@ -26,7 +27,7 @@ async def test_setup_missing_basic_config(hass): """Test setup with configuration missing required entries.""" assert await async_setup_component( - hass, binary_sensor.DOMAIN, {"binary_sensor": {"platform": "rest"}} + hass, Platform.BINARY_SENSOR, {"binary_sensor": {"platform": "rest"}} ) await hass.async_block_till_done() assert len(hass.states.async_all("binary_sensor")) == 0 @@ -36,7 +37,7 @@ async def test_setup_missing_config(hass): """Test setup with configuration missing required entries.""" assert await async_setup_component( hass, - binary_sensor.DOMAIN, + Platform.BINARY_SENSOR, { "binary_sensor": { "platform": "rest", @@ -58,7 +59,7 @@ async def test_setup_failed_connect(hass, caplog): ) assert await async_setup_component( hass, - binary_sensor.DOMAIN, + Platform.BINARY_SENSOR, { "binary_sensor": { "platform": "rest", @@ -78,7 +79,7 @@ async def test_setup_timeout(hass): respx.get("http://localhost").mock(side_effect=asyncio.TimeoutError()) assert await async_setup_component( hass, - binary_sensor.DOMAIN, + Platform.BINARY_SENSOR, { "binary_sensor": { "platform": "rest", @@ -97,7 +98,7 @@ async def test_setup_minimum(hass): respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - binary_sensor.DOMAIN, + Platform.BINARY_SENSOR, { "binary_sensor": { "platform": "rest", @@ -116,7 +117,7 @@ async def test_setup_minimum_resource_template(hass): respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - binary_sensor.DOMAIN, + Platform.BINARY_SENSOR, { "binary_sensor": { "platform": "rest", @@ -134,7 +135,7 @@ async def test_setup_duplicate_resource_template(hass): respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - binary_sensor.DOMAIN, + Platform.BINARY_SENSOR, { "binary_sensor": { "platform": "rest", @@ -167,7 +168,7 @@ async def test_setup_get(hass): "username": "my username", "password": "my password", "headers": {"Accept": CONTENT_TYPE_JSON}, - "device_class": binary_sensor.DEVICE_CLASS_PLUG, + "device_class": BinarySensorDeviceClass.PLUG, } }, ) @@ -177,7 +178,7 @@ async def test_setup_get(hass): state = hass.states.get("binary_sensor.foo") assert state.state == STATE_OFF - assert state.attributes[ATTR_DEVICE_CLASS] == binary_sensor.DEVICE_CLASS_PLUG + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.PLUG @respx.mock @@ -418,7 +419,7 @@ async def test_setup_query_params(hass): respx.get("http://localhost", params={"search": "something"}) % HTTPStatus.OK assert await async_setup_component( hass, - binary_sensor.DOMAIN, + Platform.BINARY_SENSOR, { "binary_sensor": { "platform": "rest", diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index fb826eefd78588..86ce816f93246a 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -8,15 +8,18 @@ from homeassistant import config as hass_config from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -import homeassistant.components.sensor as sensor +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONTENT_TYPE_JSON, DATA_MEGABYTES, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, SERVICE_RELOAD, STATE_UNKNOWN, TEMP_CELSIUS, @@ -28,9 +31,7 @@ async def test_setup_missing_config(hass): """Test setup with configuration missing required entries.""" - assert await async_setup_component( - hass, sensor.DOMAIN, {"sensor": {"platform": "rest"}} - ) + assert await async_setup_component(hass, DOMAIN, {"sensor": {"platform": "rest"}}) await hass.async_block_till_done() assert len(hass.states.async_all("sensor")) == 0 @@ -39,7 +40,7 @@ async def test_setup_missing_schema(hass): """Test setup with resource missing schema.""" assert await async_setup_component( hass, - sensor.DOMAIN, + DOMAIN, {"sensor": {"platform": "rest", "resource": "localhost", "method": "GET"}}, ) await hass.async_block_till_done() @@ -54,7 +55,7 @@ async def test_setup_failed_connect(hass, caplog): ) assert await async_setup_component( hass, - sensor.DOMAIN, + DOMAIN, { "sensor": { "platform": "rest", @@ -74,7 +75,7 @@ async def test_setup_timeout(hass): respx.get("http://localhost").mock(side_effect=asyncio.TimeoutError()) assert await async_setup_component( hass, - sensor.DOMAIN, + DOMAIN, {"sensor": {"platform": "rest", "resource": "localhost", "method": "GET"}}, ) await hass.async_block_till_done() @@ -87,7 +88,7 @@ async def test_setup_minimum(hass): respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - sensor.DOMAIN, + DOMAIN, { "sensor": { "platform": "rest", @@ -109,7 +110,7 @@ async def test_manual_update(hass): ) assert await async_setup_component( hass, - sensor.DOMAIN, + DOMAIN, { "sensor": { "name": "mysensor", @@ -142,7 +143,7 @@ async def test_setup_minimum_resource_template(hass): respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - sensor.DOMAIN, + DOMAIN, { "sensor": { "platform": "rest", @@ -160,7 +161,7 @@ async def test_setup_duplicate_resource_template(hass): respx.get("http://localhost") % HTTPStatus.OK assert await async_setup_component( hass, - sensor.DOMAIN, + DOMAIN, { "sensor": { "platform": "rest", @@ -194,8 +195,8 @@ async def test_setup_get(hass): "username": "my username", "password": "my password", "headers": {"Accept": CONTENT_TYPE_JSON}, - "device_class": DEVICE_CLASS_TEMPERATURE, - "state_class": sensor.STATE_CLASS_MEASUREMENT, + "device_class": SensorDeviceClass.TEMPERATURE, + "state_class": SensorStateClass.MEASUREMENT, } }, ) @@ -215,8 +216,8 @@ async def test_setup_get(hass): state = hass.states.get("sensor.foo") assert state.state == "" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE - assert state.attributes[sensor.ATTR_STATE_CLASS] == sensor.STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT @respx.mock @@ -234,8 +235,8 @@ async def test_setup_timestamp(hass, caplog): "resource": "http://localhost", "method": "GET", "value_template": "{{ value_json.key }}", - "device_class": DEVICE_CLASS_TIMESTAMP, - "state_class": sensor.STATE_CLASS_MEASUREMENT, + "device_class": SensorDeviceClass.TIMESTAMP, + "state_class": SensorStateClass.MEASUREMENT, } }, ) @@ -246,7 +247,8 @@ async def test_setup_timestamp(hass, caplog): state = hass.states.get("sensor.rest_sensor") assert state.state == "2021-11-11T11:39:00+00:00" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TIMESTAMP + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT assert "sensor.rest_sensor rendered invalid timestamp" not in caplog.text assert "sensor.rest_sensor rendered timestamp without timezone" not in caplog.text @@ -262,7 +264,7 @@ async def test_setup_timestamp(hass, caplog): ) state = hass.states.get("sensor.rest_sensor") assert state.state == "unknown" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TIMESTAMP + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP assert "sensor.rest_sensor rendered invalid timestamp" in caplog.text # Bad response: No timezone @@ -277,7 +279,7 @@ async def test_setup_timestamp(hass, caplog): ) state = hass.states.get("sensor.rest_sensor") assert state.state == "unknown" - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TIMESTAMP + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP assert "sensor.rest_sensor rendered timestamp without timezone" in caplog.text @@ -411,7 +413,7 @@ async def test_setup_query_params(hass): respx.get("http://localhost", params={"search": "something"}) % HTTPStatus.OK assert await async_setup_component( hass, - sensor.DOMAIN, + DOMAIN, { "sensor": { "platform": "rest", diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 1b724052b1ea9d..3dbef91ffb5d64 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -6,7 +6,7 @@ from homeassistant.components.rest import DOMAIN import homeassistant.components.rest.switch as rest -from homeassistant.components.switch import DEVICE_CLASS_SWITCH, DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.const import ( CONF_HEADERS, CONF_NAME, @@ -14,16 +14,15 @@ CONF_PLATFORM, CONF_RESOURCE, CONTENT_TYPE_JSON, + Platform, ) from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from tests.common import assert_setup_component -"""Tests for setting up the REST switch platform.""" - NAME = "foo" -DEVICE_CLASS = DEVICE_CLASS_SWITCH +DEVICE_CLASS = SwitchDeviceClass.SWITCH METHOD = "post" RESOURCE = "http://localhost/" STATE_RESOURCE = RESOURCE @@ -68,12 +67,12 @@ async def test_setup_timeout(hass, aioclient_mock): async def test_setup_minimum(hass, aioclient_mock): """Test setup with minimum configuration.""" aioclient_mock.get("http://localhost", status=HTTPStatus.OK) - with assert_setup_component(1, SWITCH_DOMAIN): + with assert_setup_component(1, Platform.SWITCH): assert await async_setup_component( hass, - SWITCH_DOMAIN, + Platform.SWITCH, { - SWITCH_DOMAIN: { + Platform.SWITCH: { CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "http://localhost", } @@ -86,12 +85,12 @@ async def test_setup_minimum(hass, aioclient_mock): async def test_setup_query_params(hass, aioclient_mock): """Test setup with query params.""" aioclient_mock.get("http://localhost/?search=something", status=HTTPStatus.OK) - with assert_setup_component(1, SWITCH_DOMAIN): + with assert_setup_component(1, Platform.SWITCH): assert await async_setup_component( hass, - SWITCH_DOMAIN, + Platform.SWITCH, { - SWITCH_DOMAIN: { + Platform.SWITCH: { CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "http://localhost", CONF_PARAMS: {"search": "something"}, @@ -100,7 +99,6 @@ async def test_setup_query_params(hass, aioclient_mock): ) await hass.async_block_till_done() - print(aioclient_mock) assert aioclient_mock.call_count == 1 @@ -109,9 +107,9 @@ async def test_setup(hass, aioclient_mock): aioclient_mock.get("http://localhost", status=HTTPStatus.OK) assert await async_setup_component( hass, - SWITCH_DOMAIN, + Platform.SWITCH, { - SWITCH_DOMAIN: { + Platform.SWITCH: { CONF_PLATFORM: DOMAIN, CONF_NAME: "foo", CONF_RESOURCE: "http://localhost", @@ -123,7 +121,7 @@ async def test_setup(hass, aioclient_mock): ) await hass.async_block_till_done() assert aioclient_mock.call_count == 1 - assert_setup_component(1, SWITCH_DOMAIN) + assert_setup_component(1, Platform.SWITCH) async def test_setup_with_state_resource(hass, aioclient_mock): @@ -132,9 +130,9 @@ async def test_setup_with_state_resource(hass, aioclient_mock): aioclient_mock.get("http://localhost/state", status=HTTPStatus.OK) assert await async_setup_component( hass, - SWITCH_DOMAIN, + Platform.SWITCH, { - SWITCH_DOMAIN: { + Platform.SWITCH: { CONF_PLATFORM: DOMAIN, CONF_NAME: "foo", CONF_RESOURCE: "http://localhost", @@ -147,7 +145,7 @@ async def test_setup_with_state_resource(hass, aioclient_mock): ) await hass.async_block_till_done() assert aioclient_mock.call_count == 1 - assert_setup_component(1, SWITCH_DOMAIN) + assert_setup_component(1, Platform.SWITCH) async def test_setup_with_templated_headers_params(hass, aioclient_mock): @@ -155,9 +153,9 @@ async def test_setup_with_templated_headers_params(hass, aioclient_mock): aioclient_mock.get("http://localhost", status=HTTPStatus.OK) assert await async_setup_component( hass, - SWITCH_DOMAIN, + Platform.SWITCH, { - SWITCH_DOMAIN: { + Platform.SWITCH: { CONF_PLATFORM: DOMAIN, CONF_NAME: "foo", CONF_RESOURCE: "http://localhost", @@ -178,7 +176,7 @@ async def test_setup_with_templated_headers_params(hass, aioclient_mock): assert aioclient_mock.mock_calls[-1][3].get("User-Agent") == "Mozilla/5.0" assert aioclient_mock.mock_calls[-1][1].query["start"] == "0" assert aioclient_mock.mock_calls[-1][1].query["end"] == "5" - assert_setup_component(1, SWITCH_DOMAIN) + assert_setup_component(1, Platform.SWITCH) """Tests for REST switch platform.""" diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py index 118a3689fc738b..4b6acfb43946f0 100644 --- a/tests/components/rflink/test_binary_sensor.py +++ b/tests/components/rflink/test_binary_sensor.py @@ -13,6 +13,7 @@ STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) import homeassistant.core as ha import homeassistant.util.dt as dt_util @@ -53,7 +54,7 @@ async def test_default_setup(hass, monkeypatch): # test default state of sensor loaded from config config_sensor = hass.states.get("binary_sensor.test") assert config_sensor - assert config_sensor.state == STATE_OFF + assert config_sensor.state == STATE_UNKNOWN assert config_sensor.attributes["device_class"] == "door" # test on event for config sensor @@ -95,7 +96,7 @@ async def test_entity_availability(hass, monkeypatch): ) # Entities are available by default - assert hass.states.get("binary_sensor.test").state == STATE_OFF + assert hass.states.get("binary_sensor.test").state == STATE_UNKNOWN # Mock a disconnect of the Rflink device disconnect_callback() @@ -113,7 +114,7 @@ async def test_entity_availability(hass, monkeypatch): await hass.async_block_till_done() # Entities should be available again - assert hass.states.get("binary_sensor.test").state == STATE_OFF + assert hass.states.get("binary_sensor.test").state == STATE_UNKNOWN async def test_off_delay(hass, legacy_patchable_time, monkeypatch): diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py index f93c9703d3023c..73def144aba46e 100644 --- a/tests/components/rflink/test_init.py +++ b/tests/components/rflink/test_init.py @@ -7,15 +7,24 @@ from homeassistant.bootstrap import async_setup_component from homeassistant.components.rflink import ( + CONF_KEEPALIVE_IDLE, CONF_RECONNECT_INTERVAL, DATA_ENTITY_LOOKUP, + DEFAULT_TCP_KEEPALIVE_IDLE_TIMER, + DOMAIN as RFLINK_DOMAIN, EVENT_KEY_COMMAND, EVENT_KEY_SENSOR, SERVICE_SEND_COMMAND, TMP_ENTITY, RflinkCommand, ) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_STOP_COVER, SERVICE_TURN_OFF +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_PORT, + SERVICE_STOP_COVER, + SERVICE_TURN_OFF, +) async def mock_rflink( @@ -380,3 +389,90 @@ async def test_not_connected(hass, monkeypatch): RflinkCommand.set_rflink_protocol(None) with pytest.raises(HomeAssistantError): await test_device._async_handle_command("turn_on") + + +async def test_keepalive(hass, monkeypatch, caplog): + """Validate negative keepalive values.""" + keepalive_value = -3 + domain = RFLINK_DOMAIN + config = { + RFLINK_DOMAIN: { + CONF_HOST: "10.10.0.1", + CONF_PORT: 1234, + CONF_KEEPALIVE_IDLE: keepalive_value, + } + } + + # setup mocking rflink module + _, mock_create, _, _ = await mock_rflink(hass, config, domain, monkeypatch) + + assert mock_create.call_args_list[0][1]["host"] == "10.10.0.1" + assert mock_create.call_args_list[0][1]["port"] == 1234 + assert ( + mock_create.call_args_list[0][1]["keepalive"] is None + ) # negative keepalive is not allowed + assert ( + f"A bogus TCP Keepalive IDLE timer was provided ({keepalive_value} secs)" + in caplog.text + ) + + +async def test2_keepalive(hass, monkeypatch, caplog): + """Validate very short keepalive values.""" + keepalive_value = 30 + domain = RFLINK_DOMAIN + config = { + RFLINK_DOMAIN: { + CONF_HOST: "10.10.0.1", + CONF_PORT: 1234, + CONF_KEEPALIVE_IDLE: keepalive_value, + } + } + + # setup mocking rflink module + _, mock_create, _, _ = await mock_rflink(hass, config, domain, monkeypatch) + + assert mock_create.call_args_list[0][1]["host"] == "10.10.0.1" + assert mock_create.call_args_list[0][1]["port"] == 1234 + assert ( + mock_create.call_args_list[0][1]["keepalive"] == keepalive_value + ) # very short keepalive is allowed but warned + assert ( + f"A very short TCP Keepalive IDLE timer was provided ({keepalive_value} secs)" + in caplog.text + ) + + +async def test3_keepalive(hass, monkeypatch, caplog): + """Validate keepalive=0 value.""" + domain = RFLINK_DOMAIN + config = { + RFLINK_DOMAIN: {CONF_HOST: "10.10.0.1", CONF_PORT: 1234, CONF_KEEPALIVE_IDLE: 0} + } + + # setup mocking rflink module + _, mock_create, _, _ = await mock_rflink(hass, config, domain, monkeypatch) + + assert mock_create.call_args_list[0][1]["host"] == "10.10.0.1" + assert mock_create.call_args_list[0][1]["port"] == 1234 + assert ( + mock_create.call_args_list[0][1]["keepalive"] is None + ) # keepalive=0 will disable it + assert "TCP Keepalive IDLE timer was provided" not in caplog.text + + +async def test_default_keepalive(hass, monkeypatch, caplog): + """Validate keepalive=0 value.""" + domain = RFLINK_DOMAIN + config = {RFLINK_DOMAIN: {CONF_HOST: "10.10.0.1", CONF_PORT: 1234}} + + # setup mocking rflink module + _, mock_create, _, _ = await mock_rflink(hass, config, domain, monkeypatch) + + assert mock_create.call_args_list[0][1]["host"] == "10.10.0.1" + assert mock_create.call_args_list[0][1]["port"] == 1234 + assert ( + mock_create.call_args_list[0][1]["keepalive"] + == DEFAULT_TCP_KEEPALIVE_IDLE_TIMER + ) # no keepalive config will default it + assert "TCP Keepalive IDLE timer was provided" not in caplog.text diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index 5b76c6287a3da0..96368d166d7739 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -3,6 +3,7 @@ from homeassistant.components.rfxtrx import DOMAIN from homeassistant.components.rfxtrx.const import ATTR_EVENT +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import State from tests.common import MockConfigEntry, mock_restore_cache @@ -32,7 +33,7 @@ async def test_one(hass, rfxtrx): state = hass.states.get("binary_sensor.ac_213c7f2_48") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 213c7f2:48" @@ -57,7 +58,7 @@ async def test_one_pt2262(hass, rfxtrx): state = hass.states.get("binary_sensor.pt2262_22670e") assert state - assert state.state == "off" # probably aught to be unknown + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "PT2262 22670e" await rfxtrx.signal("0913000022670e013970") @@ -84,12 +85,12 @@ async def test_pt2262_unconfigured(hass, rfxtrx): state = hass.states.get("binary_sensor.pt2262_22670e") assert state - assert state.state == "off" # probably aught to be unknown + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "PT2262 22670e" state = hass.states.get("binary_sensor.pt2262_226707") assert state - assert state.state == "off" # probably aught to be unknown + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "PT2262 226707" @@ -133,17 +134,17 @@ async def test_several(hass, rfxtrx): state = hass.states.get("binary_sensor.ac_213c7f2_48") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 213c7f2:48" state = hass.states.get("binary_sensor.ac_118cdea_2") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 118cdea:2" state = hass.states.get("binary_sensor.ac_118cdea_3") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 118cdea:3" # "2: Group on" @@ -214,7 +215,7 @@ async def test_off_delay(hass, rfxtrx, timestep): state = hass.states.get("binary_sensor.ac_118cdea_2") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN await rfxtrx.signal("0b1100100118cdea02010f70") state = hass.states.get("binary_sensor.ac_118cdea_2") @@ -317,5 +318,5 @@ async def test_pt2262_duplicate_id(hass, rfxtrx): state = hass.states.get("binary_sensor.pt2262_22670e") assert state - assert state.state == "off" # probably aught to be unknown + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "PT2262 22670e" diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 10c9ca120228d1..cd87f02c89399b 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -6,6 +6,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.rfxtrx import DOMAIN, config_flow +from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -367,7 +368,7 @@ async def test_options_add_device(hass): state = hass.states.get("binary_sensor.ac_213c7f2_48") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 213c7f2:48" @@ -456,7 +457,7 @@ async def test_options_add_remove_device(hass): state = hass.states.get("binary_sensor.ac_213c7f2_48") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 213c7f2:48" device_registry = dr.async_get(hass) @@ -900,7 +901,7 @@ async def test_options_add_and_configure_device(hass): state = hass.states.get("binary_sensor.pt2262_22670e") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "PT2262 22670e" device_registry = dr.async_get(hass) diff --git a/tests/components/rfxtrx/test_device_action.py b/tests/components/rfxtrx/test_device_action.py index 6714bfdaea96d0..54f6430fc1dcab 100644 --- a/tests/components/rfxtrx/test_device_action.py +++ b/tests/components/rfxtrx/test_device_action.py @@ -7,6 +7,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.rfxtrx import DOMAIN from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component @@ -98,7 +99,9 @@ async def test_get_actions(hass, device_reg: DeviceRegistry, device, expected): device_entry = device_reg.async_get_device(device.device_identifiers, set()) assert device_entry - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) actions = [action for action in actions if action["domain"] == DOMAIN] expected_actions = [ diff --git a/tests/components/rfxtrx/test_device_trigger.py b/tests/components/rfxtrx/test_device_trigger.py index 90be97bd56f983..eee437d495cf23 100644 --- a/tests/components/rfxtrx/test_device_trigger.py +++ b/tests/components/rfxtrx/test_device_trigger.py @@ -6,6 +6,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.rfxtrx import DOMAIN from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component @@ -93,7 +94,9 @@ async def test_get_triggers(hass, device_reg, event: EventTestData, expected): for expect in expected ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) triggers = [value for value in triggers if value["domain"] == "rfxtrx"] assert_lists_same(triggers, expected_triggers) diff --git a/tests/components/rfxtrx/test_light.py b/tests/components/rfxtrx/test_light.py index 78151c5fa9c262..8b68dc7bf47d50 100644 --- a/tests/components/rfxtrx/test_light.py +++ b/tests/components/rfxtrx/test_light.py @@ -5,6 +5,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.rfxtrx import DOMAIN +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import State from tests.common import MockConfigEntry, mock_restore_cache @@ -25,7 +26,7 @@ async def test_one_light(hass, rfxtrx): state = hass.states.get("light.ac_213c7f2_16") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 213c7f2:16" await hass.services.async_call( @@ -132,17 +133,17 @@ async def test_several_lights(hass, rfxtrx): state = hass.states.get("light.ac_213c7f2_48") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 213c7f2:48" state = hass.states.get("light.ac_118cdea_2") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 118cdea:2" state = hass.states.get("light.ac_1118cdea_2") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 1118cdea:2" await rfxtrx.signal("0b1100cd0213c7f230010f71") diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 94adf4a980e8e2..6c22ee02920d0e 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -5,6 +5,7 @@ from homeassistant import config_entries from homeassistant.components.rfxtrx import DOMAIN +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import State from tests.common import MockConfigEntry, mock_restore_cache @@ -28,7 +29,7 @@ async def test_one_switch(hass, rfxtrx): state = hass.states.get("switch.ac_213c7f2_16") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 213c7f2:16" await hass.services.async_call( @@ -90,17 +91,17 @@ async def test_several_switches(hass, rfxtrx): state = hass.states.get("switch.ac_213c7f2_48") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 213c7f2:48" state = hass.states.get("switch.ac_118cdea_2") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 118cdea:2" state = hass.states.get("switch.ac_1118cdea_2") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 1118cdea:2" @@ -142,22 +143,22 @@ async def test_switch_events(hass, rfxtrx): state = hass.states.get("switch.ac_213c7f2_16") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 213c7f2:16" state = hass.states.get("switch.ac_213c7f2_5") assert state - assert state.state == "off" + assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "AC 213c7f2:5" # "16: On" await rfxtrx.signal("0b1100100213c7f210010f70") - assert hass.states.get("switch.ac_213c7f2_5").state == "off" + assert hass.states.get("switch.ac_213c7f2_5").state == STATE_UNKNOWN assert hass.states.get("switch.ac_213c7f2_16").state == "on" # "16: Off" await rfxtrx.signal("0b1100100213c7f210000f70") - assert hass.states.get("switch.ac_213c7f2_5").state == "off" + assert hass.states.get("switch.ac_213c7f2_5").state == STATE_UNKNOWN assert hass.states.get("switch.ac_213c7f2_16").state == "off" # "5: On" diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py new file mode 100644 index 00000000000000..221ce5be8d2a1b --- /dev/null +++ b/tests/components/ridwell/conftest.py @@ -0,0 +1,85 @@ +"""Define test fixtures for Ridwell.""" +from datetime import date +from unittest.mock import AsyncMock, Mock, patch + +from aioridwell.model import EventState, RidwellPickup, RidwellPickupEvent +import pytest + +from homeassistant.components.ridwell.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +ACCOUNT_ID = "12345" + + +@pytest.fixture(name="account") +def account_fixture(): + """Define a Ridwell account.""" + return Mock( + account_id=ACCOUNT_ID, + address={ + "street1": "123 Main Street", + "city": "New York", + "state": "New York", + "postal_code": "10001", + }, + async_get_next_pickup_event=AsyncMock( + return_value=RidwellPickupEvent( + None, + "event_123", + date(2022, 1, 24), + [RidwellPickup("Plastic Film", "offer_123", 1, "product_123", 1)], + EventState.INITIALIZED, + ) + ), + ) + + +@pytest.fixture(name="client") +def client_fixture(account): + """Define an aioridwell client.""" + return Mock( + async_authenticate=AsyncMock(), + async_get_accounts=AsyncMock(return_value={ACCOUNT_ID: account}), + ) + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config, unique_id): + """Define a config entry fixture.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + } + + +@pytest.fixture(name="setup_ridwell") +async def setup_ridwell_fixture(hass, client, config): + """Define a fixture to set up Ridwell.""" + with patch( + "homeassistant.components.ridwell.config_flow.async_get_client", + return_value=client, + ), patch( + "homeassistant.components.ridwell.async_get_client", return_value=client + ), patch( + "homeassistant.components.ridwell.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="unique_id") +def unique_id_fixture(hass): + """Define a config entry unique ID fixture.""" + return "user@email.com" diff --git a/tests/components/ridwell/test_config_flow.py b/tests/components/ridwell/test_config_flow.py index 957ad31affb29f..358ac6783add91 100644 --- a/tests/components/ridwell/test_config_flow.py +++ b/tests/components/ridwell/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Ridwell config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from aioridwell.errors import InvalidCredentialsError, RidwellError import pytest @@ -14,114 +14,66 @@ RESULT_TYPE_FORM, ) -from tests.common import MockConfigEntry - -@pytest.fixture(name="client") -def client_fixture(): - """Define a fixture for an aioridwell client.""" - return AsyncMock(return_value=None) - - -@pytest.fixture(name="client_login") -def client_login_fixture(client): - """Define a fixture for patching the aioridwell coroutine to get a client.""" - with patch( - "homeassistant.components.ridwell.config_flow.async_get_client" - ) as mock_client: - mock_client.side_effect = client - yield mock_client - - -async def test_duplicate_error(hass: HomeAssistant): +async def test_duplicate_error(hass: HomeAssistant, config, config_entry): """Test that errors are shown when duplicate entries are added.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="user@email.com", - data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, - ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config ) - assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" +@pytest.mark.parametrize( + "exc,error", + [ + (InvalidCredentialsError, "invalid_auth"), + (RidwellError, "unknown"), + ], +) +async def test_errors(hass: HomeAssistant, config, error, exc) -> None: + """Test that various exceptions show the correct error.""" + with patch( + "homeassistant.components.ridwell.config_flow.async_get_client", side_effect=exc + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"]["base"] == error + + async def test_show_form_user(hass: HomeAssistant) -> None: """Test showing the form to input credentials.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"] is None -async def test_step_reauth(hass: HomeAssistant, client_login) -> None: +async def test_step_reauth( + hass: HomeAssistant, config, config_entry, setup_ridwell +) -> None: """Test a full reauth flow.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="user@email.com", + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH}, data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, - ).add_to_hass(hass) - - with patch( - "homeassistant.components.ridwell.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_REAUTH}, - data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: "password"}, - ) - await hass.async_block_till_done() - + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "password"}, + ) assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 -async def test_step_user(hass: HomeAssistant, client_login) -> None: +async def test_step_user(hass: HomeAssistant, config, setup_ridwell) -> None: """Test that the full user step succeeds.""" - with patch( - "homeassistant.components.ridwell.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, - ) - await hass.async_block_till_done() - - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - - -@pytest.mark.parametrize( - "client,error", - [ - (AsyncMock(side_effect=InvalidCredentialsError), "invalid_auth"), - (AsyncMock(side_effect=RidwellError), "unknown"), - ], -) -async def test_step_user_invalid_credentials( - hass: HomeAssistant, client_login, error -) -> None: - """Test that invalid credentials are handled correctly.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config ) - - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"]["base"] == error + assert result["type"] == RESULT_TYPE_CREATE_ENTRY diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py new file mode 100644 index 00000000000000..8427fa13e11aed --- /dev/null +++ b/tests/components/ridwell/test_diagnostics.py @@ -0,0 +1,35 @@ +"""Test Ridwell diagnostics.""" +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_ridwell): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "data": [ + { + "_async_request": None, + "event_id": "event_123", + "pickup_date": { + "__type": "", + "isoformat": "2022-01-24", + }, + "pickups": [ + { + "name": "Plastic Film", + "offer_id": "offer_123", + "priority": 1, + "product_id": "product_123", + "quantity": 1, + "category": { + "__type": "", + "repr": "", + }, + } + ], + "state": { + "__type": "", + "repr": "", + }, + } + ] + } diff --git a/tests/components/ring/test_light.py b/tests/components/ring/test_light.py index 1b8150364bc3b5..86c6033f480c72 100644 --- a/tests/components/ring/test_light.py +++ b/tests/components/ring/test_light.py @@ -1,5 +1,5 @@ """The tests for the Ring light platform.""" -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import Platform from homeassistant.helpers import entity_registry as er from .common import setup_platform @@ -9,7 +9,7 @@ async def test_entity_registry(hass, requests_mock): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, LIGHT_DOMAIN) + await setup_platform(hass, Platform.LIGHT) entity_registry = er.async_get(hass) entry = entity_registry.async_get("light.front_light") @@ -21,7 +21,7 @@ async def test_entity_registry(hass, requests_mock): async def test_light_off_reports_correctly(hass, requests_mock): """Tests that the initial state of a device that should be off is correct.""" - await setup_platform(hass, LIGHT_DOMAIN) + await setup_platform(hass, Platform.LIGHT) state = hass.states.get("light.front_light") assert state.state == "off" @@ -30,7 +30,7 @@ async def test_light_off_reports_correctly(hass, requests_mock): async def test_light_on_reports_correctly(hass, requests_mock): """Tests that the initial state of a device that should be on is correct.""" - await setup_platform(hass, LIGHT_DOMAIN) + await setup_platform(hass, Platform.LIGHT) state = hass.states.get("light.internal_light") assert state.state == "on" @@ -39,7 +39,7 @@ async def test_light_on_reports_correctly(hass, requests_mock): async def test_light_can_be_turned_on(hass, requests_mock): """Tests the light turns on correctly.""" - await setup_platform(hass, LIGHT_DOMAIN) + await setup_platform(hass, Platform.LIGHT) # Mocks the response for turning a light on requests_mock.put( @@ -61,7 +61,7 @@ async def test_light_can_be_turned_on(hass, requests_mock): async def test_updates_work(hass, requests_mock): """Tests the update service works correctly.""" - await setup_platform(hass, LIGHT_DOMAIN) + await setup_platform(hass, Platform.LIGHT) state = hass.states.get("light.front_light") assert state.state == "off" # Changes the return to indicate that the light is now on. diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index ed4e902429278b..14d0a8b213fc69 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -1,5 +1,5 @@ """The tests for the Ring switch platform.""" -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import Platform from homeassistant.helpers import entity_registry as er from .common import setup_platform @@ -9,7 +9,7 @@ async def test_entity_registry(hass, requests_mock): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, SWITCH_DOMAIN) + await setup_platform(hass, Platform.SWITCH) entity_registry = er.async_get(hass) entry = entity_registry.async_get("switch.front_siren") @@ -21,7 +21,7 @@ async def test_entity_registry(hass, requests_mock): async def test_siren_off_reports_correctly(hass, requests_mock): """Tests that the initial state of a device that should be off is correct.""" - await setup_platform(hass, SWITCH_DOMAIN) + await setup_platform(hass, Platform.SWITCH) state = hass.states.get("switch.front_siren") assert state.state == "off" @@ -30,7 +30,7 @@ async def test_siren_off_reports_correctly(hass, requests_mock): async def test_siren_on_reports_correctly(hass, requests_mock): """Tests that the initial state of a device that should be on is correct.""" - await setup_platform(hass, SWITCH_DOMAIN) + await setup_platform(hass, Platform.SWITCH) state = hass.states.get("switch.internal_siren") assert state.state == "on" @@ -40,7 +40,7 @@ async def test_siren_on_reports_correctly(hass, requests_mock): async def test_siren_can_be_turned_on(hass, requests_mock): """Tests the siren turns on correctly.""" - await setup_platform(hass, SWITCH_DOMAIN) + await setup_platform(hass, Platform.SWITCH) # Mocks the response for turning a siren on requests_mock.put( @@ -62,7 +62,7 @@ async def test_siren_can_be_turned_on(hass, requests_mock): async def test_updates_work(hass, requests_mock): """Tests the update service works correctly.""" - await setup_platform(hass, SWITCH_DOMAIN) + await setup_platform(hass, Platform.SWITCH) state = hass.states.get("switch.front_siren") assert state.state == "off" # Changes the return to indicate that the siren is now on. diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index 5ae81eb7b72c5b..c0e044a358947c 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -1,16 +1,6 @@ """Tests for the Roku component.""" -from http import HTTPStatus -import re -from socket import gaierror as SocketGIAError - from homeassistant.components import ssdp, zeroconf -from homeassistant.components.roku.const import DOMAIN from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry, load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker NAME = "Roku 3" NAME_ROKUTV = '58" Onn Roku TV' @@ -42,174 +32,3 @@ }, type="mock_type", ) - - -def mock_connection( - aioclient_mock: AiohttpClientMocker, - device: str = "roku3", - app: str = "roku", - host: str = HOST, - power: bool = True, - media_state: str = "close", - error: bool = False, - server_error: bool = False, -) -> None: - """Mock the Roku connection.""" - roku_url = f"http://{host}:8060" - - if error: - mock_connection_error( - aioclient_mock=aioclient_mock, device=device, app=app, host=host - ) - return - - if server_error: - mock_connection_server_error( - aioclient_mock=aioclient_mock, device=device, app=app, host=host - ) - return - - info_fixture = f"roku/{device}-device-info.xml" - if not power: - info_fixture = f"roku/{device}-device-info-power-off.xml" - - aioclient_mock.get( - f"{roku_url}/query/device-info", - text=load_fixture(info_fixture), - headers={"Content-Type": "text/xml"}, - ) - - apps_fixture = "roku/apps.xml" - if device == "rokutv": - apps_fixture = "roku/apps-tv.xml" - - aioclient_mock.get( - f"{roku_url}/query/apps", - text=load_fixture(apps_fixture), - headers={"Content-Type": "text/xml"}, - ) - - aioclient_mock.get( - f"{roku_url}/query/active-app", - text=load_fixture(f"roku/active-app-{app}.xml"), - headers={"Content-Type": "text/xml"}, - ) - - aioclient_mock.get( - f"{roku_url}/query/tv-active-channel", - text=load_fixture("roku/rokutv-tv-active-channel.xml"), - headers={"Content-Type": "text/xml"}, - ) - - aioclient_mock.get( - f"{roku_url}/query/tv-channels", - text=load_fixture("roku/rokutv-tv-channels.xml"), - headers={"Content-Type": "text/xml"}, - ) - - aioclient_mock.get( - f"{roku_url}/query/media-player", - text=load_fixture(f"roku/media-player-{media_state}.xml"), - headers={"Content-Type": "text/xml"}, - ) - - aioclient_mock.post( - re.compile(f"{roku_url}/keypress/.*"), - text="OK", - ) - - aioclient_mock.post( - re.compile(f"{roku_url}/launch/.*"), - text="OK", - ) - - aioclient_mock.post(f"{roku_url}/search", text="OK") - - -def mock_connection_error( - aioclient_mock: AiohttpClientMocker, - device: str = "roku3", - app: str = "roku", - host: str = HOST, -) -> None: - """Mock the Roku connection error.""" - roku_url = f"http://{host}:8060" - - aioclient_mock.get(f"{roku_url}/query/device-info", exc=SocketGIAError) - aioclient_mock.get(f"{roku_url}/query/apps", exc=SocketGIAError) - aioclient_mock.get(f"{roku_url}/query/active-app", exc=SocketGIAError) - aioclient_mock.get(f"{roku_url}/query/tv-active-channel", exc=SocketGIAError) - aioclient_mock.get(f"{roku_url}/query/tv-channels", exc=SocketGIAError) - - aioclient_mock.post(re.compile(f"{roku_url}/keypress/.*"), exc=SocketGIAError) - aioclient_mock.post(re.compile(f"{roku_url}/launch/.*"), exc=SocketGIAError) - aioclient_mock.post(f"{roku_url}/search", exc=SocketGIAError) - - -def mock_connection_server_error( - aioclient_mock: AiohttpClientMocker, - device: str = "roku3", - app: str = "roku", - host: str = HOST, -) -> None: - """Mock the Roku server error.""" - roku_url = f"http://{host}:8060" - - aioclient_mock.get( - f"{roku_url}/query/device-info", status=HTTPStatus.INTERNAL_SERVER_ERROR - ) - aioclient_mock.get( - f"{roku_url}/query/apps", status=HTTPStatus.INTERNAL_SERVER_ERROR - ) - aioclient_mock.get( - f"{roku_url}/query/active-app", status=HTTPStatus.INTERNAL_SERVER_ERROR - ) - aioclient_mock.get( - f"{roku_url}/query/tv-active-channel", status=HTTPStatus.INTERNAL_SERVER_ERROR - ) - aioclient_mock.get( - f"{roku_url}/query/tv-channels", status=HTTPStatus.INTERNAL_SERVER_ERROR - ) - - aioclient_mock.post( - re.compile(f"{roku_url}/keypress/.*"), status=HTTPStatus.INTERNAL_SERVER_ERROR - ) - aioclient_mock.post( - re.compile(f"{roku_url}/launch/.*"), status=HTTPStatus.INTERNAL_SERVER_ERROR - ) - aioclient_mock.post(f"{roku_url}/search", status=HTTPStatus.INTERNAL_SERVER_ERROR) - - -async def setup_integration( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - device: str = "roku3", - app: str = "roku", - host: str = HOST, - unique_id: str = UPNP_SERIAL, - error: bool = False, - power: bool = True, - media_state: str = "close", - server_error: bool = False, - skip_entry_setup: bool = False, -) -> MockConfigEntry: - """Set up the Roku integration in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data={CONF_HOST: host}) - - entry.add_to_hass(hass) - - if not skip_entry_setup: - mock_connection( - aioclient_mock, - device, - app=app, - host=host, - error=error, - power=power, - media_state=media_state, - server_error=server_error, - ) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry diff --git a/tests/components/roku/conftest.py b/tests/components/roku/conftest.py new file mode 100644 index 00000000000000..16261e07a89108 --- /dev/null +++ b/tests/components/roku/conftest.py @@ -0,0 +1,86 @@ +"""Fixtures for Roku integration tests.""" +from collections.abc import Generator +import json +from unittest.mock import MagicMock, patch + +import pytest +from rokuecp import Device as RokuDevice + +from homeassistant.components.roku.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +def app_icon_url(*args, **kwargs): + """Get the URL to the application icon.""" + app_id = args[0] + return f"http://192.168.1.160:8060/query/icon/{app_id}" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Roku", + domain=DOMAIN, + data={CONF_HOST: "192.168.1.160"}, + unique_id="1GU48T017973", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.roku.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_roku_config_flow( + request: pytest.FixtureRequest, +) -> Generator[None, MagicMock, None]: + """Return a mocked Roku client.""" + fixture: str = "roku/roku3.json" + if hasattr(request, "param") and request.param: + fixture = request.param + + device = RokuDevice(json.loads(load_fixture(fixture))) + with patch( + "homeassistant.components.roku.config_flow.Roku", autospec=True + ) as roku_mock: + client = roku_mock.return_value + client.app_icon_url.side_effect = app_icon_url + client.update.return_value = device + yield client + + +@pytest.fixture +def mock_roku(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: + """Return a mocked Roku client.""" + fixture: str = "roku/roku3.json" + if hasattr(request, "param") and request.param: + fixture = request.param + + device = RokuDevice(json.loads(load_fixture(fixture))) + with patch( + "homeassistant.components.roku.coordinator.Roku", autospec=True + ) as roku_mock: + client = roku_mock.return_value + client.app_icon_url.side_effect = app_icon_url + client.update.return_value = device + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_roku: MagicMock +) -> MockConfigEntry: + """Set up the Roku integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/roku/fixtures/active-app-netflix.xml b/tests/components/roku/fixtures/active-app-netflix.xml deleted file mode 100644 index 4cf7a3fc50644b..00000000000000 --- a/tests/components/roku/fixtures/active-app-netflix.xml +++ /dev/null @@ -1,4 +0,0 @@ - - -Netflix - diff --git a/tests/components/roku/fixtures/active-app-pluto.xml b/tests/components/roku/fixtures/active-app-pluto.xml deleted file mode 100644 index cb3b85dc51c9e3..00000000000000 --- a/tests/components/roku/fixtures/active-app-pluto.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - Pluto TV - It's Free TV - diff --git a/tests/components/roku/fixtures/active-app-roku.xml b/tests/components/roku/fixtures/active-app-roku.xml deleted file mode 100644 index 19808409518185..00000000000000 --- a/tests/components/roku/fixtures/active-app-roku.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - Roku - diff --git a/tests/components/roku/fixtures/active-app-screensaver.xml b/tests/components/roku/fixtures/active-app-screensaver.xml deleted file mode 100644 index fcbb85c426d8f6..00000000000000 --- a/tests/components/roku/fixtures/active-app-screensaver.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - Roku - Default screensaver - diff --git a/tests/components/roku/fixtures/active-app-tvinput-dtv.xml b/tests/components/roku/fixtures/active-app-tvinput-dtv.xml deleted file mode 100644 index 7dd4cdc8f40b18..00000000000000 --- a/tests/components/roku/fixtures/active-app-tvinput-dtv.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - Antenna TV - diff --git a/tests/components/roku/fixtures/apps-tv.xml b/tests/components/roku/fixtures/apps-tv.xml deleted file mode 100644 index e5862268d90e35..00000000000000 --- a/tests/components/roku/fixtures/apps-tv.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - Satellite TV - Blu-ray player - Antenna TV - Roku Channel Store - Netflix - Amazon Video on Demand - MLB.TV® - Free FrameChannel Service - Mediafly - Pandora - Pluto TV - It's Free TV - diff --git a/tests/components/roku/fixtures/apps.xml b/tests/components/roku/fixtures/apps.xml deleted file mode 100644 index 477304c09e8454..00000000000000 --- a/tests/components/roku/fixtures/apps.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - Roku Channel Store - Netflix - Amazon Video on Demand - MLB.TV® - Free FrameChannel Service - Mediafly - Pandora - Pluto TV - It's Free TV - diff --git a/tests/components/roku/fixtures/media-player-close.xml b/tests/components/roku/fixtures/media-player-close.xml deleted file mode 100644 index 0f542941d8ca73..00000000000000 --- a/tests/components/roku/fixtures/media-player-close.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - false - diff --git a/tests/components/roku/fixtures/media-player-live.xml b/tests/components/roku/fixtures/media-player-live.xml deleted file mode 100644 index 62d819f228c7df..00000000000000 --- a/tests/components/roku/fixtures/media-player-live.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - 73313 ms - 95000 ms - true - 25106 ms - - diff --git a/tests/components/roku/fixtures/media-player-pause.xml b/tests/components/roku/fixtures/media-player-pause.xml deleted file mode 100644 index a771208ef574e5..00000000000000 --- a/tests/components/roku/fixtures/media-player-pause.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - 313813 ms - 6496762 ms - false - 15000 ms - - diff --git a/tests/components/roku/fixtures/media-player-play.xml b/tests/components/roku/fixtures/media-player-play.xml deleted file mode 100644 index eceb3ce59a21f6..00000000000000 --- a/tests/components/roku/fixtures/media-player-play.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - 38813 ms - 6496762 ms - false - 15000 ms - - diff --git a/tests/components/roku/fixtures/roku3-app.json b/tests/components/roku/fixtures/roku3-app.json new file mode 100644 index 00000000000000..0bf9411ef5cc93 --- /dev/null +++ b/tests/components/roku/fixtures/roku3-app.json @@ -0,0 +1,93 @@ +{ + "available": true, + "standby": false, + "info": { + "udn": "015e5108-9000-1046-8035-b0a737964dfb", + "serial-number": "1GU48T017973", + "device-id": "1GU48T017973", + "vendor-name": "Roku", + "model-number": "4200X", + "model-name": "Roku 3", + "model-region": "US", + "supports-ethernet": "true", + "wifi-mac": "b0:a7:37:96:4d:fb", + "ethernet-mac": "b0:a7:37:96:4d:fa", + "network-type": "ethernet", + "user-device-name": "My Roku 3", + "software-version": "7.5.0", + "software-build": "09021", + "secure-device": "true", + "language": "en", + "country": "US", + "locale": "en_US", + "time-zone": "US/Pacific", + "time-zone-offset": "-480", + "power-mode": "PowerOn", + "supports-suspend": "false", + "supports-find-remote": "false", + "supports-audio-guide": "false", + "developer-enabled": "true", + "keyed-developer-id": "70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558", + "search-enabled": "true", + "voice-search-enabled": "true", + "notifications-enabled": "true", + "notifications-first-use": "false", + "supports-private-listening": "false", + "headphones-connected": "false" + }, + "app": { + "@id": "12", + "@type": "appl", + "@version": "4.1.218", + "#text": "Netflix" + }, + "apps": [ + { + "@id": "11", + "#text": "Roku Channel Store" + }, + { + "@id": "12", + "#text": "Netflix" + }, + { + "@id": "13", + "#text": "Amazon Video on Demand" + }, + { + "@id": "14", + "#text": "MLB.TV®" + }, + { + "@id": "26", + "#text": "Free FrameChannel Service" + }, + { + "@id": "27", + "#text": "Mediafly" + }, + { + "@id": "28", + "#text": "Pandora" + }, + { + "@id": "74519", + "@subtype": "rsga", + "@type": "appl", + "@version": "5.2.0", + "#text": "Pluto TV - It's Free TV" + } + ], + "media": { + "@error": "false", + "@state": "close", + "format": + { + "@audio": "eac3", + "@captions": "none", + "@drm": "none", + "@video": "hevc_b" + }, + "is_live": "false" + } +} diff --git a/tests/components/roku/fixtures/roku3-device-info-power-off.xml b/tests/components/roku/fixtures/roku3-device-info-power-off.xml deleted file mode 100644 index 4a89724016b64d..00000000000000 --- a/tests/components/roku/fixtures/roku3-device-info-power-off.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - 015e5108-9000-1046-8035-b0a737964dfb - 1GU48T017973 - 1GU48T017973 - Roku - 4200X - Roku 3 - US - true - b0:a7:37:96:4d:fb - b0:a7:37:96:4d:fa - ethernet - My Roku 3 - 7.5.0 - 09021 - true - en - US - en_US - US/Pacific - -480 - PowerOff - false - false - false - true - 70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558 - true - true - true - false - false - false - diff --git a/tests/components/roku/fixtures/roku3-device-info.xml b/tests/components/roku/fixtures/roku3-device-info.xml deleted file mode 100644 index e41e4201518486..00000000000000 --- a/tests/components/roku/fixtures/roku3-device-info.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - 015e5108-9000-1046-8035-b0a737964dfb - 1GU48T017973 - 1GU48T017973 - Roku - 4200X - Roku 3 - US - true - b0:a7:37:96:4d:fb - b0:a7:37:96:4d:fa - ethernet - My Roku 3 - 7.5.0 - 09021 - true - en - US - en_US - US/Pacific - -480 - PowerOn - false - false - false - true - 70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558 - true - true - true - false - false - false - diff --git a/tests/components/roku/fixtures/roku3-diagnostics-data.json b/tests/components/roku/fixtures/roku3-diagnostics-data.json new file mode 100644 index 00000000000000..a63664fa96e1bd --- /dev/null +++ b/tests/components/roku/fixtures/roku3-diagnostics-data.json @@ -0,0 +1,85 @@ +{ + "app": { + "app_id": null, + "name": "Roku", + "screensaver": false, + "version": null + }, + "apps": [ + { + "app_id": "11", + "name": "Roku Channel Store", + "screensaver": false, + "version": null + }, + { + "app_id": "12", + "name": "Netflix", + "screensaver": false, + "version": null + }, + { + "app_id": "13", + "name": "Amazon Video on Demand", + "screensaver": false, + "version": null + }, + { + "app_id": "14", + "name": "MLB.TV®", + "screensaver": false, + "version": null + }, + { + "app_id": "26", + "name": "Free FrameChannel Service", + "screensaver": false, + "version": null + }, + { + "app_id": "27", + "name": "Mediafly", + "screensaver": false, + "version": null + }, + { + "app_id": "28", + "name": "Pandora", + "screensaver": false, + "version": null + }, + { + "app_id": "74519", + "name": "Pluto TV - It's Free TV", + "screensaver": false, + "version": "5.2.0" + } + ], + "channel": null, + "channels": [], + "info": { + "brand": "Roku", + "device_location": null, + "device_type": "box", + "ethernet_mac": "b0:a7:37:96:4d:fa", + "ethernet_support": true, + "headphones_connected": false, + "model_name": "Roku 3", + "model_number": "4200X", + "name": "My Roku 3", + "network_name": null, + "network_type": "ethernet", + "serial_number": "1GU48T017973", + "supports_airplay": false, + "supports_find_remote": false, + "supports_private_listening": false, + "version": "7.5.0", + "wifi_mac": "b0:a7:37:96:4d:fb" + }, + "media": null, + "state": { + "at": "2022-01-23T21:05:03.154737", + "available": true, + "standby": false + } +} diff --git a/tests/components/roku/fixtures/roku3-idle.json b/tests/components/roku/fixtures/roku3-idle.json new file mode 100644 index 00000000000000..d092b16df69b10 --- /dev/null +++ b/tests/components/roku/fixtures/roku3-idle.json @@ -0,0 +1,90 @@ +{ + "available": true, + "standby": true, + "info": { + "udn": "015e5108-9000-1046-8035-b0a737964dfb", + "serial-number": "1GU48T017973", + "device-id": "1GU48T017973", + "vendor-name": "Roku", + "model-number": "4200X", + "model-name": "Roku 3", + "model-region": "US", + "supports-ethernet": "true", + "wifi-mac": "b0:a7:37:96:4d:fb", + "ethernet-mac": "b0:a7:37:96:4d:fa", + "network-type": "ethernet", + "user-device-name": "My Roku 3", + "software-version": "7.5.0", + "software-build": "09021", + "secure-device": "true", + "language": "en", + "country": "US", + "locale": "en_US", + "time-zone": "US/Pacific", + "time-zone-offset": "-480", + "power-mode": "PowerOn", + "supports-suspend": "false", + "supports-find-remote": "false", + "supports-audio-guide": "false", + "developer-enabled": "true", + "keyed-developer-id": "70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558", + "search-enabled": "true", + "voice-search-enabled": "true", + "notifications-enabled": "true", + "notifications-first-use": "false", + "supports-private-listening": "false", + "headphones-connected": "false" + }, + "app": { + "#text": "Roku" + }, + "apps": [ + { + "@id": "11", + "#text": "Roku Channel Store" + }, + { + "@id": "12", + "#text": "Netflix" + }, + { + "@id": "13", + "#text": "Amazon Video on Demand" + }, + { + "@id": "14", + "#text": "MLB.TV®" + }, + { + "@id": "26", + "#text": "Free FrameChannel Service" + }, + { + "@id": "27", + "#text": "Mediafly" + }, + { + "@id": "28", + "#text": "Pandora" + }, + { + "@id": "74519", + "@subtype": "rsga", + "@type": "appl", + "@version": "5.2.0", + "#text": "Pluto TV - It's Free TV" + } + ], + "media": { + "@error": "false", + "@state": "close", + "format": + { + "@audio": "eac3", + "@captions": "none", + "@drm": "none", + "@video": "hevc_b" + }, + "is_live": "false" + } +} diff --git a/tests/components/roku/fixtures/roku3-media-paused.json b/tests/components/roku/fixtures/roku3-media-paused.json new file mode 100644 index 00000000000000..cf37d05528dc1a --- /dev/null +++ b/tests/components/roku/fixtures/roku3-media-paused.json @@ -0,0 +1,116 @@ +{ + "available": true, + "standby": false, + "info": { + "udn": "015e5108-9000-1046-8035-b0a737964dfb", + "serial-number": "1GU48T017973", + "device-id": "1GU48T017973", + "vendor-name": "Roku", + "model-number": "4200X", + "model-name": "Roku 3", + "model-region": "US", + "supports-ethernet": "true", + "wifi-mac": "b0:a7:37:96:4d:fb", + "ethernet-mac": "b0:a7:37:96:4d:fa", + "network-type": "ethernet", + "user-device-name": "My Roku 3", + "software-version": "7.5.0", + "software-build": "09021", + "secure-device": "true", + "language": "en", + "country": "US", + "locale": "en_US", + "time-zone": "US/Pacific", + "time-zone-offset": "-480", + "power-mode": "PowerOn", + "supports-suspend": "false", + "supports-find-remote": "false", + "supports-audio-guide": "false", + "developer-enabled": "true", + "keyed-developer-id": "70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558", + "search-enabled": "true", + "voice-search-enabled": "true", + "notifications-enabled": "true", + "notifications-first-use": "false", + "supports-private-listening": "false", + "headphones-connected": "false" + }, + "app": { + "@id": "74519", + "@subtype": "rsga", + "@type": "appl", + "@version": "5.2.0", + "#text": "Pluto TV - It's Free TV" + }, + "apps": [ + { + "@id": "11", + "#text": "Roku Channel Store" + }, + { + "@id": "12", + "#text": "Netflix" + }, + { + "@id": "13", + "#text": "Amazon Video on Demand" + }, + { + "@id": "14", + "#text": "MLB.TV®" + }, + { + "@id": "26", + "#text": "Free FrameChannel Service" + }, + { + "@id": "27", + "#text": "Mediafly" + }, + { + "@id": "28", + "#text": "Pandora" + }, + { + "@id": "74519", + "@subtype": "rsga", + "@type": "appl", + "@version": "5.2.0", + "#text": "Pluto TV - It's Free TV" + } + ], + "media": { + "@error": "false", + "@state": "pause", + "plugin": { + "@bandwidth": "10000000 bps", + "@id": "74519", + "@name": "Pluto TV - It's Free TV" + }, + "format": { + "@audio": "aac_adts", + "@captions": "webvtt", + "@container": "hls", + "@drm": "none", + "@video": "mpeg4_10b" + }, + "buffering": { + "@current": "1000", + "@max": "1000", + "@target": "0" + }, + "new_stream": { + "@speed": "128000 bps" + }, + "position": "313813 ms", + "duration": "6496762 ms", + "is_live": "false", + "runtime": "15000 ms", + "stream_segment": { + "@bitrate": "3063648", + "@media_sequence": "61", + "@segment_type": "mux", + "@time": "310013" + } + } +} diff --git a/tests/components/roku/fixtures/roku3-media-playing.json b/tests/components/roku/fixtures/roku3-media-playing.json new file mode 100644 index 00000000000000..17f016a409f5ee --- /dev/null +++ b/tests/components/roku/fixtures/roku3-media-playing.json @@ -0,0 +1,116 @@ +{ + "available": true, + "standby": false, + "info": { + "udn": "015e5108-9000-1046-8035-b0a737964dfb", + "serial-number": "1GU48T017973", + "device-id": "1GU48T017973", + "vendor-name": "Roku", + "model-number": "4200X", + "model-name": "Roku 3", + "model-region": "US", + "supports-ethernet": "true", + "wifi-mac": "b0:a7:37:96:4d:fb", + "ethernet-mac": "b0:a7:37:96:4d:fa", + "network-type": "ethernet", + "user-device-name": "My Roku 3", + "software-version": "7.5.0", + "software-build": "09021", + "secure-device": "true", + "language": "en", + "country": "US", + "locale": "en_US", + "time-zone": "US/Pacific", + "time-zone-offset": "-480", + "power-mode": "PowerOn", + "supports-suspend": "false", + "supports-find-remote": "false", + "supports-audio-guide": "false", + "developer-enabled": "true", + "keyed-developer-id": "70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558", + "search-enabled": "true", + "voice-search-enabled": "true", + "notifications-enabled": "true", + "notifications-first-use": "false", + "supports-private-listening": "false", + "headphones-connected": "false" + }, + "app": { + "@id": "74519", + "@subtype": "rsga", + "@type": "appl", + "@version": "5.2.0", + "#text": "Pluto TV - It's Free TV" + }, + "apps": [ + { + "@id": "11", + "#text": "Roku Channel Store" + }, + { + "@id": "12", + "#text": "Netflix" + }, + { + "@id": "13", + "#text": "Amazon Video on Demand" + }, + { + "@id": "14", + "#text": "MLB.TV®" + }, + { + "@id": "26", + "#text": "Free FrameChannel Service" + }, + { + "@id": "27", + "#text": "Mediafly" + }, + { + "@id": "28", + "#text": "Pandora" + }, + { + "@id": "74519", + "@subtype": "rsga", + "@type": "appl", + "@version": "5.2.0", + "#text": "Pluto TV - It's Free TV" + } + ], + "media": { + "@error": "false", + "@state": "play", + "plugin": { + "@bandwidth": "10000000 bps", + "@id": "74519", + "@name": "Pluto TV - It's Free TV" + }, + "format": { + "@audio": "aac_adts", + "@captions": "webvtt", + "@container": "hls", + "@drm": "none", + "@video": "mpeg4_10b" + }, + "buffering": { + "@current": "1000", + "@max": "1000", + "@target": "0" + }, + "new_stream": { + "@speed": "128000 bps" + }, + "position": "38813 ms", + "duration": "6496762 ms", + "is_live": "false", + "runtime": "15000 ms", + "stream_segment": { + "@bitrate": "2000", + "@media_sequence": "68", + "@segment_type": "captions", + "@time": "39013" + } + } +} diff --git a/tests/components/roku/fixtures/roku3-screensaver.json b/tests/components/roku/fixtures/roku3-screensaver.json new file mode 100644 index 00000000000000..c2e9d36ec38a38 --- /dev/null +++ b/tests/components/roku/fixtures/roku3-screensaver.json @@ -0,0 +1,96 @@ +{ + "available": true, + "standby": false, + "info": { + "udn": "015e5108-9000-1046-8035-b0a737964dfb", + "serial-number": "1GU48T017973", + "device-id": "1GU48T017973", + "vendor-name": "Roku", + "model-number": "4200X", + "model-name": "Roku 3", + "model-region": "US", + "supports-ethernet": "true", + "wifi-mac": "b0:a7:37:96:4d:fb", + "ethernet-mac": "b0:a7:37:96:4d:fa", + "network-type": "ethernet", + "user-device-name": "My Roku 3", + "software-version": "7.5.0", + "software-build": "09021", + "secure-device": "true", + "language": "en", + "country": "US", + "locale": "en_US", + "time-zone": "US/Pacific", + "time-zone-offset": "-480", + "power-mode": "PowerOn", + "supports-suspend": "false", + "supports-find-remote": "false", + "supports-audio-guide": "false", + "developer-enabled": "true", + "keyed-developer-id": "70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558", + "search-enabled": "true", + "voice-search-enabled": "true", + "notifications-enabled": "true", + "notifications-first-use": "false", + "supports-private-listening": "false", + "headphones-connected": "false" + }, + "app": { + "app": "Roku", + "screensaver": { + "@id": "55545", + "@type": "ssvr", + "@version": "2.0.1", + "#text": "Default screensaver" + } + }, + "apps": [ + { + "@id": "11", + "#text": "Roku Channel Store" + }, + { + "@id": "12", + "#text": "Netflix" + }, + { + "@id": "13", + "#text": "Amazon Video on Demand" + }, + { + "@id": "14", + "#text": "MLB.TV®" + }, + { + "@id": "26", + "#text": "Free FrameChannel Service" + }, + { + "@id": "27", + "#text": "Mediafly" + }, + { + "@id": "28", + "#text": "Pandora" + }, + { + "@id": "74519", + "@subtype": "rsga", + "@type": "appl", + "@version": "5.2.0", + "#text": "Pluto TV - It's Free TV" + } + ], + "media": { + "@error": "false", + "@state": "close", + "format": + { + "@audio": "eac3", + "@captions": "none", + "@drm": "none", + "@video": "hevc_b" + }, + "is_live": "false" + } +} diff --git a/tests/components/roku/fixtures/roku3.json b/tests/components/roku/fixtures/roku3.json new file mode 100644 index 00000000000000..bf731a3b200c10 --- /dev/null +++ b/tests/components/roku/fixtures/roku3.json @@ -0,0 +1,90 @@ +{ + "available": true, + "standby": false, + "info": { + "udn": "015e5108-9000-1046-8035-b0a737964dfb", + "serial-number": "1GU48T017973", + "device-id": "1GU48T017973", + "vendor-name": "Roku", + "model-number": "4200X", + "model-name": "Roku 3", + "model-region": "US", + "supports-ethernet": "true", + "wifi-mac": "b0:a7:37:96:4d:fb", + "ethernet-mac": "b0:a7:37:96:4d:fa", + "network-type": "ethernet", + "user-device-name": "My Roku 3", + "software-version": "7.5.0", + "software-build": "09021", + "secure-device": "true", + "language": "en", + "country": "US", + "locale": "en_US", + "time-zone": "US/Pacific", + "time-zone-offset": "-480", + "power-mode": "PowerOn", + "supports-suspend": "false", + "supports-find-remote": "false", + "supports-audio-guide": "false", + "developer-enabled": "true", + "keyed-developer-id": "70f6ed9c90cf60718a26f3a7c3e5af1c3ec29558", + "search-enabled": "true", + "voice-search-enabled": "true", + "notifications-enabled": "true", + "notifications-first-use": "false", + "supports-private-listening": "false", + "headphones-connected": "false" + }, + "app": { + "#text": "Roku" + }, + "apps": [ + { + "@id": "11", + "#text": "Roku Channel Store" + }, + { + "@id": "12", + "#text": "Netflix" + }, + { + "@id": "13", + "#text": "Amazon Video on Demand" + }, + { + "@id": "14", + "#text": "MLB.TV®" + }, + { + "@id": "26", + "#text": "Free FrameChannel Service" + }, + { + "@id": "27", + "#text": "Mediafly" + }, + { + "@id": "28", + "#text": "Pandora" + }, + { + "@id": "74519", + "@subtype": "rsga", + "@type": "appl", + "@version": "5.2.0", + "#text": "Pluto TV - It's Free TV" + } + ], + "media": { + "@error": "false", + "@state": "close", + "format": + { + "@audio": "eac3", + "@captions": "none", + "@drm": "none", + "@video": "hevc_b" + }, + "is_live": "false" + } +} diff --git a/tests/components/roku/fixtures/rokutv-7820x.json b/tests/components/roku/fixtures/rokutv-7820x.json new file mode 100644 index 00000000000000..42181b087458ba --- /dev/null +++ b/tests/components/roku/fixtures/rokutv-7820x.json @@ -0,0 +1,184 @@ +{ + "available": true, + "standby": false, + "info": { + "udn": "015e5555-9000-5555-5555-b0a555555dfb", + "serial-number": "YN00H5555555", + "device-id": "0S596H055555", + "advertising-id": "055555a9-d82b-5c75-b8fe-5555550cb7ee", + "vendor-name": "Onn", + "model-name": "100005844", + "model-number": "7820X", + "model-region": "US", + "is-tv": "true", + "is-stick": "false", + "screen-size": "58", + "panel-id": "2", + "tuner-type": "ATSC", + "supports-ethernet": "true", + "wifi-mac": "d8:13:99:f8:b0:c6", + "wifi-driver": "realtek", + "ethernet-mac": "d4:3a:2e:07:fd:cb", + "network-type": "wifi", + "network-name": "NetworkSSID", + "friendly-device-name": "58\" Onn Roku TV", + "friendly-model-name": "Onn Roku TV", + "default-device-name": "Onn Roku TV - YN00H5555555", + "user-device-name": "58\" Onn Roku TV", + "user-device-location": "Living room", + "build-number": "AT9.20E04502A", + "software-version": "9.2.0", + "software-build": "4502", + "secure-device": "true", + "language": "en", + "country": "US", + "locale": "en_US", + "time-zone-auto": "true", + "time-zone": "US/Central", + "time-zone-name": "United States/Central", + "time-zone-tz": "America/Chicago", + "time-zone-offset": "-300", + "clock-format": "12-hour", + "uptime": "264789", + "power-mode": "PowerOn", + "supports-suspend": "true", + "supports-find-remote": "true", + "find-remote-is-possible": "false", + "supports-audio-guide": "true", + "supports-rva": "true", + "developer-enabled": "false", + "keyed-developer-id": [], + "search-enabled": "true", + "search-channels-enabled": "true", + "voice-search-enabled": "true", + "notifications-enabled": "true", + "notifications-first-use": "false", + "supports-private-listening": "true", + "supports-private-listening-dtv": "true", + "supports-warm-standby": "true", + "headphones-connected": "false", + "expert-pq-enabled": "0.9", + "supports-ecs-textedit": "true", + "supports-ecs-microphone": "true", + "supports-wake-on-wlan": "true", + "supports-airplay": "true", + "has-play-on-roku": "true", + "has-mobile-screensaver": "true", + "support-url": "https://www.onntvsupport.com/", + "grandcentral-version": "2.9.57", + "trc-version": "3.0", + "trc-channel-version": "2.9.42", + "davinci-version": "2.8.20", + "has-wifi-extender": "false", + "has-wifi-5G-support": "true", + "can-use-wifi-extender": "true" + }, + "app": { + "@id": "tvinput.dtv", + "@type": "tvin", + "@version": "1.0.0", + "#text": "Antenna TV" + }, + "apps": [ + { + "@id": "tvinput.hdmi2", + "@type": "tvin", + "@version": "1.0.0", + "#text": "Satellite TV" + }, + { + "@id": "tvinput.hdmi1", + "@type": "tvin", + "@version": "1.0.0", + "#text": "Blu-ray player" + }, + { + "@id": "tvinput.dtv", + "@type": "tvin", + "@version": "1.0.0", + "#text": "Antenna TV" + }, + { + "@id": "11", + "#text": "Roku Channel Store" + }, + { + "@id": "12", + "#text": "Netflix" + }, + { + "@id": "13", + "#text": "Amazon Video on Demand" + }, + { + "@id": "14", + "#text": "MLB.TV®" + }, + { + "@id": "26", + "#text": "Free FrameChannel Service" + }, + { + "@id": "27", + "#text": "Mediafly" + }, + { + "@id": "28", + "#text": "Pandora" + }, + { + "@id": "74519", + "@subtype": "rsga", + "@type": "appl", + "@version": "5.2.0", + "#text": "Pluto TV - It's Free TV" + } + ], + "channel": { + "number": "14.3", + "name": "getTV", + "type": "air-digital", + "user-hidden": "false", + "active-input": "true", + "signal-state": "valid", + "signal-mode": "480i", + "signal-quality": "20", + "signal-strength": "-75", + "program-title": "Airwolf", + "program-description": "The team will travel all around the world in order to shut down a global crime ring.", + "program-ratings": "TV-14-D-V", + "program-analog-audio": "none", + "program-digital-audio": "stereo", + "program-audio-languages": "eng", + "program-audio-formats": "AC3", + "program-audio-language": "eng", + "program-audio-format": "AC3", + "program-has-cc": "true" + }, + "channels": [ + { + "number": "1.1", + "name": "WhatsOn", + "type": "air-digital", + "user-hidden": "false" + }, + { + "number": "1.3", + "name": "QVC", + "type": "air-digital", + "user-hidden": "false" + } + ], + "media": { + "@error": "false", + "@state": "close", + "format": + { + "@audio": "eac3", + "@captions": "none", + "@drm": "none", + "@video": "hevc_b" + }, + "is_live": "false" + } +} diff --git a/tests/components/roku/fixtures/rokutv-device-info-power-off.xml b/tests/components/roku/fixtures/rokutv-device-info-power-off.xml deleted file mode 100644 index 658fc130629dc5..00000000000000 --- a/tests/components/roku/fixtures/rokutv-device-info-power-off.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - 015e5555-9000-5555-5555-b0a555555dfb - YN00H5555555 - 0S596H055555 - 055555a9-d82b-5c75-b8fe-5555550cb7ee - Onn - 100005844 - 7820X - US - true - false - 58 - 2 - ATSC - true - d8:13:99:f8:b0:c6 - realtek - d4:3a:2e:07:fd:cb - wifi - NetworkSSID - 58" Onn Roku TV - Onn Roku TV - Onn Roku TV - YN00H5555555 - 58" Onn Roku TV - Living room - AT9.20E04502A - 9.2.0 - 4502 - true - en - US - en_US - true - US/Central - United States/Central - America/Chicago - -300 - 12-hour - 264789 - PowerOn - true - true - false - true - true - false - - true - true - true - true - false - true - true - true - false - 0.9 - true - true - true - true - true - https://www.onntvsupport.com/ - 2.9.57 - 3.0 - 2.9.42 - 2.8.20 - false - true - true - diff --git a/tests/components/roku/fixtures/rokutv-device-info.xml b/tests/components/roku/fixtures/rokutv-device-info.xml deleted file mode 100644 index 658fc130629dc5..00000000000000 --- a/tests/components/roku/fixtures/rokutv-device-info.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - 015e5555-9000-5555-5555-b0a555555dfb - YN00H5555555 - 0S596H055555 - 055555a9-d82b-5c75-b8fe-5555550cb7ee - Onn - 100005844 - 7820X - US - true - false - 58 - 2 - ATSC - true - d8:13:99:f8:b0:c6 - realtek - d4:3a:2e:07:fd:cb - wifi - NetworkSSID - 58" Onn Roku TV - Onn Roku TV - Onn Roku TV - YN00H5555555 - 58" Onn Roku TV - Living room - AT9.20E04502A - 9.2.0 - 4502 - true - en - US - en_US - true - US/Central - United States/Central - America/Chicago - -300 - 12-hour - 264789 - PowerOn - true - true - false - true - true - false - - true - true - true - true - false - true - true - true - false - 0.9 - true - true - true - true - true - https://www.onntvsupport.com/ - 2.9.57 - 3.0 - 2.9.42 - 2.8.20 - false - true - true - diff --git a/tests/components/roku/fixtures/rokutv-tv-active-channel.xml b/tests/components/roku/fixtures/rokutv-tv-active-channel.xml deleted file mode 100644 index 9d6bf582726dc7..00000000000000 --- a/tests/components/roku/fixtures/rokutv-tv-active-channel.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - 14.3 - getTV - air-digital - false - true - valid - 480i - 20 - -75 - Airwolf - The team will travel all around the world in order to shut down a global crime ring. - TV-14-D-V - none - stereo - eng - AC3 - eng - AC3 - true - - diff --git a/tests/components/roku/fixtures/rokutv-tv-channels.xml b/tests/components/roku/fixtures/rokutv-tv-channels.xml deleted file mode 100644 index db4b816c9e29e9..00000000000000 --- a/tests/components/roku/fixtures/rokutv-tv-channels.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - 1.1 - WhatsOn - air-digital - false - - - 1.3 - QVC - air-digital - false - - diff --git a/tests/components/roku/test_binary_sensor.py b/tests/components/roku/test_binary_sensor.py new file mode 100644 index 00000000000000..d551a548c4c10b --- /dev/null +++ b/tests/components/roku/test_binary_sensor.py @@ -0,0 +1,167 @@ +"""Tests for the sensors provided by the Roku integration.""" +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON +from homeassistant.components.roku.const import DOMAIN +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from tests.common import MockConfigEntry +from tests.components.roku import UPNP_SERIAL + + +async def test_roku_binary_sensors( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test the Roku binary sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("binary_sensor.my_roku_3_headphones_connected") + entry = entity_registry.async_get("binary_sensor.my_roku_3_headphones_connected") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_headphones_connected" + assert entry.entity_category is None + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Headphones Connected" + assert state.attributes.get(ATTR_ICON) == "mdi:headphones" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.my_roku_3_supports_airplay") + entry = entity_registry.async_get("binary_sensor.my_roku_3_supports_airplay") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_supports_airplay" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports AirPlay" + assert state.attributes.get(ATTR_ICON) == "mdi:cast-variant" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.my_roku_3_supports_ethernet") + entry = entity_registry.async_get("binary_sensor.my_roku_3_supports_ethernet") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_supports_ethernet" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports Ethernet" + assert state.attributes.get(ATTR_ICON) == "mdi:ethernet" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.my_roku_3_supports_find_remote") + entry = entity_registry.async_get("binary_sensor.my_roku_3_supports_find_remote") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_supports_find_remote" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports Find Remote" + assert state.attributes.get(ATTR_ICON) == "mdi:remote" + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, UPNP_SERIAL)} + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "b0:a7:37:96:4d:fb"), + (dr.CONNECTION_NETWORK_MAC, "b0:a7:37:96:4d:fa"), + } + assert device_entry.manufacturer == "Roku" + assert device_entry.model == "Roku 3" + assert device_entry.name == "My Roku 3" + assert device_entry.entry_type is None + assert device_entry.sw_version == "7.5.0" + assert device_entry.hw_version == "4200X" + assert device_entry.suggested_area is None + + +@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True) +async def test_rokutv_binary_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, +) -> None: + """Test the Roku binary sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("binary_sensor.58_onn_roku_tv_headphones_connected") + entry = entity_registry.async_get( + "binary_sensor.58_onn_roku_tv_headphones_connected" + ) + assert entry + assert state + assert entry.unique_id == "YN00H5555555_headphones_connected" + assert entry.entity_category is None + assert state.state == STATE_OFF + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == '58" Onn Roku TV Headphones Connected' + ) + assert state.attributes.get(ATTR_ICON) == "mdi:headphones" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.58_onn_roku_tv_supports_airplay") + entry = entity_registry.async_get("binary_sensor.58_onn_roku_tv_supports_airplay") + assert entry + assert state + assert entry.unique_id == "YN00H5555555_supports_airplay" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports AirPlay' + ) + assert state.attributes.get(ATTR_ICON) == "mdi:cast-variant" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.58_onn_roku_tv_supports_ethernet") + entry = entity_registry.async_get("binary_sensor.58_onn_roku_tv_supports_ethernet") + assert entry + assert state + assert entry.unique_id == "YN00H5555555_supports_ethernet" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports Ethernet' + ) + assert state.attributes.get(ATTR_ICON) == "mdi:ethernet" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.58_onn_roku_tv_supports_find_remote") + entry = entity_registry.async_get( + "binary_sensor.58_onn_roku_tv_supports_find_remote" + ) + assert entry + assert state + assert entry.unique_id == "YN00H5555555_supports_find_remote" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == '58" Onn Roku TV Supports Find Remote' + ) + assert state.attributes.get(ATTR_ICON) == "mdi:remote" + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "YN00H5555555")} + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "d8:13:99:f8:b0:c6"), + (dr.CONNECTION_NETWORK_MAC, "d4:3a:2e:07:fd:cb"), + } + assert device_entry.manufacturer == "Onn" + assert device_entry.model == "100005844" + assert device_entry.name == '58" Onn Roku TV' + assert device_entry.entry_type is None + assert device_entry.sw_version == "9.2.0" + assert device_entry.hw_version == "7820X" + assert device_entry.suggested_area == "Living room" diff --git a/tests/components/roku/test_config_flow.py b/tests/components/roku/test_config_flow.py index 8aa015d3e01ebc..99d0d1bb2c0150 100644 --- a/tests/components/roku/test_config_flow.py +++ b/tests/components/roku/test_config_flow.py @@ -1,6 +1,9 @@ """Test the Roku config flow.""" import dataclasses -from unittest.mock import patch +from unittest.mock import MagicMock + +import pytest +from rokuecp import RokuConnectionError from homeassistant.components.roku.const import DOMAIN from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_SSDP, SOURCE_USER @@ -12,6 +15,7 @@ RESULT_TYPE_FORM, ) +from tests.common import MockConfigEntry from tests.components.roku import ( HOMEKIT_HOST, HOST, @@ -19,20 +23,18 @@ MOCK_SSDP_DISCOVERY_INFO, NAME_ROKUTV, UPNP_FRIENDLY_NAME, - mock_connection, - setup_integration, ) -from tests.test_util.aiohttp import AiohttpClientMocker async def test_duplicate_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roku_config_flow: MagicMock, ) -> None: """Test that errors are shown when duplicates are added.""" - await setup_integration(hass, aioclient_mock, skip_entry_setup=True) - mock_connection(aioclient_mock) + mock_config_entry.add_to_hass(hass) - user_input = {CONF_HOST: HOST} + user_input = {CONF_HOST: mock_config_entry.data[CONF_HOST]} result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input ) @@ -40,7 +42,7 @@ async def test_duplicate_error( assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - user_input = {CONF_HOST: HOST} + user_input = {CONF_HOST: mock_config_entry.data[CONF_HOST]} result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input ) @@ -57,11 +59,12 @@ async def test_duplicate_error( assert result["reason"] == "already_configured" -async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_form( + hass: HomeAssistant, + mock_roku_config_flow: MagicMock, + mock_setup_entry: None, +) -> None: """Test the user step.""" - - mock_connection(aioclient_mock) - result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) @@ -69,29 +72,26 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> assert result["errors"] == {} user_input = {CONF_HOST: HOST} - with patch( - "homeassistant.components.roku.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=user_input - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=user_input + ) + await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == UPNP_FRIENDLY_NAME + assert result["title"] == "My Roku 3" - assert result["data"] + assert "data" in result assert result["data"][CONF_HOST] == HOST - assert len(mock_setup_entry.mock_calls) == 1 + assert "result" in result + assert result["result"].unique_id == "1GU48T017973" async def test_form_cannot_connect( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, mock_roku_config_flow: MagicMock ) -> None: """Test we handle cannot connect roku error.""" - mock_connection(aioclient_mock, error=True) + mock_roku_config_flow.update.side_effect = RokuConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} @@ -106,40 +106,29 @@ async def test_form_cannot_connect( async def test_form_unknown_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, mock_roku_config_flow: MagicMock ) -> None: """Test we handle unknown error.""" - mock_connection(aioclient_mock) + mock_roku_config_flow.update.side_effect = Exception result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) user_input = {CONF_HOST: HOST} - with patch( - "homeassistant.components.roku.config_flow.Roku._request", - side_effect=Exception, - ) as mock_validate_input: - result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=user_input - ) + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=user_input + ) 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_homekit_cannot_connect( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, mock_roku_config_flow: MagicMock ) -> None: """Test we abort homekit flow on connection error.""" - mock_connection( - aioclient_mock, - host=HOMEKIT_HOST, - error=True, - ) + mock_roku_config_flow.update.side_effect = RokuConnectionError discovery_info = dataclasses.replace(MOCK_HOMEKIT_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( @@ -153,32 +142,31 @@ async def test_homekit_cannot_connect( async def test_homekit_unknown_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, mock_roku_config_flow: MagicMock ) -> None: """Test we abort homekit flow on unknown error.""" - mock_connection(aioclient_mock) + mock_roku_config_flow.update.side_effect = Exception discovery_info = dataclasses.replace(MOCK_HOMEKIT_DISCOVERY_INFO) - with patch( - "homeassistant.components.roku.config_flow.Roku._request", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_HOMEKIT}, - data=discovery_info, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_HOMEKIT}, + data=discovery_info, + ) assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "unknown" +@pytest.mark.parametrize( + "mock_roku_config_flow", ["roku/rokutv-7820x.json"], indirect=True +) async def test_homekit_discovery( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_roku_config_flow: MagicMock, + mock_setup_entry: None, ) -> None: """Test the homekit discovery flow.""" - mock_connection(aioclient_mock, device="rokutv", host=HOMEKIT_HOST) - discovery_info = dataclasses.replace(MOCK_HOMEKIT_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_HOMEKIT}, data=discovery_info @@ -188,24 +176,18 @@ async def test_homekit_discovery( assert result["step_id"] == "discovery_confirm" assert result["description_placeholders"] == {CONF_NAME: NAME_ROKUTV} - with patch( - "homeassistant.components.roku.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input={} - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input={} + ) + await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == NAME_ROKUTV - assert result["data"] + assert "data" in result assert result["data"][CONF_HOST] == HOMEKIT_HOST assert result["data"][CONF_NAME] == NAME_ROKUTV - assert len(mock_setup_entry.mock_calls) == 1 - # test abort on existing host discovery_info = dataclasses.replace(MOCK_HOMEKIT_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( @@ -217,10 +199,10 @@ async def test_homekit_discovery( async def test_ssdp_cannot_connect( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, mock_roku_config_flow: MagicMock ) -> None: """Test we abort SSDP flow on connection error.""" - mock_connection(aioclient_mock, error=True) + mock_roku_config_flow.update.side_effect = RokuConnectionError discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( @@ -234,32 +216,28 @@ async def test_ssdp_cannot_connect( async def test_ssdp_unknown_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, mock_roku_config_flow: MagicMock ) -> None: """Test we abort SSDP flow on unknown error.""" - mock_connection(aioclient_mock) + mock_roku_config_flow.update.side_effect = Exception discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) - with patch( - "homeassistant.components.roku.config_flow.Roku._request", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_SSDP}, - data=discovery_info, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data=discovery_info, + ) assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "unknown" async def test_ssdp_discovery( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_roku_config_flow: MagicMock, + mock_setup_entry: None, ) -> None: """Test the SSDP discovery flow.""" - mock_connection(aioclient_mock) - discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info @@ -269,14 +247,10 @@ async def test_ssdp_discovery( assert result["step_id"] == "discovery_confirm" assert result["description_placeholders"] == {CONF_NAME: UPNP_FRIENDLY_NAME} - with patch( - "homeassistant.components.roku.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input={} - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input={} + ) + await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == UPNP_FRIENDLY_NAME @@ -284,5 +258,3 @@ async def test_ssdp_discovery( assert result["data"] assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_NAME] == UPNP_FRIENDLY_NAME - - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/roku/test_diagnostics.py b/tests/components/roku/test_diagnostics.py new file mode 100644 index 00000000000000..d4e36e2a9a9dbc --- /dev/null +++ b/tests/components/roku/test_diagnostics.py @@ -0,0 +1,38 @@ +"""Tests for the diagnostics data provided by the Roku integration.""" +import json + +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics for config entry.""" + diagnostics_data = json.loads(load_fixture("roku/roku3-diagnostics-data.json")) + + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert isinstance(result, dict) + assert isinstance(result["entry"], dict) + assert result["entry"]["data"] == {"host": "192.168.1.160"} + assert result["entry"]["unique_id"] == "1GU48T017973" + + assert isinstance(result["data"], dict) + assert result["data"]["app"] == diagnostics_data["app"] + assert result["data"]["apps"] == diagnostics_data["apps"] + assert result["data"]["channel"] == diagnostics_data["channel"] + assert result["data"]["channels"] == diagnostics_data["channels"] + assert result["data"]["info"] == diagnostics_data["info"] + assert result["data"]["media"] == diagnostics_data["media"] + + data_state = result["data"]["state"] + assert isinstance(data_state, dict) + assert data_state["available"] == diagnostics_data["state"]["available"] + assert data_state["standby"] == diagnostics_data["state"]["standby"] diff --git a/tests/components/roku/test_init.py b/tests/components/roku/test_init.py index fc624f5cb641f1..f8820e711a2725 100644 --- a/tests/components/roku/test_init.py +++ b/tests/components/roku/test_init.py @@ -1,41 +1,45 @@ """Tests for the Roku integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch + +from rokuecp import RokuConnectionError from homeassistant.components.roku.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.components.roku import setup_integration -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry +@patch( + "homeassistant.components.roku.coordinator.Roku._request", + side_effect=RokuConnectionError, +) async def test_config_entry_not_ready( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + mock_request: MagicMock, hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test the Roku configuration entry not ready.""" - entry = await setup_integration(hass, aioclient_mock, error=True) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY + assert mock_request.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_config_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roku: AsyncMock, ) -> None: - """Test the Roku configuration entry unloading.""" - with patch( - "homeassistant.components.roku.media_player.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.roku.remote.async_setup_entry", - return_value=True, - ): - entry = await setup_integration(hass, aioclient_mock) - - assert hass.data[DOMAIN][entry.entry_id] - assert entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(entry.entry_id) + """Test the Roku configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] - assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.entry_id not in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 9d97e467c68e9c..a039b313702acf 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -1,10 +1,11 @@ """Tests for the Roku Media Player platform.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch +import pytest from rokuecp import RokuError -from homeassistant.components.media_player import DEVICE_CLASS_RECEIVER, DEVICE_CLASS_TV +from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.media_player.const import ( ATTR_APP_ID, ATTR_APP_NAME, @@ -13,6 +14,7 @@ ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_DURATION, + ATTR_MEDIA_EXTRA, ATTR_MEDIA_POSITION, ATTR_MEDIA_TITLE, ATTR_MEDIA_VOLUME_MUTED, @@ -20,10 +22,12 @@ MEDIA_CLASS_APP, MEDIA_CLASS_CHANNEL, MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_VIDEO, MEDIA_TYPE_APP, MEDIA_TYPE_APPS, MEDIA_TYPE_CHANNEL, MEDIA_TYPE_CHANNELS, + MEDIA_TYPE_URL, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, SUPPORT_BROWSE_MEDIA, @@ -38,11 +42,20 @@ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, ) -from homeassistant.components.roku.const import ATTR_KEYWORD, DOMAIN, SERVICE_SEARCH +from homeassistant.components.roku.const import ( + ATTR_CONTENT_ID, + ATTR_FORMAT, + ATTR_KEYWORD, + ATTR_MEDIA_TYPE, + DOMAIN, + SERVICE_SEARCH, +) +from homeassistant.components.stream.const import FORMAT_CONTENT_TYPE, HLS_PROVIDER from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_NAME, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -63,81 +76,108 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed -from tests.components.roku import NAME_ROKUTV, UPNP_SERIAL, setup_integration -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry, async_fire_time_changed MAIN_ENTITY_ID = f"{MP_DOMAIN}.my_roku_3" TV_ENTITY_ID = f"{MP_DOMAIN}.58_onn_roku_tv" -TV_HOST = "192.168.1.161" -TV_LOCATION = "Living room" -TV_MANUFACTURER = "Onn" -TV_MODEL = "100005844" -TV_SERIAL = "YN00H5555555" -TV_SW_VERSION = "9.2.0" - -async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_setup(hass: HomeAssistant, init_integration: MockConfigEntry) -> None: """Test setup with basic config.""" - await setup_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) - main = entity_registry.async_get(MAIN_ENTITY_ID) - - assert hass.states.get(MAIN_ENTITY_ID) - assert main - assert main.original_device_class == DEVICE_CLASS_RECEIVER - assert main.unique_id == UPNP_SERIAL - + device_registry = dr.async_get(hass) + state = hass.states.get(MAIN_ENTITY_ID) + entry = entity_registry.async_get(MAIN_ENTITY_ID) + + assert state + assert entry + assert entry.original_device_class is MediaPlayerDeviceClass.RECEIVER + assert entry.unique_id == "1GU48T017973" + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "1GU48T017973")} + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "b0:a7:37:96:4d:fb"), + (dr.CONNECTION_NETWORK_MAC, "b0:a7:37:96:4d:fa"), + } + assert device_entry.manufacturer == "Roku" + assert device_entry.model == "Roku 3" + assert device_entry.name == "My Roku 3" + assert device_entry.entry_type is None + assert device_entry.sw_version == "7.5.0" + assert device_entry.hw_version == "4200X" + assert device_entry.suggested_area is None + + +@pytest.mark.parametrize("mock_roku", ["roku/roku3-idle.json"], indirect=True) async def test_idle_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: """Test setup with idle device.""" - await setup_integration(hass, aioclient_mock, power=False) - state = hass.states.get(MAIN_ENTITY_ID) + assert state assert state.state == STATE_STANDBY +@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True) async def test_tv_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: """Test Roku TV setup.""" - await setup_integration( - hass, - aioclient_mock, - device="rokutv", - app="tvinput-dtv", - host=TV_HOST, - unique_id=TV_SERIAL, - ) - entity_registry = er.async_get(hass) - tv = entity_registry.async_get(TV_ENTITY_ID) + device_registry = dr.async_get(hass) - assert hass.states.get(TV_ENTITY_ID) - assert tv - assert tv.original_device_class == DEVICE_CLASS_TV - assert tv.unique_id == TV_SERIAL + state = hass.states.get(TV_ENTITY_ID) + entry = entity_registry.async_get(TV_ENTITY_ID) + + assert state + assert entry + assert entry.original_device_class is MediaPlayerDeviceClass.TV + assert entry.unique_id == "YN00H5555555" + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "YN00H5555555")} + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "d8:13:99:f8:b0:c6"), + (dr.CONNECTION_NETWORK_MAC, "d4:3a:2e:07:fd:cb"), + } + assert device_entry.manufacturer == "Onn" + assert device_entry.model == "100005844" + assert device_entry.name == '58" Onn Roku TV' + assert device_entry.entry_type is None + assert device_entry.sw_version == "9.2.0" + assert device_entry.hw_version == "7820X" + assert device_entry.suggested_area == "Living room" async def test_availability( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_roku: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test entity availability.""" now = dt_util.utcnow() future = now + timedelta(minutes=1) + mock_config_entry.add_to_hass(hass) with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass, aioclient_mock) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - with patch( - "homeassistant.components.roku.coordinator.Roku.update", side_effect=RokuError - ), patch("homeassistant.util.dt.utcnow", return_value=future): + with patch("homeassistant.util.dt.utcnow", return_value=future): + mock_roku.update.side_effect = RokuError async_fire_time_changed(hass, future) await hass.async_block_till_done() assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE @@ -145,17 +185,18 @@ async def test_availability( future += timedelta(minutes=1) with patch("homeassistant.util.dt.utcnow", return_value=future): + mock_roku.update.side_effect = None async_fire_time_changed(hass, future) await hass.async_block_till_done() assert hass.states.get(MAIN_ENTITY_ID).state == STATE_HOME async def test_supported_features( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: """Test supported features.""" - await setup_integration(hass, aioclient_mock) - # Features supported for Rokus state = hass.states.get(MAIN_ENTITY_ID) assert ( @@ -174,20 +215,15 @@ async def test_supported_features( ) +@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True) async def test_tv_supported_features( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: """Test supported features for Roku TV.""" - await setup_integration( - hass, - aioclient_mock, - device="rokutv", - app="tvinput-dtv", - host=TV_HOST, - unique_id=TV_SERIAL, - ) - state = hass.states.get(TV_ENTITY_ID) + assert state assert ( SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK @@ -205,12 +241,11 @@ async def test_tv_supported_features( async def test_attributes( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test attributes.""" - await setup_integration(hass, aioclient_mock) - state = hass.states.get(MAIN_ENTITY_ID) + assert state assert state.state == STATE_HOME assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) is None @@ -219,13 +254,15 @@ async def test_attributes( assert state.attributes.get(ATTR_INPUT_SOURCE) == "Roku" +@pytest.mark.parametrize("mock_roku", ["roku/roku3-app.json"], indirect=True) async def test_attributes_app( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: """Test attributes for app.""" - await setup_integration(hass, aioclient_mock, app="netflix") - state = hass.states.get(MAIN_ENTITY_ID) + assert state assert state.state == STATE_ON assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_APP @@ -234,13 +271,15 @@ async def test_attributes_app( assert state.attributes.get(ATTR_INPUT_SOURCE) == "Netflix" +@pytest.mark.parametrize("mock_roku", ["roku/roku3-media-playing.json"], indirect=True) async def test_attributes_app_media_playing( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: """Test attributes for app with playing media.""" - await setup_integration(hass, aioclient_mock, app="pluto", media_state="play") - state = hass.states.get(MAIN_ENTITY_ID) + assert state assert state.state == STATE_PLAYING assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_APP @@ -251,13 +290,15 @@ async def test_attributes_app_media_playing( assert state.attributes.get(ATTR_INPUT_SOURCE) == "Pluto TV - It's Free TV" +@pytest.mark.parametrize("mock_roku", ["roku/roku3-media-paused.json"], indirect=True) async def test_attributes_app_media_paused( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: """Test attributes for app with paused media.""" - await setup_integration(hass, aioclient_mock, app="pluto", media_state="pause") - state = hass.states.get(MAIN_ENTITY_ID) + assert state assert state.state == STATE_PAUSED assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_APP @@ -268,13 +309,15 @@ async def test_attributes_app_media_paused( assert state.attributes.get(ATTR_INPUT_SOURCE) == "Pluto TV - It's Free TV" +@pytest.mark.parametrize("mock_roku", ["roku/roku3-screensaver.json"], indirect=True) async def test_attributes_screensaver( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: """Test attributes for app with screensaver.""" - await setup_integration(hass, aioclient_mock, app="screensaver") - state = hass.states.get(MAIN_ENTITY_ID) + assert state assert state.state == STATE_IDLE assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) is None @@ -283,20 +326,13 @@ async def test_attributes_screensaver( assert state.attributes.get(ATTR_INPUT_SOURCE) == "Roku" +@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True) async def test_tv_attributes( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test attributes for Roku TV.""" - await setup_integration( - hass, - aioclient_mock, - device="rokutv", - app="tvinput-dtv", - host=TV_HOST, - unique_id=TV_SERIAL, - ) - state = hass.states.get(TV_ENTITY_ID) + assert state assert state.state == STATE_ON assert state.attributes.get(ATTR_APP_ID) == "tvinput.dtv" @@ -307,217 +343,283 @@ async def test_tv_attributes( assert state.attributes.get(ATTR_MEDIA_TITLE) == "Airwolf" -async def test_tv_device_registry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_services( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: - """Test device registered for Roku TV in the device registry.""" - await setup_integration( - hass, - aioclient_mock, - device="rokutv", - app="tvinput-dtv", - host=TV_HOST, - unique_id=TV_SERIAL, + """Test the different media player services.""" + await hass.services.async_call( + MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, blocking=True ) - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TV_SERIAL)}) + assert mock_roku.remote.call_count == 1 + mock_roku.remote.assert_called_with("poweroff") - assert reg_device.model == TV_MODEL - assert reg_device.sw_version == TV_SW_VERSION - assert reg_device.manufacturer == TV_MANUFACTURER - assert reg_device.suggested_area == TV_LOCATION - assert reg_device.name == NAME_ROKUTV + await hass.services.async_call( + MP_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, blocking=True + ) + assert mock_roku.remote.call_count == 2 + mock_roku.remote.assert_called_with("poweron") -async def test_services( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test the different media player services.""" - await setup_integration(hass, aioclient_mock) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, + blocking=True, + ) - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, blocking=True - ) + assert mock_roku.remote.call_count == 3 + mock_roku.remote.assert_called_with("play") - remote_mock.assert_called_once_with("poweroff") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, + blocking=True, + ) - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, blocking=True - ) + assert mock_roku.remote.call_count == 4 + mock_roku.remote.assert_called_with("play") - remote_mock.assert_called_once_with("poweron") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_PLAY_PAUSE, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, + blocking=True, + ) - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_MEDIA_PAUSE, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, - blocking=True, - ) + assert mock_roku.remote.call_count == 5 + mock_roku.remote.assert_called_with("play") - remote_mock.assert_called_once_with("play") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, + blocking=True, + ) - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_MEDIA_PLAY, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, - blocking=True, - ) + assert mock_roku.remote.call_count == 6 + mock_roku.remote.assert_called_with("forward") - remote_mock.assert_called_once_with("play") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, + blocking=True, + ) - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_MEDIA_PLAY_PAUSE, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, - blocking=True, - ) + assert mock_roku.remote.call_count == 7 + mock_roku.remote.assert_called_with("reverse") - remote_mock.assert_called_once_with("play") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_INPUT_SOURCE: "Home"}, + blocking=True, + ) - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_MEDIA_NEXT_TRACK, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, - blocking=True, - ) + assert mock_roku.remote.call_count == 8 + mock_roku.remote.assert_called_with("home") - remote_mock.assert_called_once_with("forward") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_APP, + ATTR_MEDIA_CONTENT_ID: "11", + }, + blocking=True, + ) - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_MEDIA_PREVIOUS_TRACK, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, - blocking=True, - ) + assert mock_roku.launch.call_count == 1 + mock_roku.launch.assert_called_with("11", {}) - remote_mock.assert_called_once_with("reverse") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_APP, + ATTR_MEDIA_CONTENT_ID: "291097", + ATTR_MEDIA_EXTRA: { + ATTR_MEDIA_TYPE: "movie", + ATTR_CONTENT_ID: "8e06a8b7-d667-4e31-939d-f40a6dd78a88", + }, + }, + blocking=True, + ) - with patch("homeassistant.components.roku.coordinator.Roku.launch") as launch_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: MAIN_ENTITY_ID, - ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_APP, - ATTR_MEDIA_CONTENT_ID: "11", + assert mock_roku.launch.call_count == 2 + mock_roku.launch.assert_called_with( + "291097", + { + "contentID": "8e06a8b7-d667-4e31-939d-f40a6dd78a88", + "MediaType": "movie", + }, + ) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_URL, + ATTR_MEDIA_CONTENT_ID: "https://awesome.tld/media.mp4", + ATTR_MEDIA_EXTRA: { + ATTR_NAME: "Sent from HA", + ATTR_FORMAT: "mp4", }, - blocking=True, - ) + }, + blocking=True, + ) - launch_mock.assert_called_once_with("11") + assert mock_roku.play_on_roku.call_count == 1 + mock_roku.play_on_roku.assert_called_with( + "https://awesome.tld/media.mp4", + { + "videoName": "Sent from HA", + "videoFormat": "mp4", + }, + ) - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_INPUT_SOURCE: "Home"}, - blocking=True, - ) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[HLS_PROVIDER], + ATTR_MEDIA_CONTENT_ID: "https://awesome.tld/api/hls/api_token/master_playlist.m3u8", + }, + blocking=True, + ) - remote_mock.assert_called_once_with("home") + assert mock_roku.play_on_roku.call_count == 2 + mock_roku.play_on_roku.assert_called_with( + "https://awesome.tld/api/hls/api_token/master_playlist.m3u8", + { + "MediaType": "hls", + }, + ) - with patch("homeassistant.components.roku.coordinator.Roku.launch") as launch_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_INPUT_SOURCE: "Netflix"}, - blocking=True, - ) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_INPUT_SOURCE: "Netflix"}, + blocking=True, + ) - launch_mock.assert_called_once_with("12") + assert mock_roku.launch.call_count == 3 + mock_roku.launch.assert_called_with("12") - with patch("homeassistant.components.roku.coordinator.Roku.launch") as launch_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_INPUT_SOURCE: 12}, - blocking=True, - ) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_INPUT_SOURCE: 12}, + blocking=True, + ) - launch_mock.assert_called_once_with("12") + assert mock_roku.launch.call_count == 4 + mock_roku.launch.assert_called_with("12") -async def test_tv_services( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_services_play_media_local_source( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: - """Test the media player services related to Roku TV.""" - await setup_integration( - hass, - aioclient_mock, - device="rokutv", - app="tvinput-dtv", - host=TV_HOST, - unique_id=TV_SERIAL, + """Test the media player services related to playing media.""" + local_media = hass.config.path("media") + await async_process_ha_core_config( + hass, {"media_dirs": {"local": local_media, "recordings": local_media}} ) + await hass.async_block_till_done() - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: TV_ENTITY_ID}, blocking=True - ) + assert await async_setup_component(hass, "media_source", {}) + await hass.async_block_till_done() - remote_mock.assert_called_once_with("volume_up") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "video/mp4", + ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4", + }, + blocking=True, + ) - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_VOLUME_DOWN, - {ATTR_ENTITY_ID: TV_ENTITY_ID}, - blocking=True, - ) + assert mock_roku.play_on_roku.call_count == 1 + assert mock_roku.play_on_roku.call_args + call_args = mock_roku.play_on_roku.call_args.args + assert "/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0] - remote_mock.assert_called_once_with("volume_down") - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_VOLUME_MUTE, - {ATTR_ENTITY_ID: TV_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True}, - blocking=True, - ) +@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True) +async def test_tv_services( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, +) -> None: + """Test the media player services related to Roku TV.""" + await hass.services.async_call( + MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: TV_ENTITY_ID}, blocking=True + ) - remote_mock.assert_called_once_with("volume_mute") + assert mock_roku.remote.call_count == 1 + mock_roku.remote.assert_called_with("volume_up") - with patch("homeassistant.components.roku.coordinator.Roku.tune") as tune_mock: - await hass.services.async_call( - MP_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: TV_ENTITY_ID, - ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_CHANNEL, - ATTR_MEDIA_CONTENT_ID: "55", - }, - blocking=True, - ) + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_DOWN, + {ATTR_ENTITY_ID: TV_ENTITY_ID}, + blocking=True, + ) - tune_mock.assert_called_once_with("55") + assert mock_roku.remote.call_count == 2 + mock_roku.remote.assert_called_with("volume_down") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: TV_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) -async def test_media_browse(hass, aioclient_mock, hass_ws_client): - """Test browsing media.""" - await setup_integration( - hass, - aioclient_mock, - device="rokutv", - app="tvinput-dtv", - host=TV_HOST, - unique_id=TV_SERIAL, + assert mock_roku.remote.call_count == 3 + mock_roku.remote.assert_called_with("volume_mute") + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: TV_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_CHANNEL, + ATTR_MEDIA_CONTENT_ID: "55", + }, + blocking=True, ) + assert mock_roku.tune.call_count == 1 + mock_roku.tune.assert_called_with("55") + + +async def test_media_browse( + hass, + init_integration, + mock_roku, + hass_ws_client, +): + """Test browsing media.""" client = await hass_ws_client(hass) await client.send_json( { "id": 1, "type": "media_player/browse_media", - "entity_id": TV_ENTITY_ID, + "entity_id": MAIN_ENTITY_ID, } ) @@ -528,21 +630,29 @@ async def test_media_browse(hass, aioclient_mock, hass_ws_client): assert msg["success"] assert msg["result"] - assert msg["result"]["title"] == "Media Library" + assert msg["result"]["title"] == "Apps" assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY - assert msg["result"]["media_content_type"] == "library" + assert msg["result"]["media_content_type"] == MEDIA_TYPE_APPS + assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP assert msg["result"]["can_expand"] assert not msg["result"]["can_play"] - assert len(msg["result"]["children"]) == 2 + assert len(msg["result"]["children"]) == 8 + assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP - # test apps + assert msg["result"]["children"][0]["title"] == "Roku Channel Store" + assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APP + assert msg["result"]["children"][0]["media_content_id"] == "11" + assert "/browse_media/app/11" in msg["result"]["children"][0]["thumbnail"] + assert msg["result"]["children"][0]["can_play"] + + # test invalid media type await client.send_json( { "id": 2, "type": "media_player/browse_media", - "entity_id": TV_ENTITY_ID, - "media_content_type": MEDIA_TYPE_APPS, - "media_content_id": "apps", + "entity_id": MAIN_ENTITY_ID, + "media_content_type": "invalid", + "media_content_id": "invalid", } ) @@ -550,6 +660,42 @@ async def test_media_browse(hass, aioclient_mock, hass_ws_client): assert msg["id"] == 2 assert msg["type"] == TYPE_RESULT + assert not msg["success"] + + +async def test_media_browse_internal( + hass, + init_integration, + mock_roku, + hass_ws_client, +): + """Test browsing media with internal url.""" + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) + + assert hass.config.internal_url == "http://example.local:8123" + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.helpers.network._get_request_host", return_value="example.local" + ): + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": MAIN_ENTITY_ID, + "media_content_type": MEDIA_TYPE_APPS, + "media_content_id": "apps", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["type"] == TYPE_RESULT assert msg["success"] assert msg["result"] @@ -559,107 +705,181 @@ async def test_media_browse(hass, aioclient_mock, hass_ws_client): assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP assert msg["result"]["can_expand"] assert not msg["result"]["can_play"] - assert len(msg["result"]["children"]) == 11 + assert len(msg["result"]["children"]) == 8 assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP - assert msg["result"]["children"][0]["title"] == "Satellite TV" + assert msg["result"]["children"][0]["title"] == "Roku Channel Store" assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APP - assert msg["result"]["children"][0]["media_content_id"] == "tvinput.hdmi2" - assert ( - "/browse_media/app/tvinput.hdmi2" in msg["result"]["children"][0]["thumbnail"] - ) + assert msg["result"]["children"][0]["media_content_id"] == "11" + assert "/query/icon/11" in msg["result"]["children"][0]["thumbnail"] assert msg["result"]["children"][0]["can_play"] - assert msg["result"]["children"][3]["title"] == "Roku Channel Store" - assert msg["result"]["children"][3]["media_content_type"] == MEDIA_TYPE_APP - assert msg["result"]["children"][3]["media_content_id"] == "11" - assert "/browse_media/app/11" in msg["result"]["children"][3]["thumbnail"] - assert msg["result"]["children"][3]["can_play"] - # test channels +async def test_media_browse_local_source( + hass, + init_integration, + mock_roku, + hass_ws_client, +): + """Test browsing local media source.""" + local_media = hass.config.path("media") + await async_process_ha_core_config( + hass, {"media_dirs": {"local": local_media, "recordings": local_media}} + ) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "media_source", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json( { - "id": 3, + "id": 1, "type": "media_player/browse_media", - "entity_id": TV_ENTITY_ID, - "media_content_type": MEDIA_TYPE_CHANNELS, - "media_content_id": "channels", + "entity_id": MAIN_ENTITY_ID, } ) msg = await client.receive_json() - assert msg["id"] == 3 + assert msg["id"] == 1 assert msg["type"] == TYPE_RESULT assert msg["success"] assert msg["result"] - assert msg["result"]["title"] == "Channels" + assert msg["result"]["title"] == "Roku" assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY - assert msg["result"]["media_content_type"] == MEDIA_TYPE_CHANNELS - assert msg["result"]["children_media_class"] == MEDIA_CLASS_CHANNEL + assert msg["result"]["media_content_type"] == "root" assert msg["result"]["can_expand"] assert not msg["result"]["can_play"] assert len(msg["result"]["children"]) == 2 - assert msg["result"]["children_media_class"] == MEDIA_CLASS_CHANNEL - assert msg["result"]["children"][0]["title"] == "WhatsOn" - assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_CHANNEL - assert msg["result"]["children"][0]["media_content_id"] == "1.1" - assert msg["result"]["children"][0]["can_play"] + assert msg["result"]["children"][0]["title"] == "Apps" + assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APPS - # test invalid media type + assert msg["result"]["children"][1]["title"] == "Local Media" + assert msg["result"]["children"][1]["media_class"] == MEDIA_CLASS_DIRECTORY + assert msg["result"]["children"][1]["media_content_type"] is None + assert ( + msg["result"]["children"][1]["media_content_id"] + == "media-source://media_source" + ) + assert not msg["result"]["children"][1]["can_play"] + assert msg["result"]["children"][1]["can_expand"] + + # test local media await client.send_json( { - "id": 4, + "id": 2, "type": "media_player/browse_media", - "entity_id": TV_ENTITY_ID, - "media_content_type": "invalid", - "media_content_id": "invalid", + "entity_id": MAIN_ENTITY_ID, + "media_content_type": "", + "media_content_id": "media-source://media_source", } ) msg = await client.receive_json() - assert msg["id"] == 4 + assert msg["id"] == 2 assert msg["type"] == TYPE_RESULT - assert not msg["success"] + assert msg["success"] + assert msg["result"] + assert msg["result"]["title"] == "Local Media" + assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY + assert msg["result"]["media_content_type"] is None + assert len(msg["result"]["children"]) == 2 -async def test_media_browse_internal(hass, aioclient_mock, hass_ws_client): - """Test browsing media with internal url.""" - await async_process_ha_core_config( - hass, - {"internal_url": "http://example.local:8123"}, + assert msg["result"]["children"][0]["title"] == "media" + assert msg["result"]["children"][0]["media_content_type"] == "" + assert ( + msg["result"]["children"][0]["media_content_id"] + == "media-source://media_source/local/." ) - assert hass.config.internal_url == "http://example.local:8123" + assert msg["result"]["children"][1]["title"] == "media" + assert msg["result"]["children"][1]["media_content_type"] == "" + assert ( + msg["result"]["children"][1]["media_content_id"] + == "media-source://media_source/recordings/." + ) - await setup_integration( - hass, - aioclient_mock, - device="rokutv", - app="tvinput-dtv", - host=TV_HOST, - unique_id=TV_SERIAL, + # test local media directory + await client.send_json( + { + "id": 3, + "type": "media_player/browse_media", + "entity_id": MAIN_ENTITY_ID, + "media_content_type": "", + "media_content_id": "media-source://media_source/local/.", + } ) + msg = await client.receive_json() + + assert msg["id"] == 3 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + assert msg["result"]["title"] == "media" + assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY + assert msg["result"]["media_content_type"] == "" + assert len(msg["result"]["children"]) == 2 + + assert msg["result"]["children"][0]["title"] == "Epic Sax Guy 10 Hours.mp4" + assert msg["result"]["children"][0]["media_class"] == MEDIA_CLASS_VIDEO + assert msg["result"]["children"][0]["media_content_type"] == "video/mp4" + assert ( + msg["result"]["children"][0]["media_content_id"] + == "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4" + ) + + +@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True) +async def test_tv_media_browse( + hass, + init_integration, + mock_roku, + hass_ws_client, +): + """Test browsing media.""" client = await hass_ws_client(hass) - with patch( - "homeassistant.helpers.network._get_request_host", return_value="example.local" - ): - await client.send_json( - { - "id": 2, - "type": "media_player/browse_media", - "entity_id": TV_ENTITY_ID, - "media_content_type": MEDIA_TYPE_APPS, - "media_content_id": "apps", - } - ) + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": TV_ENTITY_ID, + } + ) - msg = await client.receive_json() + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + assert msg["result"] + assert msg["result"]["title"] == "Roku" + assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY + assert msg["result"]["media_content_type"] == "root" + assert msg["result"]["can_expand"] + assert not msg["result"]["can_play"] + assert len(msg["result"]["children"]) == 2 + + # test apps + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": TV_ENTITY_ID, + "media_content_type": MEDIA_TYPE_APPS, + "media_content_id": "apps", + } + ) + + msg = await client.receive_json() assert msg["id"] == 2 assert msg["type"] == TYPE_RESULT @@ -678,27 +898,60 @@ async def test_media_browse_internal(hass, aioclient_mock, hass_ws_client): assert msg["result"]["children"][0]["title"] == "Satellite TV" assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APP assert msg["result"]["children"][0]["media_content_id"] == "tvinput.hdmi2" - assert "/query/icon/tvinput.hdmi2" in msg["result"]["children"][0]["thumbnail"] + assert ( + "/browse_media/app/tvinput.hdmi2" in msg["result"]["children"][0]["thumbnail"] + ) assert msg["result"]["children"][0]["can_play"] assert msg["result"]["children"][3]["title"] == "Roku Channel Store" assert msg["result"]["children"][3]["media_content_type"] == MEDIA_TYPE_APP assert msg["result"]["children"][3]["media_content_id"] == "11" - assert "/query/icon/11" in msg["result"]["children"][3]["thumbnail"] + assert "/browse_media/app/11" in msg["result"]["children"][3]["thumbnail"] assert msg["result"]["children"][3]["can_play"] + # test channels + await client.send_json( + { + "id": 3, + "type": "media_player/browse_media", + "entity_id": TV_ENTITY_ID, + "media_content_type": MEDIA_TYPE_CHANNELS, + "media_content_id": "channels", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 3 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + assert msg["result"] + assert msg["result"]["title"] == "TV Channels" + assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY + assert msg["result"]["media_content_type"] == MEDIA_TYPE_CHANNELS + assert msg["result"]["children_media_class"] == MEDIA_CLASS_CHANNEL + assert msg["result"]["can_expand"] + assert not msg["result"]["can_play"] + assert len(msg["result"]["children"]) == 2 + assert msg["result"]["children_media_class"] == MEDIA_CLASS_CHANNEL + + assert msg["result"]["children"][0]["title"] == "WhatsOn" + assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_CHANNEL + assert msg["result"]["children"][0]["media_content_id"] == "1.1" + assert msg["result"]["children"][0]["can_play"] + async def test_integration_services( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: """Test integration services.""" - await setup_integration(hass, aioclient_mock) - - with patch("homeassistant.components.roku.coordinator.Roku.search") as search_mock: - await hass.services.async_call( - DOMAIN, - SERVICE_SEARCH, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_KEYWORD: "Space Jam"}, - blocking=True, - ) - search_mock.assert_called_once_with("Space Jam") + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_KEYWORD: "Space Jam"}, + blocking=True, + ) + mock_roku.search.assert_called_once_with("Space Jam") diff --git a/tests/components/roku/test_remote.py b/tests/components/roku/test_remote.py index c0df380c1e8f71..f1685609563695 100644 --- a/tests/components/roku/test_remote.py +++ b/tests/components/roku/test_remote.py @@ -1,5 +1,5 @@ """The tests for the Roku remote platform.""" -from unittest.mock import patch +from unittest.mock import MagicMock from homeassistant.components.remote import ( ATTR_COMMAND, @@ -10,26 +10,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.components.roku import UPNP_SERIAL, setup_integration -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry +from tests.components.roku import UPNP_SERIAL MAIN_ENTITY_ID = f"{REMOTE_DOMAIN}.my_roku_3" # pylint: disable=redefined-outer-name -async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_setup(hass: HomeAssistant, init_integration: MockConfigEntry) -> None: """Test setup with basic config.""" - await setup_integration(hass, aioclient_mock) assert hass.states.get(MAIN_ENTITY_ID) async def test_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test unique id.""" - await setup_integration(hass, aioclient_mock) - entity_registry = er.async_get(hass) main = entity_registry.async_get(MAIN_ENTITY_ID) @@ -37,34 +34,34 @@ async def test_unique_id( async def test_main_services( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, ) -> None: """Test platform services.""" - await setup_integration(hass, aioclient_mock) - - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - REMOTE_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, - blocking=True, - ) - remote_mock.assert_called_once_with("poweroff") + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, + blocking=True, + ) + assert mock_roku.remote.call_count == 1 + mock_roku.remote.assert_called_with("poweroff") - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - REMOTE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, - blocking=True, - ) - remote_mock.assert_called_once_with("poweron") + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, + blocking=True, + ) + assert mock_roku.remote.call_count == 2 + mock_roku.remote.assert_called_with("poweron") - with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: - await hass.services.async_call( - REMOTE_DOMAIN, - SERVICE_SEND_COMMAND, - {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_COMMAND: ["home"]}, - blocking=True, - ) - remote_mock.assert_called_once_with("home") + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_COMMAND: ["home"]}, + blocking=True, + ) + assert mock_roku.remote.call_count == 3 + mock_roku.remote.assert_called_with("home") diff --git a/tests/components/roku/test_sensor.py b/tests/components/roku/test_sensor.py new file mode 100644 index 00000000000000..6ca27635d30225 --- /dev/null +++ b/tests/components/roku/test_sensor.py @@ -0,0 +1,114 @@ +"""Tests for the sensors provided by the Roku integration.""" +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.roku.const import DOMAIN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from tests.common import MockConfigEntry +from tests.components.roku import UPNP_SERIAL + + +async def test_roku_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Roku sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.my_roku_3_active_app") + entry = entity_registry.async_get("sensor.my_roku_3_active_app") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_active_app" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "Roku" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active App" + assert state.attributes.get(ATTR_ICON) == "mdi:application" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.my_roku_3_active_app_id") + entry = entity_registry.async_get("sensor.my_roku_3_active_app_id") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_active_app_id" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active App ID" + assert state.attributes.get(ATTR_ICON) == "mdi:application-cog" + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, UPNP_SERIAL)} + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "b0:a7:37:96:4d:fb"), + (dr.CONNECTION_NETWORK_MAC, "b0:a7:37:96:4d:fa"), + } + assert device_entry.manufacturer == "Roku" + assert device_entry.model == "Roku 3" + assert device_entry.name == "My Roku 3" + assert device_entry.entry_type is None + assert device_entry.sw_version == "7.5.0" + assert device_entry.hw_version == "4200X" + assert device_entry.suggested_area is None + + +@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True) +async def test_rokutv_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_roku: MagicMock, +) -> None: + """Test the Roku TV sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.58_onn_roku_tv_active_app") + entry = entity_registry.async_get("sensor.58_onn_roku_tv_active_app") + assert entry + assert state + assert entry.unique_id == "YN00H5555555_active_app" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "Antenna TV" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active App' + assert state.attributes.get(ATTR_ICON) == "mdi:application" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.58_onn_roku_tv_active_app_id") + entry = entity_registry.async_get("sensor.58_onn_roku_tv_active_app_id") + assert entry + assert state + assert entry.unique_id == "YN00H5555555_active_app_id" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "tvinput.dtv" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active App ID' + assert state.attributes.get(ATTR_ICON) == "mdi:application-cog" + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "YN00H5555555")} + assert device_entry.connections == { + (dr.CONNECTION_NETWORK_MAC, "d8:13:99:f8:b0:c6"), + (dr.CONNECTION_NETWORK_MAC, "d4:3a:2e:07:fd:cb"), + } + assert device_entry.manufacturer == "Onn" + assert device_entry.model == "100005844" + assert device_entry.name == '58" Onn Roku TV' + assert device_entry.entry_type is None + assert device_entry.sw_version == "9.2.0" + assert device_entry.hw_version == "7820X" + assert device_entry.suggested_area == "Living room" diff --git a/tests/components/rtsp_to_webrtc/__init__.py b/tests/components/rtsp_to_webrtc/__init__.py new file mode 100644 index 00000000000000..ee4206e357d8f0 --- /dev/null +++ b/tests/components/rtsp_to_webrtc/__init__.py @@ -0,0 +1 @@ +"""Tests for the RTSPtoWebRTC integration.""" diff --git a/tests/components/rtsp_to_webrtc/conftest.py b/tests/components/rtsp_to_webrtc/conftest.py new file mode 100644 index 00000000000000..7148896e45401a --- /dev/null +++ b/tests/components/rtsp_to_webrtc/conftest.py @@ -0,0 +1,98 @@ +"""Tests for RTSPtoWebRTC inititalization.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +from typing import Any, TypeVar +from unittest.mock import patch + +import pytest +import rtsp_to_webrtc + +from homeassistant.components import camera +from homeassistant.components.rtsp_to_webrtc import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +STREAM_SOURCE = "rtsp://example.com" +SERVER_URL = "http://127.0.0.1:8083" + +CONFIG_ENTRY_DATA = {"server_url": SERVER_URL} + +# Typing helpers +ComponentSetup = Callable[[], Awaitable[None]] +T = TypeVar("T") +YieldFixture = Generator[T, None, None] + + +@pytest.fixture(autouse=True) +async def webrtc_server() -> None: + """Patch client library to force usage of RTSPtoWebRTC server.""" + with patch( + "rtsp_to_webrtc.client.WebClient.heartbeat", + side_effect=rtsp_to_webrtc.exceptions.ResponseError(), + ): + yield + + +@pytest.fixture +async def mock_camera(hass) -> AsyncGenerator[None, None]: + """Initialize a demo camera platform.""" + assert await async_setup_component( + hass, "camera", {camera.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + with patch( + "homeassistant.components.demo.camera.Path.read_bytes", + return_value=b"Test", + ), patch( + "homeassistant.components.camera.Camera.stream_source", + return_value=STREAM_SOURCE, + ), patch( + "homeassistant.components.camera.Camera.supported_features", + return_value=camera.SUPPORT_STREAM, + ): + yield + + +@pytest.fixture +async def config_entry_data() -> dict[str, Any]: + """Fixture for MockConfigEntry data.""" + return CONFIG_ENTRY_DATA + + +@pytest.fixture +async def config_entry(config_entry_data: dict[str, Any]) -> MockConfigEntry: + """Fixture for MockConfigEntry.""" + return MockConfigEntry(domain=DOMAIN, data=config_entry_data) + + +@pytest.fixture +async def rtsp_to_webrtc_client() -> None: + """Fixture for mock rtsp_to_webrtc client.""" + with patch("rtsp_to_webrtc.client.Client.heartbeat"): + yield + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> YieldFixture[ComponentSetup]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + async def func() -> None: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + yield func + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert entries[0].state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/rtsp_to_webrtc/test_config_flow.py b/tests/components/rtsp_to_webrtc/test_config_flow.py new file mode 100644 index 00000000000000..a6cd4d6798f8ff --- /dev/null +++ b/tests/components/rtsp_to_webrtc/test_config_flow.py @@ -0,0 +1,214 @@ +"""Test the RTSPtoWebRTC config flow.""" + +from __future__ import annotations + +from unittest.mock import patch + +import rtsp_to_webrtc + +from homeassistant import config_entries +from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.components.rtsp_to_webrtc import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_web_full_flow(hass: HomeAssistant) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "user" + assert result.get("data_schema").schema.get("server_url") == str + assert not result.get("errors") + assert "flow_id" in result + with patch("rtsp_to_webrtc.client.Client.heartbeat"), patch( + "homeassistant.components.rtsp_to_webrtc.async_setup_entry", + return_value=True, + ) as mock_setup: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"server_url": "https://example.com"} + ) + assert result.get("type") == "create_entry" + assert result.get("title") == "https://example.com" + assert "result" in result + assert result["result"].data == {"server_url": "https://example.com"} + + assert len(mock_setup.mock_calls) == 1 + + +async def test_single_config_entry(hass: HomeAssistant) -> None: + """Test that only a single config entry is allowed.""" + old_entry = MockConfigEntry(domain=DOMAIN, data={"example": True}) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "abort" + assert result.get("reason") == "single_instance_allowed" + + +async def test_invalid_url(hass: HomeAssistant) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "user" + assert result.get("data_schema").schema.get("server_url") == str + assert not result.get("errors") + assert "flow_id" in result + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"server_url": "not-a-url"} + ) + + assert result.get("type") == "form" + assert result.get("step_id") == "user" + assert result.get("errors") == {"server_url": "invalid_url"} + + +async def test_server_unreachable(hass: HomeAssistant) -> None: + """Exercise case where the server is unreachable.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "user" + assert not result.get("errors") + assert "flow_id" in result + with patch( + "rtsp_to_webrtc.client.Client.heartbeat", + side_effect=rtsp_to_webrtc.exceptions.ClientError(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"server_url": "https://example.com"} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "server_unreachable"} + + +async def test_server_failure(hass: HomeAssistant) -> None: + """Exercise case where server returns a failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "user" + assert not result.get("errors") + assert "flow_id" in result + with patch( + "rtsp_to_webrtc.client.Client.heartbeat", + side_effect=rtsp_to_webrtc.exceptions.ResponseError(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"server_url": "https://example.com"} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "server_failure"} + + +async def test_hassio_discovery(hass): + """Test supervisor add-on discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=HassioServiceInfo( + config={ + "addon": "RTSPtoWebRTC", + "host": "fake-server", + "port": 8083, + } + ), + context={"source": config_entries.SOURCE_HASSIO}, + ) + assert result.get("type") == "form" + assert result.get("step_id") == "hassio_confirm" + assert result.get("description_placeholders") == {"addon": "RTSPtoWebRTC"} + + with patch("rtsp_to_webrtc.client.Client.heartbeat"), patch( + "homeassistant.components.rtsp_to_webrtc.async_setup_entry", + return_value=True, + ) as mock_setup: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result.get("type") == "create_entry" + assert result.get("title") == "RTSPtoWebRTC" + assert "result" in result + assert result["result"].data == {"server_url": "http://fake-server:8083"} + + assert len(mock_setup.mock_calls) == 1 + + +async def test_hassio_single_config_entry(hass: HomeAssistant) -> None: + """Test supervisor add-on discovery only allows a single entry.""" + old_entry = MockConfigEntry(domain=DOMAIN, data={"example": True}) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=HassioServiceInfo( + config={ + "addon": "RTSPtoWebRTC", + "host": "fake-server", + "port": 8083, + } + ), + context={"source": config_entries.SOURCE_HASSIO}, + ) + assert result.get("type") == "abort" + assert result.get("reason") == "single_instance_allowed" + + +async def test_hassio_ignored(hass: HomeAssistant) -> None: + """Test ignoring superversor add-on discovery.""" + old_entry = MockConfigEntry(domain=DOMAIN, source=config_entries.SOURCE_IGNORE) + old_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=HassioServiceInfo( + config={ + "addon": "RTSPtoWebRTC", + "host": "fake-server", + "port": 8083, + } + ), + context={"source": config_entries.SOURCE_HASSIO}, + ) + assert result.get("type") == "abort" + assert result.get("reason") == "single_instance_allowed" + + +async def test_hassio_discovery_server_failure(hass: HomeAssistant) -> None: + """Test server failure during supvervisor add-on discovery shows an error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=HassioServiceInfo( + config={ + "addon": "RTSPtoWebRTC", + "host": "fake-server", + "port": 8083, + } + ), + context={"source": config_entries.SOURCE_HASSIO}, + ) + + assert result.get("type") == "form" + assert result.get("step_id") == "hassio_confirm" + assert not result.get("errors") + assert "flow_id" in result + + with patch( + "rtsp_to_webrtc.client.Client.heartbeat", + side_effect=rtsp_to_webrtc.exceptions.ResponseError(), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result.get("type") == "abort" + assert result.get("reason") == "server_failure" diff --git a/tests/components/rtsp_to_webrtc/test_diagnostics.py b/tests/components/rtsp_to_webrtc/test_diagnostics.py new file mode 100644 index 00000000000000..27b801a71ed41a --- /dev/null +++ b/tests/components/rtsp_to_webrtc/test_diagnostics.py @@ -0,0 +1,27 @@ +"""Test nest diagnostics.""" + +from typing import Any + +from .conftest import ComponentSetup + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + +THERMOSTAT_TYPE = "sdm.devices.types.THERMOSTAT" + + +async def test_entry_diagnostics( + hass, + hass_client, + config_entry: MockConfigEntry, + rtsp_to_webrtc_client: Any, + setup_integration: ComponentSetup, +): + """Test config entry diagnostics.""" + await setup_integration() + + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "discovery": {"attempt": 1, "web.failure": 1, "webrtc.success": 1}, + "web": {}, + "webrtc": {}, + } diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py new file mode 100644 index 00000000000000..759fea7c813141 --- /dev/null +++ b/tests/components/rtsp_to_webrtc/test_init.py @@ -0,0 +1,156 @@ +"""Tests for RTSPtoWebRTC inititalization.""" + +from __future__ import annotations + +import base64 +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import patch + +import aiohttp +import pytest +import rtsp_to_webrtc + +from homeassistant.components.rtsp_to_webrtc import DOMAIN +from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup + +from tests.test_util.aiohttp import AiohttpClientMocker + +# The webrtc component does not inspect the details of the offer and answer, +# and is only a pass through. +OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." +ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..." + + +async def test_setup_success( + hass: HomeAssistant, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup +) -> None: + """Test successful setup and unload.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize("config_entry_data", [{}]) +async def test_invalid_config_entry( + hass: HomeAssistant, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup +) -> None: + """Test a config entry with missing required fields.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_server_failure( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test server responds with a failure on startup.""" + with patch( + "rtsp_to_webrtc.client.Client.heartbeat", + side_effect=rtsp_to_webrtc.exceptions.ResponseError(), + ): + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_communication_failure( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test unable to talk to server on startup.""" + with patch( + "rtsp_to_webrtc.client.Client.heartbeat", + side_effect=rtsp_to_webrtc.exceptions.ClientError(), + ): + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + +async def test_offer_for_stream_source( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + mock_camera: Any, + rtsp_to_webrtc_client: Any, + setup_integration: ComponentSetup, +) -> None: + """Test successful response from RTSPtoWebRTC server.""" + await setup_integration() + + aioclient_mock.post( + f"{SERVER_URL}/stream", + json={"sdp64": base64.b64encode(ANSWER_SDP.encode("utf-8")).decode("utf-8")}, + ) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "camera/web_rtc_offer", + "entity_id": "camera.demo_camera", + "offer": OFFER_SDP, + } + ) + response = await client.receive_json() + assert response.get("id") == 1 + assert response.get("type") == TYPE_RESULT + assert response.get("success") + assert "result" in response + assert response["result"].get("answer") == ANSWER_SDP + assert "error" not in response + + # Validate request parameters were sent correctly + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[-1][2] == { + "sdp64": base64.b64encode(OFFER_SDP.encode("utf-8")).decode("utf-8"), + "url": STREAM_SOURCE, + } + + +async def test_offer_failure( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + mock_camera: Any, + rtsp_to_webrtc_client: Any, + setup_integration: ComponentSetup, +) -> None: + """Test a transient failure talking to RTSPtoWebRTC server.""" + await setup_integration() + + aioclient_mock.post( + f"{SERVER_URL}/stream", + exc=aiohttp.ClientError, + ) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 2, + "type": "camera/web_rtc_offer", + "entity_id": "camera.demo_camera", + "offer": OFFER_SDP, + } + ) + response = await client.receive_json() + assert response.get("id") == 2 + assert response.get("type") == TYPE_RESULT + assert "success" in response + assert not response.get("success") + assert "error" in response + assert response["error"].get("code") == "web_rtc_offer_failed" + assert "message" in response["error"] + assert "RTSPtoWebRTC server communication failure" in response["error"]["message"] diff --git a/tests/components/ruckus_unleashed/__init__.py b/tests/components/ruckus_unleashed/__init__.py index eff80a0387a1fd..5c50f84506400a 100644 --- a/tests/components/ruckus_unleashed/__init__.py +++ b/tests/components/ruckus_unleashed/__init__.py @@ -16,6 +16,7 @@ API_VERSION, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -68,6 +69,13 @@ def mock_config_entry() -> MockConfigEntry: async def init_integration(hass) -> MockConfigEntry: """Set up the Ruckus Unleashed integration in Home Assistant.""" entry = mock_config_entry() + entry.add_to_hass(hass) + # Make device tied to other integration so device tracker entities get enabled + dr.async_get(hass).async_get_or_create( + name="Device from other integration", + config_entry_id=MockConfigEntry().entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, TEST_CLIENT[API_MAC])}, + ) with patch( "homeassistant.components.ruckus_unleashed.Ruckus.connect", return_value=None, @@ -86,7 +94,6 @@ async def init_integration(hass) -> MockConfigEntry: TEST_CLIENT[API_MAC]: TEST_CLIENT, }, ): - entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/ruckus_unleashed/test_device_tracker.py b/tests/components/ruckus_unleashed/test_device_tracker.py index 9238200727396e..2c64bd3d0a8d07 100644 --- a/tests/components/ruckus_unleashed/test_device_tracker.py +++ b/tests/components/ruckus_unleashed/test_device_tracker.py @@ -3,10 +3,8 @@ from unittest.mock import patch from homeassistant.components.ruckus_unleashed import API_MAC, DOMAIN -from homeassistant.components.ruckus_unleashed.const import API_AP, API_ID, API_NAME from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers import entity_registry as er from homeassistant.util import utcnow from tests.common import async_fire_time_changed @@ -112,24 +110,3 @@ async def test_restoring_clients(hass): device = hass.states.get(TEST_CLIENT_ENTITY_ID) assert device is not None assert device.state == STATE_NOT_HOME - - -async def test_client_device_setup(hass): - """Test a client device is created.""" - await init_integration(hass) - - router_info = DEFAULT_AP_INFO[API_AP][API_ID]["1"] - - device_registry = dr.async_get(hass) - client_device = device_registry.async_get_device( - identifiers={}, - connections={(CONNECTION_NETWORK_MAC, TEST_CLIENT[API_MAC])}, - ) - router_device = device_registry.async_get_device( - identifiers={(CONNECTION_NETWORK_MAC, router_info[API_MAC])}, - connections={(CONNECTION_NETWORK_MAC, router_info[API_MAC])}, - ) - - assert client_device - assert client_device.name == TEST_CLIENT[API_NAME] - assert client_device.via_device_id == router_device.id diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 1d81769ad8bcfc..f64634acd3bca9 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -9,7 +9,7 @@ from samsungtvws.exceptions import ConnectionFailure from websocket import WebSocketException -from homeassistant.components.media_player import DEVICE_CLASS_TV +from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, ATTR_MEDIA_CONTENT_ID, @@ -517,7 +517,7 @@ async def test_device_class(hass, remote): """Test for device_class property.""" await setup_samsungtv(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TV + assert state.attributes[ATTR_DEVICE_CLASS] is MediaPlayerDeviceClass.TV.value async def test_turn_off_websocket(hass, remotews): diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py index 4c5b832ac14e11..41b16261cd1ac1 100644 --- a/tests/components/scene/test_init.py +++ b/tests/components/scene/test_init.py @@ -1,14 +1,22 @@ """The tests for the Scene component.""" import io +from unittest.mock import patch import pytest from homeassistant.components import light, scene -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + ENTITY_MATCH_ALL, + SERVICE_TURN_ON, + STATE_UNKNOWN, +) +from homeassistant.core import State from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.yaml import loader as yaml_loader -from tests.common import async_mock_service +from tests.common import async_mock_service, mock_restore_cache @pytest.fixture(autouse=True) @@ -111,7 +119,14 @@ async def test_activate_scene(hass, entities, enable_custom_integrations): }, ) await hass.async_block_till_done() - await activate(hass, "scene.test") + + assert hass.states.get("scene.test").state == STATE_UNKNOWN + + now = dt_util.utcnow() + with patch("homeassistant.core.dt_util.utcnow", return_value=now): + await activate(hass, "scene.test") + + assert hass.states.get("scene.test").state == now.isoformat() assert light.is_on(hass, light_1.entity_id) assert light.is_on(hass, light_2.entity_id) @@ -121,10 +136,14 @@ async def test_activate_scene(hass, entities, enable_custom_integrations): calls = async_mock_service(hass, "light", "turn_on") - await hass.services.async_call( - scene.DOMAIN, "turn_on", {"transition": 42, "entity_id": "scene.test"} - ) - await hass.async_block_till_done() + now = dt_util.utcnow() + with patch("homeassistant.core.dt_util.utcnow", return_value=now): + await hass.services.async_call( + scene.DOMAIN, "turn_on", {"transition": 42, "entity_id": "scene.test"} + ) + await hass.async_block_till_done() + + assert hass.states.get("scene.test").state == now.isoformat() assert len(calls) == 1 assert calls[0].domain == "light" @@ -132,6 +151,32 @@ async def test_activate_scene(hass, entities, enable_custom_integrations): assert calls[0].data.get("transition") == 42 +async def test_restore_state(hass, entities, enable_custom_integrations): + """Test we restore state integration.""" + mock_restore_cache(hass, (State("scene.test", "2021-01-01T23:59:59+00:00"),)) + + light_1, light_2 = await setup_lights(hass, entities) + + assert await async_setup_component( + hass, + scene.DOMAIN, + { + "scene": [ + { + "name": "test", + "entities": { + light_1.entity_id: "on", + light_2.entity_id: "on", + }, + } + ] + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("scene.test").state == "2021-01-01T23:59:59+00:00" + + async def activate(hass, entity_id=ENTITY_MATCH_ALL): """Activate a scene.""" data = {} diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index 1c02a35792bed8..10e73a809394fb 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -1,8 +1,8 @@ """Test script blueprints.""" import asyncio +from collections.abc import Iterator import contextlib import pathlib -from typing import Iterator from unittest.mock import patch from homeassistant.components import script diff --git a/tests/components/season/test_sensor.py b/tests/components/season/test_sensor.py index d1c6cfb0b9f225..7e673832121bf1 100644 --- a/tests/components/season/test_sensor.py +++ b/tests/components/season/test_sensor.py @@ -16,17 +16,17 @@ from homeassistant.setup import async_setup_component HEMISPHERE_NORTHERN = { - "homeassistant": {"latitude": "48.864716", "longitude": "2.349014"}, + "homeassistant": {"latitude": 48.864716, "longitude": 2.349014}, "sensor": {"platform": "season", "type": "astronomical"}, } HEMISPHERE_SOUTHERN = { - "homeassistant": {"latitude": "-33.918861", "longitude": "18.423300"}, + "homeassistant": {"latitude": -33.918861, "longitude": 18.423300}, "sensor": {"platform": "season", "type": "astronomical"}, } HEMISPHERE_EQUATOR = { - "homeassistant": {"latitude": "0", "longitude": "-51.065100"}, + "homeassistant": {"latitude": 0, "longitude": -51.065100}, "sensor": {"platform": "season", "type": "astronomical"}, } diff --git a/tests/components/select/test_device_action.py b/tests/components/select/test_device_action.py index 5c2486a4e267df..1d6ebe926268ae 100644 --- a/tests/components/select/test_device_action.py +++ b/tests/components/select/test_device_action.py @@ -3,6 +3,7 @@ import voluptuous_serialize from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.select import DOMAIN from homeassistant.components.select.device_action import async_get_action_capabilities from homeassistant.core import HomeAssistant @@ -56,7 +57,9 @@ async def test_get_actions( "entity_id": "select.test_5678", } ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) diff --git a/tests/components/select/test_device_condition.py b/tests/components/select/test_device_condition.py index d5ee88156cfe27..5a309924f81dc4 100644 --- a/tests/components/select/test_device_condition.py +++ b/tests/components/select/test_device_condition.py @@ -5,6 +5,7 @@ import voluptuous_serialize from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.select import DOMAIN from homeassistant.components.select.device_condition import ( async_get_condition_capabilities, @@ -67,7 +68,9 @@ async def test_get_conditions( "entity_id": f"{DOMAIN}.test_5678", } ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert_lists_same(conditions, expected_conditions) diff --git a/tests/components/select/test_device_trigger.py b/tests/components/select/test_device_trigger.py index b0066e9ac22899..3798d72b9484f6 100644 --- a/tests/components/select/test_device_trigger.py +++ b/tests/components/select/test_device_trigger.py @@ -5,6 +5,7 @@ import voluptuous_serialize from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.select import DOMAIN from homeassistant.components.select.device_trigger import ( async_get_trigger_capabilities, @@ -64,7 +65,9 @@ async def test_get_triggers( "entity_id": f"{DOMAIN}.test_5678", } ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) diff --git a/tests/components/senseme/__init__.py b/tests/components/senseme/__init__.py new file mode 100644 index 00000000000000..8c9a7669889ddf --- /dev/null +++ b/tests/components/senseme/__init__.py @@ -0,0 +1,143 @@ +"""Tests for the SenseME integration.""" + +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from aiosenseme import SensemeDevice, SensemeDiscovery + +from homeassistant.components.senseme import config_flow + +MOCK_NAME = "Haiku Fan" +MOCK_UUID = "77a6b7b3-925d-4695-a415-76d76dca4444" +MOCK_ADDRESS = "127.0.0.1" +MOCK_MAC = "20:F8:5E:92:5A:75" + + +def _mock_device(): + device = MagicMock(auto_spec=SensemeDevice) + device.async_update = AsyncMock() + device.model = "Haiku Fan" + device.fan_speed_max = 7 + device.mac = "aa:bb:cc:dd:ee:ff" + device.fan_dir = "REV" + device.has_light = True + device.is_light = False + device.light_brightness = 50 + device.room_name = "Main" + device.room_type = "Main" + device.fw_version = "1" + device.fan_autocomfort = "COOLING" + device.fan_smartmode = "OFF" + device.fan_whoosh_mode = "on" + device.name = MOCK_NAME + device.uuid = MOCK_UUID + device.address = MOCK_ADDRESS + device.get_device_info = { + "name": MOCK_NAME, + "uuid": MOCK_UUID, + "mac": MOCK_ADDRESS, + "address": MOCK_ADDRESS, + "base_model": "FAN,HAIKU,HSERIES", + "has_light": False, + "has_sensor": True, + "is_fan": True, + "is_light": False, + } + return device + + +device_alternate_ip = MagicMock(auto_spec=SensemeDevice) +device_alternate_ip.async_update = AsyncMock() +device_alternate_ip.model = "Haiku Fan" +device_alternate_ip.fan_speed_max = 7 +device_alternate_ip.mac = "aa:bb:cc:dd:ee:ff" +device_alternate_ip.fan_dir = "REV" +device_alternate_ip.room_name = "Main" +device_alternate_ip.room_type = "Main" +device_alternate_ip.fw_version = "1" +device_alternate_ip.fan_autocomfort = "on" +device_alternate_ip.fan_smartmode = "on" +device_alternate_ip.fan_whoosh_mode = "on" +device_alternate_ip.name = MOCK_NAME +device_alternate_ip.uuid = MOCK_UUID +device_alternate_ip.address = "127.0.0.8" +device_alternate_ip.get_device_info = { + "name": MOCK_NAME, + "uuid": MOCK_UUID, + "mac": "20:F8:5E:92:5A:75", + "address": "127.0.0.8", + "base_model": "FAN,HAIKU,HSERIES", + "has_light": False, + "has_sensor": True, + "is_fan": True, + "is_light": False, +} + + +device2 = MagicMock(auto_spec=SensemeDevice) +device2.async_update = AsyncMock() +device2.model = "Haiku Fan" +device2.fan_speed_max = 7 +device2.mac = "aa:bb:cc:dd:ee:ff" +device2.fan_dir = "FWD" +device2.room_name = "Main" +device2.room_type = "Main" +device2.fw_version = "1" +device2.fan_autocomfort = "on" +device2.fan_smartmode = "on" +device2.fan_whoosh_mode = "on" +device2.name = "Device 2" +device2.uuid = "uuid2" +device2.address = "127.0.0.2" +device2.get_device_info = { + "name": "Device 2", + "uuid": "uuid2", + "mac": "20:F8:5E:92:5A:76", + "address": "127.0.0.2", + "base_model": "FAN,HAIKU,HSERIES", + "has_light": True, + "has_sensor": True, + "is_fan": True, + "is_light": False, +} + +device_no_uuid = MagicMock(auto_spec=SensemeDevice) +device_no_uuid.uuid = None + + +MOCK_DEVICE = _mock_device() +MOCK_DEVICE_ALTERNATE_IP = device_alternate_ip +MOCK_DEVICE2 = device2 +MOCK_DEVICE_NO_UUID = device_no_uuid + + +def _patch_discovery(device=None, no_device=None): + """Patch discovery.""" + mock_senseme_discovery = MagicMock(auto_spec=SensemeDiscovery) + if not no_device: + mock_senseme_discovery.devices = [device or MOCK_DEVICE] + + @contextmanager + def _patcher(): + + with patch.object(config_flow, "DISCOVER_TIMEOUT", 0), patch( + "homeassistant.components.senseme.discovery.SensemeDiscovery", + return_value=mock_senseme_discovery, + ): + yield + + return _patcher() + + +def _patch_device(device=None, no_device=False): + async def _device_mocker(*args, **kwargs): + if no_device: + return False, None + if device: + return True, device + return True, _mock_device() + + return patch( + "homeassistant.components.senseme.async_get_device_by_device_info", + new=_device_mocker, + ) diff --git a/tests/components/senseme/test_config_flow.py b/tests/components/senseme/test_config_flow.py new file mode 100644 index 00000000000000..93a42d1e8ab11e --- /dev/null +++ b/tests/components/senseme/test_config_flow.py @@ -0,0 +1,365 @@ +"""Test the SenseME config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.components.senseme.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import ( + MOCK_ADDRESS, + MOCK_DEVICE, + MOCK_DEVICE2, + MOCK_DEVICE_ALTERNATE_IP, + MOCK_DEVICE_NO_UUID, + MOCK_MAC, + MOCK_UUID, + _patch_discovery, +) + +from tests.common import MockConfigEntry + +DHCP_DISCOVERY = dhcp.DhcpServiceInfo( + hostname="any", + ip=MOCK_ADDRESS, + macaddress=MOCK_MAC, +) + + +async def test_form_user(hass: HomeAssistant) -> None: + """Test we get the form as a user.""" + + with _patch_discovery(), patch( + "homeassistant.components.senseme.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "device": MOCK_UUID, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Haiku Fan" + assert result2["data"] == { + "info": MOCK_DEVICE.get_device_info, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_manual_entry(hass: HomeAssistant) -> None: + """Test we get the form as a user with a discovery but user chooses manual.""" + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "device": None, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "manual" + + with patch( + "homeassistant.components.senseme.config_flow.async_get_device_by_ip_address", + return_value=MOCK_DEVICE, + ), patch( + "homeassistant.components.senseme.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_ADDRESS, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "Haiku Fan" + assert result3["data"] == { + "info": MOCK_DEVICE.get_device_info, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_no_discovery(hass: HomeAssistant) -> None: + """Test we get the form as a user with no discovery.""" + + with _patch_discovery(no_device=True), patch( + "homeassistant.components.senseme.config_flow.async_get_device_by_ip_address", + return_value=MOCK_DEVICE, + ), patch( + "homeassistant.components.senseme.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "not a valid address", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "manual" + assert result2["errors"] == {CONF_HOST: "invalid_host"} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: MOCK_ADDRESS, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "Haiku Fan" + assert result3["data"] == { + "info": MOCK_DEVICE.get_device_info, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_user_manual_entry_cannot_connect(hass: HomeAssistant) -> None: + """Test we get the form as a user.""" + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "device": None, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "manual" + + with patch( + "homeassistant.components.senseme.config_flow.async_get_device_by_ip_address", + return_value=None, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_ADDRESS, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["step_id"] == "manual" + assert result3["errors"] == {CONF_HOST: "cannot_connect"} + + +async def test_discovery(hass: HomeAssistant) -> None: + """Test we can setup a discovered device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "info": MOCK_DEVICE2.get_device_info, + }, + unique_id=MOCK_DEVICE2.uuid, + ) + entry.add_to_hass(hass) + + with _patch_discovery(), patch( + "homeassistant.components.senseme.async_get_device_by_device_info", + return_value=(True, MOCK_DEVICE2), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with _patch_discovery(), patch( + "homeassistant.components.senseme.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={CONF_ID: MOCK_UUID}, + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "device": MOCK_UUID, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Haiku Fan" + assert result2["data"] == { + "info": MOCK_DEVICE.get_device_info, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_discovery_existing_device_no_ip_change(hass: HomeAssistant) -> None: + """Test we can setup a discovered device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "info": MOCK_DEVICE.get_device_info, + }, + unique_id=MOCK_DEVICE.uuid, + ) + entry.add_to_hass(hass) + + with _patch_discovery(), patch( + "homeassistant.components.senseme.async_get_device_by_device_info", + return_value=(True, MOCK_DEVICE), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={CONF_ID: MOCK_UUID}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_discovery_existing_device_ip_change(hass: HomeAssistant) -> None: + """Test a config entry ips get updated from discovery.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "info": MOCK_DEVICE.get_device_info, + }, + unique_id=MOCK_DEVICE.uuid, + ) + entry.add_to_hass(hass) + + with _patch_discovery(device=MOCK_DEVICE_ALTERNATE_IP), patch( + "homeassistant.components.senseme.async_get_device_by_device_info", + return_value=(True, MOCK_DEVICE), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data={CONF_ID: MOCK_UUID}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data["info"]["address"] == "127.0.0.8" + + +async def test_dhcp_discovery_existing_config_entry(hass: HomeAssistant) -> None: + """Test dhcp discovery is aborted if there is an existing config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "info": MOCK_DEVICE2.get_device_info, + }, + unique_id=MOCK_DEVICE2.uuid, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery(hass: HomeAssistant) -> None: + """Test we can setup a dhcp discovered device.""" + with _patch_discovery(), patch( + "homeassistant.components.senseme.config_flow.async_get_device_by_ip_address", + return_value=MOCK_DEVICE, + ), patch( + "homeassistant.components.senseme.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "device": MOCK_UUID, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Haiku Fan" + assert result2["data"] == { + "info": MOCK_DEVICE.get_device_info, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_discovery_cannot_connect(hass: HomeAssistant) -> None: + """Test we abort if we cannot cannot to a dhcp discovered device.""" + with _patch_discovery(), patch( + "homeassistant.components.senseme.config_flow.async_get_device_by_ip_address", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_discovery_cannot_connect_no_uuid(hass: HomeAssistant) -> None: + """Test we abort if the discovered device has no uuid.""" + with _patch_discovery(), patch( + "homeassistant.components.senseme.config_flow.async_get_device_by_ip_address", + return_value=MOCK_DEVICE_NO_UUID, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/senseme/test_light.py b/tests/components/senseme/test_light.py new file mode 100644 index 00000000000000..c585cfc31bf423 --- /dev/null +++ b/tests/components/senseme/test_light.py @@ -0,0 +1,118 @@ +"""Tests for senseme light platform.""" + + +from aiosenseme import SensemeDevice + +from homeassistant.components import senseme +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, + ATTR_COLOR_TEMP, + ATTR_SUPPORTED_COLOR_MODES, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.senseme.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from . import _mock_device, _patch_device, _patch_discovery + +from tests.common import MockConfigEntry + + +async def _setup_mocked_entry(hass: HomeAssistant, device: SensemeDevice) -> None: + """Set up a mocked entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"info": device.get_device_info}, + unique_id=device.uuid, + ) + entry.add_to_hass(hass) + with _patch_discovery(), _patch_device(device=device): + await async_setup_component(hass, senseme.DOMAIN, {senseme.DOMAIN: {}}) + await hass.async_block_till_done() + + +async def test_light_unique_id(hass: HomeAssistant) -> None: + """Test a light unique id.""" + device = _mock_device() + await _setup_mocked_entry(hass, device) + entity_id = "light.haiku_fan" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == f"{device.uuid}-LIGHT" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + +async def test_fan_light(hass: HomeAssistant) -> None: + """Test a fan light.""" + device = _mock_device() + await _setup_mocked_entry(hass, device) + entity_id = "light.haiku_fan" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 255 + assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_BRIGHTNESS + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_BRIGHTNESS] + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert device.light_on is False + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert device.light_on is True + + +async def test_fan_light_no_brightness(hass: HomeAssistant) -> None: + """Test a fan light without brightness.""" + device = _mock_device() + device.brightness = None + await _setup_mocked_entry(hass, device) + entity_id = "light.haiku_fan" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 255 + assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_BRIGHTNESS + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_BRIGHTNESS] + + +async def test_standalone_light(hass: HomeAssistant) -> None: + """Test a standalone light.""" + device = _mock_device() + device.is_light = True + device.light_color_temp_max = 6500 + device.light_color_temp_min = 2700 + device.light_color_temp = 4000 + await _setup_mocked_entry(hass, device) + entity_id = "light.haiku_fan_light" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + attributes = state.attributes + assert attributes[ATTR_BRIGHTNESS] == 255 + assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_COLOR_TEMP + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_COLOR_TEMP] + assert attributes[ATTR_COLOR_TEMP] == 250 + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert device.light_on is False + + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert device.light_on is True diff --git a/tests/components/sensibo/__init__.py b/tests/components/sensibo/__init__.py new file mode 100644 index 00000000000000..8dd2ed661bc128 --- /dev/null +++ b/tests/components/sensibo/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sensibo integration.""" diff --git a/tests/components/sensibo/test_config_flow.py b/tests/components/sensibo/test_config_flow.py new file mode 100644 index 00000000000000..cf3716f09e4046 --- /dev/null +++ b/tests/components/sensibo/test_config_flow.py @@ -0,0 +1,150 @@ +"""Test the Sensibo config flow.""" +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +import aiohttp +from pysensibo import SensiboError +import pytest + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + +DOMAIN = "sensibo" + + +def devices(): + """Return list of test devices.""" + return (yield from [{"id": "xyzxyz"}, {"id": "abcabc"}]) + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", + return_value=devices(), + ), patch( + "homeassistant.components.sensibo.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + "api_key": "1234567890", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + + with patch( + "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", + return_value=devices(), + ), patch( + "homeassistant.components.sensibo.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Sensibo" + assert result2["data"] == { + "api_key": "1234567890", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + }, + unique_id="1234567890", + ).add_to_hass(hass) + + with patch( + "homeassistant.components.sensibo.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", + return_value=devices(), + ): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "error_message", + [ + (aiohttp.ClientConnectionError), + (asyncio.TimeoutError), + (SensiboError), + ], +) +async def test_flow_fails(hass: HomeAssistant, error_message) -> None: + """Test config flow errors.""" + + result4 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result4["type"] == RESULT_TYPE_FORM + assert result4["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", + side_effect=error_message, + ): + result4 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + }, + ) + + assert result4["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 5742f7b47c4c0b..d2be84e12337ab 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -2,14 +2,10 @@ import pytest import homeassistant.components.automation as automation -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.sensor import DOMAIN, SensorDeviceClass from homeassistant.components.sensor.device_condition import ENTITY_CONDITIONS -from homeassistant.const import ( - CONF_PLATFORM, - DEVICE_CLASS_BATTERY, - PERCENTAGE, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_PLATFORM, PERCENTAGE, STATE_UNKNOWN from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -22,10 +18,7 @@ mock_registry, ) from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 -from tests.testing_config.custom_components.test.sensor import ( - DEVICE_CLASSES, - UNITS_OF_MEASUREMENT, -) +from tests.testing_config.custom_components.test.sensor import UNITS_OF_MEASUREMENT @pytest.fixture @@ -57,7 +50,7 @@ async def test_get_conditions(hass, device_reg, entity_reg, enable_custom_integr config_entry_id=config_entry.entry_id, connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - for device_class in DEVICE_CLASSES: + for device_class in SensorDeviceClass: entity_reg.async_get_or_create( DOMAIN, "test", @@ -76,12 +69,15 @@ async def test_get_conditions(hass, device_reg, entity_reg, enable_custom_integr "device_id": device_entry.id, "entity_id": platform.ENTITIES[device_class].entity_id, } - for device_class in DEVICE_CLASSES + for device_class in SensorDeviceClass if device_class in UNITS_OF_MEASUREMENT for condition in ENTITY_CONDITIONS[device_class] if device_class != "none" ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) + assert len(conditions) == 26 assert conditions == expected_conditions @@ -94,7 +90,7 @@ async def test_get_conditions_no_state(hass, device_reg, entity_reg): connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity_ids = {} - for device_class in DEVICE_CLASSES: + for device_class in SensorDeviceClass: entity_ids[device_class] = entity_reg.async_get_or_create( DOMAIN, "test", @@ -114,20 +110,22 @@ async def test_get_conditions_no_state(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": entity_ids[device_class], } - for device_class in DEVICE_CLASSES + for device_class in SensorDeviceClass if device_class in UNITS_OF_MEASUREMENT for condition in ENTITY_CONDITIONS[device_class] if device_class != "none" ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert conditions == expected_conditions @pytest.mark.parametrize( "set_state,device_class_reg,device_class_state,unit_reg,unit_state", [ - (False, DEVICE_CLASS_BATTERY, None, PERCENTAGE, None), - (True, None, DEVICE_CLASS_BATTERY, None, PERCENTAGE), + (False, SensorDeviceClass.BATTERY, None, PERCENTAGE, None), + (True, None, SensorDeviceClass.BATTERY, None, PERCENTAGE), ], ) async def test_get_condition_capabilities( @@ -181,11 +179,13 @@ async def test_get_condition_capabilities( }, ] } - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert len(conditions) == 1 for condition in conditions: capabilities = await async_get_device_automation_capabilities( - hass, "condition", condition + hass, DeviceAutomationType.CONDITION, condition ) assert capabilities == expected_capabilities @@ -223,7 +223,7 @@ async def test_get_condition_capabilities_none( expected_capabilities = {} for condition in conditions: capabilities = await async_get_device_automation_capabilities( - hass, "condition", condition + hass, DeviceAutomationType.CONDITION, condition ) assert capabilities == expected_capabilities diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index b8b3ee46a4335a..e37b0e9470fb97 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -4,14 +4,10 @@ import pytest import homeassistant.components.automation as automation -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.sensor import DOMAIN, SensorDeviceClass from homeassistant.components.sensor.device_trigger import ENTITY_TRIGGERS -from homeassistant.const import ( - CONF_PLATFORM, - DEVICE_CLASS_BATTERY, - PERCENTAGE, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_PLATFORM, PERCENTAGE, STATE_UNKNOWN from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -26,10 +22,7 @@ mock_registry, ) from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 -from tests.testing_config.custom_components.test.sensor import ( - DEVICE_CLASSES, - UNITS_OF_MEASUREMENT, -) +from tests.testing_config.custom_components.test.sensor import UNITS_OF_MEASUREMENT @pytest.fixture @@ -61,7 +54,7 @@ async def test_get_triggers(hass, device_reg, entity_reg, enable_custom_integrat config_entry_id=config_entry.entry_id, connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - for device_class in DEVICE_CLASSES: + for device_class in SensorDeviceClass: entity_reg.async_get_or_create( DOMAIN, "test", @@ -80,21 +73,23 @@ async def test_get_triggers(hass, device_reg, entity_reg, enable_custom_integrat "device_id": device_entry.id, "entity_id": platform.ENTITIES[device_class].entity_id, } - for device_class in DEVICE_CLASSES + for device_class in SensorDeviceClass if device_class in UNITS_OF_MEASUREMENT for trigger in ENTITY_TRIGGERS[device_class] if device_class != "none" ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert len(triggers) == 24 + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert len(triggers) == 26 assert triggers == expected_triggers @pytest.mark.parametrize( "set_state,device_class_reg,device_class_state,unit_reg,unit_state", [ - (False, DEVICE_CLASS_BATTERY, None, PERCENTAGE, None), - (True, None, DEVICE_CLASS_BATTERY, None, PERCENTAGE), + (False, SensorDeviceClass.BATTERY, None, PERCENTAGE, None), + (True, None, SensorDeviceClass.BATTERY, None, PERCENTAGE), ], ) async def test_get_trigger_capabilities( @@ -149,11 +144,13 @@ async def test_get_trigger_capabilities( {"name": "for", "optional": True, "type": "positive_time_period_dict"}, ] } - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 1 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == expected_capabilities @@ -191,7 +188,7 @@ async def test_get_trigger_capabilities_none( expected_capabilities = {} for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == expected_capabilities diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index d5deee416798ff..b49d8894932b3d 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -4,12 +4,9 @@ import pytest from pytest import approx -from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_DATE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, @@ -45,7 +42,7 @@ async def test_temperature_conversion( name="Test", native_value=str(native_value), native_unit_of_measurement=native_unit, - device_class=DEVICE_CLASS_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, ) entity0 = platform.ENTITIES["0"] @@ -84,12 +81,15 @@ async def test_deprecated_temperature_conversion( ) in caplog.text -async def test_deprecated_last_reset(hass, caplog, enable_custom_integrations): +@pytest.mark.parametrize("state_class", ("measurement", "total_increasing")) +async def test_deprecated_last_reset( + hass, caplog, enable_custom_integrations, state_class +): """Test warning on deprecated last reset.""" platform = getattr(hass.components, "test.sensor") platform.init(empty=True) platform.ENTITIES["0"] = platform.MockSensor( - name="Test", state_class="measurement", last_reset=dt_util.utc_from_timestamp(0) + name="Test", state_class=state_class, last_reset=dt_util.utc_from_timestamp(0) ) assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) @@ -97,13 +97,15 @@ async def test_deprecated_last_reset(hass, caplog, enable_custom_integrations): assert ( "Entity sensor.test () " - "with state_class measurement has set last_reset. Setting last_reset for " - "entities with state_class other than 'total' is deprecated and will be " - "removed from Home Assistant Core 2021.11. Please update your configuration if " - "state_class is manually configured, otherwise report it to the custom " - "component author." + f"with state_class {state_class} has set last_reset. Setting last_reset for " + "entities with state_class other than 'total' is not supported. Please update " + "your configuration if state_class is manually configured, otherwise report it " + "to the custom component author." ) in caplog.text + state = hass.states.get("sensor.test") + assert "last_reset" not in state.attributes + async def test_deprecated_unit_of_measurement(hass, caplog, enable_custom_integrations): """Test warning on deprecated unit_of_measurement.""" @@ -124,21 +126,23 @@ async def test_datetime_conversion(hass, caplog, enable_custom_integrations): platform = getattr(hass.components, "test.sensor") platform.init(empty=True) platform.ENTITIES["0"] = platform.MockSensor( - name="Test", native_value=test_timestamp, device_class=DEVICE_CLASS_TIMESTAMP + name="Test", + native_value=test_timestamp, + device_class=SensorDeviceClass.TIMESTAMP, ) platform.ENTITIES["1"] = platform.MockSensor( - name="Test", native_value=test_date, device_class=DEVICE_CLASS_DATE + name="Test", native_value=test_date, device_class=SensorDeviceClass.DATE ) platform.ENTITIES["2"] = platform.MockSensor( - name="Test", native_value=None, device_class=DEVICE_CLASS_TIMESTAMP + name="Test", native_value=None, device_class=SensorDeviceClass.TIMESTAMP ) platform.ENTITIES["3"] = platform.MockSensor( - name="Test", native_value=None, device_class=DEVICE_CLASS_DATE + name="Test", native_value=None, device_class=SensorDeviceClass.DATE ) platform.ENTITIES["4"] = platform.MockSensor( name="Test", native_value=test_local_timestamp, - device_class=DEVICE_CLASS_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, ) assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) @@ -163,44 +167,44 @@ async def test_datetime_conversion(hass, caplog, enable_custom_integrations): @pytest.mark.parametrize( "device_class,native_value,state_value", [ - (DEVICE_CLASS_DATE, "2021-11-09", "2021-11-09"), + (SensorDeviceClass.DATE, "2021-11-09", "2021-11-09"), ( - DEVICE_CLASS_DATE, + SensorDeviceClass.DATE, "2021-01-09T12:00:00+00:00", "2021-01-09", ), ( - DEVICE_CLASS_DATE, + SensorDeviceClass.DATE, "2021-01-09T00:00:00+01:00", "2021-01-08", ), ( - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.TIMESTAMP, "2021-01-09T12:00:00+00:00", "2021-01-09T12:00:00+00:00", ), ( - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.TIMESTAMP, "2021-01-09 12:00:00+00:00", "2021-01-09T12:00:00+00:00", ), ( - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.TIMESTAMP, "2021-01-09T12:00:00+04:00", "2021-01-09T08:00:00+00:00", ), ( - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.TIMESTAMP, "2021-01-09 12:00:00+01:00", "2021-01-09T11:00:00+00:00", ), ( - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.TIMESTAMP, "2021-01-09 12:00:00", "2021-01-09T12:00:00", ), ( - DEVICE_CLASS_TIMESTAMP, + SensorDeviceClass.TIMESTAMP, "2021-01-09T12:00:00", "2021-01-09T12:00:00", ), @@ -237,7 +241,9 @@ async def test_reject_timezoneless_datetime_str( platform = getattr(hass.components, "test.sensor") platform.init(empty=True) platform.ENTITIES["0"] = platform.MockSensor( - name="Test", native_value=test_timestamp, device_class=DEVICE_CLASS_TIMESTAMP + name="Test", + native_value=test_timestamp, + device_class=SensorDeviceClass.TIMESTAMP, ) assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 2da1a203dfdae7..155060222c807a 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -36,7 +36,7 @@ } ENERGY_SENSOR_ATTRIBUTES = { "device_class": "energy", - "state_class": "measurement", + "state_class": "total", "unit_of_measurement": "kWh", } NONE_SENSOR_ATTRIBUTES = { @@ -59,7 +59,7 @@ } GAS_SENSOR_ATTRIBUTES = { "device_class": "gas", - "state_class": "measurement", + "state_class": "total", "unit_of_measurement": "m³", } @@ -305,7 +305,7 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes assert "Error while processing event StatisticsTask" not in caplog.text -@pytest.mark.parametrize("state_class", ["measurement", "total"]) +@pytest.mark.parametrize("state_class", ["total"]) @pytest.mark.parametrize( "units,device_class,unit,display_unit,factor", [ @@ -431,7 +431,7 @@ def test_compile_hourly_sum_statistics_amount( assert "Detected new cycle for sensor.test1, value dropped" not in caplog.text -@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize("state_class", ["total"]) @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -540,7 +540,7 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( assert "Error while processing event StatisticsTask" not in caplog.text -@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize("state_class", ["total"]) @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -617,7 +617,7 @@ def test_compile_hourly_sum_statistics_amount_invalid_last_reset( assert "Ignoring invalid last reset 'festivus' for sensor.test1" in caplog.text -@pytest.mark.parametrize("state_class", ["measurement"]) +@pytest.mark.parametrize("state_class", ["total"]) @pytest.mark.parametrize( "device_class,unit,native_unit,factor", [ @@ -1032,12 +1032,13 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( recorder.do_adhoc_statistics(start=period2) wait_recording_done(hass) state = states["sensor.test1"][6].state + previous_state = float(states["sensor.test1"][5].state) last_updated = states["sensor.test1"][6].last_updated.isoformat() assert ( "Entity sensor.test1 has state class total_increasing, but its state is not " - f"strictly increasing. Triggered by state {state} with last_updated set to " - f"{last_updated}. Please create a bug report at https://github.com/home-assistant" - "/core/issues?q=is%3Aopen+is%3Aissue" + f"strictly increasing. Triggered by state {state} ({previous_state}) with " + f"last_updated set to {last_updated}. Please create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) in caplog.text statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ @@ -1100,21 +1101,15 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): setup_component(hass, "sensor", {}) sns1_attr = { "device_class": "energy", - "state_class": "measurement", + "state_class": "total", "unit_of_measurement": "kWh", "last_reset": None, } sns2_attr = {"device_class": "energy"} sns3_attr = {} - sns4_attr = { - "device_class": "energy", - "state_class": "measurement", - "unit_of_measurement": "kWh", - } seq1 = [10, 15, 20, 10, 30, 40, 50, 60, 70] seq2 = [110, 120, 130, 0, 30, 45, 55, 65, 75] seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] four, eight, states = record_meter_states( hass, period0, "sensor.test1", sns1_attr, seq1 @@ -1123,8 +1118,6 @@ def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): states = {**states, **_states} _, _, _states = record_meter_states(hass, period0, "sensor.test3", sns3_attr, seq3) states = {**states, **_states} - _, _, _states = record_meter_states(hass, period0, "sensor.test4", sns4_attr, seq4) - states = {**states, **_states} hist = history.get_significant_states( hass, period0 - timedelta.resolution, eight + timedelta.resolution @@ -1203,11 +1196,9 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): "unit_of_measurement": "Wh", "last_reset": None, } - sns4_attr = {**ENERGY_SENSOR_ATTRIBUTES} seq1 = [10, 15, 20, 10, 30, 40, 50, 60, 70] seq2 = [110, 120, 130, 0, 30, 45, 55, 65, 75] seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] four, eight, states = record_meter_states( hass, period0, "sensor.test1", sns1_attr, seq1 @@ -1216,8 +1207,6 @@ def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): states = {**states, **_states} _, _, _states = record_meter_states(hass, period0, "sensor.test3", sns3_attr, seq3) states = {**states, **_states} - _, _, _states = record_meter_states(hass, period0, "sensor.test4", sns4_attr, seq4) - states = {**states, **_states} hist = history.get_significant_states( hass, period0 - timedelta.resolution, eight + timedelta.resolution ) @@ -1522,29 +1511,35 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog): @pytest.mark.parametrize( - "device_class,unit,native_unit,statistic_type", + "state_class,device_class,unit,native_unit,statistic_type", [ - ("battery", "%", "%", "mean"), - ("battery", None, None, "mean"), - ("energy", "Wh", "kWh", "sum"), - ("energy", "kWh", "kWh", "sum"), - ("humidity", "%", "%", "mean"), - ("humidity", None, None, "mean"), - ("monetary", "USD", "USD", "sum"), - ("monetary", "None", "None", "sum"), - ("gas", "m³", "m³", "sum"), - ("gas", "ft³", "m³", "sum"), - ("pressure", "Pa", "Pa", "mean"), - ("pressure", "hPa", "Pa", "mean"), - ("pressure", "mbar", "Pa", "mean"), - ("pressure", "inHg", "Pa", "mean"), - ("pressure", "psi", "Pa", "mean"), - ("temperature", "°C", "°C", "mean"), - ("temperature", "°F", "°C", "mean"), + ("measurement", "battery", "%", "%", "mean"), + ("measurement", "battery", None, None, "mean"), + ("total", "energy", "Wh", "kWh", "sum"), + ("total", "energy", "kWh", "kWh", "sum"), + ("measurement", "energy", "Wh", "kWh", "mean"), + ("measurement", "energy", "kWh", "kWh", "mean"), + ("measurement", "humidity", "%", "%", "mean"), + ("measurement", "humidity", None, None, "mean"), + ("total", "monetary", "USD", "USD", "sum"), + ("total", "monetary", "None", "None", "sum"), + ("total", "gas", "m³", "m³", "sum"), + ("total", "gas", "ft³", "m³", "sum"), + ("measurement", "monetary", "USD", "USD", "mean"), + ("measurement", "monetary", "None", "None", "mean"), + ("measurement", "gas", "m³", "m³", "mean"), + ("measurement", "gas", "ft³", "m³", "mean"), + ("measurement", "pressure", "Pa", "Pa", "mean"), + ("measurement", "pressure", "hPa", "Pa", "mean"), + ("measurement", "pressure", "mbar", "Pa", "mean"), + ("measurement", "pressure", "inHg", "Pa", "mean"), + ("measurement", "pressure", "psi", "Pa", "mean"), + ("measurement", "temperature", "°C", "°C", "mean"), + ("measurement", "temperature", "°F", "°C", "mean"), ], ) def test_list_statistic_ids( - hass_recorder, caplog, device_class, unit, native_unit, statistic_type + hass_recorder, caplog, state_class, device_class, unit, native_unit, statistic_type ): """Test listing future statistic ids.""" hass = hass_recorder() @@ -1552,7 +1547,7 @@ def test_list_statistic_ids( attributes = { "device_class": device_class, "last_reset": 0, - "state_class": "measurement", + "state_class": state_class, "unit_of_measurement": unit, } hass.states.set("sensor.test1", 0, attributes=attributes) diff --git a/tests/components/sensor/test_significant_change.py b/tests/components/sensor/test_significant_change.py index 22a2c22ecc70b4..051a92f3b07ef4 100644 --- a/tests/components/sensor/test_significant_change.py +++ b/tests/components/sensor/test_significant_change.py @@ -1,13 +1,7 @@ """Test the sensor significant change platform.""" import pytest -from homeassistant.components.sensor.significant_change import ( - DEVICE_CLASS_AQI, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, - async_check_significant_change, -) +from homeassistant.components.sensor import SensorDeviceClass, significant_change from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, @@ -16,24 +10,24 @@ ) AQI_ATTRS = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_AQI, + ATTR_DEVICE_CLASS: SensorDeviceClass.AQI, } BATTERY_ATTRS = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, } HUMIDITY_ATTRS = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, } TEMP_CELSIUS_ATTRS = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, } TEMP_FREEDOM_ATTRS = { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, } @@ -63,6 +57,8 @@ async def test_significant_change_temperature(old_state, new_state, attrs, result): """Detect temperature significant changes.""" assert ( - async_check_significant_change(None, old_state, attrs, new_state, attrs) + significant_change.async_check_significant_change( + None, old_state, attrs, new_state, attrs + ) is result ) diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index 4e22822150bbb2..98e64a8c7784b6 100644 --- a/tests/components/seventeentrack/test_sensor.py +++ b/tests/components/seventeentrack/test_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import patch from py17track.package import Package import pytest @@ -301,12 +301,14 @@ async def test_delivered_not_shown(hass): ) ProfileMock.package_list = [package] - hass.components.persistent_notification = MagicMock() - await _setup_seventeentrack(hass, VALID_CONFIG_FULL_NO_DELIVERED) - await _goto_future(hass) + with patch( + "homeassistant.components.seventeentrack.sensor.persistent_notification" + ) as persistent_notification_mock: + await _setup_seventeentrack(hass, VALID_CONFIG_FULL_NO_DELIVERED) + await _goto_future(hass) - assert not hass.states.async_entity_ids() - hass.components.persistent_notification.create.assert_called() + assert not hass.states.async_entity_ids() + persistent_notification_mock.create.assert_called() async def test_delivered_shown(hass): @@ -324,12 +326,14 @@ async def test_delivered_shown(hass): ) ProfileMock.package_list = [package] - hass.components.persistent_notification = MagicMock() - await _setup_seventeentrack(hass, VALID_CONFIG_FULL) + with patch( + "homeassistant.components.seventeentrack.sensor.persistent_notification" + ) as persistent_notification_mock: + await _setup_seventeentrack(hass, VALID_CONFIG_FULL) - assert hass.states.get("sensor.seventeentrack_package_456") is not None - assert len(hass.states.async_entity_ids()) == 1 - hass.components.persistent_notification.create.assert_not_called() + assert hass.states.get("sensor.seventeentrack_package_456") is not None + assert len(hass.states.async_entity_ids()) == 1 + persistent_notification_mock.create.assert_not_called() async def test_becomes_delivered_not_shown_notification(hass): @@ -364,21 +368,48 @@ async def test_becomes_delivered_not_shown_notification(hass): ) ProfileMock.package_list = [package_delivered] - hass.components.persistent_notification = MagicMock() - await _goto_future(hass) + with patch( + "homeassistant.components.seventeentrack.sensor.persistent_notification" + ) as persistent_notification_mock: + await _goto_future(hass) - hass.components.persistent_notification.create.assert_called() - assert not hass.states.async_entity_ids() + persistent_notification_mock.create.assert_called() + assert not hass.states.async_entity_ids() async def test_summary_correctly_updated(hass): """Ensure summary entities are not duplicated.""" + package = Package( + tracking_number="456", + destination_country=206, + friendly_name="friendly name 1", + info_text="info text 1", + location="location 1", + timestamp="2020-08-10 10:32", + origin_country=206, + package_type=2, + status=30, + ) + ProfileMock.package_list = [package] + await _setup_seventeentrack(hass, summary_data=DEFAULT_SUMMARY) - assert len(hass.states.async_entity_ids()) == 7 + assert len(hass.states.async_entity_ids()) == 8 for state in hass.states.async_all(): + if state.entity_id == "sensor.seventeentrack_package_456": + break assert state.state == "0" + assert ( + len( + hass.states.get( + "sensor.seventeentrack_packages_ready_to_be_picked_up" + ).attributes["packages"] + ) + == 1 + ) + + ProfileMock.package_list = [] ProfileMock.summary_data = NEW_SUMMARY_DATA await _goto_future(hass) @@ -387,6 +418,13 @@ async def test_summary_correctly_updated(hass): for state in hass.states.async_all(): assert state.state == "1" + assert ( + hass.states.get( + "sensor.seventeentrack_packages_ready_to_be_picked_up" + ).attributes["packages"] + is None + ) + async def test_utc_timestamp(hass): """Ensure package timestamp is converted correctly from HA-defined time zone to UTC.""" diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index b36359ed31aceb..5080c37910886d 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -1,9 +1,10 @@ """Test the Shark IQ vacuum entity.""" from __future__ import annotations +from collections.abc import Iterable from copy import deepcopy import enum -from typing import Any, Iterable +from typing import Any from unittest.mock import patch import pytest diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index 238fb9b0336169..0532fa5c82ca1d 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) @@ -28,28 +29,51 @@ ) -async def test_get_triggers_block_device(hass, coap_wrapper): +@pytest.mark.parametrize( + "button_type, is_valid", + [ + ("momentary", True), + ("momentary_on_release", True), + ("detached", True), + ("toggle", False), + ], +) +async def test_get_triggers_block_device( + hass, coap_wrapper, monkeypatch, button_type, is_valid +): """Test we get the expected triggers from a shelly block device.""" assert coap_wrapper - expected_triggers = [ - { - CONF_PLATFORM: "device", - CONF_DEVICE_ID: coap_wrapper.device_id, - CONF_DOMAIN: DOMAIN, - CONF_TYPE: "single", - CONF_SUBTYPE: "button1", - }, - { - CONF_PLATFORM: "device", - CONF_DEVICE_ID: coap_wrapper.device_id, - CONF_DOMAIN: DOMAIN, - CONF_TYPE: "long", - CONF_SUBTYPE: "button1", - }, - ] + + monkeypatch.setitem( + coap_wrapper.device.settings, + "relays", + [ + {"btn_type": button_type}, + {"btn_type": "toggle"}, + ], + ) + + expected_triggers = [] + if is_valid: + expected_triggers = [ + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "single", + CONF_SUBTYPE: "button1", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: coap_wrapper.device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: "long", + CONF_SUBTYPE: "button1", + }, + ] triggers = await async_get_device_automations( - hass, "trigger", coap_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, coap_wrapper.device_id ) assert_lists_same(triggers, expected_triggers) @@ -97,7 +121,7 @@ async def test_get_triggers_rpc_device(hass, rpc_wrapper): ] triggers = await async_get_device_automations( - hass, "trigger", rpc_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, rpc_wrapper.device_id ) assert_lists_same(triggers, expected_triggers) @@ -162,7 +186,7 @@ async def test_get_triggers_button(hass): ] triggers = await async_get_device_automations( - hass, "trigger", coap_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, coap_wrapper.device_id ) assert_lists_same(triggers, expected_triggers) @@ -198,7 +222,7 @@ async def test_get_triggers_non_initialized_devices(hass): expected_triggers = [] triggers = await async_get_device_automations( - hass, "trigger", coap_wrapper.device_id + hass, DeviceAutomationType.TRIGGER, coap_wrapper.device_id ) assert_lists_same(triggers, expected_triggers) @@ -215,7 +239,9 @@ async def test_get_triggers_for_invalid_device_id(hass, device_reg, coap_wrapper ) with pytest.raises(InvalidDeviceAutomationConfig): - await async_get_device_automations(hass, "trigger", invalid_device.id) + await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, invalid_device.id + ) async def test_if_fires_on_click_event_block_device(hass, calls, coap_wrapper): diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py new file mode 100644 index 00000000000000..4f3ca89e548b21 --- /dev/null +++ b/tests/components/shelly/test_diagnostics.py @@ -0,0 +1,80 @@ +"""The scene tests for the myq platform.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.diagnostics import TO_REDACT +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry + +RELAY_BLOCK_ID = 0 + + +async def test_block_config_entry_diagnostics( + hass: HomeAssistant, hass_client: ClientSession, coap_wrapper +): + """Test config entry diagnostics for block device.""" + assert coap_wrapper + + entry = hass.config_entries.async_entries(DOMAIN)[0] + entry_dict = entry.as_dict() + entry_dict["data"].update( + {key: REDACTED for key in TO_REDACT if key in entry_dict["data"]} + ) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == { + "entry": entry_dict, + "device_info": { + "name": coap_wrapper.name, + "model": coap_wrapper.model, + "sw_version": coap_wrapper.sw_version, + }, + "device_settings": {"coiot": {"update_period": 15}}, + "device_status": { + "update": { + "beta_version": "some_beta_version", + "has_update": True, + "new_version": "some_new_version", + "old_version": "some_old_version", + "status": "pending", + } + }, + } + + +async def test_rpc_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + rpc_wrapper, +): + """Test config entry diagnostics for rpc device.""" + assert rpc_wrapper + + entry = hass.config_entries.async_entries(DOMAIN)[0] + entry_dict = entry.as_dict() + entry_dict["data"].update( + {key: REDACTED for key in TO_REDACT if key in entry_dict["data"]} + ) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == { + "entry": entry_dict, + "device_info": { + "name": rpc_wrapper.name, + "model": rpc_wrapper.model, + "sw_version": rpc_wrapper.sw_version, + }, + "device_settings": {}, + "device_status": { + "sys": { + "available_updates": { + "beta": {"version": "some_beta_version"}, + "stable": {"version": "some_beta_version"}, + } + } + }, + } diff --git a/tests/components/signal_messenger/conftest.py b/tests/components/signal_messenger/conftest.py index bc7aa7f84a1ced..017f598b93c9d6 100644 --- a/tests/components/signal_messenger/conftest.py +++ b/tests/components/signal_messenger/conftest.py @@ -3,37 +3,69 @@ from pysignalclirestapi import SignalCliRestApi import pytest +from requests_mock.mocker import Mocker from homeassistant.components.signal_messenger.notify import SignalNotificationService +from homeassistant.core import HomeAssistant + +SIGNAL_SEND_PATH_SUFIX = "/v2/send" +MESSAGE = "Testing Signal Messenger platform :)" +CONTENT = b"TestContent" +NUMBER_FROM = "+43443434343" +NUMBERS_TO = ["+435565656565"] +URL_ATTACHMENT = "http://127.0.0.1:8080/image.jpg" @pytest.fixture -def signal_notification_service(): +def signal_notification_service(hass: HomeAssistant) -> SignalNotificationService: """Set up signal notification service.""" + hass.config.allowlist_external_urls.add(URL_ATTACHMENT) recipients = ["+435565656565"] number = "+43443434343" client = SignalCliRestApi("http://127.0.0.1:8080", number) - return SignalNotificationService(recipients, client) + return SignalNotificationService(hass, recipients, client) -SIGNAL_SEND_PATH_SUFIX = "/v2/send" -MESSAGE = "Testing Signal Messenger platform :)" -NUMBER_FROM = "+43443434343" -NUMBERS_TO = ["+435565656565"] +@pytest.fixture +def signal_requests_mock_factory(requests_mock: Mocker) -> Mocker: + """Create signal service mock from factory.""" + def _signal_requests_mock_factory( + success_send_result: bool = True, content_length_header: str = None + ) -> Mocker: + requests_mock.register_uri( + "GET", + "http://127.0.0.1:8080/v1/about", + status_code=HTTPStatus.OK, + json={"versions": ["v1", "v2"]}, + ) + if success_send_result: + requests_mock.register_uri( + "POST", + "http://127.0.0.1:8080" + SIGNAL_SEND_PATH_SUFIX, + status_code=HTTPStatus.CREATED, + ) + else: + requests_mock.register_uri( + "POST", + "http://127.0.0.1:8080" + SIGNAL_SEND_PATH_SUFIX, + status_code=HTTPStatus.BAD_REQUEST, + ) + if content_length_header is not None: + requests_mock.register_uri( + "GET", + URL_ATTACHMENT, + status_code=HTTPStatus.OK, + content=CONTENT, + headers={"Content-Length": content_length_header}, + ) + else: + requests_mock.register_uri( + "GET", + URL_ATTACHMENT, + status_code=HTTPStatus.OK, + content=CONTENT, + ) + return requests_mock -@pytest.fixture -def signal_requests_mock(requests_mock): - """Prepare signal service mock.""" - requests_mock.register_uri( - "POST", - "http://127.0.0.1:8080" + SIGNAL_SEND_PATH_SUFIX, - status_code=HTTPStatus.CREATED, - ) - requests_mock.register_uri( - "GET", - "http://127.0.0.1:8080/v1/about", - status_code=HTTPStatus.OK, - json={"versions": ["v1", "v2"]}, - ) - return requests_mock + return _signal_requests_mock_factory diff --git a/tests/components/signal_messenger/test_notify.py b/tests/components/signal_messenger/test_notify.py index 1feefa28513191..6ed57813f46475 100644 --- a/tests/components/signal_messenger/test_notify.py +++ b/tests/components/signal_messenger/test_notify.py @@ -1,23 +1,33 @@ """The tests for the signal_messenger platform.""" +import base64 import json import logging import os import tempfile from unittest.mock import patch +from pysignalclirestapi.api import SignalCliRestApiError +import pytest +from requests_mock.mocker import Mocker +import voluptuous as vol + +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.components.signal_messenger.conftest import ( + CONTENT, MESSAGE, NUMBER_FROM, NUMBERS_TO, SIGNAL_SEND_PATH_SUFIX, + URL_ATTACHMENT, + SignalNotificationService, ) BASE_COMPONENT = "notify" -async def test_signal_messenger_init(hass): +async def test_signal_messenger_init(hass: HomeAssistant) -> None: """Test that service loads successfully.""" config = { BASE_COMPONENT: { @@ -36,8 +46,13 @@ async def test_signal_messenger_init(hass): assert hass.services.has_service(BASE_COMPONENT, "test") -def test_send_message(signal_notification_service, signal_requests_mock, caplog): +def test_send_message( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: """Test send message.""" + signal_requests_mock = signal_requests_mock_factory() with caplog.at_level( logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" ): @@ -48,32 +63,58 @@ def test_send_message(signal_notification_service, signal_requests_mock, caplog) assert_sending_requests(signal_requests_mock) -def test_send_message_should_show_deprecation_warning( - signal_notification_service, signal_requests_mock, caplog -): - """Test send message should show deprecation warning.""" +def test_send_message_to_api_with_bad_data_throws_error( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sending a message with bad data to the API throws an error.""" + signal_requests_mock = signal_requests_mock_factory(False) with caplog.at_level( - logging.WARNING, logger="homeassistant.components.signal_messenger.notify" + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" ): - send_message_with_attachment(signal_notification_service, True) + with pytest.raises(SignalCliRestApiError) as exc: + signal_notification_service.send_message(MESSAGE) - assert ( - "The 'attachment' option is deprecated, please replace it with 'attachments'. This option will become invalid in version 0.108" - in caplog.text - ) + assert "Sending signal message" in caplog.text assert signal_requests_mock.called assert signal_requests_mock.call_count == 2 - assert_sending_requests(signal_requests_mock, 1) + assert "Couldn't send signal message" in str(exc.value) + + +def test_send_message_with_bad_data_throws_vol_error( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sending a message with bad data throws an error.""" + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ): + with pytest.raises(vol.Invalid) as exc: + data = {"test": "test"} + signal_notification_service.send_message(MESSAGE, **{"data": data}) + + assert "Sending signal message" in caplog.text + assert "extra keys not allowed" in str(exc.value) def test_send_message_with_attachment( - signal_notification_service, signal_requests_mock, caplog -): + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: """Test send message with attachment.""" + signal_requests_mock = signal_requests_mock_factory() with caplog.at_level( logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" ): - send_message_with_attachment(signal_notification_service, False) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".png", prefix=os.path.basename(__file__) + ) as temp_file: + temp_file.write("attachment_data") + data = {"attachments": [temp_file.name]} + signal_notification_service.send_message(MESSAGE, **{"data": data}) assert "Sending signal message" in caplog.text assert signal_requests_mock.called @@ -81,19 +122,211 @@ def test_send_message_with_attachment( assert_sending_requests(signal_requests_mock, 1) -def send_message_with_attachment(signal_notification_service, deprecated=False): - """Send message with attachment.""" - with tempfile.NamedTemporaryFile( - mode="w", suffix=".png", prefix=os.path.basename(__file__) - ) as tf: - tf.write("attachment_data") - data = {"attachment": tf.name} if deprecated else {"attachments": [tf.name]} +def test_send_message_with_attachment_as_url( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test send message with attachment as URL.""" + signal_requests_mock = signal_requests_mock_factory(True, str(len(CONTENT))) + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ): + data = {"urls": [URL_ATTACHMENT]} signal_notification_service.send_message(MESSAGE, **{"data": data}) + assert "Sending signal message" in caplog.text + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 3 + assert_sending_requests(signal_requests_mock, 1) + + +def test_get_attachments( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL.""" + signal_requests_mock = signal_requests_mock_factory(True, str(len(CONTENT))) + data = {"urls": [URL_ATTACHMENT]} + result = signal_notification_service.get_attachments_as_bytes( + data, len(CONTENT), hass + ) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert result == [bytearray(CONTENT)] + + +def test_get_attachments_not_on_allowlist( + signal_notification_service: SignalNotificationService, + caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL that aren't on the allowlist.""" + url = "http://dodgyurl.com" + data = {"urls": [url]} + with caplog.at_level( + logging.ERROR, logger="homeassistant.components.signal_messenger.notify" + ): + result = signal_notification_service.get_attachments_as_bytes( + data, len(CONTENT), hass + ) + + assert f"URL '{url}' not in allow list" in caplog.text + assert result is None + + +def test_get_attachments_with_large_attachment( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with large attachment (per Content-Length header) throws error.""" + signal_requests_mock = signal_requests_mock_factory(True, str(len(CONTENT) + 1)) + with pytest.raises(ValueError) as exc: + data = {"urls": [URL_ATTACHMENT]} + signal_notification_service.get_attachments_as_bytes(data, len(CONTENT), hass) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert "Attachment too large (Content-Length reports" in str(exc.value) + + +def test_get_attachments_with_large_attachment_no_header( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with large attachment (per content length) throws error.""" + signal_requests_mock = signal_requests_mock_factory() + with pytest.raises(ValueError) as exc: + data = {"urls": [URL_ATTACHMENT]} + signal_notification_service.get_attachments_as_bytes( + data, len(CONTENT) - 1, hass + ) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert "Attachment too large (Stream reports" in str(exc.value) + + +def test_get_filenames_with_none_data( + signal_notification_service: SignalNotificationService, +) -> None: + """Test getting filenames with None data returns None.""" + data = None + result = signal_notification_service.get_filenames(data) + + assert result is None + + +def test_get_filenames_with_attachments_data( + signal_notification_service: SignalNotificationService, +) -> None: + """Test getting filenames with 'attachments' in data.""" + data = {"attachments": ["test"]} + result = signal_notification_service.get_filenames(data) + + assert result == ["test"] + + +def test_get_filenames_with_multiple_attachments_data( + signal_notification_service: SignalNotificationService, +) -> None: + """Test getting filenames with multiple 'attachments' in data.""" + data = {"attachments": ["test", "test2"]} + result = signal_notification_service.get_filenames(data) + + assert result == ["test", "test2"] + + +def test_get_filenames_with_non_list_returns_none( + signal_notification_service: SignalNotificationService, +) -> None: + """Test getting filenames with non list data.""" + data = {"attachments": "test"} + result = signal_notification_service.get_filenames(data) + + assert result is None + + +def test_get_attachments_with_non_list_returns_none( + signal_notification_service: SignalNotificationService, + hass: HomeAssistant, +) -> None: + """Test getting attachments with non list data.""" + data = {"urls": URL_ATTACHMENT} + result = signal_notification_service.get_attachments_as_bytes( + data, len(CONTENT), hass + ) + + assert result is None + -def assert_sending_requests(signal_requests_mock, attachments_num=0): +def test_get_attachments_with_verify_unset( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with verify_ssl unset results in verify=true.""" + signal_requests_mock = signal_requests_mock_factory() + data = {"urls": [URL_ATTACHMENT]} + signal_notification_service.get_attachments_as_bytes(data, len(CONTENT), hass) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert signal_requests_mock.last_request.verify is True + + +def test_get_attachments_with_verify_set_true( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with verify_ssl set to true results in verify=true.""" + signal_requests_mock = signal_requests_mock_factory() + data = {"verify_ssl": True, "urls": [URL_ATTACHMENT]} + signal_notification_service.get_attachments_as_bytes(data, len(CONTENT), hass) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert signal_requests_mock.last_request.verify is True + + +def test_get_attachments_with_verify_set_false( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with verify_ssl set to false results in verify=false.""" + signal_requests_mock = signal_requests_mock_factory() + data = {"verify_ssl": False, "urls": [URL_ATTACHMENT]} + signal_notification_service.get_attachments_as_bytes(data, len(CONTENT), hass) + + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 1 + assert signal_requests_mock.last_request.verify is False + + +def test_get_attachments_with_verify_set_garbage( + signal_notification_service: SignalNotificationService, + hass: HomeAssistant, +) -> None: + """Test getting attachments as URL with verify_ssl set to garbage results in None.""" + data = {"verify_ssl": "test", "urls": [URL_ATTACHMENT]} + result = signal_notification_service.get_attachments_as_bytes( + data, len(CONTENT), hass + ) + + assert result is None + + +def assert_sending_requests( + signal_requests_mock_factory: Mocker, attachments_num: int = 0 +) -> None: """Assert message was send with correct parameters.""" - send_request = signal_requests_mock.request_history[-1] + send_request = signal_requests_mock_factory.request_history[-1] assert send_request.path == SIGNAL_SEND_PATH_SUFIX body_request = json.loads(send_request.text) @@ -101,3 +334,7 @@ def assert_sending_requests(signal_requests_mock, attachments_num=0): assert body_request["number"] == NUMBER_FROM assert body_request["recipients"] == NUMBERS_TO assert len(body_request["base64_attachments"]) == attachments_num + + for attachment in body_request["base64_attachments"]: + if len(attachment) > 0: + assert base64.b64decode(attachment) == CONTENT diff --git a/tests/components/simplisafe/conftest.py b/tests/components/simplisafe/conftest.py new file mode 100644 index 00000000000000..d9e6d46c2eb1a0 --- /dev/null +++ b/tests/components/simplisafe/conftest.py @@ -0,0 +1,119 @@ +"""Define test fixtures for SimpliSafe.""" +import json +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from simplipy.system.v3 import SystemV3 + +from homeassistant.components.simplisafe.config_flow import CONF_AUTH_CODE +from homeassistant.components.simplisafe.const import CONF_USER_ID, DOMAIN +from homeassistant.const import CONF_TOKEN +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + +REFRESH_TOKEN = "token123" +SYSTEM_ID = "system_123" +USER_ID = "12345" + + +@pytest.fixture(name="api") +def api_fixture(system_v3, websocket): + """Define a fixture for a simplisafe-python API object.""" + return Mock( + async_get_systems=AsyncMock(return_value={SYSTEM_ID: system_v3}), + refresh_token=REFRESH_TOKEN, + user_id=USER_ID, + websocket=websocket, + ) + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config): + """Define a config entry fixture.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=USER_ID, data=config) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_USER_ID: USER_ID, + CONF_TOKEN: REFRESH_TOKEN, + } + + +@pytest.fixture(name="config_code") +def config_code_fixture(hass): + """Define a authorization code.""" + return { + CONF_AUTH_CODE: "code123", + } + + +@pytest.fixture(name="data_latest_event", scope="session") +def data_latest_event_fixture(): + """Define latest event data.""" + return json.loads(load_fixture("latest_event_data.json", "simplisafe")) + + +@pytest.fixture(name="data_sensor", scope="session") +def data_sensor_fixture(): + """Define sensor data.""" + return json.loads(load_fixture("sensor_data.json", "simplisafe")) + + +@pytest.fixture(name="data_settings", scope="session") +def data_settings_fixture(): + """Define settings data.""" + return json.loads(load_fixture("settings_data.json", "simplisafe")) + + +@pytest.fixture(name="data_subscription", scope="session") +def data_subscription_fixture(): + """Define subscription data.""" + return json.loads(load_fixture("subscription_data.json", "simplisafe")) + + +@pytest.fixture(name="setup_simplisafe") +async def setup_simplisafe_fixture(hass, api, config): + """Define a fixture to set up SimpliSafe.""" + with patch( + "homeassistant.components.simplisafe.config_flow.API.async_from_auth", + return_value=api, + ), patch( + "homeassistant.components.simplisafe.API.async_from_auth", return_value=api + ), patch( + "homeassistant.components.simplisafe.API.async_from_refresh_token", + return_value=api, + ), patch( + "homeassistant.components.simplisafe.SimpliSafe._async_start_websocket_loop" + ), patch( + "homeassistant.components.simplisafe.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="system_v3") +def system_v3_fixture(data_latest_event, data_sensor, data_settings, data_subscription): + """Define a fixture for a simplisafe-python V3 System object.""" + system = SystemV3(Mock(subscription_data=data_subscription), SYSTEM_ID) + system.async_get_latest_event = AsyncMock(return_value=data_latest_event) + system.sensor_data = data_sensor + system.settings_data = data_settings + system.generate_device_objects() + return system + + +@pytest.fixture(name="websocket") +def websocket_fixture(): + """Define a fixture for a simplisafe-python websocket object.""" + return Mock( + async_connect=AsyncMock(), + async_disconnect=AsyncMock(), + async_listen=AsyncMock(), + ) diff --git a/tests/components/simplisafe/fixtures/latest_event_data.json b/tests/components/simplisafe/fixtures/latest_event_data.json new file mode 100644 index 00000000000000..ca44c0674f11b5 --- /dev/null +++ b/tests/components/simplisafe/fixtures/latest_event_data.json @@ -0,0 +1,21 @@ +{ + "eventId": 1234567890, + "eventTimestamp": 1564018073, + "eventCid": 1400, + "zoneCid": "2", + "sensorType": 1, + "sensorSerial": "01010101", + "account": "00011122", + "userId": 12345, + "sid": "system_123", + "info": "System Disarmed by PIN 2", + "pinName": "", + "sensorName": "Kitchen", + "messageSubject": "SimpliSafe System Disarmed", + "messageBody": "System Disarmed: Your SimpliSafe security system was ...", + "eventType": "activity", + "timezone": 2, + "locationOffset": -360, + "videoStartedBy": "", + "video": {} +} diff --git a/tests/components/simplisafe/fixtures/sensor_data.json b/tests/components/simplisafe/fixtures/sensor_data.json new file mode 100644 index 00000000000000..073d51b0538eff --- /dev/null +++ b/tests/components/simplisafe/fixtures/sensor_data.json @@ -0,0 +1,75 @@ +{ + "825": { + "type": 5, + "serial": "825", + "name": "Fire Door", + "setting": { + "instantTrigger": false, + "away2": 1, + "away": 1, + "home2": 1, + "home": 1, + "off": 0 + }, + "status": { + "triggered": false + }, + "flags": { + "swingerShutdown": false, + "lowBattery": false, + "offline": false + } + }, + "14": { + "type": 12, + "serial": "14", + "name": "Front Door", + "setting": { + "instantTrigger": false, + "away2": 1, + "away": 1, + "home2": 1, + "home": 1, + "off": 0 + }, + "status": { + "triggered": false + }, + "flags": { + "swingerShutdown": false, + "lowBattery": false, + "offline": false + } + }, + "987": { + "serial": "987", + "type": 16, + "status": { + "pinPadState": 0, + "lockState": 1, + "pinPadOffline": false, + "pinPadLowBattery": false, + "lockDisabled": false, + "lockLowBattery": false, + "calibrationErrDelta": 0, + "calibrationErrZero": 0, + "lockJamState": 0 + }, + "name": "Front Door", + "deviceGroupID": 1, + "firmwareVersion": "1.0.0", + "bootVersion": "1.0.0", + "setting": { + "autoLock": 3, + "away": 1, + "home": 1, + "awayToOff": 0, + "homeToOff": 1 + }, + "flags": { + "swingerShutdown": false, + "lowBattery": false, + "offline": false + } + } +} diff --git a/tests/components/simplisafe/fixtures/settings_data.json b/tests/components/simplisafe/fixtures/settings_data.json new file mode 100644 index 00000000000000..2b617bb86634d8 --- /dev/null +++ b/tests/components/simplisafe/fixtures/settings_data.json @@ -0,0 +1,69 @@ +{ + "account": "12345012", + "settings": { + "normal": { + "wifiSSID": "MY_WIFI", + "alarmDuration": 240, + "alarmVolume": 3, + "doorChime": 2, + "entryDelayAway": 30, + "entryDelayAway2": 30, + "entryDelayHome": 30, + "entryDelayHome2": 30, + "exitDelayAway": 60, + "exitDelayAway2": 60, + "exitDelayHome": 0, + "exitDelayHome2": 0, + "lastUpdated": "2019-07-03T03:24:20.999Z", + "light": true, + "voicePrompts": 2, + "_id": "1197192618725121765212" + }, + "pins": { + "lastUpdated": "2019-07-04T20:47:44.016Z", + "_id": "asd6281526381253123", + "users": [ + { + "_id": "1271279d966212121124c7", + "pin": "3456", + "name": "Test 1" + }, + { + "_id": "1271279d966212121124c6", + "pin": "5423", + "name": "Test 2" + }, + { + "_id": "1271279d966212121124c5", + "pin": "", + "name": "" + }, + { + "_id": "1271279d966212121124c4", + "pin": "", + "name": "" + } + ], + "duress": { + "pin": "9876" + }, + "master": { + "pin": "1234" + } + } + }, + "basestationStatus": { + "lastUpdated": "2019-07-15T15:28:22.961Z", + "rfJamming": false, + "ethernetStatus": 4, + "gsmRssi": -73, + "gsmStatus": 3, + "backupBattery": 5293, + "wallPower": 5933, + "wifiRssi": -49, + "wifiStatus": 1, + "_id": "6128153715231t237123", + "encryptionErrors": [] + }, + "lastUpdated": 1562273264 +} diff --git a/tests/components/simplisafe/fixtures/subscription_data.json b/tests/components/simplisafe/fixtures/subscription_data.json new file mode 100644 index 00000000000000..56731307e427af --- /dev/null +++ b/tests/components/simplisafe/fixtures/subscription_data.json @@ -0,0 +1,374 @@ +{ + "system_123": { + "uid": 12345, + "sid": "system_123", + "sStatus": 20, + "activated": 1445034752, + "planSku": "SSEDSM2", + "planName": "Interactive Monitoring", + "price": 24.99, + "currency": "USD", + "country": "US", + "expires": 1602887552, + "canceled": 0, + "extraTime": 0, + "creditCard": { + "lastFour": "", + "type": "", + "ppid": "ABCDE12345", + "uid": 12345 + }, + "time": 2628000, + "paymentProfileId": "ABCDE12345", + "features": { + "monitoring": true, + "alerts": true, + "online": true, + "hazard": true, + "video": true, + "cameras": 10, + "dispatch": true, + "proInstall": false, + "discount": 0, + "vipCS": false, + "medical": true, + "careVisit": false, + "storageDays": 30 + }, + "status": { + "hasBaseStation": true, + "isActive": true, + "monitoring": "Active" + }, + "subscriptionFeatures": { + "monitoredSensorsTypes": [ + "Entry", + "Motion", + "GlassBreak", + "Smoke", + "CO", + "Freeze", + "Water" + ], + "monitoredPanicConditions": [ + "Fire", + "Medical", + "Duress" + ], + "dispatchTypes": [ + "Police", + "Fire", + "Medical", + "Guard" + ], + "remoteControl": [ + "ArmDisarm", + "LockUnlock", + "ViewSettings", + "ConfigureSettings" + ], + "cameraFeatures": { + "liveView": true, + "maxRecordingCameras": 10, + "recordingStorageDays": 30, + "videoVerification": true + }, + "support": { + "level": "Basic", + "annualVisit": false, + "professionalInstall": false + }, + "cellCommunicationBackup": true, + "alertChannels": [ + "Push", + "SMS", + "Email" + ], + "alertTypes": [ + "Alarm", + "Error", + "Activity", + "Camera" + ], + "alarmModes": [ + "Alarm", + "SecretAlert", + "Disabled" + ], + "supportedIntegrations": [ + "GoogleAssistant", + "AmazonAlexa", + "AugustLock" + ], + "timeline": {} + }, + "dispatcher": "cops", + "dcid": 0, + "location": { + "sid": 12345, + "uid": 12345, + "lStatus": 10, + "account": "1234ABCD", + "street1": "1234 Main Street", + "street2": "", + "locationName": "", + "city": "Atlantis", + "county": "SEA", + "state": "UW", + "zip": "12345", + "country": "US", + "crossStreet": "River 1 and River 2", + "notes": "", + "residenceType": 2, + "numAdults": 2, + "numChildren": 0, + "locationOffset": -360, + "safeWord": "TRITON", + "signature": "Atlantis Citizen 1", + "timeZone": 2, + "primaryContacts": [ + { + "name": "John Doe", + "phone": "1234567890" + } + ], + "secondaryContacts": [ + { + "name": "Jane Doe", + "phone": "9876543210" + } + ], + "copsOptIn": false, + "certificateUri": "https://simplisafe.com/account2/12345/alarm-certificate/12345", + "nestStructureId": "", + "system": { + "serial": "1234ABCD", + "alarmState": "OFF", + "alarmStateTimestamp": 0, + "isAlarming": false, + "version": 3, + "capabilities": { + "setWifiOverCell": true, + "setDoorbellChimeVolume": true, + "outdoorBattCamera": true + }, + "temperature": 67, + "exitDelayRemaining": 60, + "cameras": [ + { + "staleSettingsTypes": [], + "upgradeWhitelisted": false, + "model": "SS001", + "uuid": "1234567890", + "uid": 12345, + "sid": 12345, + "cameraSettings": { + "cameraName": "Camera", + "pictureQuality": "720p", + "nightVision": "auto", + "statusLight": "off", + "micSensitivity": 100, + "micEnable": true, + "speakerVolume": 75, + "motionSensitivity": 0, + "shutterHome": "closedAlarmOnly", + "shutterAway": "open", + "shutterOff": "closedAlarmOnly", + "wifiSsid": "", + "canStream": false, + "canRecord": false, + "pirEnable": true, + "vaEnable": true, + "notificationsEnable": false, + "enableDoorbellNotification": true, + "doorbellChimeVolume": "off", + "privacyEnable": false, + "hdr": false, + "vaZoningEnable": false, + "vaZoningRows": 0, + "vaZoningCols": 0, + "vaZoningMask": [], + "maxDigitalZoom": 10, + "supportedResolutions": [ + "480p", + "720p" + ], + "admin": { + "IRLED": 0, + "pirSens": 0, + "statusLEDState": 1, + "lux": "lowLux", + "motionDetectionEnabled": false, + "motionThresholdZero": 0, + "motionThresholdOne": 10000, + "levelChangeDelayZero": 30, + "levelChangeDelayOne": 10, + "audioDetectionEnabled": false, + "audioChannelNum": 2, + "audioSampleRate": 16000, + "audioChunkBytes": 2048, + "audioSampleFormat": 3, + "audioSensitivity": 50, + "audioThreshold": 50, + "audioDirection": 0, + "bitRate": 284, + "longPress": 2000, + "kframe": 1, + "gopLength": 40, + "idr": 1, + "fps": 20, + "firmwareVersion": "2.6.1.107", + "netConfigVersion": "", + "camAgentVersion": "", + "lastLogin": 1600639997, + "lastLogout": 1600639944, + "pirSampleRateMs": 800, + "pirHysteresisHigh": 2, + "pirHysteresisLow": 10, + "pirFilterCoefficient": 1, + "logEnabled": true, + "logLevel": 3, + "logQDepth": 20, + "firmwareGroup": "public", + "irOpenThreshold": 445, + "irCloseThreshold": 840, + "irOpenDelay": 3, + "irCloseDelay": 3, + "irThreshold1x": 388, + "irThreshold2x": 335, + "irThreshold3x": 260, + "rssi": [ + [ + 1600935204, + -43 + ] + ], + "battery": [], + "dbm": 0, + "vmUse": 161592, + "resSet": 10540, + "uptime": 810043.74, + "wifiDisconnects": 1, + "wifiDriverReloads": 1, + "statsPeriod": 3600000, + "sarlaccDebugLogTypes": 0, + "odProcessingFps": 8, + "odObjectMinWidthPercent": 6, + "odObjectMinHeightPercent": 24, + "odEnableObjectDetection": true, + "odClassificationMask": 2, + "odClassificationConfidenceThreshold": 0.95, + "odEnableOverlay": false, + "odAnalyticsLib": 2, + "odSensitivity": 85, + "odEventObjectMask": 2, + "odLuxThreshold": 445, + "odLuxHysteresisHigh": 4, + "odLuxHysteresisLow": 4, + "odLuxSamplingFrequency": 30, + "odFGExtractorMode": 2, + "odVideoScaleFactor": 1, + "odSceneType": 1, + "odCameraView": 3, + "odCameraFOV": 2, + "odBackgroundLearnStationary": true, + "odBackgroundLearnStationarySpeed": 15, + "odClassifierQualityProfile": 1, + "odEnableVideoAnalyticsWhileStreaming": false, + "wlanMac": "XX:XX:XX:XX:XX:XX", + "region": "us-east-1", + "enableWifiAnalyticsLib": false, + "ivLicense": "" + }, + "pirLevel": "medium", + "odLevel": "medium" + }, + "__v": 0, + "cameraStatus": { + "firmwareVersion": "2.6.1.107", + "netConfigVersion": "", + "camAgentVersion": "", + "lastLogin": 1600639997, + "lastLogout": 1600639944, + "wlanMac": "XX:XX:XX:XX:XX:XX", + "fwDownloadVersion": "", + "fwDownloadPercentage": 0, + "recovered": false, + "recoveredFromVersion": "", + "_id": "1234567890", + "initErrors": [], + "speedTestTokenCreated": 1600235629 + }, + "supportedFeatures": { + "providers": { + "webrtc": "none", + "recording": "simplisafe", + "live": "simplisafe" + }, + "audioEncodings": [ + "speex" + ], + "resolutions": [ + "480p", + "720p" + ], + "_id": "1234567890", + "pir": true, + "videoAnalytics": false, + "privacyShutter": true, + "microphone": true, + "fullDuplexAudio": false, + "wired": true, + "networkSpeedTest": false, + "videoEncoding": "h264" + }, + "subscription": { + "enabled": true, + "freeTrialActive": false, + "freeTrialUsed": true, + "freeTrialEnds": 0, + "freeTrialExpires": 0, + "planSku": "SSVM1", + "price": 0, + "expires": 0, + "storageDays": 30, + "trialUsed": true, + "trialActive": false, + "trialExpires": 0 + }, + "status": "online" + } + ], + "connType": "wifi", + "stateUpdated": 1601502948, + "messages": [ + { + "_id": "xxxxxxxxxxxxxxxxxxxxxxxx", + "id": "xxxxxxxxxxxxxxxxxxxxxxxx", + "textTemplate": "Power Outage - Backup battery in use.", + "data": { + "time": "2020-02-16T03:20:28+00:00" + }, + "text": "Power Outage - Backup battery in use.", + "code": "2000", + "filters": [], + "link": "http://link.to.info", + "linkLabel": "More Info", + "expiration": 0, + "category": "error", + "timestamp": 1581823228 + } + ], + "powerOutage": false, + "lastPowerOutage": 1581991064, + "lastSuccessfulWifiTS": 1601424776, + "isOffline": false + } + }, + "pinUnlocked": true, + "billDate": 1602887552, + "billInterval": 2628000, + "pinUnlockedBy": "pin", + "autoActivation": null + } +} diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py index 0597ad377cf67f..2e8fe309ff2c6f 100644 --- a/tests/components/simplisafe/test_config_flow.py +++ b/tests/components/simplisafe/test_config_flow.py @@ -1,51 +1,39 @@ """Define tests for the SimpliSafe config flow.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import patch import pytest from simplipy.errors import InvalidCredentialsError, SimplipyError from homeassistant import data_entry_flow from homeassistant.components.simplisafe import DOMAIN -from homeassistant.components.simplisafe.config_flow import CONF_AUTH_CODE -from homeassistant.components.simplisafe.const import CONF_USER_ID from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER -from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.const import CONF_CODE -from tests.common import MockConfigEntry +async def test_duplicate_error(hass, config_entry, config_code, setup_simplisafe): + """Test that errors are shown when duplicates are added.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM -@pytest.fixture(name="api") -def api_fixture(): - """Define a fixture for simplisafe-python API object.""" - api = Mock() - api.refresh_token = "token123" - api.user_id = "12345" - return api - - -@pytest.fixture(name="mock_async_from_auth") -def mock_async_from_auth_fixture(api): - """Define a fixture for simplipy.API.async_from_auth.""" - with patch( - "homeassistant.components.simplisafe.config_flow.API.async_from_auth", - ) as mock_async_from_auth: - mock_async_from_auth.side_effect = AsyncMock(return_value=api) - yield mock_async_from_auth - + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config_code + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" -async def test_duplicate_error(hass, mock_async_from_auth): - """Test that errors are shown when duplicates are added.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="12345", - data={ - CONF_USER_ID: "12345", - CONF_TOKEN: "token123", - }, - ).add_to_hass(hass) +@pytest.mark.parametrize( + "exc,error_string", + [(InvalidCredentialsError, "invalid_auth"), (SimplipyError, "unknown")], +) +async def test_errors(hass, config_code, exc, error_string): + """Test that exceptions show the appropriate error.""" with patch( - "homeassistant.components.simplisafe.async_setup_entry", return_value=True + "homeassistant.components.simplisafe.API.async_from_auth", + side_effect=exc, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -54,135 +42,75 @@ async def test_duplicate_error(hass, mock_async_from_auth): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} + result["flow_id"], user_input=config_code ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_invalid_credentials(hass, mock_async_from_auth): - """Test that invalid credentials show the correct error.""" - mock_async_from_auth.side_effect = AsyncMock(side_effect=InvalidCredentialsError) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "invalid_auth"} + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": error_string} -async def test_options_flow(hass): +async def test_options_flow(hass, config_entry): """Test config flow options.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="abcde12345", - data={CONF_USER_ID: "12345", CONF_TOKEN: "token456"}, - options={CONF_CODE: "1234"}, - ) - entry.add_to_hass(hass) - with patch( "homeassistant.components.simplisafe.async_setup_entry", return_value=True ): - await hass.config_entries.async_setup(entry.entry_id) - result = await hass.config_entries.options.async_init(entry.entry_id) - + await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_CODE: "4321"} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert entry.options == {CONF_CODE: "4321"} + assert config_entry.options == {CONF_CODE: "4321"} -async def test_step_reauth_old_format(hass, mock_async_from_auth): +async def test_step_reauth_old_format( + hass, config, config_code, config_entry, setup_simplisafe +): """Test the re-auth step with "old" config entries (those with user IDs).""" - MockConfigEntry( - domain=DOMAIN, - unique_id="user@email.com", - data={ - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - }, - ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH}, - data={CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}, + DOMAIN, context={"source": SOURCE_REAUTH}, data=config ) assert result["step_id"] == "user" - with patch( - "homeassistant.components.simplisafe.async_setup_entry", return_value=True - ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "reauth_successful" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config_code + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 [config_entry] = hass.config_entries.async_entries(DOMAIN) - assert config_entry.data == {CONF_USER_ID: "12345", CONF_TOKEN: "token123"} + assert config_entry.data == config -async def test_step_reauth_new_format(hass, mock_async_from_auth): +async def test_step_reauth_new_format( + hass, config, config_code, config_entry, setup_simplisafe +): """Test the re-auth step with "new" config entries (those with user IDs).""" - MockConfigEntry( - domain=DOMAIN, - unique_id="12345", - data={ - CONF_USER_ID: "12345", - CONF_TOKEN: "token123", - }, - ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH}, - data={CONF_USER_ID: "12345", CONF_TOKEN: "token123"}, + DOMAIN, context={"source": SOURCE_REAUTH}, data=config ) assert result["step_id"] == "user" - with patch( - "homeassistant.components.simplisafe.async_setup_entry", return_value=True - ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "reauth_successful" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config_code + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 [config_entry] = hass.config_entries.async_entries(DOMAIN) - assert config_entry.data == {CONF_USER_ID: "12345", CONF_TOKEN: "token123"} + assert config_entry.data == config -async def test_step_reauth_wrong_account(hass, api, mock_async_from_auth): +async def test_step_reauth_wrong_account( + hass, api, config, config_code, config_entry, setup_simplisafe +): """Test the re-auth step returning a different account from this one.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="12345", - data={ - CONF_USER_ID: "12345", - CONF_TOKEN: "token123", - }, - ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH}, - data={CONF_USER_ID: "12345", CONF_TOKEN: "token123"}, + DOMAIN, context={"source": SOURCE_REAUTH}, data=config ) assert result["step_id"] == "user" @@ -190,52 +118,29 @@ async def test_step_reauth_wrong_account(hass, api, mock_async_from_auth): # identified as this entry's unique ID: api.user_id = "67890" - with patch( - "homeassistant.components.simplisafe.async_setup_entry", return_value=True - ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "wrong_account" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config_code + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "wrong_account" assert len(hass.config_entries.async_entries()) == 1 [config_entry] = hass.config_entries.async_entries(DOMAIN) assert config_entry.unique_id == "12345" -async def test_step_user(hass, mock_async_from_auth): +async def test_step_user(hass, config, config_code, setup_simplisafe): """Test the user step.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result["step_id"] == "user" - with patch( - "homeassistant.components.simplisafe.async_setup_entry", return_value=True - ), patch("homeassistant.config_entries.ConfigEntries.async_reload"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config_code + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert len(hass.config_entries.async_entries()) == 1 [config_entry] = hass.config_entries.async_entries(DOMAIN) - assert config_entry.data == {CONF_USER_ID: "12345", CONF_TOKEN: "token123"} - - -async def test_unknown_error(hass, mock_async_from_auth): - """Test that an unknown error shows ohe correct error.""" - mock_async_from_auth.side_effect = AsyncMock(side_effect=SimplipyError) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_AUTH_CODE: "code123"} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "unknown"} + assert config_entry.data == config diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py new file mode 100644 index 00000000000000..d2c2866bf5b790 --- /dev/null +++ b/tests/components/simplisafe/test_diagnostics.py @@ -0,0 +1,226 @@ +"""Test SimpliSafe diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_simplisafe): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": {"options": {}}, + "systems": [ + { + "address": REDACTED, + "alarm_going_off": False, + "connection_type": "wifi", + "notifications": [], + "serial": REDACTED, + "state": 99, + "system_id": REDACTED, + "temperature": 67, + "version": 3, + "sensors": [ + { + "name": "Fire Door", + "serial": REDACTED, + "type": 5, + "error": False, + "low_battery": False, + "offline": False, + "settings": { + "instantTrigger": False, + "away2": 1, + "away": 1, + "home2": 1, + "home": 1, + "off": 0, + }, + "trigger_instantly": False, + "triggered": False, + }, + { + "name": "Front Door", + "serial": REDACTED, + "type": 12, + "error": False, + "low_battery": False, + "offline": False, + "settings": { + "instantTrigger": False, + "away2": 1, + "away": 1, + "home2": 1, + "home": 1, + "off": 0, + }, + "trigger_instantly": False, + "triggered": False, + }, + ], + "alarm_duration": 240, + "alarm_volume": 3, + "battery_backup_power_level": 5293, + "cameras": [ + { + "camera_settings": { + "cameraName": "Camera", + "pictureQuality": "720p", + "nightVision": "auto", + "statusLight": "off", + "micSensitivity": 100, + "micEnable": True, + "speakerVolume": 75, + "motionSensitivity": 0, + "shutterHome": "closedAlarmOnly", + "shutterAway": "open", + "shutterOff": "closedAlarmOnly", + "wifiSsid": "", + "canStream": False, + "canRecord": False, + "pirEnable": True, + "vaEnable": True, + "notificationsEnable": False, + "enableDoorbellNotification": True, + "doorbellChimeVolume": "off", + "privacyEnable": False, + "hdr": False, + "vaZoningEnable": False, + "vaZoningRows": 0, + "vaZoningCols": 0, + "vaZoningMask": [], + "maxDigitalZoom": 10, + "supportedResolutions": ["480p", "720p"], + "admin": { + "IRLED": 0, + "pirSens": 0, + "statusLEDState": 1, + "lux": "lowLux", + "motionDetectionEnabled": False, + "motionThresholdZero": 0, + "motionThresholdOne": 10000, + "levelChangeDelayZero": 30, + "levelChangeDelayOne": 10, + "audioDetectionEnabled": False, + "audioChannelNum": 2, + "audioSampleRate": 16000, + "audioChunkBytes": 2048, + "audioSampleFormat": 3, + "audioSensitivity": 50, + "audioThreshold": 50, + "audioDirection": 0, + "bitRate": 284, + "longPress": 2000, + "kframe": 1, + "gopLength": 40, + "idr": 1, + "fps": 20, + "firmwareVersion": "2.6.1.107", + "netConfigVersion": "", + "camAgentVersion": "", + "lastLogin": 1600639997, + "lastLogout": 1600639944, + "pirSampleRateMs": 800, + "pirHysteresisHigh": 2, + "pirHysteresisLow": 10, + "pirFilterCoefficient": 1, + "logEnabled": True, + "logLevel": 3, + "logQDepth": 20, + "firmwareGroup": "public", + "irOpenThreshold": 445, + "irCloseThreshold": 840, + "irOpenDelay": 3, + "irCloseDelay": 3, + "irThreshold1x": 388, + "irThreshold2x": 335, + "irThreshold3x": 260, + "rssi": [[1600935204, -43]], + "battery": [], + "dbm": 0, + "vmUse": 161592, + "resSet": 10540, + "uptime": 810043.74, + "wifiDisconnects": 1, + "wifiDriverReloads": 1, + "statsPeriod": 3600000, + "sarlaccDebugLogTypes": 0, + "odProcessingFps": 8, + "odObjectMinWidthPercent": 6, + "odObjectMinHeightPercent": 24, + "odEnableObjectDetection": True, + "odClassificationMask": 2, + "odClassificationConfidenceThreshold": 0.95, + "odEnableOverlay": False, + "odAnalyticsLib": 2, + "odSensitivity": 85, + "odEventObjectMask": 2, + "odLuxThreshold": 445, + "odLuxHysteresisHigh": 4, + "odLuxHysteresisLow": 4, + "odLuxSamplingFrequency": 30, + "odFGExtractorMode": 2, + "odVideoScaleFactor": 1, + "odSceneType": 1, + "odCameraView": 3, + "odCameraFOV": 2, + "odBackgroundLearnStationary": True, + "odBackgroundLearnStationarySpeed": 15, + "odClassifierQualityProfile": 1, + "odEnableVideoAnalyticsWhileStreaming": False, + "wlanMac": "XX:XX:XX:XX:XX:XX", + "region": "us-east-1", + "enableWifiAnalyticsLib": False, + "ivLicense": "", + }, + "pirLevel": "medium", + "odLevel": "medium", + }, + "camera_type": 0, + "name": "Camera", + "serial": REDACTED, + "shutter_open_when_away": True, + "shutter_open_when_home": False, + "shutter_open_when_off": False, + "status": "online", + "subscription_enabled": True, + }, + ], + "chime_volume": 2, + "entry_delay_away": 30, + "entry_delay_home": 30, + "exit_delay_away": 60, + "exit_delay_home": 0, + "gsm_strength": -73, + "light": True, + "locks": [ + { + "name": "Front Door", + "serial": REDACTED, + "type": 16, + "error": False, + "low_battery": False, + "offline": False, + "settings": { + "autoLock": 3, + "away": 1, + "home": 1, + "awayToOff": 0, + "homeToOff": 1, + }, + "disabled": False, + "lock_low_battery": False, + "pin_pad_low_battery": False, + "pin_pad_offline": False, + "state": 1, + } + ], + "offline": False, + "power_outage": False, + "rf_jamming": False, + "voice_prompt_volume": 2, + "wall_power_level": 5933, + "wifi_ssid": REDACTED, + "wifi_strength": -49, + } + ], + } diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 4210772420c85c..0c6c8a7ee67f86 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -16,85 +16,6 @@ "password": "password", } -MOCK_IMPORT = { - "platform": "sma", - "host": "1.1.1.1", - "ssl": True, - "verify_ssl": False, - "group": "user", - "password": "password", - "sensors": ["pv_power", "daily_yield", "total_yield", "not_existing_sensors"], - "custom": { - "yesterday_consumption": { - "factor": 1000.0, - "key": "6400_00543A01", - "unit": "kWh", - } - }, -} - -MOCK_IMPORT_DICT = { - "platform": "sma", - "host": "1.1.1.1", - "ssl": True, - "verify_ssl": False, - "group": "user", - "password": "password", - "sensors": { - "pv_power": [], - "pv_gen_meter": [], - "solar_daily": ["daily_yield", "total_yield"], - "status": ["grid_power", "frequency", "voltage_l1", "operating_time"], - }, - "custom": { - "operating_time": {"key": "6400_00462E00", "unit": "uur", "factor": 3600}, - "solar_daily": {"key": "6400_00262200", "unit": "kWh", "factor": 1000}, - }, -} - -MOCK_CUSTOM_SENSOR = { - "name": "yesterday_consumption", - "key": "6400_00543A01", - "unit": "kWh", - "factor": 1000, -} - -MOCK_CUSTOM_SENSOR2 = { - "name": "device_type_id", - "key": "6800_08822000", - "unit": "", - "path": '"1"[0].val[0].tag', -} - -MOCK_SETUP_DATA = dict( - { - "custom": {}, - "sensors": [], - }, - **MOCK_USER_INPUT, -) - -MOCK_CUSTOM_SETUP_DATA = dict( - { - "custom": { - MOCK_CUSTOM_SENSOR["name"]: { - "factor": MOCK_CUSTOM_SENSOR["factor"], - "key": MOCK_CUSTOM_SENSOR["key"], - "path": None, - "unit": MOCK_CUSTOM_SENSOR["unit"], - }, - MOCK_CUSTOM_SENSOR2["name"]: { - "factor": 1.0, - "key": MOCK_CUSTOM_SENSOR2["key"], - "path": MOCK_CUSTOM_SENSOR2["path"], - "unit": MOCK_CUSTOM_SENSOR2["unit"], - }, - }, - "sensors": [], - }, - **MOCK_USER_INPUT, -) - def _patch_async_setup_entry(return_value=True): return patch( diff --git a/tests/components/sma/conftest.py b/tests/components/sma/conftest.py index 80d9b38e28bdc1..b953d8692a8e51 100644 --- a/tests/components/sma/conftest.py +++ b/tests/components/sma/conftest.py @@ -9,7 +9,7 @@ from homeassistant import config_entries from homeassistant.components.sma.const import DOMAIN -from . import MOCK_CUSTOM_SETUP_DATA, MOCK_DEVICE +from . import MOCK_DEVICE, MOCK_USER_INPUT from tests.common import MockConfigEntry @@ -21,7 +21,7 @@ def mock_config_entry(): domain=DOMAIN, title=MOCK_DEVICE["name"], unique_id=MOCK_DEVICE["serial"], - data=MOCK_CUSTOM_SETUP_DATA, + data=MOCK_USER_INPUT, source=config_entries.SOURCE_IMPORT, ) diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 9194bc15d6f3f2..8cf22b3634e31f 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -8,21 +8,14 @@ ) from homeassistant.components.sma.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM, ) -from . import ( - MOCK_DEVICE, - MOCK_IMPORT, - MOCK_IMPORT_DICT, - MOCK_SETUP_DATA, - MOCK_USER_INPUT, - _patch_async_setup_entry, -) +from . import MOCK_DEVICE, MOCK_USER_INPUT, _patch_async_setup_entry async def test_form(hass): @@ -45,7 +38,7 @@ async def test_form(hass): assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == MOCK_USER_INPUT["host"] - assert result["data"] == MOCK_SETUP_DATA + assert result["data"] == MOCK_USER_INPUT assert len(mock_setup_entry.mock_calls) == 1 @@ -148,43 +141,3 @@ async def test_form_already_configured(hass, mock_config_entry): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_import(hass): - """Test we can import.""" - - with patch("pysma.SMA.new_session", return_value=True), patch( - "pysma.SMA.device_info", return_value=MOCK_DEVICE - ), patch( - "pysma.SMA.close_session", return_value=True - ), _patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=MOCK_IMPORT, - ) - - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == MOCK_USER_INPUT["host"] - assert result["data"] == MOCK_IMPORT - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_sensor_dict(hass): - """Test we can import.""" - - with patch("pysma.SMA.new_session", return_value=True), patch( - "pysma.SMA.device_info", return_value=MOCK_DEVICE - ), patch( - "pysma.SMA.close_session", return_value=True - ), _patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=MOCK_IMPORT_DICT, - ) - - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == MOCK_USER_INPUT["host"] - assert result["data"] == MOCK_IMPORT_DICT - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/sma/test_sensor.py b/tests/components/sma/test_sensor.py index 129af154924a0c..58fafe930c7a67 100644 --- a/tests/components/sma/test_sensor.py +++ b/tests/components/sma/test_sensor.py @@ -1,11 +1,5 @@ """Test the sma sensor platform.""" -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, -) - -from . import MOCK_CUSTOM_SENSOR +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, POWER_WATT async def test_sensors(hass, init_integration): @@ -13,7 +7,3 @@ async def test_sensors(hass, init_integration): state = hass.states.get("sensor.grid_power") assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT - - state = hass.states.get(f"sensor.{MOCK_CUSTOM_SENSOR['name']}") - assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 2a7b5ed7084a9f..e3e80d80e525b3 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -289,6 +289,8 @@ def _factory(name): scene = Mock(SceneEntity) scene.scene_id = str(uuid4()) scene.name = name + scene.icon = None + scene.color = None scene.location_id = location.location_id return scene diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 7f4748bc215c97..b636cffbc2e087 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -13,13 +13,10 @@ from homeassistant.components.smartthings import binary_sensor from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_UNAVAILABLE, -) +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import EntityCategory from .conftest import setup_platform @@ -125,4 +122,4 @@ async def test_entity_category(hass, device_factory): entry = entity_registry.async_get("binary_sensor.tamper_sensor_2_tamper") assert entry - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 7f8950f18b58b2..420c07d2a04fc1 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -355,9 +355,11 @@ async def test_entry_created_with_cloudhook( request.refresh_token = refresh_token with patch.object( - hass.components.cloud, "async_active_subscription", Mock(return_value=True) + smartapp.cloud, + "async_active_subscription", + Mock(return_value=True), ), patch.object( - hass.components.cloud, + smartapp.cloud, "async_create_cloudhook", AsyncMock(return_value="http://cloud.test"), ) as mock_create_cloudhook: diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 0e22a1facbaa09..98464af24af9d0 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -17,13 +17,13 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, - ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import EntityCategory from .conftest import setup_platform @@ -95,7 +95,7 @@ async def test_entity_and_device_attributes(hass, device_factory): entry = entity_registry.async_get("sensor.sensor_1_battery") assert entry assert entry.unique_id == f"{device.device_id}.{Attribute.battery}" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" diff --git a/tests/components/smarttub/test_init.py b/tests/components/smarttub/test_init.py index f316a66c5d1850..32b15e06c8aa18 100644 --- a/tests/components/smarttub/test_init.py +++ b/tests/components/smarttub/test_init.py @@ -43,6 +43,7 @@ async def test_setup_auth_failed(setup_component, hass, config_entry, smarttub_a "source": SOURCE_REAUTH, "entry_id": config_entry.entry_id, "unique_id": config_entry.unique_id, + "title_placeholders": {"name": config_entry.title}, }, data=config_entry.data, ) diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py index 3f189b52311982..60879e8af750e3 100644 --- a/tests/components/smhi/test_config_flow.py +++ b/tests/components/smhi/test_config_flow.py @@ -1,268 +1,165 @@ -"""Tests for SMHI config flow.""" -from unittest.mock import Mock, patch +"""Test the Smhi config flow.""" +from __future__ import annotations -from smhi.smhi_lib import Smhi as SmhiApi, SmhiForecastException - -from homeassistant.components.smhi import config_flow -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from unittest.mock import patch +from smhi.smhi_lib import SmhiForecastException -# pylint: disable=protected-access -async def test_homeassistant_location_exists() -> None: - """Test if Home Assistant location exists it should return True.""" - hass = Mock() - flow = config_flow.SmhiFlowHandler() - flow.hass = hass - with patch.object(flow, "_check_location", return_value=True): - # Test exists - hass.config.location_name = "Home" - hass.config.latitude = 17.8419 - hass.config.longitude = 59.3262 - - assert await flow._homeassistant_location_exists() is True +from homeassistant import config_entries +from homeassistant.components.smhi.const import DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) - # Test not exists - hass.config.location_name = None - hass.config.latitude = 0 - hass.config.longitude = 0 +from tests.common import MockConfigEntry - assert await flow._homeassistant_location_exists() is False +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form and create an entry.""" -async def test_name_in_configuration_exists() -> None: - """Test if home location exists in configuration.""" - hass = Mock() - flow = config_flow.SmhiFlowHandler() - flow.hass = hass + hass.config.latitude = 0.0 + hass.config.longitude = 0.0 - # Test exists - hass.config.location_name = "Home" - hass.config.latitude = 17.8419 - hass.config.longitude = 59.3262 + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} - # Check not exists - with patch.object( - config_flow, - "smhi_locations", + with patch( + "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", return_value={"test": "something", "test2": "something else"}, + ), patch( + "homeassistant.components.smhi.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Home" + assert result2["data"] == { + "latitude": 0.0, + "longitude": 0.0, + "name": "Home", + } + assert len(mock_setup_entry.mock_calls) == 1 + + # Check title is "Weather" when not home coordinates + result3 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + return_value={"test": "something", "test2": "something else"}, + ), patch( + "homeassistant.components.smhi.async_setup_entry", + return_value=True, ): - - assert flow._name_in_configuration_exists("no_exist_name") is False - - # Check exists - with patch.object( - config_flow, - "smhi_locations", - return_value={"test": "something", "name_exist": "config"}, - ): - - assert flow._name_in_configuration_exists("name_exist") is True - - -def test_smhi_locations(hass) -> None: - """Test return empty set.""" - locations = config_flow.smhi_locations(hass) - assert not locations - - -async def test_show_config_form() -> None: - """Test show configuration form.""" - hass = Mock() - flow = config_flow.SmhiFlowHandler() - flow.hass = hass - - result = await flow._show_config_form() - - assert result["type"] == "form" - assert result["step_id"] == "user" - - -async def test_show_config_form_default_values() -> None: - """Test show configuration form.""" - hass = Mock() - flow = config_flow.SmhiFlowHandler() - flow.hass = hass - - result = await flow._show_config_form(name="test", latitude="65", longitude="17") - - assert result["type"] == "form" - assert result["step_id"] == "user" - - -async def test_flow_with_home_location(hass) -> None: - """Test config flow . - - Tests the flow when a default location is configured - then it should return a form with default values - """ - flow = config_flow.SmhiFlowHandler() - flow.hass = hass - - with patch.object(flow, "_check_location", return_value=True): - hass.config.location_name = "Home" - hass.config.latitude = 17.8419 - hass.config.longitude = 59.3262 - - result = await flow.async_step_user() - assert result["type"] == "form" - assert result["step_id"] == "user" - - -async def test_flow_show_form() -> None: - """Test show form scenarios first time. - - Test when the form should show when no configurations exists - """ - hass = Mock() - flow = config_flow.SmhiFlowHandler() - flow.hass = hass - - # Test show form when Home Assistant config exists and - # home is already configured, then new config is allowed - with patch.object( - flow, "_show_config_form", return_value=None - ) as config_form, patch.object( - flow, "_homeassistant_location_exists", return_value=True - ), patch.object( - config_flow, - "smhi_locations", - return_value={"test": "something", "name_exist": "config"}, - ): - await flow.async_step_user() - assert len(config_form.mock_calls) == 1 - - # Test show form when Home Assistant config not and - # home is not configured - with patch.object( - flow, "_show_config_form", return_value=None - ) as config_form, patch.object( - flow, "_homeassistant_location_exists", return_value=False - ), patch.object( - config_flow, - "smhi_locations", - return_value={"test": "something", "name_exist": "config"}, + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + { + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 1.0, + }, + ) + await hass.async_block_till_done() + + assert result4["type"] == RESULT_TYPE_CREATE_ENTRY + assert result4["title"] == "Weather 1.0 1.0" + assert result4["data"] == { + "latitude": 1.0, + "longitude": 1.0, + "name": "Weather", + } + + +async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: + """Test we handle invalid coordinates.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + side_effect=SmhiForecastException, ): - - await flow.async_step_user() - assert len(config_form.mock_calls) == 1 - - -async def test_flow_show_form_name_exists() -> None: - """Test show form if name already exists. - - Test when the form should show when no configurations exists - """ - hass = Mock() - flow = config_flow.SmhiFlowHandler() - flow.hass = hass - test_data = {"name": "home", CONF_LONGITUDE: "0", CONF_LATITUDE: "0"} - # Test show form when Home Assistant config exists and - # home is already configured, then new config is allowed - with patch.object( - flow, "_show_config_form", return_value=None - ) as config_form, patch.object( - flow, "_name_in_configuration_exists", return_value=True - ), patch.object( - config_flow, - "smhi_locations", - return_value={"test": "something", "name_exist": "config"}, - ), patch.object( - flow, "_check_location", return_value=True - ): - - await flow.async_step_user(user_input=test_data) - - assert len(config_form.mock_calls) == 1 - assert len(flow._errors) == 1 - - -async def test_flow_entry_created_from_user_input() -> None: - """Test that create data from user input. - - Test when the form should show when no configurations exists - """ - hass = Mock() - flow = config_flow.SmhiFlowHandler() - flow.hass = hass - - test_data = {"name": "home", CONF_LONGITUDE: "0", CONF_LATITUDE: "0"} - - # Test that entry created when user_input name not exists - with patch.object( - flow, "_show_config_form", return_value=None - ) as config_form, patch.object( - flow, "_name_in_configuration_exists", return_value=False - ), patch.object( - flow, "_homeassistant_location_exists", return_value=False - ), patch.object( - config_flow, - "smhi_locations", - return_value={"test": "something", "name_exist": "config"}, - ), patch.object( - flow, "_check_location", return_value=True + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "wrong_location"} + + # Continue flow with new coordinates + with patch( + "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + return_value={"test": "something", "test2": "something else"}, + ), patch( + "homeassistant.components.smhi.async_setup_entry", + return_value=True, ): - - result = await flow.async_step_user(user_input=test_data) - - assert result["type"] == "create_entry" - assert result["data"] == test_data - assert not config_form.mock_calls - - -async def test_flow_entry_created_user_input_faulty() -> None: - """Test that create data from user input and are faulty. - - Test when the form should show when user puts faulty location - in the config gui. Then the form should show with error - """ - hass = Mock() - flow = config_flow.SmhiFlowHandler() - flow.hass = hass - - test_data = {"name": "home", CONF_LONGITUDE: "0", CONF_LATITUDE: "0"} - - # Test that entry created when user_input name not exists - with patch.object(flow, "_check_location", return_value=True), patch.object( - flow, "_show_config_form", return_value=None - ) as config_form, patch.object( - flow, "_name_in_configuration_exists", return_value=False - ), patch.object( - flow, "_homeassistant_location_exists", return_value=False - ), patch.object( - config_flow, - "smhi_locations", - return_value={"test": "something", "name_exist": "config"}, - ), patch.object( - flow, "_check_location", return_value=False + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LATITUDE: 2.0, + CONF_LONGITUDE: 2.0, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "Weather 2.0 2.0" + assert result3["data"] == { + "latitude": 2.0, + "longitude": 2.0, + "name": "Weather", + } + + +async def test_form_unique_id_exist(hass: HomeAssistant) -> None: + """Test we handle unique id already exist.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.0-1.0", + data={ + "latitude": 1.0, + "longitude": 1.0, + "name": "Weather", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + return_value={"test": "something", "test2": "something else"}, ): - - await flow.async_step_user(user_input=test_data) - - assert len(config_form.mock_calls) == 1 - assert len(flow._errors) == 1 - - -async def test_check_location_correct() -> None: - """Test check location when correct input.""" - hass = Mock() - flow = config_flow.SmhiFlowHandler() - flow.hass = hass - - with patch.object( - config_flow.aiohttp_client, "async_get_clientsession" - ), patch.object(SmhiApi, "async_get_forecast", return_value=None): - - assert await flow._check_location("58", "17") is True - - -async def test_check_location_faulty() -> None: - """Test check location when faulty input.""" - hass = Mock() - flow = config_flow.SmhiFlowHandler() - flow.hass = hass - - with patch.object( - config_flow.aiohttp_client, "async_get_clientsession" - ), patch.object(SmhiApi, "async_get_forecast", side_effect=SmhiForecastException()): - - assert await flow._check_location("58", "17") is False + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 1.0, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/solax/__init__.py b/tests/components/solax/__init__.py new file mode 100644 index 00000000000000..09d2a78299e2eb --- /dev/null +++ b/tests/components/solax/__init__.py @@ -0,0 +1 @@ +"""Tests for the solax integration.""" diff --git a/tests/components/solax/test_config_flow.py b/tests/components/solax/test_config_flow.py new file mode 100644 index 00000000000000..56db8d3f6cb858 --- /dev/null +++ b/tests/components/solax/test_config_flow.py @@ -0,0 +1,131 @@ +"""Tests for the solax config flow.""" +from unittest.mock import patch + +from solax import RealTimeAPI, inverter +from solax.inverter import InverterResponse + +from homeassistant import config_entries +from homeassistant.components.solax.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT + + +def __mock_real_time_api_success(): + return RealTimeAPI(inverter.X1MiniV34) + + +def __mock_get_data(): + return InverterResponse( + data=None, serial_number="ABCDEFGHIJ", version="2.034.06", type=4 + ) + + +async def test_form_success(hass): + """Test successful form.""" + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert flow["type"] == "form" + assert flow["errors"] == {} + + with patch( + "homeassistant.components.solax.config_flow.real_time_api", + return_value=__mock_real_time_api_success(), + ), patch("solax.RealTimeAPI.get_data", return_value=__mock_get_data()), patch( + "homeassistant.components.solax.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + entry_result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + {CONF_IP_ADDRESS: "192.168.1.87", CONF_PORT: 80, CONF_PASSWORD: "password"}, + ) + await hass.async_block_till_done() + + assert entry_result["type"] == "create_entry" + assert entry_result["title"] == "ABCDEFGHIJ" + assert entry_result["data"] == { + CONF_IP_ADDRESS: "192.168.1.87", + CONF_PORT: 80, + CONF_PASSWORD: "password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_connect_error(hass): + """Test cannot connect form.""" + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert flow["type"] == "form" + assert flow["errors"] == {} + + with patch( + "homeassistant.components.solax.config_flow.real_time_api", + side_effect=ConnectionError, + ): + entry_result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + {CONF_IP_ADDRESS: "192.168.1.87", CONF_PORT: 80, CONF_PASSWORD: "password"}, + ) + + assert entry_result["type"] == "form" + assert entry_result["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass): + """Test unknown error form.""" + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert flow["type"] == "form" + assert flow["errors"] == {} + + with patch( + "homeassistant.components.solax.config_flow.real_time_api", + side_effect=Exception, + ): + entry_result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + {CONF_IP_ADDRESS: "192.168.1.87", CONF_PORT: 80, CONF_PASSWORD: "password"}, + ) + + assert entry_result["type"] == "form" + assert entry_result["errors"] == {"base": "unknown"} + + +async def test_import_success(hass): + """Test import success.""" + conf = {CONF_IP_ADDRESS: "192.168.1.87", CONF_PORT: 80} + with patch( + "homeassistant.components.solax.config_flow.real_time_api", + return_value=__mock_real_time_api_success(), + ), patch("solax.RealTimeAPI.get_data", return_value=__mock_get_data()), patch( + "homeassistant.components.solax.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + entry_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + + assert entry_result["type"] == "create_entry" + assert entry_result["title"] == "ABCDEFGHIJ" + assert entry_result["data"] == { + CONF_IP_ADDRESS: "192.168.1.87", + CONF_PORT: 80, + CONF_PASSWORD: "", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_error(hass): + """Test import success.""" + conf = {CONF_IP_ADDRESS: "192.168.1.87", CONF_PORT: 80} + with patch( + "homeassistant.components.solax.config_flow.real_time_api", + side_effect=ConnectionError, + ): + entry_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + + assert entry_result["type"] == "form" + assert entry_result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/sonarr/test_init.py b/tests/components/sonarr/test_init.py index 6dfce4ee2fec0d..39d9b7fc24ea24 100644 --- a/tests/components/sonarr/test_init.py +++ b/tests/components/sonarr/test_init.py @@ -33,6 +33,7 @@ async def test_config_entry_reauth( CONF_SOURCE: SOURCE_REAUTH, "entry_id": entry.entry_id, "unique_id": entry.unique_id, + "title_placeholders": {"name": entry.title}, }, data=entry.data, ) diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 96d350ac6df8e5..f68920e4e4f15a 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -116,7 +116,7 @@ async def test_disabled_by_default_sensors( entry = registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_availability( diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 18c5366d4aefa3..14ad17bec8b65f 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -1,9 +1,10 @@ """Configuration for Sonos tests.""" -from unittest.mock import AsyncMock, MagicMock, Mock, patch as patch +from copy import copy +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from homeassistant.components import ssdp +from homeassistant.components import ssdp, zeroconf from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos import DOMAIN from homeassistant.const import CONF_HOSTS @@ -36,12 +37,44 @@ def increment_variable(self, var_name): Assumes value has a format of :. """ + self.variables = copy(self.variables) base, count = self.variables[var_name].split(":") newcount = int(count) + 1 self.variables[var_name] = ":".join([base, str(newcount)]) return self.variables[var_name] +@pytest.fixture +def zeroconf_payload(): + """Return a default zeroconf payload.""" + return zeroconf.ZeroconfServiceInfo( + host="192.168.4.2", + hostname="Sonos-aaa", + name="Sonos-aaa@Living Room._sonos._tcp.local.", + port=None, + properties={"bootseq": "1234"}, + type="mock_type", + ) + + +@pytest.fixture +async def async_autosetup_sonos(async_setup_sonos): + """Set up a Sonos integration instance on test run.""" + await async_setup_sonos() + + +@pytest.fixture +def async_setup_sonos(hass, config_entry): + """Return a coroutine to set up a Sonos integration instance on demand.""" + + async def _wrapper(): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return _wrapper + + @pytest.fixture(name="config_entry") def config_entry_fixture(): """Create a mock Sonos config entry.""" @@ -70,6 +103,7 @@ def soco_fixture(music_library, speaker_info, battery_info, alarm_clock): mock_soco.night_mode = True mock_soco.dialog_level = True mock_soco.volume = 19 + mock_soco.audio_delay = 2 mock_soco.bass = 1 mock_soco.treble = -1 mock_soco.sub_enabled = False @@ -174,6 +208,8 @@ def speaker_info_fixture(): "zone_name": "Zone A", "uid": "RINCON_test", "model_name": "Model Name", + "model_number": "S12", + "hardware_version": "1.20.1.6-1.1", "software_version": "49.2-64250", "mac_address": "00-11-22-33-44-55", "display_version": "13.1", @@ -191,11 +227,12 @@ def battery_info_fixture(): } -@pytest.fixture(name="battery_event") -def battery_event_fixture(soco): - """Create battery_event fixture.""" +@pytest.fixture(name="device_properties_event") +def device_properties_event_fixture(soco): + """Create device_properties_event fixture.""" variables = { "zone_name": "Zone A", + "mic_enabled": "1", "more_info": "BattChg:NOT_CHARGING,RawBattPct:100,BattPct:100,BattTmp:25", } return SonosMockEvent(soco, soco.deviceProperties, variables) diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 9677ee7375983c..aa2ce6cc0be73a 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -4,14 +4,36 @@ from unittest.mock import MagicMock, patch from homeassistant import config_entries, core -from homeassistant.components import zeroconf +from homeassistant.components import ssdp, zeroconf +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN +from homeassistant.const import CONF_HOSTS +from homeassistant.setup import async_setup_component -@patch("homeassistant.components.sonos.config_flow.soco.discover", return_value=True) -async def test_user_form(discover_mock: MagicMock, hass: core.HomeAssistant): +async def test_user_form( + hass: core.HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo +): """Test we get the user initiated form.""" + # Ensure config flow will fail if no devices discovered yet + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == "abort" + assert result["reason"] == "no_devices_found" + + # Initiate a discovery to allow config entry creation + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_payload, + ) + + # Ensure config flow succeeds after discovery result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -37,21 +59,29 @@ async def test_user_form(discover_mock: MagicMock, hass: core.HomeAssistant): assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_form(hass: core.HomeAssistant): - """Test we pass sonos devices to the discovery manager.""" +async def test_user_form_already_created(hass: core.HomeAssistant): + """Ensure we abort a flow if the entry is already created from config.""" + config = {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: "192.168.4.2"}}} + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_zeroconf_form( + hass: core.HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo +): + """Test we pass Zeroconf discoveries to the manager.""" mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - host="192.168.4.2", - hostname="Sonos-aaa", - name="Sonos-aaa@Living Room._sonos._tcp.local.", - port=None, - properties={"bootseq": "1234"}, - type="mock_type", - ), + data=zeroconf_payload, ) assert result["type"] == "form" assert result["errors"] is None @@ -78,6 +108,47 @@ async def test_zeroconf_form(hass: core.HomeAssistant): assert len(mock_manager.mock_calls) == 2 +async def test_ssdp_discovery(hass: core.HomeAssistant, soco): + """Test that SSDP discoveries create a config flow.""" + + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_location=f"http://{soco.ip_address}/", + ssdp_st="urn:schemas-upnp-org:device:ZonePlayer:1", + ssdp_usn=f"uuid:{soco.uid}_MR::urn:schemas-upnp-org:service:GroupRenderingControl:1", + upnp={ + ssdp.ATTR_UPNP_UDN: f"uuid:{soco.uid}", + }, + ), + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + flow = flows[0] + + with patch( + "homeassistant.components.sonos.async_setup", + return_value=True, + ) as mock_setup, patch( + "homeassistant.components.sonos.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == "Sonos" + assert result["data"] == {} + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_sonos_v1(hass: core.HomeAssistant): """Test we pass sonos devices to the discovery manager with v1 firmware devices.""" @@ -128,21 +199,18 @@ async def test_zeroconf_sonos_v1(hass: core.HomeAssistant): assert len(mock_manager.mock_calls) == 2 -async def test_zeroconf_form_not_sonos(hass: core.HomeAssistant): +async def test_zeroconf_form_not_sonos( + hass: core.HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo +): """Test we abort on non-sonos devices.""" mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() + zeroconf_payload.hostname = "not-aaa" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - host="192.168.4.2", - hostname="not-aaa", - name="mock_name", - port=None, - properties={"bootseq": "1234"}, - type="mock_type", - ), + data=zeroconf_payload, ) assert result["type"] == "abort" assert result["reason"] == "not_sonos_device" diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index bf4b5d5e7cc167..02897c523c1c8f 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,19 +1,26 @@ """Tests for the Sonos config flow.""" from unittest.mock import patch -from homeassistant import config_entries, data_entry_flow -from homeassistant.components import sonos +from homeassistant import config_entries, core, data_entry_flow +from homeassistant.components import sonos, zeroconf from homeassistant.setup import async_setup_component -from tests.common import mock_coro - -async def test_creating_entry_sets_up_media_player(hass): +async def test_creating_entry_sets_up_media_player( + hass: core.HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo +): """Test setting up Sonos loads the media player.""" + + # Initiate a discovery to allow a user config flow + await hass.config_entries.flow.async_init( + sonos.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_payload, + ) + with patch( "homeassistant.components.sonos.media_player.async_setup_entry", - return_value=mock_coro(True), - ) as mock_setup, patch("soco.discover", return_value=True): + ) as mock_setup: result = await hass.config_entries.flow.async_init( sonos.DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -29,11 +36,12 @@ async def test_creating_entry_sets_up_media_player(hass): assert len(mock_setup.mock_calls) == 1 -async def test_configuring_sonos_creates_entry(hass): +async def test_configuring_sonos_creates_entry(hass: core.HomeAssistant): """Test that specifying config will create an entry.""" with patch( - "homeassistant.components.sonos.async_setup_entry", return_value=mock_coro(True) - ) as mock_setup, patch("soco.discover", return_value=True): + "homeassistant.components.sonos.async_setup_entry", + return_value=True, + ) as mock_setup: await async_setup_component( hass, sonos.DOMAIN, @@ -44,11 +52,12 @@ async def test_configuring_sonos_creates_entry(hass): assert len(mock_setup.mock_calls) == 1 -async def test_not_configuring_sonos_not_creates_entry(hass): +async def test_not_configuring_sonos_not_creates_entry(hass: core.HomeAssistant): """Test that no config will not create an entry.""" with patch( - "homeassistant.components.sonos.async_setup_entry", return_value=mock_coro(True) - ) as mock_setup, patch("soco.discover", return_value=True): + "homeassistant.components.sonos.async_setup_entry", + return_value=True, + ) as mock_setup: await async_setup_component(hass, sonos.DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 9fb1d7639eb698..a73ff22aba6e30 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -9,54 +9,23 @@ from homeassistant.core import Context from homeassistant.exceptions import Unauthorized from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component -async def setup_platform(hass, config_entry, config): - """Set up the media player platform for testing.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - -async def test_async_setup_entry_hosts(hass, config_entry, config, soco): - """Test static setup.""" - await setup_platform(hass, config_entry, config) - - speakers = list(hass.data[DATA_SONOS].discovered.values()) - speaker = speakers[0] - assert speaker.soco == soco - - media_player = hass.states.get("media_player.zone_a") - assert media_player.state == STATE_IDLE - - -async def test_async_setup_entry_discover(hass, config_entry, discover): - """Test discovery setup.""" - await setup_platform(hass, config_entry, {}) - - speakers = list(hass.data[DATA_SONOS].discovered.values()) - speaker = speakers[0] - assert speaker.soco.uid == "RINCON_test" - - media_player = hass.states.get("media_player.zone_a") - assert media_player.state == STATE_IDLE - - -async def test_discovery_ignore_unsupported_device(hass, config_entry, soco, caplog): +async def test_discovery_ignore_unsupported_device( + hass, async_setup_sonos, soco, caplog +): """Test discovery setup.""" message = f"GetVolume not supported on {soco.ip_address}" type(soco).volume = PropertyMock(side_effect=NotSupportedException(message)) - await setup_platform(hass, config_entry, {}) + + await async_setup_sonos() assert message in caplog.text assert not hass.data[DATA_SONOS].discovered -async def test_services(hass, config_entry, config, hass_read_only_user): +async def test_services(hass, async_autosetup_sonos, hass_read_only_user): """Test join/unjoin requires control access.""" - await setup_platform(hass, config_entry, config) - with pytest.raises(Unauthorized): await hass.services.async_call( DOMAIN, @@ -67,10 +36,8 @@ async def test_services(hass, config_entry, config, hass_read_only_user): ) -async def test_device_registry(hass, config_entry, config, soco): +async def test_device_registry(hass, async_autosetup_sonos, soco): """Test sonos device registered in the device registry.""" - await setup_platform(hass, config_entry, config) - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( identifiers={("sonos", "RINCON_test")} @@ -86,10 +53,8 @@ async def test_device_registry(hass, config_entry, config, soco): assert reg_device.name == "Zone A" -async def test_entity_basic(hass, config_entry, discover): +async def test_entity_basic(hass, async_autosetup_sonos, discover): """Test basic state and attributes.""" - await setup_platform(hass, config_entry, {}) - state = hass.states.get("media_player.zone_a") assert state.state == STATE_IDLE attributes = state.attributes diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py new file mode 100644 index 00000000000000..91c00e053908f3 --- /dev/null +++ b/tests/components/sonos/test_number.py @@ -0,0 +1,32 @@ +"""Tests for the Sonos number platform.""" +from unittest.mock import patch + +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import entity_registry as ent_reg + + +async def test_audio_input_sensor(hass, async_autosetup_sonos, soco): + """Test audio input sensor.""" + entity_registry = ent_reg.async_get(hass) + + bass_number = entity_registry.entities["number.zone_a_bass"] + bass_state = hass.states.get(bass_number.entity_id) + assert bass_state.state == "1" + + treble_number = entity_registry.entities["number.zone_a_treble"] + treble_state = hass.states.get(treble_number.entity_id) + assert treble_state.state == "-1" + + audio_delay_number = entity_registry.entities["number.zone_a_audio_delay"] + audio_delay_state = hass.states.get(audio_delay_number.entity_id) + assert audio_delay_state.state == "2" + + with patch("soco.SoCo.audio_delay") as mock_audio_delay: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: audio_delay_number.entity_id, "value": 3}, + blocking=True, + ) + assert mock_audio_delay.called_with(3) diff --git a/tests/components/sonos/test_plex_playback.py b/tests/components/sonos/test_plex_playback.py index f9bedbfe1f72a8..eeaa5544222d93 100644 --- a/tests/components/sonos/test_plex_playback.py +++ b/tests/components/sonos/test_plex_playback.py @@ -14,24 +14,21 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.exceptions import HomeAssistantError -from .test_media_player import setup_platform - -async def test_plex_play_media( - hass, - config_entry, - config, -): +async def test_plex_play_media(hass, async_autosetup_sonos): """Test playing media via the Plex integration.""" - await setup_platform(hass, config_entry, config) media_player = "media_player.zone_a" media_content_id = ( '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}' ) with patch( - "homeassistant.components.sonos.media_player.play_on_sonos" - ) as mock_play: + "homeassistant.components.sonos.media_player.lookup_plex_media" + ) as mock_lookup, patch( + "soco.plugins.plex.PlexPlugin.play_now" + ) as mock_play_now, patch( + "homeassistant.components.sonos.media_player.SonosMediaPlayerEntity.set_shuffle" + ) as mock_shuffle: # Test successful Plex service call assert await hass.services.async_call( MP_DOMAIN, @@ -44,14 +41,38 @@ async def test_plex_play_media( blocking=True, ) - assert len(mock_play.mock_calls) == 1 - assert mock_play.mock_calls[0][1][1] == MEDIA_TYPE_MUSIC - assert mock_play.mock_calls[0][1][2] == media_content_id - assert mock_play.mock_calls[0][1][3] == "Zone A" + assert len(mock_lookup.mock_calls) == 1 + assert len(mock_play_now.mock_calls) == 1 + assert not mock_shuffle.called + assert mock_lookup.mock_calls[0][1][1] == MEDIA_TYPE_MUSIC + assert mock_lookup.mock_calls[0][1][2] == media_content_id + + # Test handling shuffle in payload + mock_lookup.reset_mock() + mock_play_now.reset_mock() + shuffle_media_content_id = '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album", "shuffle": 1}' + + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: f"{PLEX_URI_SCHEME}{shuffle_media_content_id}", + }, + blocking=True, + ) + + assert mock_shuffle.called + assert len(mock_lookup.mock_calls) == 1 + assert len(mock_play_now.mock_calls) == 1 + assert mock_lookup.mock_calls[0][1][1] == MEDIA_TYPE_MUSIC + assert mock_lookup.mock_calls[0][1][2] == media_content_id # Test failed Plex service call - mock_play.reset_mock() - mock_play.side_effect = HomeAssistantError + mock_lookup.reset_mock() + mock_lookup.side_effect = HomeAssistantError + mock_play_now.reset_mock() with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -64,4 +85,5 @@ async def test_plex_play_media( }, blocking=True, ) - assert mock_play.called + assert mock_lookup.called + assert not mock_play_now.called diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index a45d587cc08a84..8fb757891496a6 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -1,48 +1,36 @@ """Tests for the Sonos battery sensor platform.""" from soco.exceptions import NotSupportedException -from homeassistant.components.sonos import DOMAIN from homeassistant.components.sonos.binary_sensor import ATTR_BATTERY_POWER_SOURCE from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as ent_reg -async def setup_platform(hass, config_entry, config): - """Set up the media player platform for testing.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - -async def test_entity_registry_unsupported(hass, config_entry, config, soco): +async def test_entity_registry_unsupported(hass, async_setup_sonos, soco): """Test sonos device without battery registered in the device registry.""" soco.get_battery_info.side_effect = NotSupportedException - await setup_platform(hass, config_entry, config) + await async_setup_sonos() - entity_registry = await hass.helpers.entity_registry.async_get_registry() + entity_registry = ent_reg.async_get(hass) assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" not in entity_registry.entities assert "binary_sensor.zone_a_power" not in entity_registry.entities -async def test_entity_registry_supported(hass, config_entry, config, soco): +async def test_entity_registry_supported(hass, async_autosetup_sonos, soco): """Test sonos device with battery registered in the device registry.""" - await setup_platform(hass, config_entry, config) - - entity_registry = await hass.helpers.entity_registry.async_get_registry() + entity_registry = ent_reg.async_get(hass) assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" in entity_registry.entities assert "binary_sensor.zone_a_power" in entity_registry.entities -async def test_battery_attributes(hass, config_entry, config, soco): +async def test_battery_attributes(hass, async_autosetup_sonos, soco): """Test sonos device with battery state.""" - await setup_platform(hass, config_entry, config) - - entity_registry = await hass.helpers.entity_registry.async_get_registry() + entity_registry = ent_reg.async_get(hass) battery = entity_registry.entities["sensor.zone_a_battery"] battery_state = hass.states.get(battery.entity_id) @@ -57,22 +45,22 @@ async def test_battery_attributes(hass, config_entry, config, soco): ) -async def test_battery_on_S1(hass, config_entry, config, soco, battery_event): +async def test_battery_on_s1(hass, async_setup_sonos, soco, device_properties_event): """Test battery state updates on a Sonos S1 device.""" soco.get_battery_info.return_value = {} - await setup_platform(hass, config_entry, config) + await async_setup_sonos() subscription = soco.deviceProperties.subscribe.return_value sub_callback = subscription.callback - entity_registry = await hass.helpers.entity_registry.async_get_registry() + entity_registry = ent_reg.async_get(hass) assert "sensor.zone_a_battery" not in entity_registry.entities assert "binary_sensor.zone_a_power" not in entity_registry.entities # Update the speaker with a callback event - sub_callback(battery_event) + sub_callback(device_properties_event) await hass.async_block_till_done() battery = entity_registry.entities["sensor.zone_a_battery"] @@ -86,51 +74,66 @@ async def test_battery_on_S1(hass, config_entry, config, soco, battery_event): async def test_device_payload_without_battery( - hass, config_entry, config, soco, battery_event, caplog + hass, async_setup_sonos, soco, device_properties_event, caplog ): """Test device properties event update without battery info.""" soco.get_battery_info.return_value = None - await setup_platform(hass, config_entry, config) + await async_setup_sonos() subscription = soco.deviceProperties.subscribe.return_value sub_callback = subscription.callback bad_payload = "BadKey:BadValue" - battery_event.variables["more_info"] = bad_payload + device_properties_event.variables["more_info"] = bad_payload - sub_callback(battery_event) + sub_callback(device_properties_event) await hass.async_block_till_done() assert bad_payload in caplog.text async def test_device_payload_without_battery_and_ignored_keys( - hass, config_entry, config, soco, battery_event, caplog + hass, async_setup_sonos, soco, device_properties_event, caplog ): """Test device properties event update without battery info and ignored keys.""" soco.get_battery_info.return_value = None - await setup_platform(hass, config_entry, config) + await async_setup_sonos() subscription = soco.deviceProperties.subscribe.return_value sub_callback = subscription.callback ignored_payload = "SPID:InCeiling,TargetRoomName:Bouncy House" - battery_event.variables["more_info"] = ignored_payload + device_properties_event.variables["more_info"] = ignored_payload - sub_callback(battery_event) + sub_callback(device_properties_event) await hass.async_block_till_done() assert ignored_payload not in caplog.text -async def test_audio_input_sensor(hass, config_entry, config, soco): - """Test sonos device with battery state.""" - await setup_platform(hass, config_entry, config) - - entity_registry = await hass.helpers.entity_registry.async_get_registry() +async def test_audio_input_sensor(hass, async_autosetup_sonos, soco): + """Test audio input sensor.""" + entity_registry = ent_reg.async_get(hass) audio_input_sensor = entity_registry.entities["sensor.zone_a_audio_input_format"] audio_input_state = hass.states.get(audio_input_sensor.entity_id) assert audio_input_state.state == "Dolby 5.1" + + +async def test_microphone_binary_sensor( + hass, async_autosetup_sonos, soco, device_properties_event +): + """Test microphone binary sensor.""" + entity_registry = ent_reg.async_get(hass) + assert "binary_sensor.zone_a_microphone" not in entity_registry.entities + + # Update the speaker with a callback event + subscription = soco.deviceProperties.subscribe.return_value + subscription.callback(device_properties_event) + await hass.async_block_till_done() + + mic_binary_sensor = entity_registry.entities["binary_sensor.zone_a_microphone"] + mic_binary_sensor_state = hass.states.get(mic_binary_sensor.entity_id) + assert mic_binary_sensor_state.state == STATE_ON diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py new file mode 100644 index 00000000000000..cb53fb43ed2524 --- /dev/null +++ b/tests/components/sonos/test_speaker.py @@ -0,0 +1,33 @@ +"""Tests for common SonosSpeaker behavior.""" +from unittest.mock import patch + +from homeassistant.components.sonos.const import DATA_SONOS, SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + + +async def test_fallback_to_polling( + hass: HomeAssistant, async_autosetup_sonos, soco, caplog +): + """Test that polling fallback works.""" + speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + assert speaker.soco is soco + assert speaker._subscriptions + + caplog.clear() + + # Ensure subscriptions are cancelled and polling methods are called when subscriptions time out + with patch( + "homeassistant.components.sonos.speaker.SonosSpeaker.update_media" + ), patch( + "homeassistant.components.sonos.speaker.SonosSpeaker.subscription_address" + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + assert not speaker._subscriptions + assert speaker.subscriptions_failed + assert "falling back to polling" in caplog.text + assert "Activity on Zone A from SonosSpeaker.update_volume" in caplog.text diff --git a/tests/components/sonos/test_statistics.py b/tests/components/sonos/test_statistics.py new file mode 100644 index 00000000000000..4270a13e65ce10 --- /dev/null +++ b/tests/components/sonos/test_statistics.py @@ -0,0 +1,30 @@ +"""Tests for the Sonos statistics.""" +from homeassistant.components.sonos.const import DATA_SONOS + + +async def test_statistics_duplicate( + hass, async_autosetup_sonos, soco, device_properties_event +): + """Test Sonos statistics.""" + speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + + subscription = soco.deviceProperties.subscribe.return_value + sub_callback = subscription.callback + + # Update the speaker with a callback event + sub_callback(device_properties_event) + await hass.async_block_till_done() + + report = speaker.event_stats.report() + assert report["DeviceProperties"]["received"] == 1 + assert report["DeviceProperties"]["duplicates"] == 0 + assert report["DeviceProperties"]["processed"] == 1 + + # Ensure a duplicate is registered in the statistics + sub_callback(device_properties_event) + await hass.async_block_till_done() + + report = speaker.event_stats.report() + assert report["DeviceProperties"]["received"] == 2 + assert report["DeviceProperties"]["duplicates"] == 1 + assert report["DeviceProperties"]["processed"] == 1 diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 9c25e2b8cc750d..63ac9de6065077 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -3,7 +3,6 @@ from datetime import timedelta from unittest.mock import patch -from homeassistant.components.sonos import DOMAIN from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER from homeassistant.components.sonos.switch import ( ATTR_DURATION, @@ -15,8 +14,7 @@ ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_TIME, STATE_OFF, STATE_ON -from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as ent_reg from homeassistant.util import dt from .conftest import SonosMockEvent @@ -24,18 +22,9 @@ from tests.common import async_fire_time_changed -async def setup_platform(hass, config_entry, config): - """Set up the switch platform for testing.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - -async def test_entity_registry(hass, config_entry, config): +async def test_entity_registry(hass, async_autosetup_sonos): """Test sonos device with alarm registered in the device registry.""" - await setup_platform(hass, config_entry, config) - - entity_registry = await hass.helpers.entity_registry.async_get_registry() + entity_registry = ent_reg.async_get(hass) assert "media_player.zone_a" in entity_registry.entities assert "switch.sonos_alarm_14" in entity_registry.entities @@ -47,11 +36,9 @@ async def test_entity_registry(hass, config_entry, config): assert "switch.sonos_zone_a_touch_controls" in entity_registry.entities -async def test_switch_attributes(hass, config_entry, config, soco): +async def test_switch_attributes(hass, async_autosetup_sonos, soco): """Test for correct Sonos switch states.""" - await setup_platform(hass, config_entry, config) - - entity_registry = await hass.helpers.entity_registry.async_get_registry() + entity_registry = ent_reg.async_get(hass) alarm = entity_registry.entities["switch.sonos_alarm_14"] alarm_state = hass.states.get(alarm.entity_id) @@ -125,15 +112,15 @@ async def test_switch_attributes(hass, config_entry, config, soco): async def test_alarm_create_delete( - hass, config_entry, config, soco, alarm_clock, alarm_clock_extended, alarm_event + hass, async_setup_sonos, soco, alarm_clock, alarm_clock_extended, alarm_event ): """Test for correct creation and deletion of alarms during runtime.""" - entity_registry = async_get_entity_registry(hass) + entity_registry = ent_reg.async_get(hass) one_alarm = copy(alarm_clock.ListAlarms.return_value) two_alarms = copy(alarm_clock_extended.ListAlarms.return_value) - await setup_platform(hass, config_entry, config) + await async_setup_sonos() assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" not in entity_registry.entities diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 11f59444c2c5d1..629ec464e58b09 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -31,6 +31,30 @@ async def test_query(hass): assert state.attributes["value"] == 5 +async def test_query_limit(hass): + """Test the SQL sensor with a query containing 'LIMIT' in lowercase.""" + config = { + "sensor": { + "platform": "sql", + "db_url": "sqlite://", + "queries": [ + { + "name": "count_tables", + "query": "SELECT 5 as value limit 1", + "column": "value", + } + ], + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.count_tables") + assert state.state == "5" + assert state.attributes["value"] == 5 + + async def test_invalid_query(hass): """Test the SQL sensor for invalid queries.""" with pytest.raises(vol.Invalid): diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index 70f5f1233d776b..22181d73fd3031 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -246,69 +246,3 @@ async def test_dhcp_discovery_existing_player(hass): ), ) assert result["type"] == RESULT_TYPE_ABORT - - -async def test_import(hass): - """Test handling of configuration imported.""" - with patch("pysqueezebox.Server.async_query", return_value={"uuid": UUID},), patch( - "homeassistant.components.squeezebox.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, - ) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_bad_host(hass): - """Test handling of configuration imported with bad host.""" - with patch("pysqueezebox.Server.async_query", return_value=False): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, - ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_bad_auth(hass): - """Test handling of configuration import with bad authentication.""" - with patch("pysqueezebox.Server.async_query", new=patch_async_query_unauthorized): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: HOST, - CONF_PORT: PORT, - CONF_USERNAME: "test", - CONF_PASSWORD: "bad", - }, - ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "invalid_auth" - - -async def test_import_existing(hass): - """Test handling of configuration import of existing server.""" - with patch( - "homeassistant.components.squeezebox.async_setup_entry", - return_value=True, - ), patch( - "pysqueezebox.Server.async_query", - return_value={"ip": HOST, "uuid": UUID}, - ): - entry = MockConfigEntry(domain=DOMAIN, unique_id=UUID) - await hass.config_entries.async_add(entry) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, - ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 5a4585cc8e596d..cb19ae8720a3eb 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -1,7 +1,7 @@ """Tests for the srp_energy sensor platform.""" from unittest.mock import MagicMock -from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.srp_energy.const import ( ATTRIBUTION, DEFAULT_NAME, @@ -11,11 +11,7 @@ SRP_ENERGY_DOMAIN, ) from homeassistant.components.srp_energy.sensor import SrpEntity, async_setup_entry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, -) +from homeassistant.const import ATTR_ATTRIBUTION, ENERGY_KILO_WATT_HOUR async def test_async_setup_entry(hass): @@ -99,8 +95,8 @@ async def test_srp_entity(hass): assert srp_entity.should_poll is False assert srp_entity.extra_state_attributes[ATTR_ATTRIBUTION] == ATTRIBUTION assert srp_entity.available is not None - assert srp_entity.device_class == DEVICE_CLASS_ENERGY - assert srp_entity.state_class == STATE_CLASS_TOTAL_INCREASING + assert srp_entity.device_class is SensorDeviceClass.ENERGY + assert srp_entity.state_class is SensorStateClass.TOTAL_INCREASING await srp_entity.async_added_to_hass() assert srp_entity.state is not None diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 9e4d7424eb94e4..758f8fb79bae23 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -793,3 +793,61 @@ async def test_ipv4_does_additional_search_for_sonos( ), ) assert ssdp_listener.async_search.call_args[1] == {} + + +@pytest.mark.usefixtures("mock_integration_frame") +async def test_service_info_compatibility(hass, caplog): + """Test compatibility with old-style dict. + + To be removed in 2022.6 + """ + discovery_info = ssdp.SsdpServiceInfo( + ssdp_st="mock-st", + ssdp_location="http://1.1.1.1", + ssdp_usn="uuid:mock-udn::mock-st", + ssdp_server="mock-server", + ssdp_ext="", + ssdp_headers=_ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "server": "mock-server", + "ext": "", + } + ), + upnp={ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC"}, + ) + + with patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()): + assert discovery_info["ssdp_st"] == "mock-st" + assert "Detected integration that accessed discovery_info['ssdp_st']" in caplog.text + + with patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()): + assert discovery_info.get("ssdp_location") == "http://1.1.1.1" + assert ( + "Detected integration that accessed discovery_info.get('ssdp_location')" + in caplog.text + ) + + with patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()): + assert "ssdp_usn" in discovery_info + assert ( + "Detected integration that accessed discovery_info.__contains__('ssdp_usn')" + in caplog.text + ) + + # Root item + assert discovery_info["ssdp_usn"] == "uuid:mock-udn::mock-st" + assert discovery_info.get("ssdp_usn") == "uuid:mock-udn::mock-st" + assert "ssdp_usn" in discovery_info + + # SSDP header + assert discovery_info["st"] == "mock-st" + assert discovery_info.get("st") == "mock-st" + assert "st" in discovery_info + + # UPnP item + assert discovery_info[ssdp.ATTR_UPNP_DEVICE_TYPE] == "ABC" + assert discovery_info.get(ssdp.ATTR_UPNP_DEVICE_TYPE) == "ABC" + assert ssdp.ATTR_UPNP_DEVICE_TYPE in discovery_info diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 658d6a089e75e1..e2e6c7dfd5d21e 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -1,15 +1,16 @@ """The test for the statistics sensor platform.""" +from __future__ import annotations + +from collections.abc import Sequence from datetime import datetime, timedelta import statistics -import unittest +from typing import Any from unittest.mock import patch -import pytest - from homeassistant import config as hass_config -from homeassistant.components import recorder -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT -from homeassistant.components.statistics.sensor import DOMAIN, StatisticsSensor +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass +from homeassistant.components.statistics import DOMAIN as STATISTICS_DOMAIN +from homeassistant.components.statistics.sensor import StatisticsSensor from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, SERVICE_RELOAD, @@ -17,213 +18,304 @@ STATE_UNKNOWN, TEMP_CELSIUS, ) -from homeassistant.setup import async_setup_component, setup_component +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import ( - fire_time_changed, + async_fire_time_changed, + async_init_recorder_component, get_fixture_path, - get_test_home_assistant, - init_recorder_component, ) -from tests.components.recorder.common import wait_recording_done +from tests.components.recorder.common import async_wait_recording_done_without_instance +VALUES_BINARY = ["on", "off", "on", "off", "on", "off", "on", "off", "on"] +VALUES_NUMERIC = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6] -@pytest.fixture(autouse=True) -def mock_legacy_time(legacy_patchable_time): - """Make time patchable for all the tests.""" - yield +async def test_unique_id(hass: HomeAssistant): + """Test configuration defined unique_id.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "unique_id": "uniqueid_sensor_test", + }, + ] + }, + ) + await hass.async_block_till_done() + + entity_reg = er.async_get(hass) + entity_id = entity_reg.async_get_entity_id( + "sensor", STATISTICS_DOMAIN, "uniqueid_sensor_test" + ) + assert entity_id == "sensor.test" -class TestStatisticsSensor(unittest.TestCase): - """Test the Statistics sensor.""" - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.values_binary = ["on", "off", "on", "off", "on", "off", "on", "off", "on"] - self.mean_binary = round( - 100 / len(self.values_binary) * self.values_binary.count("on"), 2 +async def test_sensor_defaults_numeric(hass: HomeAssistant): + """Test the general behavior of the sensor, with numeric source sensor.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + }, + ] + }, + ) + await hass.async_block_till_done() + + for value in VALUES_NUMERIC: + hass.states.async_set( + "sensor.test_monitored", + str(value), + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) - self.values = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6] - self.mean = round(sum(self.values) / len(self.values), 2) - self.addCleanup(self.hass.stop) - - def test_sensor_defaults_numeric(self): - """Test the general behavior of the sensor, with numeric source sensor.""" - assert setup_component( - self.hass, - "sensor", - { - "sensor": [ - { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - }, - ] - }, + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == str(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)) + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + assert state.attributes.get("source_value_valid") is True + assert "age_coverage_ratio" not in state.attributes + + # Source sensor turns unavailable, then available with valid value, + # statistics sensor should follow + state = hass.states.get("sensor.test") + hass.states.async_set( + "sensor.test_monitored", + STATE_UNAVAILABLE, + ) + await hass.async_block_till_done() + new_state = hass.states.get("sensor.test") + assert new_state is not None + assert new_state.state == STATE_UNAVAILABLE + assert new_state.attributes.get("source_value_valid") is None + hass.states.async_set( + "sensor.test_monitored", + "0", + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + await hass.async_block_till_done() + new_state = hass.states.get("sensor.test") + new_mean = round(sum(VALUES_NUMERIC) / (len(VALUES_NUMERIC) + 1), 2) + assert new_state is not None + assert new_state.state == str(new_mean) + assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert new_state.attributes.get("buffer_usage_ratio") == round(10 / 20, 2) + assert new_state.attributes.get("source_value_valid") is True + + # Source sensor has a nonnumerical state, unit and state should not change + state = hass.states.get("sensor.test") + hass.states.async_set("sensor.test_monitored", "beer", {}) + await hass.async_block_till_done() + new_state = hass.states.get("sensor.test") + assert new_state is not None + assert new_state.state == str(new_mean) + assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert new_state.attributes.get("source_value_valid") is False + + # Source sensor has the STATE_UNKNOWN state, unit and state should not change + state = hass.states.get("sensor.test") + hass.states.async_set("sensor.test_monitored", STATE_UNKNOWN, {}) + await hass.async_block_till_done() + new_state = hass.states.get("sensor.test") + assert new_state is not None + assert new_state.state == str(new_mean) + assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert new_state.attributes.get("source_value_valid") is False + + # Source sensor is removed, unit and state should not change + # This is equal to a None value being published + hass.states.async_remove("sensor.test_monitored") + await hass.async_block_till_done() + new_state = hass.states.get("sensor.test") + assert new_state is not None + assert new_state.state == str(new_mean) + assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert new_state.attributes.get("source_value_valid") is False + + +async def test_sensor_defaults_binary(hass: HomeAssistant): + """Test the general behavior of the sensor, with binary source sensor.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "binary_sensor.test_monitored", + }, + ] + }, + ) + await hass.async_block_till_done() + + for value in VALUES_BINARY: + hass.states.async_set( + "binary_sensor.test_monitored", + value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) + await hass.async_block_till_done() - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == str(len(VALUES_BINARY)) + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + assert state.attributes.get("source_value_valid") is True + assert "age_coverage_ratio" not in state.attributes - for value in self.values: - self.hass.states.set( - "sensor.test_monitored", - value, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test") - assert state.state == str(self.mean) - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT - assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) - assert state.attributes.get("source_value_valid") is True - assert "age_coverage_ratio" not in state.attributes - - # Source sensor turns unavailable, then available with valid value, - # statistics sensor should follow - state = self.hass.states.get("sensor.test") - self.hass.states.set( - "sensor.test_monitored", - STATE_UNAVAILABLE, + +async def test_sensor_source_with_force_update(hass: HomeAssistant): + """Test the behavior of the sensor when the source sensor force-updates with same value.""" + repeating_values = [18, 0, 0, 0, 0, 0, 0, 0, 9] + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_normal", + "entity_id": "sensor.test_monitored_normal", + "state_characteristic": "mean", + }, + { + "platform": "statistics", + "name": "test_force", + "entity_id": "sensor.test_monitored_force", + "state_characteristic": "mean", + }, + ] + }, + ) + await hass.async_block_till_done() + + for value in repeating_values: + hass.states.async_set( + "sensor.test_monitored_normal", + str(value), + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) - self.hass.block_till_done() - new_state = self.hass.states.get("sensor.test") - assert new_state.state == STATE_UNAVAILABLE - assert new_state.attributes.get("source_value_valid") is None - self.hass.states.set( - "sensor.test_monitored", - 0, + hass.states.async_set( + "sensor.test_monitored_force", + str(value), {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + force_update=True, ) - self.hass.block_till_done() - new_state = self.hass.states.get("sensor.test") - new_mean = round(sum(self.values) / (len(self.values) + 1), 2) - assert new_state.state == str(new_mean) - assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert new_state.attributes.get("buffer_usage_ratio") == round(10 / 20, 2) - assert new_state.attributes.get("source_value_valid") is True - - # Source sensor has a nonnumerical state, unit and state should not change - state = self.hass.states.get("sensor.test") - self.hass.states.set("sensor.test_monitored", "beer", {}) - self.hass.block_till_done() - new_state = self.hass.states.get("sensor.test") - assert new_state.state == str(new_mean) - assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert new_state.attributes.get("source_value_valid") is False - - # Source sensor has the STATE_UNKNOWN state, unit and state should not change - state = self.hass.states.get("sensor.test") - self.hass.states.set("sensor.test_monitored", STATE_UNKNOWN, {}) - self.hass.block_till_done() - new_state = self.hass.states.get("sensor.test") - assert new_state.state == str(new_mean) - assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert new_state.attributes.get("source_value_valid") is False - - # Source sensor is removed, unit and state should not change - # This is equal to a None value being published - self.hass.states.remove("sensor.test_monitored") - self.hass.block_till_done() - new_state = self.hass.states.get("sensor.test") - assert new_state.state == str(new_mean) - assert new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - assert new_state.attributes.get("source_value_valid") is False - - def test_sensor_defaults_binary(self): - """Test the general behavior of the sensor, with binary source sensor.""" - assert setup_component( - self.hass, - "sensor", - { - "sensor": [ - { - "platform": "statistics", - "name": "test", - "entity_id": "binary_sensor.test_monitored", - }, - ] - }, + await hass.async_block_till_done() + + state_normal = hass.states.get("sensor.test_normal") + state_force = hass.states.get("sensor.test_force") + assert state_normal and state_force + assert state_normal.state == str(round(sum(repeating_values) / 3, 2)) + assert state_force.state == str(round(sum(repeating_values) / 9, 2)) + assert state_normal.attributes.get("buffer_usage_ratio") == round(3 / 20, 2) + assert state_force.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + + +async def test_sampling_size_non_default(hass: HomeAssistant): + """Test rotation.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 5, + }, + ] + }, + ) + await hass.async_block_till_done() + + for value in VALUES_NUMERIC: + hass.states.async_set( + "sensor.test_monitored", + str(value), + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) + await hass.async_block_till_done() - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() - for value in self.values_binary: - self.hass.states.set( - "binary_sensor.test_monitored", - value, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test") - assert state.state == str(len(self.values_binary)) - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT - assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) - assert state.attributes.get("source_value_valid") is True - assert "age_coverage_ratio" not in state.attributes - - def test_sensor_source_with_force_update(self): - """Test the behavior of the sensor when the source sensor force-updates with same value.""" - repeating_values = [18, 0, 0, 0, 0, 0, 0, 0, 9] - assert setup_component( - self.hass, - "sensor", - { - "sensor": [ - { - "platform": "statistics", - "name": "test_normal", - "entity_id": "sensor.test_monitored_normal", - "state_characteristic": "mean", - }, - { - "platform": "statistics", - "name": "test_force", - "entity_id": "sensor.test_monitored_force", - "state_characteristic": "mean", - }, - ] - }, + state = hass.states.get("sensor.test") + new_mean = round(sum(VALUES_NUMERIC[-5:]) / len(VALUES_NUMERIC[-5:]), 2) + assert state is not None + assert state.state == str(new_mean) + assert state.attributes.get("buffer_usage_ratio") == round(5 / 5, 2) + + +async def test_sampling_size_1(hass: HomeAssistant): + """Test validity of stats requiring only one sample.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 1, + }, + ] + }, + ) + await hass.async_block_till_done() + + for value in VALUES_NUMERIC[-3:]: # just the last 3 will do + hass.states.async_set( + "sensor.test_monitored", + str(value), + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) + await hass.async_block_till_done() - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() + state = hass.states.get("sensor.test") + new_mean = float(VALUES_NUMERIC[-1]) + assert state is not None + assert state.state == str(new_mean) + assert state.attributes.get("buffer_usage_ratio") == round(1 / 1, 2) - for value in repeating_values: - self.hass.states.set( - "sensor.test_monitored_normal", - value, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.states.set( - "sensor.test_monitored_force", - value, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - force_update=True, - ) - self.hass.block_till_done() - - state_normal = self.hass.states.get("sensor.test_normal") - state_force = self.hass.states.get("sensor.test_force") - assert state_normal.state == str(round(sum(repeating_values) / 3, 2)) - assert state_force.state == str(round(sum(repeating_values) / 9, 2)) - assert state_normal.attributes.get("buffer_usage_ratio") == round(3 / 20, 2) - assert state_force.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) - - def test_sampling_size_non_default(self): - """Test rotation.""" - assert setup_component( - self.hass, + +async def test_age_limit_expiry(hass: HomeAssistant): + """Test that values are removed after certain age.""" + now = dt_util.utcnow() + mock_data = { + "return_time": datetime(now.year + 1, 8, 2, 12, 23, tzinfo=dt_util.UTC) + } + + def mock_now(): + return mock_data["return_time"] + + with patch( + "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now + ): + assert await async_setup_component( + hass, "sensor", { "sensor": [ @@ -232,735 +324,663 @@ def test_sampling_size_non_default(self): "name": "test", "entity_id": "sensor.test_monitored", "state_characteristic": "mean", - "sampling_size": 5, + "max_age": {"minutes": 4}, }, ] }, ) + await hass.async_block_till_done() - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() - - for value in self.values: - self.hass.states.set( + for value in VALUES_NUMERIC: + mock_data["return_time"] += timedelta(minutes=1) + async_fire_time_changed(hass, mock_data["return_time"]) + hass.states.async_set( "sensor.test_monitored", - value, + str(value), {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) - self.hass.block_till_done() + await hass.async_block_till_done() + + # After adding all values, we should only see 5 values in memory - state = self.hass.states.get("sensor.test") - new_mean = round(sum(self.values[-5:]) / len(self.values[-5:]), 2) + state = hass.states.get("sensor.test") + new_mean = round(sum(VALUES_NUMERIC[-5:]) / len(VALUES_NUMERIC[-5:]), 2) + assert state is not None assert state.state == str(new_mean) - assert state.attributes.get("buffer_usage_ratio") == round(5 / 5, 2) + assert state.attributes.get("buffer_usage_ratio") == round(5 / 20, 2) + assert state.attributes.get("age_coverage_ratio") == 1.0 - def test_sampling_size_1(self): - """Test validity of stats requiring only one sample.""" - assert setup_component( - self.hass, - "sensor", + # Values expire over time. Only two are left + + mock_data["return_time"] += timedelta(minutes=3) + async_fire_time_changed(hass, mock_data["return_time"]) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + new_mean = round(sum(VALUES_NUMERIC[-2:]) / len(VALUES_NUMERIC[-2:]), 2) + assert state is not None + assert state.state == str(new_mean) + assert state.attributes.get("buffer_usage_ratio") == round(2 / 20, 2) + assert state.attributes.get("age_coverage_ratio") == 1 / 4 + + # Values expire over time. Only one is left + + mock_data["return_time"] += timedelta(minutes=1) + async_fire_time_changed(hass, mock_data["return_time"]) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + new_mean = float(VALUES_NUMERIC[-1]) + assert state is not None + assert state.state == str(new_mean) + assert state.attributes.get("buffer_usage_ratio") == round(1 / 20, 2) + assert state.attributes.get("age_coverage_ratio") == 0 + + # Values expire over time. Buffer is empty + + mock_data["return_time"] += timedelta(minutes=1) + async_fire_time_changed(hass, mock_data["return_time"]) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get("buffer_usage_ratio") == round(0 / 20, 2) + assert state.attributes.get("age_coverage_ratio") is None + + +async def test_precision(hass: HomeAssistant): + """Test correct result with precision set.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_precision_0", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "precision": 0, + }, + { + "platform": "statistics", + "name": "test_precision_3", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "precision": 3, + }, + ] + }, + ) + await hass.async_block_till_done() + + for value in VALUES_NUMERIC: + hass.states.async_set( + "sensor.test_monitored", + str(value), + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + await hass.async_block_till_done() + + mean = sum(VALUES_NUMERIC) / len(VALUES_NUMERIC) + state = hass.states.get("sensor.test_precision_0") + assert state is not None + assert state.state == str(int(round(mean, 0))) + state = hass.states.get("sensor.test_precision_3") + assert state is not None + assert state.state == str(round(mean, 3)) + + +async def test_state_class(hass: HomeAssistant): + """Test state class, which depends on the characteristic configured.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_normal", + "entity_id": "sensor.test_monitored", + "state_characteristic": "count", + }, + { + "platform": "statistics", + "name": "test_nan", + "entity_id": "sensor.test_monitored", + "state_characteristic": "datetime_oldest", + }, + ] + }, + ) + await hass.async_block_till_done() + + for value in VALUES_NUMERIC: + hass.states.async_set( + "sensor.test_monitored", + str(value), + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_normal") + assert state is not None + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT + state = hass.states.get("sensor.test_nan") + assert state is not None + assert state.attributes.get(ATTR_STATE_CLASS) is None + + +async def test_unitless_source_sensor(hass: HomeAssistant): + """Statistics for a unitless source sensor should never have a unit.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_unitless_1", + "entity_id": "sensor.test_monitored_unitless", + "state_characteristic": "count", + }, + { + "platform": "statistics", + "name": "test_unitless_2", + "entity_id": "sensor.test_monitored_unitless", + "state_characteristic": "mean", + }, + { + "platform": "statistics", + "name": "test_unitless_3", + "entity_id": "sensor.test_monitored_unitless", + "state_characteristic": "change_second", + }, + { + "platform": "statistics", + "name": "test_unitless_4", + "entity_id": "binary_sensor.test_monitored_unitless", + }, + { + "platform": "statistics", + "name": "test_unitless_5", + "entity_id": "binary_sensor.test_monitored_unitless", + "state_characteristic": "mean", + }, + ] + }, + ) + await hass.async_block_till_done() + + for value_numeric in VALUES_NUMERIC: + hass.states.async_set( + "sensor.test_monitored_unitless", + str(value_numeric), + ) + for value_binary in VALUES_BINARY: + hass.states.async_set( + "binary_sensor.test_monitored_unitless", + str(value_binary), + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_unitless_1") + assert state and state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + state = hass.states.get("sensor.test_unitless_2") + assert state and state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + state = hass.states.get("sensor.test_unitless_3") + assert state and state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + state = hass.states.get("sensor.test_unitless_4") + assert state and state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + state = hass.states.get("sensor.test_unitless_5") + assert state and state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "%" + + +async def test_state_characteristics(hass: HomeAssistant): + """Test configured state characteristic for value and unit.""" + now = dt_util.utcnow() + start_datetime = datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) + mock_data = {"return_time": start_datetime} + + def mock_now(): + return mock_data["return_time"] + + characteristics: Sequence[dict[str, Any]] = ( + { + "source_sensor_domain": "sensor", + "name": "average_linear", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": 10.68, + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "average_step", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": 11.36, + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "average_timeless", + "value_0": STATE_UNKNOWN, + "value_1": float(VALUES_NUMERIC[-1]), + "value_9": float(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "change", + "value_0": STATE_UNKNOWN, + "value_1": float(0), + "value_9": float(round(VALUES_NUMERIC[-1] - VALUES_NUMERIC[0], 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "change_sample", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float( + round( + (VALUES_NUMERIC[-1] - VALUES_NUMERIC[0]) + / (len(VALUES_NUMERIC) - 1), + 2, + ) + ), + "unit": "°C/sample", + }, + { + "source_sensor_domain": "sensor", + "name": "change_second", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float( + round( + (VALUES_NUMERIC[-1] - VALUES_NUMERIC[0]) + / (60 * (len(VALUES_NUMERIC) - 1)), + 2, + ) + ), + "unit": "°C/s", + }, + { + "source_sensor_domain": "sensor", + "name": "count", + "value_0": 0, + "value_1": 1, + "value_9": 9, + "unit": None, + }, + { + "source_sensor_domain": "sensor", + "name": "datetime_newest", + "value_0": STATE_UNKNOWN, + "value_1": (start_datetime + timedelta(minutes=9)).isoformat(), + "value_9": (start_datetime + timedelta(minutes=9)).isoformat(), + "unit": None, + }, + { + "source_sensor_domain": "sensor", + "name": "datetime_oldest", + "value_0": STATE_UNKNOWN, + "value_1": (start_datetime + timedelta(minutes=9)).isoformat(), + "value_9": (start_datetime + timedelta(minutes=1)).isoformat(), + "unit": None, + }, + { + "source_sensor_domain": "sensor", + "name": "distance_95_percent_of_values", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float(round(2 * 1.96 * statistics.stdev(VALUES_NUMERIC), 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "distance_99_percent_of_values", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float(round(2 * 2.58 * statistics.stdev(VALUES_NUMERIC), 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "distance_absolute", + "value_0": STATE_UNKNOWN, + "value_1": float(0), + "value_9": float(max(VALUES_NUMERIC) - min(VALUES_NUMERIC)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "mean", + "value_0": STATE_UNKNOWN, + "value_1": float(VALUES_NUMERIC[-1]), + "value_9": float(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "median", + "value_0": STATE_UNKNOWN, + "value_1": float(VALUES_NUMERIC[-1]), + "value_9": float(round(statistics.median(VALUES_NUMERIC), 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "noisiness", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float(round(sum([3, 4.8, 10.2, 1.2, 5.4, 2.5, 7.3, 8]) / 8, 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "quantiles", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": [ + round(quantile, 2) for quantile in statistics.quantiles(VALUES_NUMERIC) + ], + "unit": None, + }, + { + "source_sensor_domain": "sensor", + "name": "standard_deviation", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float(round(statistics.stdev(VALUES_NUMERIC), 2)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "total", + "value_0": STATE_UNKNOWN, + "value_1": float(VALUES_NUMERIC[-1]), + "value_9": float(sum(VALUES_NUMERIC)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "value_max", + "value_0": STATE_UNKNOWN, + "value_1": float(VALUES_NUMERIC[-1]), + "value_9": float(max(VALUES_NUMERIC)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "value_min", + "value_0": STATE_UNKNOWN, + "value_1": float(VALUES_NUMERIC[-1]), + "value_9": float(min(VALUES_NUMERIC)), + "unit": "°C", + }, + { + "source_sensor_domain": "sensor", + "name": "variance", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": float(round(statistics.variance(VALUES_NUMERIC), 2)), + "unit": "°C²", + }, + { + "source_sensor_domain": "binary_sensor", + "name": "average_step", + "value_0": STATE_UNKNOWN, + "value_1": STATE_UNKNOWN, + "value_9": 50.0, + "unit": "%", + }, + { + "source_sensor_domain": "binary_sensor", + "name": "average_timeless", + "value_0": STATE_UNKNOWN, + "value_1": 100.0, + "value_9": float( + round(100 / len(VALUES_BINARY) * VALUES_BINARY.count("on"), 2) + ), + "unit": "%", + }, + { + "source_sensor_domain": "binary_sensor", + "name": "count", + "value_0": 0, + "value_1": 1, + "value_9": len(VALUES_BINARY), + "unit": None, + }, + { + "source_sensor_domain": "binary_sensor", + "name": "mean", + "value_0": STATE_UNKNOWN, + "value_1": 100.0, + "value_9": float( + round(100 / len(VALUES_BINARY) * VALUES_BINARY.count("on"), 2) + ), + "unit": "%", + }, + ) + sensors_config = [] + for characteristic in characteristics: + sensors_config.append( { - "sensor": [ - { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "state_characteristic": "mean", - "sampling_size": 1, - }, - ] - }, + "platform": "statistics", + "name": f"test_{characteristic['source_sensor_domain']}_{characteristic['name']}", + "entity_id": f"{characteristic['source_sensor_domain']}.test_monitored", + "state_characteristic": characteristic["name"], + "max_age": {"minutes": 8}, # 9 values spaces by one minute + } ) - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() - - for value in self.values[-3:]: # just the last 3 will do - self.hass.states.set( - "sensor.test_monitored", - value, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test") - new_mean = float(self.values[-1]) - assert state.state == str(new_mean) - assert state.attributes.get("buffer_usage_ratio") == round(1 / 1, 2) - - def test_age_limit_expiry(self): - """Test that values are removed after certain age.""" - now = dt_util.utcnow() - mock_data = { - "return_time": datetime(now.year + 1, 8, 2, 12, 23, tzinfo=dt_util.UTC) - } - - def mock_now(): - return mock_data["return_time"] - - with patch( - "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now - ): - assert setup_component( - self.hass, - "sensor", - { - "sensor": [ - { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "state_characteristic": "mean", - "max_age": {"minutes": 4}, - }, - ] - }, - ) - - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() - - for value in self.values: - self.hass.states.set( - "sensor.test_monitored", - value, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.block_till_done() - mock_data["return_time"] += timedelta(minutes=1) - - # After adding all values, we should only see 5 values in memory - - state = self.hass.states.get("sensor.test") - new_mean = round(sum(self.values[-5:]) / len(self.values[-5:]), 2) - assert state.state == str(new_mean) - assert state.attributes.get("buffer_usage_ratio") == round(5 / 20, 2) - assert state.attributes.get("age_coverage_ratio") == 1.0 - - # Values expire over time. Only two are left - - mock_data["return_time"] += timedelta(minutes=2) - fire_time_changed(self.hass, mock_data["return_time"]) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test") - new_mean = round(sum(self.values[-2:]) / len(self.values[-2:]), 2) - assert state.state == str(new_mean) - assert state.attributes.get("buffer_usage_ratio") == round(2 / 20, 2) - assert state.attributes.get("age_coverage_ratio") == 1 / 4 - - # Values expire over time. Only one is left - - mock_data["return_time"] += timedelta(minutes=1) - fire_time_changed(self.hass, mock_data["return_time"]) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test") - new_mean = float(self.values[-1]) - assert state.state == str(new_mean) - assert state.attributes.get("buffer_usage_ratio") == round(1 / 20, 2) - assert state.attributes.get("age_coverage_ratio") == 0 - - # Values expire over time. Memory is empty - - mock_data["return_time"] += timedelta(minutes=1) - fire_time_changed(self.hass, mock_data["return_time"]) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test") - assert state.state == STATE_UNKNOWN - assert state.attributes.get("buffer_usage_ratio") == round(0 / 20, 2) - assert state.attributes.get("age_coverage_ratio") is None - - def test_precision_0(self): - """Test correct result with precision=0 as integer.""" - assert setup_component( - self.hass, + with patch( + "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now + ): + assert await async_setup_component( + hass, "sensor", - { - "sensor": [ - { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "state_characteristic": "mean", - "precision": 0, - }, - ] - }, + {"sensor": sensors_config}, ) + await hass.async_block_till_done() - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() + # With all values in buffer - for value in self.values: - self.hass.states.set( + for i in range(len(VALUES_NUMERIC)): + mock_data["return_time"] += timedelta(minutes=1) + async_fire_time_changed(hass, mock_data["return_time"]) + hass.states.async_set( "sensor.test_monitored", - value, + str(VALUES_NUMERIC[i]), {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test") - assert state.state == str(int(round(self.mean))) - - def test_precision_1(self): - """Test correct result with precision=1 rounded to one decimal.""" - assert setup_component( - self.hass, - "sensor", - { - "sensor": [ - { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "state_characteristic": "mean", - "precision": 1, - }, - ] - }, - ) - - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() - - for value in self.values: - self.hass.states.set( - "sensor.test_monitored", - value, + hass.states.async_set( + "binary_sensor.test_monitored", + str(VALUES_BINARY[i]), {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) - self.hass.block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get("sensor.test") - assert state.state == str(round(sum(self.values) / len(self.values), 1)) + for characteristic in characteristics: + state = hass.states.get( + f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" + ) + assert state is not None, ( + f"no state object for characteristic " + f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " + f"(buffer filled)" + ) + assert state.state == str(characteristic["value_9"]), ( + f"value mismatch for characteristic " + f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " + f"(buffer filled) - " + f"assert {state.state} == {str(characteristic['value_9'])}" + ) + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == characteristic["unit"] + ), f"unit mismatch for characteristic '{characteristic['name']}'" - def test_state_class(self): - """Test state class, which depends on the characteristic configured.""" - assert setup_component( - self.hass, - "sensor", - { - "sensor": [ - { - "platform": "statistics", - "name": "test_normal", - "entity_id": "sensor.test_monitored", - "state_characteristic": "count", - }, - { - "platform": "statistics", - "name": "test_nan", - "entity_id": "sensor.test_monitored", - "state_characteristic": "datetime_oldest", - }, - ] - }, - ) + # With single value in buffer - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() + mock_data["return_time"] += timedelta(minutes=8) + async_fire_time_changed(hass, mock_data["return_time"]) + await hass.async_block_till_done() - for value in self.values: - self.hass.states.set( - "sensor.test_monitored", - value, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + for characteristic in characteristics: + state = hass.states.get( + f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" + ) + assert state is not None, ( + f"no state object for characteristic " + f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " + f"(one stored value)" + ) + assert state.state == str(characteristic["value_1"]), ( + f"value mismatch for characteristic " + f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " + f"(one stored value) - " + f"assert {state.state} == {str(characteristic['value_1'])}" ) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test_normal") - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT - state = self.hass.states.get("sensor.test_nan") - assert state.attributes.get(ATTR_STATE_CLASS) is None - def test_unitless_source_sensor(self): - """Statistics for a unitless source sensor should never have a unit.""" - assert setup_component( - self.hass, - "sensor", - { - "sensor": [ - { - "platform": "statistics", - "name": "test_unitless_1", - "entity_id": "sensor.test_monitored_unitless", - "state_characteristic": "count", - }, - { - "platform": "statistics", - "name": "test_unitless_2", - "entity_id": "sensor.test_monitored_unitless", - "state_characteristic": "mean", - }, - { - "platform": "statistics", - "name": "test_unitless_3", - "entity_id": "sensor.test_monitored_unitless", - "state_characteristic": "change_second", - }, - { - "platform": "statistics", - "name": "test_unitless_4", - "entity_id": "binary_sensor.test_monitored_unitless", - }, - { - "platform": "statistics", - "name": "test_unitless_5", - "entity_id": "binary_sensor.test_monitored_unitless", - "state_characteristic": "mean", - }, - ] - }, - ) + # With empty buffer - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() + mock_data["return_time"] += timedelta(minutes=1) + async_fire_time_changed(hass, mock_data["return_time"]) + await hass.async_block_till_done() - for value in self.values: - self.hass.states.set( - "sensor.test_monitored_unitless", - value, + for characteristic in characteristics: + state = hass.states.get( + f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" ) - self.hass.block_till_done() - for value in self.values_binary: - self.hass.states.set( - "binary_sensor.test_monitored_unitless", - value, + assert state is not None, ( + f"no state object for characteristic " + f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " + f"(buffer empty)" ) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test_unitless_1") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - state = self.hass.states.get("sensor.test_unitless_2") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - state = self.hass.states.get("sensor.test_unitless_3") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - state = self.hass.states.get("sensor.test_unitless_4") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - state = self.hass.states.get("sensor.test_unitless_5") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "%" - - def test_state_characteristics(self): - """Test configured state characteristic for value and unit.""" - now = dt_util.utcnow() - mock_data = { - "return_time": datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) - } - - def mock_now(): - return mock_data["return_time"] - - value_spacing_minutes = 1 - - characteristics = ( - { - "source_sensor_domain": "sensor", - "name": "average_linear", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": 10.68, - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "average_step", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": 11.36, - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "average_timeless", - "value_0": STATE_UNKNOWN, - "value_1": float(self.values[0]), - "value_9": float(self.mean), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "change", - "value_0": STATE_UNKNOWN, - "value_1": float(0), - "value_9": float(round(self.values[-1] - self.values[0], 2)), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "change_sample", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": float( - round( - (self.values[-1] - self.values[0]) / (len(self.values) - 1), 2 - ) - ), - "unit": "°C/sample", - }, - { - "source_sensor_domain": "sensor", - "name": "change_second", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": float( - round( - (self.values[-1] - self.values[0]) - / (60 * (len(self.values) - 1)), - 2, - ) - ), - "unit": "°C/s", - }, - { - "source_sensor_domain": "sensor", - "name": "count", - "value_0": 0, - "value_1": 1, - "value_9": len(self.values), - "unit": None, - }, - { - "source_sensor_domain": "sensor", - "name": "datetime_newest", - "value_0": STATE_UNKNOWN, - "value_1": datetime( - now.year + 1, - 8, - 2, - 12, - 23 + len(self.values) + 10, - 42, - tzinfo=dt_util.UTC, - ), - "value_9": datetime( - now.year + 1, - 8, - 2, - 12, - 23 + len(self.values) - 1, - 42, - tzinfo=dt_util.UTC, - ), - "unit": None, - }, - { - "source_sensor_domain": "sensor", - "name": "datetime_oldest", - "value_0": STATE_UNKNOWN, - "value_1": datetime( - now.year + 1, - 8, - 2, - 12, - 23 + len(self.values) + 10, - 42, - tzinfo=dt_util.UTC, - ), - "value_9": datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC), - "unit": None, - }, - { - "source_sensor_domain": "sensor", - "name": "distance_95_percent_of_values", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": float(round(2 * 1.96 * statistics.stdev(self.values), 2)), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "distance_99_percent_of_values", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": float(round(2 * 2.58 * statistics.stdev(self.values), 2)), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "distance_absolute", - "value_0": STATE_UNKNOWN, - "value_1": float(0), - "value_9": float(max(self.values) - min(self.values)), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "mean", - "value_0": STATE_UNKNOWN, - "value_1": float(self.values[0]), - "value_9": float(self.mean), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "median", - "value_0": STATE_UNKNOWN, - "value_1": float(self.values[0]), - "value_9": float(round(statistics.median(self.values), 2)), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "noisiness", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": float( - round(sum([3, 4.8, 10.2, 1.2, 5.4, 2.5, 7.3, 8]) / 8, 2) - ), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "quantiles", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": [ - round(quantile, 2) for quantile in statistics.quantiles(self.values) - ], - "unit": None, - }, - { - "source_sensor_domain": "sensor", - "name": "standard_deviation", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": float(round(statistics.stdev(self.values), 2)), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "total", - "value_0": STATE_UNKNOWN, - "value_1": float(self.values[0]), - "value_9": float(sum(self.values)), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "value_max", - "value_0": STATE_UNKNOWN, - "value_1": float(self.values[0]), - "value_9": float(max(self.values)), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "value_min", - "value_0": STATE_UNKNOWN, - "value_1": float(self.values[0]), - "value_9": float(min(self.values)), - "unit": "°C", - }, - { - "source_sensor_domain": "sensor", - "name": "variance", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": float(round(statistics.variance(self.values), 2)), - "unit": "°C²", - }, - { - "source_sensor_domain": "binary_sensor", - "name": "average_step", - "value_0": STATE_UNKNOWN, - "value_1": STATE_UNKNOWN, - "value_9": 50.0, - "unit": "%", - }, - { - "source_sensor_domain": "binary_sensor", - "name": "average_timeless", - "value_0": STATE_UNKNOWN, - "value_1": 100.0, - "value_9": float(self.mean_binary), - "unit": "%", - }, - { - "source_sensor_domain": "binary_sensor", - "name": "count", - "value_0": 0, - "value_1": 1, - "value_9": len(self.values_binary), - "unit": None, - }, - { - "source_sensor_domain": "binary_sensor", - "name": "mean", - "value_0": STATE_UNKNOWN, - "value_1": 100.0, - "value_9": float(self.mean_binary), - "unit": "%", - }, - ) - sensors_config = [] - for characteristic in characteristics: - sensors_config.append( - { - "platform": "statistics", - "name": f"test_{characteristic['source_sensor_domain']}_{characteristic['name']}", - "entity_id": f"{characteristic['source_sensor_domain']}.test_monitored", - "state_characteristic": characteristic["name"], - "max_age": {"minutes": 10}, - } + assert state.state == str(characteristic["value_0"]), ( + f"value mismatch for characteristic " + f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " + f"(buffer empty) - " + f"assert {state.state} == {str(characteristic['value_0'])}" ) - with patch( - "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now - ): - assert setup_component( - self.hass, - "sensor", - {"sensor": sensors_config}, - ) - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() +async def test_invalid_state_characteristic(hass: HomeAssistant): + """Test the detection of wrong state_characteristics selected.""" + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_numeric", + "entity_id": "sensor.test_monitored", + "state_characteristic": "invalid", + }, + { + "platform": "statistics", + "name": "test_binary", + "entity_id": "binary_sensor.test_monitored", + "state_characteristic": "variance", + }, + ] + }, + ) + await hass.async_block_till_done() - # With all values in buffer + hass.states.async_set( + "sensor.test_monitored", + str(VALUES_NUMERIC[0]), + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + await hass.async_block_till_done() - for i in range(len(self.values)): - self.hass.states.set( - "sensor.test_monitored", - self.values[i], - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.states.set( - "binary_sensor.test_monitored", - self.values_binary[i], - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.block_till_done() - mock_data["return_time"] += timedelta(minutes=value_spacing_minutes) + state = hass.states.get("sensor.test_numeric") + assert state is None + state = hass.states.get("sensor.test_binary") + assert state is None - for characteristic in characteristics: - state = self.hass.states.get( - f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" - ) - assert state.state == str(characteristic["value_9"]), ( - f"value mismatch for characteristic " - f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " - f"(buffer filled) - " - f"assert {state.state} == {str(characteristic['value_9'])}" - ) - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == characteristic["unit"] - ), f"unit mismatch for characteristic '{characteristic['name']}'" - # With empty buffer +async def test_initialize_from_database(hass: HomeAssistant): + """Test initializing the statistics from the recorder database.""" + # enable and pre-fill the recorder + await async_init_recorder_component(hass) + await hass.async_block_till_done() + await async_wait_recording_done_without_instance(hass) - mock_data["return_time"] += timedelta(minutes=10) - fire_time_changed(self.hass, mock_data["return_time"]) - self.hass.block_till_done() + for value in VALUES_NUMERIC: + hass.states.async_set( + "sensor.test_monitored", + str(value), + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + await hass.async_block_till_done() + await async_wait_recording_done_without_instance(hass) - for characteristic in characteristics: - state = self.hass.states.get( - f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" - ) - assert state.state == str(characteristic["value_0"]), ( - f"value mismatch for characteristic " - f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " - f"(buffer empty) - " - f"assert {state.state} == {str(characteristic['value_0'])}" - ) + # create the statistics component, get filled from database + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean", + "sampling_size": 100, + }, + ] + }, + ) + await hass.async_block_till_done() - # With single value in buffer + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == str(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)) + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - self.hass.states.set( - "sensor.test_monitored", - self.values[0], - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.states.set( - "binary_sensor.test_monitored", - self.values_binary[0], - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - force_update=True, - ) - mock_data["return_time"] += timedelta(minutes=1) - fire_time_changed(self.hass, mock_data["return_time"]) - self.hass.block_till_done() - for characteristic in characteristics: - state = self.hass.states.get( - f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" - ) - assert state.state == str(characteristic["value_1"]), ( - f"value mismatch for characteristic " - f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " - f"(one stored value) - " - f"assert {state.state} == {str(characteristic['value_1'])}" - ) +async def test_initialize_from_database_with_maxage(hass: HomeAssistant): + """Test initializing the statistics from the database.""" + now = dt_util.utcnow() + mock_data = { + "return_time": datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) + } - def test_invalid_state_characteristic(self): - """Test the detection of wrong state_characteristics selected.""" - assert setup_component( - self.hass, - "sensor", - { - "sensor": [ - { - "platform": "statistics", - "name": "test_numeric", - "entity_id": "sensor.test_monitored", - "state_characteristic": "invalid", - }, - { - "platform": "statistics", - "name": "test_binary", - "entity_id": "binary_sensor.test_monitored", - "state_characteristic": "variance", - }, - ] - }, - ) + def mock_now(): + return mock_data["return_time"] - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() + # Testing correct retrieval from recorder, thus we do not + # want purging to occur within the class itself. + def mock_purge(self, *args): + return - self.hass.states.set( - "sensor.test_monitored", - self.values[0], - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.block_till_done() - - state = self.hass.states.get("sensor.test_numeric") - assert state is None - state = self.hass.states.get("sensor.test_binary") - assert state is None - - def test_initialize_from_database(self): - """Test initializing the statistics from the database.""" - # enable the recorder - init_recorder_component(self.hass) - self.hass.block_till_done() - self.hass.data[recorder.DATA_INSTANCE].block_till_done() - # store some values - for value in self.values: - self.hass.states.set( + # enable and pre-fill the recorder + await async_init_recorder_component(hass) + await hass.async_block_till_done() + await async_wait_recording_done_without_instance(hass) + + with patch( + "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now + ), patch.object(StatisticsSensor, "_purge_old_states", mock_purge): + for value in VALUES_NUMERIC: + hass.states.async_set( "sensor.test_monitored", - value, + str(value), {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, ) - self.hass.block_till_done() - # wait for the recorder to really store the data - wait_recording_done(self.hass) - # only now create the statistics component, so that it must read the - # data from the database - assert setup_component( - self.hass, + await hass.async_block_till_done() + mock_data["return_time"] += timedelta(hours=1) + await async_wait_recording_done_without_instance(hass) + # create the statistics component, get filled from database + assert await async_setup_component( + hass, "sensor", { "sensor": [ @@ -968,100 +988,29 @@ def test_initialize_from_database(self): "platform": "statistics", "name": "test", "entity_id": "sensor.test_monitored", - "state_characteristic": "mean", "sampling_size": 100, + "state_characteristic": "datetime_newest", + "max_age": {"hours": 3}, }, ] }, ) + await hass.async_block_till_done() - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() - - # check if the result is as in test_sensor_source() - state = self.hass.states.get("sensor.test") - assert str(self.mean) == state.state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS - - def test_initialize_from_database_with_maxage(self): - """Test initializing the statistics from the database.""" - now = dt_util.utcnow() - mock_data = { - "return_time": datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) - } - - def mock_now(): - return mock_data["return_time"] - - # Testing correct retrieval from recorder, thus we do not - # want purging to occur within the class itself. - def mock_purge(self): - return - - # enable the recorder - init_recorder_component(self.hass) - self.hass.block_till_done() - self.hass.data[recorder.DATA_INSTANCE].block_till_done() - - with patch( - "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now - ), patch.object(StatisticsSensor, "_purge_old", mock_purge): - # store some values - for value in self.values: - self.hass.states.set( - "sensor.test_monitored", - value, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, - ) - self.hass.block_till_done() - # insert the next value 1 hour later - mock_data["return_time"] += timedelta(hours=1) - - # wait for the recorder to really store the data - wait_recording_done(self.hass) - # only now create the statistics component, so that it must read - # the data from the database - assert setup_component( - self.hass, - "sensor", - { - "sensor": [ - { - "platform": "statistics", - "name": "test", - "entity_id": "sensor.test_monitored", - "sampling_size": 100, - "state_characteristic": "datetime_newest", - "max_age": {"hours": 3}, - }, - ] - }, - ) - self.hass.block_till_done() - - self.hass.block_till_done() - self.hass.start() - self.hass.block_till_done() - - # check if the result is as in test_sensor_source() - state = self.hass.states.get("sensor.test") - - assert state.attributes.get("age_coverage_ratio") == round(2 / 3, 2) - # The max_age timestamp should be 1 hour before what we have right - # now in mock_data['return_time']. - assert mock_data["return_time"] == datetime.strptime( - state.state, "%Y-%m-%d %H:%M:%S%z" - ) + timedelta(hours=1) + state = hass.states.get("sensor.test") + assert state is not None + assert state.attributes.get("age_coverage_ratio") == round(2 / 3, 2) + # The max_age timestamp should be 1 hour before what we have right + # now in mock_data['return_time']. + assert mock_data["return_time"] == datetime.strptime( + state.state, "%Y-%m-%dT%H:%M:%S%z" + ) + timedelta(hours=1) -async def test_reload(hass): - """Verify we can reload filter sensors.""" - await hass.async_add_executor_job( - init_recorder_component, hass - ) # force in memory db +async def test_reload(hass: HomeAssistant): + """Verify we can reload statistics sensors.""" + await async_init_recorder_component(hass) - hass.states.async_set("sensor.test_monitored", 12345) await async_setup_component( hass, "sensor", @@ -1078,22 +1027,22 @@ async def test_reload(hass): }, ) await hass.async_block_till_done() - await hass.async_start() + + hass.states.async_set("sensor.test_monitored", "0") await hass.async_block_till_done() assert len(hass.states.async_all()) == 2 - assert hass.states.get("sensor.test") yaml_path = get_fixture_path("configuration.yaml", "statistics") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): await hass.services.async_call( - DOMAIN, + STATISTICS_DOMAIN, SERVICE_RELOAD, {}, blocking=True, ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert len(hass.states.async_all()) == 2 diff --git a/tests/components/steamist/__init__.py b/tests/components/steamist/__init__.py new file mode 100644 index 00000000000000..4868bb6a956588 --- /dev/null +++ b/tests/components/steamist/__init__.py @@ -0,0 +1,117 @@ +"""Tests for the Steamist integration.""" +from __future__ import annotations + +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from aiosteamist import Steamist, SteamistStatus +from discovery30303 import AIODiscovery30303, Device30303 + +from homeassistant.components import steamist +from homeassistant.components.steamist.const import CONF_MODEL, DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +MOCK_ASYNC_GET_STATUS_INACTIVE = SteamistStatus( + temp=70, temp_units="F", minutes_remain=0, active=False +) +MOCK_ASYNC_GET_STATUS_ACTIVE = SteamistStatus( + temp=102, temp_units="F", minutes_remain=14, active=True +) +DEVICE_IP_ADDRESS = "127.0.0.1" +DEVICE_NAME = "Master Bath" +DEVICE_MAC_ADDRESS = "AA:BB:CC:DD:EE:FF" +DEVICE_HOSTNAME = "MY450-EEFF" +FORMATTED_MAC_ADDRESS = dr.format_mac(DEVICE_MAC_ADDRESS) +DEVICE_MODEL = "MY450" +DEVICE_30303 = Device30303( + ipaddress=DEVICE_IP_ADDRESS, + name=DEVICE_NAME, + mac=DEVICE_MAC_ADDRESS, + hostname=DEVICE_HOSTNAME, +) +DEVICE_30303_NOT_STEAMIST = Device30303( + ipaddress=DEVICE_IP_ADDRESS, + name=DEVICE_NAME, + mac=DEVICE_MAC_ADDRESS, + hostname="not_steamist", +) +DISCOVERY_30303 = { + "ipaddress": DEVICE_IP_ADDRESS, + "name": DEVICE_NAME, + "mac": DEVICE_MAC_ADDRESS, + "hostname": DEVICE_HOSTNAME, +} +DISCOVERY_30303_NOT_STEAMIST = { + "ipaddress": DEVICE_IP_ADDRESS, + "name": DEVICE_NAME, + "mac": DEVICE_MAC_ADDRESS, + "hostname": "not_steamist", +} +DEFAULT_ENTRY_DATA = { + CONF_HOST: DEVICE_IP_ADDRESS, + CONF_NAME: DEVICE_NAME, + CONF_MODEL: DEVICE_MODEL, +} + + +async def _async_setup_entry_with_status( + hass: HomeAssistant, status: SteamistStatus +) -> tuple[Steamist, ConfigEntry]: + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1"}, + ) + config_entry.add_to_hass(hass) + client = _mocked_steamist() + client.async_get_status = AsyncMock(return_value=status) + with _patch_status(status, client): + await async_setup_component(hass, steamist.DOMAIN, {steamist.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + return client, config_entry + + +def _mocked_steamist() -> Steamist: + client = MagicMock(auto_spec=Steamist) + client.async_turn_on_steam = AsyncMock() + client.async_turn_off_steam = AsyncMock() + client.async_get_status = AsyncMock(return_value=MOCK_ASYNC_GET_STATUS_ACTIVE) + return client + + +def _patch_status(status: SteamistStatus, client: Steamist | None = None): + if client is None: + client = _mocked_steamist() + client.async_get_status = AsyncMock(return_value=status) + + @contextmanager + def _patcher(): + with patch("homeassistant.components.steamist.Steamist", return_value=client): + yield + + return _patcher() + + +def _patch_discovery(device=None, no_device=False): + mock_aio_discovery = MagicMock(auto_spec=AIODiscovery30303) + if no_device: + mock_aio_discovery.async_scan = AsyncMock(side_effect=OSError) + else: + mock_aio_discovery.async_scan = AsyncMock() + mock_aio_discovery.found_devices = [] if no_device else [device or DEVICE_30303] + + @contextmanager + def _patcher(): + with patch( + "homeassistant.components.steamist.discovery.AIODiscovery30303", + return_value=mock_aio_discovery, + ): + yield + + return _patcher() diff --git a/tests/components/steamist/test_config_flow.py b/tests/components/steamist/test_config_flow.py new file mode 100644 index 00000000000000..7876272368afe4 --- /dev/null +++ b/tests/components/steamist/test_config_flow.py @@ -0,0 +1,399 @@ +"""Test the Steamist config flow.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.components.steamist.const import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import ( + DEFAULT_ENTRY_DATA, + DEVICE_30303_NOT_STEAMIST, + DEVICE_HOSTNAME, + DEVICE_IP_ADDRESS, + DEVICE_MAC_ADDRESS, + DEVICE_NAME, + DISCOVERY_30303, + FORMATTED_MAC_ADDRESS, + MOCK_ASYNC_GET_STATUS_INACTIVE, + _patch_discovery, + _patch_status, +) + +from tests.common import MockConfigEntry + +MODULE = "homeassistant.components.steamist" + + +DHCP_DISCOVERY = dhcp.DhcpServiceInfo( + hostname=DEVICE_HOSTNAME, + ip=DEVICE_IP_ADDRESS, + macaddress=DEVICE_MAC_ADDRESS, +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with _patch_discovery(no_device=True), patch( + "homeassistant.components.steamist.config_flow.Steamist.async_get_status" + ), patch( + "homeassistant.components.steamist.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "127.0.0.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "127.0.0.1" + assert result2["data"] == { + "host": "127.0.0.1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_discovery(hass: HomeAssistant) -> None: + """Test we can also discovery the device during manual setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with _patch_discovery(), patch( + "homeassistant.components.steamist.config_flow.Steamist.async_get_status" + ), patch( + "homeassistant.components.steamist.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "127.0.0.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == DEVICE_NAME + assert result2["data"] == DEFAULT_ENTRY_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """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.steamist.config_flow.Steamist.async_get_status", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "127.0.0.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass: HomeAssistant) -> None: + """Test we handle unknown exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.steamist.config_flow.Steamist.async_get_status", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "127.0.0.1", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_discovery(hass: HomeAssistant) -> None: + """Test setting up discovery.""" + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + # test we can try again + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + assert result2["type"] == "form" + assert result2["step_id"] == "pick_device" + assert not result2["errors"] + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE: FORMATTED_MAC_ADDRESS}, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == DEVICE_NAME + assert result3["data"] == DEFAULT_ENTRY_DATA + mock_setup.assert_called_once() + mock_setup_entry.assert_called_once() + + # ignore configured devices + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "no_devices_found" + + +async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: + """Test we get the form with discovery and abort for dhcp source when we get both.""" + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=DISCOVERY_30303, + ) + await hass.async_block_till_done() + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE): + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + await hass.async_block_till_done() + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_in_progress" + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="any", + ip=DEVICE_IP_ADDRESS, + macaddress="00:00:00:00:00:00", + ), + ) + await hass.async_block_till_done() + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "already_in_progress" + + +async def test_discovered_by_discovery(hass: HomeAssistant) -> None: + """Test we can setup when discovered from discovery.""" + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=DISCOVERY_30303, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == DEFAULT_ENTRY_DATA + assert mock_async_setup.called + assert mock_async_setup_entry.called + + +async def test_discovered_by_dhcp(hass: HomeAssistant) -> None: + """Test we can setup when discovered from dhcp.""" + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == DEFAULT_ENTRY_DATA + assert mock_async_setup.called + assert mock_async_setup_entry.called + + +async def test_discovered_by_dhcp_discovery_fails(hass: HomeAssistant) -> None: + """Test we can setup when discovered from dhcp but then we cannot get the device name.""" + + with _patch_discovery(no_device=True), _patch_status( + MOCK_ASYNC_GET_STATUS_INACTIVE + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_discovered_by_dhcp_discovery_finds_non_steamist_device( + hass: HomeAssistant, +) -> None: + """Test we can setup when discovered from dhcp but its not a steamist device.""" + + with _patch_discovery(device=DEVICE_30303_NOT_STEAMIST), _patch_status( + MOCK_ASYNC_GET_STATUS_INACTIVE + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "not_steamist_device" + + +@pytest.mark.parametrize( + "source, data", + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_DISCOVERY, DISCOVERY_30303), + ], +) +async def test_discovered_by_dhcp_or_discovery_adds_missing_unique_id( + hass, source, data +): + """Test we can setup when discovered from dhcp or discovery and add a missing unique id.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: DEVICE_IP_ADDRESS}) + config_entry.add_to_hass(hass) + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + assert config_entry.unique_id == FORMATTED_MAC_ADDRESS + assert mock_setup.called + assert mock_setup_entry.called + + +@pytest.mark.parametrize( + "source, data", + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_DISCOVERY, DISCOVERY_30303), + ], +) +async def test_discovered_by_dhcp_or_discovery_existing_unique_id_does_not_reload( + hass, source, data +): + """Test we can setup when discovered from dhcp or discovery and it does not reload.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=DEFAULT_ENTRY_DATA, unique_id=FORMATTED_MAC_ADDRESS + ) + config_entry.add_to_hass(hass) + + with _patch_discovery(), _patch_status(MOCK_ASYNC_GET_STATUS_INACTIVE), patch( + f"{MODULE}.async_setup", return_value=True + ) as mock_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert not mock_setup.called + assert not mock_setup_entry.called diff --git a/tests/components/steamist/test_init.py b/tests/components/steamist/test_init.py new file mode 100644 index 00000000000000..a40917cfc3c076 --- /dev/null +++ b/tests/components/steamist/test_init.py @@ -0,0 +1,135 @@ +"""Tests for the steamist component.""" +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +from discovery30303 import AIODiscovery30303 +import pytest + +from homeassistant.components import steamist +from homeassistant.components.steamist.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from . import ( + DEFAULT_ENTRY_DATA, + DEVICE_30303, + DEVICE_IP_ADDRESS, + DEVICE_MODEL, + DEVICE_NAME, + FORMATTED_MAC_ADDRESS, + MOCK_ASYNC_GET_STATUS_ACTIVE, + _async_setup_entry_with_status, + _patch_status, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture +def mock_single_broadcast_address(): + """Mock network's async_async_get_ipv4_broadcast_addresses.""" + with patch( + "homeassistant.components.network.async_get_ipv4_broadcast_addresses", + return_value={"10.255.255.255"}, + ): + yield + + +async def test_config_entry_reload(hass: HomeAssistant) -> None: + """Test that a config entry can be reloaded.""" + _, config_entry = await _async_setup_entry_with_status( + hass, MOCK_ASYNC_GET_STATUS_ACTIVE + ) + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_config_entry_retry_later(hass: HomeAssistant) -> None: + """Test that a config entry retry on connection error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1"}, + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.steamist.Steamist.async_get_status", + side_effect=asyncio.TimeoutError, + ): + await async_setup_component(hass, steamist.DOMAIN, {steamist.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_fills_unique_id_with_directed_discovery( + hass: HomeAssistant, +) -> None: + """Test that the unique id is added if its missing via directed (not broadcast) discovery.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: DEVICE_IP_ADDRESS}, unique_id=None + ) + config_entry.add_to_hass(hass) + last_address = None + + async def _async_scan(*args, address=None, **kwargs): + # Only return discovery results when doing directed discovery + nonlocal last_address + last_address = address + + @property + def found_devices(self): + nonlocal last_address + return [DEVICE_30303] if last_address == DEVICE_IP_ADDRESS else [] + + mock_aio_discovery = MagicMock(auto_spec=AIODiscovery30303) + mock_aio_discovery.async_scan = _async_scan + type(mock_aio_discovery).found_devices = found_devices + + with _patch_status(MOCK_ASYNC_GET_STATUS_ACTIVE), patch( + "homeassistant.components.steamist.discovery.AIODiscovery30303", + return_value=mock_aio_discovery, + ): + await async_setup_component(hass, steamist.DOMAIN, {steamist.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert config_entry.unique_id == FORMATTED_MAC_ADDRESS + assert config_entry.data[CONF_NAME] == DEVICE_NAME + assert config_entry.title == DEVICE_NAME + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, FORMATTED_MAC_ADDRESS)}, identifiers={} + ) + assert isinstance(device_entry, dr.DeviceEntry) + assert device_entry.name == DEVICE_NAME + assert device_entry.model == DEVICE_MODEL + + +@pytest.mark.usefixtures("mock_single_broadcast_address") +async def test_discovery_happens_at_interval(hass: HomeAssistant) -> None: + """Test that discovery happens at interval.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=DEFAULT_ENTRY_DATA, unique_id=FORMATTED_MAC_ADDRESS + ) + config_entry.add_to_hass(hass) + mock_aio_discovery = MagicMock(auto_spec=AIODiscovery30303) + mock_aio_discovery.async_scan = AsyncMock() + with patch( + "homeassistant.components.steamist.discovery.AIODiscovery30303", + return_value=mock_aio_discovery, + ), _patch_status(MOCK_ASYNC_GET_STATUS_ACTIVE): + await async_setup_component(hass, steamist.DOMAIN, {steamist.DOMAIN: {}}) + await hass.async_block_till_done() + + assert len(mock_aio_discovery.async_scan.mock_calls) == 2 + + async_fire_time_changed(hass, utcnow() + steamist.DISCOVERY_INTERVAL) + await hass.async_block_till_done() + assert len(mock_aio_discovery.async_scan.mock_calls) == 3 diff --git a/tests/components/steamist/test_sensor.py b/tests/components/steamist/test_sensor.py new file mode 100644 index 00000000000000..4d83a0eb80d188 --- /dev/null +++ b/tests/components/steamist/test_sensor.py @@ -0,0 +1,33 @@ +"""Tests for the steamist sensos.""" +from __future__ import annotations + +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TIME_MINUTES +from homeassistant.core import HomeAssistant + +from . import ( + MOCK_ASYNC_GET_STATUS_ACTIVE, + MOCK_ASYNC_GET_STATUS_INACTIVE, + _async_setup_entry_with_status, +) + + +async def test_steam_active(hass: HomeAssistant) -> None: + """Test that the sensors are setup with the expected values when steam is active.""" + await _async_setup_entry_with_status(hass, MOCK_ASYNC_GET_STATUS_ACTIVE) + state = hass.states.get("sensor.steam_temperature") + assert state.state == "39" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + state = hass.states.get("sensor.steam_minutes_remain") + assert state.state == "14" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TIME_MINUTES + + +async def test_steam_inactive(hass: HomeAssistant) -> None: + """Test that the sensors are setup with the expected values when steam is not active.""" + await _async_setup_entry_with_status(hass, MOCK_ASYNC_GET_STATUS_INACTIVE) + state = hass.states.get("sensor.steam_temperature") + assert state.state == "21" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + state = hass.states.get("sensor.steam_minutes_remain") + assert state.state == "0" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TIME_MINUTES diff --git a/tests/components/steamist/test_switch.py b/tests/components/steamist/test_switch.py new file mode 100644 index 00000000000000..47a9cbf6708b6a --- /dev/null +++ b/tests/components/steamist/test_switch.py @@ -0,0 +1,56 @@ +"""Tests for the steamist switch.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from . import ( + MOCK_ASYNC_GET_STATUS_ACTIVE, + MOCK_ASYNC_GET_STATUS_INACTIVE, + _async_setup_entry_with_status, +) + +from tests.common import async_fire_time_changed + + +async def test_steam_active(hass: HomeAssistant) -> None: + """Test that the switches are setup with the expected values when steam is active.""" + client, _ = await _async_setup_entry_with_status(hass, MOCK_ASYNC_GET_STATUS_ACTIVE) + assert len(hass.states.async_all("switch")) == 1 + assert hass.states.get("switch.steam_active").state == STATE_ON + + client.async_get_status = AsyncMock(return_value=MOCK_ASYNC_GET_STATUS_INACTIVE) + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {ATTR_ENTITY_ID: "switch.steam_active"}, + blocking=True, + ) + client.async_turn_off_steam.assert_called_once() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + assert hass.states.get("switch.steam_active").state == STATE_OFF + + +async def test_steam_inactive(hass: HomeAssistant) -> None: + """Test that the switches are setup with the expected values when steam is not active.""" + client, _ = await _async_setup_entry_with_status( + hass, MOCK_ASYNC_GET_STATUS_INACTIVE + ) + + assert len(hass.states.async_all("switch")) == 1 + assert hass.states.get("switch.steam_active").state == STATE_OFF + + client.async_get_status = AsyncMock(return_value=MOCK_ASYNC_GET_STATUS_ACTIVE) + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: "switch.steam_active"}, blocking=True + ) + client.async_turn_on_steam.assert_called_once() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + assert hass.states.get("switch.steam_active").state == STATE_ON diff --git a/tests/components/stookalert/test_config_flow.py b/tests/components/stookalert/test_config_flow.py index ceee26fa8e21cd..6b5dd6fd4ceb95 100644 --- a/tests/components/stookalert/test_config_flow.py +++ b/tests/components/stookalert/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch from homeassistant.components.stookalert.const import CONF_PROVINCE, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -63,16 +63,3 @@ async def test_already_configured(hass: HomeAssistant) -> None: assert result2.get("type") == RESULT_TYPE_ABORT assert result2.get("reason") == "already_configured" - - -async def test_import_flow(hass: HomeAssistant) -> None: - """Test the import configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={"province": "Overijssel"} - ) - - assert result.get("type") == RESULT_TYPE_CREATE_ENTRY - assert result.get("title") == "Overijssel" - assert result.get("data") == { - CONF_PROVINCE: "Overijssel", - } diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index 0b25fd7e7c51ef..7fc25cb8478788 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -1,6 +1,7 @@ """Collection of test helpers.""" from datetime import datetime from fractions import Fraction +import functools from functools import partial import io @@ -23,6 +24,11 @@ AUDIO_SAMPLE_RATE = 8000 +def stream_teardown(): + """Perform test teardown.""" + frame_image_data.cache_clear() + + def generate_audio_frame(pcm_mulaw=False): """Generate a blank audio frame.""" if pcm_mulaw: @@ -37,6 +43,19 @@ def generate_audio_frame(pcm_mulaw=False): return audio_frame +@functools.lru_cache(maxsize=1024) +def frame_image_data(frame_i, total_frames): + """Generate image content for a frame of a video.""" + img = np.empty((480, 320, 3)) + img[:, :, 0] = 0.5 + 0.5 * np.sin(2 * np.pi * (0 / 3 + frame_i / total_frames)) + img[:, :, 1] = 0.5 + 0.5 * np.sin(2 * np.pi * (1 / 3 + frame_i / total_frames)) + img[:, :, 2] = 0.5 + 0.5 * np.sin(2 * np.pi * (2 / 3 + frame_i / total_frames)) + + img = np.round(255 * img).astype(np.uint8) + img = np.clip(img, 0, 255) + return img + + def generate_video(encoder, container_format, duration): """ Generate a test video. @@ -58,15 +77,7 @@ def generate_video(encoder, container_format, duration): stream.options.update({"g": str(fps), "keyint_min": str(fps)}) for frame_i in range(total_frames): - - img = np.empty((480, 320, 3)) - img[:, :, 0] = 0.5 + 0.5 * np.sin(2 * np.pi * (0 / 3 + frame_i / total_frames)) - img[:, :, 1] = 0.5 + 0.5 * np.sin(2 * np.pi * (1 / 3 + frame_i / total_frames)) - img[:, :, 2] = 0.5 + 0.5 * np.sin(2 * np.pi * (2 / 3 + frame_i / total_frames)) - - img = np.round(255 * img).astype(np.uint8) - img = np.clip(img, 0, 255) - + img = frame_image_data(frame_i, total_frames) frame = av.VideoFrame.from_ndarray(img, format="rgb24") for packet in stream.encode(frame): container.mux(packet) diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index 10328a8f87b4ff..c754903965ab1a 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -25,6 +25,8 @@ from homeassistant.components.stream.core import Segment, StreamOutput from homeassistant.components.stream.worker import StreamState +from .common import generate_h264_video, stream_teardown + TEST_TIMEOUT = 7.0 # Lower than 9s home assistant timeout @@ -78,8 +80,9 @@ class SaveRecordWorkerSync: to avoid thread leaks in tests. """ - def __init__(self): + def __init__(self, hass): """Initialize SaveRecordWorkerSync.""" + self._hass = hass self._save_event = None self._segments = None self._save_thread = None @@ -91,7 +94,7 @@ def recorder_save_worker(self, file_out: str, segments: deque[Segment]): assert self._save_thread is None self._segments = segments self._save_thread = threading.current_thread() - self._save_event.set() + self._hass.loop.call_soon_threadsafe(self._save_event.set) async def get_segments(self): """Return the recorded video segments.""" @@ -115,7 +118,7 @@ def reset(self): @pytest.fixture() def record_worker_sync(hass): """Patch recorder_save_worker for clean thread shutdown for test.""" - sync = SaveRecordWorkerSync() + sync = SaveRecordWorkerSync(hass) with patch( "homeassistant.components.stream.recorder.recorder_save_worker", side_effect=sync.recorder_save_worker, @@ -214,3 +217,16 @@ def hls_sync(): side_effect=sync.response, ): yield sync + + +@pytest.fixture(scope="package") +def h264_video(): + """Generate a video, shared across tests.""" + return generate_h264_video() + + +@pytest.fixture(scope="package", autouse=True) +def fixture_teardown(): + """Destroy package level test state.""" + yield + stream_teardown() diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 9c529d7abe5bfa..0492fec14f0fbd 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -20,11 +20,7 @@ import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -from tests.components.stream.common import ( - FAKE_TIME, - DefaultSegment as Segment, - generate_h264_video, -) +from tests.components.stream.common import FAKE_TIME, DefaultSegment as Segment STREAM_SOURCE = "some-stream-source" INIT_BYTES = b"init" @@ -33,6 +29,18 @@ TEST_TIMEOUT = 5.0 # Lower than 9s home assistant timeout MAX_ABORT_SEGMENTS = 20 # Abort test to avoid looping forever +HLS_CONFIG = { + "stream": { + "ll_hls": False, + } +} + + +@pytest.fixture +async def setup_component(hass) -> None: + """Test fixture to setup the stream component.""" + await async_setup_component(hass, "stream", HLS_CONFIG) + class HlsClient: """Test fixture for fetching the hls stream.""" @@ -118,24 +126,23 @@ def make_playlist( return "\n".join(response) -async def test_hls_stream(hass, hls_stream, stream_worker_sync): +async def test_hls_stream( + hass, setup_component, hls_stream, stream_worker_sync, h264_video +): """ Test hls stream. Purposefully not mocking anything here to test full integration with the stream component. """ - await async_setup_component(hass, "stream", {"stream": {}}) stream_worker_sync.pause() # Setup demo HLS track - source = generate_h264_video() - stream = create_stream(hass, source, {}) + stream = create_stream(hass, h264_video, {}) # Request stream stream.add_provider(HLS_PROVIDER) - assert stream.available stream.start() hls_client = await hls_stream(stream) @@ -162,9 +169,6 @@ async def test_hls_stream(hass, hls_stream, stream_worker_sync): stream_worker_sync.resume() - # The stream worker reported end of stream and exited - assert not stream.available - # Stop stream, if it hasn't quit already stream.stop() @@ -173,19 +177,25 @@ async def test_hls_stream(hass, hls_stream, stream_worker_sync): assert fail_response.status == HTTPStatus.NOT_FOUND -async def test_stream_timeout(hass, hass_client, stream_worker_sync): +async def test_stream_timeout( + hass, hass_client, setup_component, stream_worker_sync, h264_video +): """Test hls stream timeout.""" - await async_setup_component(hass, "stream", {"stream": {}}) - stream_worker_sync.pause() # Setup demo HLS track - source = generate_h264_video() - stream = create_stream(hass, source, {}) + stream = create_stream(hass, h264_video, {}) + + available_states = [] + + def update_callback() -> None: + nonlocal available_states + available_states.append(stream.available) + + stream.set_update_callback(update_callback) # Request stream stream.add_provider(HLS_PROVIDER) - assert stream.available stream.start() url = stream.endpoint_url(HLS_PROVIDER) @@ -215,16 +225,18 @@ async def test_stream_timeout(hass, hass_client, stream_worker_sync): fail_response = await http_client.get(parsed_url.path) assert fail_response.status == HTTPStatus.NOT_FOUND + # Streams only marked as failure when keepalive is true + assert available_states == [True] -async def test_stream_timeout_after_stop(hass, hass_client, stream_worker_sync): - """Test hls stream timeout after the stream has been stopped already.""" - await async_setup_component(hass, "stream", {"stream": {}}) +async def test_stream_timeout_after_stop( + hass, hass_client, setup_component, stream_worker_sync, h264_video +): + """Test hls stream timeout after the stream has been stopped already.""" stream_worker_sync.pause() # Setup demo HLS track - source = generate_h264_video() - stream = create_stream(hass, source, {}) + stream = create_stream(hass, h264_video, {}) # Request stream stream.add_provider(HLS_PROVIDER) @@ -240,16 +252,22 @@ async def test_stream_timeout_after_stop(hass, hass_client, stream_worker_sync): await hass.async_block_till_done() -async def test_stream_keepalive(hass): +async def test_stream_keepalive(hass, setup_component): """Test hls stream retries the stream when keepalive=True.""" - await async_setup_component(hass, "stream", {"stream": {}}) - # Setup demo HLS track source = "test_stream_keepalive_source" stream = create_stream(hass, source, {}) track = stream.add_provider(HLS_PROVIDER) track.num_segments = 2 + available_states = [] + + def update_callback() -> None: + nonlocal available_states + available_states.append(stream.available) + + stream.set_update_callback(update_callback) + cur_time = 0 def time_side_effect(): @@ -272,16 +290,18 @@ def time_side_effect(): stream._thread.join() stream._thread = None assert av_open.call_count == 2 - assert not stream.available + await hass.async_block_till_done() # Stop stream, if it hasn't quit already stream.stop() + # Stream marked initially available, then marked as failed, then marked available + # before the final failure that exits the stream. + assert available_states == [True, False, True] -async def test_hls_playlist_view_no_output(hass, hls_stream): - """Test rendering the hls playlist with no output segments.""" - await async_setup_component(hass, "stream", {"stream": {}}) +async def test_hls_playlist_view_no_output(hass, setup_component, hls_stream): + """Test rendering the hls playlist with no output segments.""" stream = create_stream(hass, STREAM_SOURCE, {}) stream.add_provider(HLS_PROVIDER) @@ -292,10 +312,8 @@ async def test_hls_playlist_view_no_output(hass, hls_stream): assert resp.status == HTTPStatus.NOT_FOUND -async def test_hls_playlist_view(hass, hls_stream, stream_worker_sync): +async def test_hls_playlist_view(hass, setup_component, hls_stream, stream_worker_sync): """Test rendering the hls playlist with 1 and 2 output segments.""" - await async_setup_component(hass, "stream", {"stream": {}}) - stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) @@ -325,10 +343,8 @@ async def test_hls_playlist_view(hass, hls_stream, stream_worker_sync): stream.stop() -async def test_hls_max_segments(hass, hls_stream, stream_worker_sync): +async def test_hls_max_segments(hass, setup_component, hls_stream, stream_worker_sync): """Test rendering the hls playlist with more segments than the segment deque can hold.""" - await async_setup_component(hass, "stream", {"stream": {}}) - stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) @@ -376,9 +392,10 @@ async def test_hls_max_segments(hass, hls_stream, stream_worker_sync): stream.stop() -async def test_hls_playlist_view_discontinuity(hass, hls_stream, stream_worker_sync): +async def test_hls_playlist_view_discontinuity( + hass, setup_component, hls_stream, stream_worker_sync +): """Test a discontinuity across segments in the stream with 3 segments.""" - await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() @@ -413,10 +430,10 @@ async def test_hls_playlist_view_discontinuity(hass, hls_stream, stream_worker_s stream.stop() -async def test_hls_max_segments_discontinuity(hass, hls_stream, stream_worker_sync): +async def test_hls_max_segments_discontinuity( + hass, setup_component, hls_stream, stream_worker_sync +): """Test a discontinuity with more segments than the segment deque can hold.""" - await async_setup_component(hass, "stream", {"stream": {}}) - stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() hls = stream.add_provider(HLS_PROVIDER) @@ -456,10 +473,10 @@ async def test_hls_max_segments_discontinuity(hass, hls_stream, stream_worker_sy stream.stop() -async def test_remove_incomplete_segment_on_exit(hass, stream_worker_sync): +async def test_remove_incomplete_segment_on_exit( + hass, setup_component, stream_worker_sync +): """Test that the incomplete segment gets removed when the worker thread quits.""" - await async_setup_component(hass, "stream", {"stream": {}}) - stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() stream.start() diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index fc6b48d273b6bf..50aa4df3f1c57f 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -16,17 +16,14 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from .common import DefaultSegment as Segment, generate_h264_video, remux_with_audio + from tests.common import async_fire_time_changed -from tests.components.stream.common import ( - DefaultSegment as Segment, - generate_h264_video, - remux_with_audio, -) MAX_ABORT_SEGMENTS = 20 # Abort test to avoid looping forever -async def test_record_stream(hass, hass_client, record_worker_sync): +async def test_record_stream(hass, hass_client, record_worker_sync, h264_video): """ Test record stream. @@ -37,8 +34,7 @@ async def test_record_stream(hass, hass_client, record_worker_sync): await async_setup_component(hass, "stream", {"stream": {}}) # Setup demo track - source = generate_h264_video() - stream = create_stream(hass, source, {}) + stream = create_stream(hass, h264_video, {}) with patch.object(hass.config, "is_allowed_path", return_value=True): await stream.async_record("/example/path") @@ -54,13 +50,12 @@ async def test_record_stream(hass, hass_client, record_worker_sync): async def test_record_lookback( - hass, hass_client, stream_worker_sync, record_worker_sync + hass, hass_client, stream_worker_sync, record_worker_sync, h264_video ): """Exercise record with loopback.""" await async_setup_component(hass, "stream", {"stream": {}}) - source = generate_h264_video() - stream = create_stream(hass, source, {}) + stream = create_stream(hass, h264_video, {}) # Start an HLS feed to enable lookback stream.add_provider(HLS_PROVIDER) @@ -74,7 +69,7 @@ async def test_record_lookback( stream.stop() -async def test_recorder_timeout(hass, hass_client, stream_worker_sync): +async def test_recorder_timeout(hass, hass_client, stream_worker_sync, h264_video): """ Test recorder timeout. @@ -87,9 +82,7 @@ async def test_recorder_timeout(hass, hass_client, stream_worker_sync): with patch("homeassistant.components.stream.IdleTimer.fire") as mock_timeout: # Setup demo track - source = generate_h264_video() - - stream = create_stream(hass, source, {}) + stream = create_stream(hass, h264_video, {}) with patch.object(hass.config, "is_allowed_path", return_value=True): await stream.async_record("/example/path") recorder = stream.add_provider(RECORDER_PROVIDER) @@ -109,13 +102,11 @@ async def test_recorder_timeout(hass, hass_client, stream_worker_sync): await hass.async_block_till_done() -async def test_record_path_not_allowed(hass, hass_client): +async def test_record_path_not_allowed(hass, hass_client, h264_video): """Test where the output path is not allowed by home assistant configuration.""" await async_setup_component(hass, "stream", {"stream": {}}) - # Setup demo track - source = generate_h264_video() - stream = create_stream(hass, source, {}) + stream = create_stream(hass, h264_video, {}) with patch.object( hass.config, "is_allowed_path", return_value=False ), pytest.raises(HomeAssistantError): @@ -136,15 +127,14 @@ def add_parts_to_segment(segment, source): ] -async def test_recorder_save(tmpdir): +async def test_recorder_save(tmpdir, h264_video): """Test recorder save.""" # Setup - source = generate_h264_video() filename = f"{tmpdir}/test.mp4" # Run segment = Segment(sequence=1) - add_parts_to_segment(segment, source) + add_parts_to_segment(segment, h264_video) segment.duration = 4 recorder_save_worker(filename, [segment]) @@ -152,18 +142,17 @@ async def test_recorder_save(tmpdir): assert os.path.exists(filename) -async def test_recorder_discontinuity(tmpdir): +async def test_recorder_discontinuity(tmpdir, h264_video): """Test recorder save across a discontinuity.""" # Setup - source = generate_h264_video() filename = f"{tmpdir}/test.mp4" # Run segment_1 = Segment(sequence=1, stream_id=0) - add_parts_to_segment(segment_1, source) + add_parts_to_segment(segment_1, h264_video) segment_1.duration = 4 segment_2 = Segment(sequence=2, stream_id=1) - add_parts_to_segment(segment_2, source) + add_parts_to_segment(segment_2, h264_video) segment_2.duration = 4 recorder_save_worker(filename, [segment_1, segment_2]) # Assert @@ -182,8 +171,29 @@ async def test_recorder_no_segments(tmpdir): assert not os.path.exists(filename) +@pytest.fixture(scope="module") +def h264_mov_video(): + """Generate a source video with no audio.""" + return generate_h264_video(container_format="mov") + + +@pytest.mark.parametrize( + "audio_codec,expected_audio_streams", + [ + ("aac", 1), # aac is a valid mp4 codec + ("pcm_mulaw", 0), # G.711 is not a valid mp4 codec + ("empty", 0), # audio stream with no packets + (None, 0), # no audio stream + ], +) async def test_record_stream_audio( - hass, hass_client, stream_worker_sync, record_worker_sync + hass, + hass_client, + stream_worker_sync, + record_worker_sync, + audio_codec, + expected_audio_streams, + h264_mov_video, ): """ Test treatment of different audio inputs. @@ -193,47 +203,38 @@ async def test_record_stream_audio( """ await async_setup_component(hass, "stream", {"stream": {}}) - # Generate source video with no audio - orig_source = generate_h264_video(container_format="mov") + # Remux source video with new audio + source = remux_with_audio(h264_mov_video, "mov", audio_codec) # mov can store PCM - for a_codec, expected_audio_streams in ( - ("aac", 1), # aac is a valid mp4 codec - ("pcm_mulaw", 0), # G.711 is not a valid mp4 codec - ("empty", 0), # audio stream with no packets - (None, 0), # no audio stream - ): - # Remux source video with new audio - source = remux_with_audio(orig_source, "mov", a_codec) # mov can store PCM + record_worker_sync.reset() + stream_worker_sync.pause() - record_worker_sync.reset() - stream_worker_sync.pause() + stream = create_stream(hass, source, {}) + with patch.object(hass.config, "is_allowed_path", return_value=True): + await stream.async_record("/example/path") + recorder = stream.add_provider(RECORDER_PROVIDER) - stream = create_stream(hass, source, {}) - with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") - recorder = stream.add_provider(RECORDER_PROVIDER) + while True: + await recorder.recv() + if not (segment := recorder.last_segment): + break + last_segment = segment + stream_worker_sync.resume() - while True: - await recorder.recv() - if not (segment := recorder.last_segment): - break - last_segment = segment - stream_worker_sync.resume() - - result = av.open( - BytesIO(last_segment.init + last_segment.get_data()), - "r", - format="mp4", - ) + result = av.open( + BytesIO(last_segment.init + last_segment.get_data()), + "r", + format="mp4", + ) - assert len(result.streams.audio) == expected_audio_streams - result.close() - stream.stop() - await hass.async_block_till_done() + assert len(result.streams.audio) == expected_audio_streams + result.close() + stream.stop() + await hass.async_block_till_done() - # Verify that the save worker was invoked, then block until its - # thread completes and is shutdown completely to avoid thread leaks. - await record_worker_sync.join() + # Verify that the save worker was invoked, then block until its + # thread completes and is shutdown completely to avoid thread leaks. + await record_worker_sync.join() async def test_recorder_log(hass, caplog): diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 3e9ea157934f02..b54c8dc3472440 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -23,7 +23,7 @@ import av import pytest -from homeassistant.components.stream import Stream, create_stream +from homeassistant.components.stream import KeyFrameConverter, Stream, create_stream from homeassistant.components.stream.const import ( ATTR_SETTINGS, CONF_LL_HLS, @@ -45,6 +45,7 @@ ) from homeassistant.setup import async_setup_component +from tests.components.camera.common import EMPTY_8_6_JPEG, mock_turbo_jpeg from tests.components.stream.common import generate_h264_video, generate_h265_video from tests.components.stream.test_ll_hls import TEST_PART_DURATION @@ -97,6 +98,17 @@ class FakeCodec: self.codec = FakeCodec() + class FakeCodecContext: + name = "h264" + extradata = None + + self.codec_context = FakeCodecContext() + + @property + def type(self): + """Return packet type.""" + return "video" if self.name == VIDEO_STREAM_FORMAT else "audio" + def __str__(self) -> str: """Return a stream name for debugging.""" return f"FakePyAvStream<{self.name}, {self.time_base}>" @@ -195,6 +207,7 @@ def add_stream(self, template=None): class FakeAvOutputStream: def __init__(self, capture_packets): self.capture_packets = capture_packets + self.type = "ignored-type" def close(self): return @@ -258,7 +271,9 @@ def open(self, stream_source, *args, **kwargs): def run_worker(hass, stream, stream_source): """Run the stream worker under test.""" stream_state = StreamState(hass, stream.outputs) - stream_worker(stream_source, {}, stream_state, threading.Event()) + stream_worker( + stream_source, {}, stream_state, KeyFrameConverter(hass), threading.Event() + ) async def async_decode_stream(hass, packets, py_av=None): @@ -701,7 +716,10 @@ async def test_worker_log(hass, caplog): av_open.side_effect = av.error.InvalidDataError(-2, "error") run_worker(hass, stream, "https://abcd:efgh@foo.bar") await hass.async_block_till_done() - assert str(err.value) == "Error opening stream https://****:****@foo.bar" + assert ( + str(err.value) + == "Error opening stream (ERRORTYPE_-2, error) https://****:****@foo.bar" + ) assert "https://abcd:efgh@foo.bar" not in caplog.text @@ -724,7 +742,7 @@ async def test_durations(hass, record_worker_sync): ) source = generate_h264_video(duration=SEGMENT_DURATION + 1) - stream = create_stream(hass, source, {}) + stream = create_stream(hass, source, {}, stream_label="camera") # use record_worker_sync to grab output segments with patch.object(hass.config, "is_allowed_path", return_value=True): @@ -781,7 +799,7 @@ async def test_durations(hass, record_worker_sync): stream.stop() -async def test_has_keyframe(hass, record_worker_sync): +async def test_has_keyframe(hass, record_worker_sync, h264_video): """Test that the has_keyframe metadata matches the media.""" await async_setup_component( hass, @@ -797,8 +815,7 @@ async def test_has_keyframe(hass, record_worker_sync): }, ) - source = generate_h264_video() - stream = create_stream(hass, source, {}) + stream = create_stream(hass, h264_video, {}, stream_label="camera") # use record_worker_sync to grab output segments with patch.object(hass.config, "is_allowed_path", return_value=True): @@ -837,7 +854,7 @@ async def test_h265_video_is_hvc1(hass, record_worker_sync): ) source = generate_h265_video() - stream = create_stream(hass, source, {}) + stream = create_stream(hass, source, {}, stream_label="camera") # use record_worker_sync to grab output segments with patch.object(hass.config, "is_allowed_path", return_value=True): @@ -855,3 +872,29 @@ async def test_h265_video_is_hvc1(hass, record_worker_sync): await record_worker_sync.join() stream.stop() + + +async def test_get_image(hass, record_worker_sync): + """Test that the has_keyframe metadata matches the media.""" + await async_setup_component(hass, "stream", {"stream": {}}) + + source = generate_h264_video() + + # Since libjpeg-turbo is not installed on the CI runner, we use a mock + with patch( + "homeassistant.components.camera.img_util.TurboJPEGSingleton" + ) as mock_turbo_jpeg_singleton: + mock_turbo_jpeg_singleton.instance.return_value = mock_turbo_jpeg() + stream = create_stream(hass, source, {}) + + # use record_worker_sync to grab output segments + with patch.object(hass.config, "is_allowed_path", return_value=True): + await stream.async_record("/example/path") + + assert stream._keyframe_converter._image is None + + await record_worker_sync.join() + + assert await stream.async_get_image() == EMPTY_8_6_JPEG + + stream.stop() diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index 2ccfb26d3ef5d0..bdfaac5d1ef331 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.switch import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -64,7 +65,9 @@ async def test_get_actions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert actions == expected_actions diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index e2102976f8d907..b4663008b174fe 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -5,6 +5,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.switch import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -65,7 +66,9 @@ async def test_get_conditions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert conditions == expected_conditions @@ -83,10 +86,12 @@ async def test_get_condition_capabilities(hass, device_reg, entity_reg): {"name": "for", "optional": True, "type": "positive_time_period_dict"} ] } - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) for condition in conditions: capabilities = await async_get_device_automation_capabilities( - hass, "condition", condition + hass, DeviceAutomationType.CONDITION, condition ) assert capabilities == expected_capabilities diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index e871bf6f645a39..832a58d6f2cae9 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.switch import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.helpers import device_registry @@ -50,6 +51,13 @@ async def test_get_triggers(hass, device_reg, entity_reg): ) entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "changed_states", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, { "platform": "device", "domain": DOMAIN, @@ -65,7 +73,9 @@ async def test_get_triggers(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert triggers == expected_triggers @@ -83,10 +93,12 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): {"name": "for", "optional": True, "type": "positive_time_period_dict"} ] } - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == expected_capabilities @@ -154,6 +166,30 @@ async def test_if_fires_on_state_change(hass, calls, enable_custom_integrations) }, }, }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "changed_states", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on_or_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, ] }, ) @@ -163,17 +199,19 @@ async def test_if_fires_on_state_change(hass, calls, enable_custom_integrations) hass.states.async_set(ent1.entity_id, STATE_OFF) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "turn_off device - {} - on - off - None".format( - ent1.entity_id - ) + assert len(calls) == 2 + assert {calls[0].data["some"], calls[1].data["some"]} == { + f"turn_off device - {ent1.entity_id} - on - off - None", + f"turn_on_or_off device - {ent1.entity_id} - on - off - None", + } hass.states.async_set(ent1.entity_id, STATE_ON) await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "turn_on device - {} - off - on - None".format( - ent1.entity_id - ) + assert len(calls) == 4 + assert {calls[2].data["some"], calls[3].data["some"]} == { + f"turn_on device - {ent1.entity_id} - off - on - None", + f"turn_on_or_off device - {ent1.entity_id} - off - on - None", + } async def test_if_fires_on_state_change_with_for( diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index 44302ec311b9e3..ed3d3c59da9ddd 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -72,13 +72,3 @@ async def test_switch_context( assert state2 is not None assert state.state != state2.state assert state2.context.user_id == hass_admin_user.id - - -def test_deprecated_base_class(caplog): - """Test deprecated base class.""" - - class CustomSwitch(switch.SwitchDevice): - pass - - CustomSwitch() - assert "SwitchDevice is deprecated, modify CustomSwitch" in caplog.text diff --git a/tests/components/switch/test_light.py b/tests/components/switch/test_light.py index e260ce83dbf529..62fef242e9fe50 100644 --- a/tests/components/switch/test_light.py +++ b/tests/components/switch/test_light.py @@ -1,5 +1,10 @@ """The tests for the Light Switch platform.""" +from homeassistant.components.light import ( + ATTR_COLOR_MODE, + ATTR_SUPPORTED_COLOR_MODES, + COLOR_MODE_ONOFF, +) from homeassistant.setup import async_setup_component from tests.components.light import common @@ -31,6 +36,8 @@ async def test_default_state(hass): assert state.attributes.get("white_value") is None assert state.attributes.get("effect_list") is None assert state.attributes.get("effect") is None + assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [COLOR_MODE_ONOFF] + assert state.attributes.get(ATTR_COLOR_MODE) is None async def test_light_service_calls(hass): @@ -54,6 +61,10 @@ async def test_light_service_calls(hass): assert hass.states.get("switch.decorative_lights").state == "on" assert hass.states.get("light.light_switch").state == "on" + assert ( + hass.states.get("light.light_switch").attributes.get(ATTR_COLOR_MODE) + == COLOR_MODE_ONOFF + ) await common.async_turn_off(hass, "light.light_switch") await hass.async_block_till_done() diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 367d215862e0dc..55b6fda547ef12 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -35,7 +35,7 @@ async def test_async_setup_yaml_config(hass, mock_bridge) -> None: @pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) async def test_async_setup_user_config_flow(hass, mock_bridge) -> None: """Test setup started by user config flow.""" - with patch("homeassistant.components.switcher_kis.utils.asyncio.sleep"): + with patch("homeassistant.components.switcher_kis.utils.DISCOVERY_TIME_SEC", 0): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/switcher_kis/test_sensor.py b/tests/components/switcher_kis/test_sensor.py index b6fc1f2a49e1e2..6fe3088625d1d6 100644 --- a/tests/components/switcher_kis/test_sensor.py +++ b/tests/components/switcher_kis/test_sensor.py @@ -61,7 +61,7 @@ async def test_sensor_disabled(hass, mock_bridge): assert entry assert entry.unique_id == unique_id assert entry.disabled is True - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity updated_entry = registry.async_update_entity( diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 3907f9c42eca3d..8cec30abefa3df 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -14,10 +14,12 @@ from homeassistant.components import ssdp from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( + CONF_SNAPSHOT_QUALITY, CONF_VOLUMES, DEFAULT_PORT, DEFAULT_PORT_SSL, DEFAULT_SCAN_INTERVAL, + DEFAULT_SNAPSHOT_QUALITY, DEFAULT_TIMEOUT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, @@ -545,13 +547,15 @@ async def test_options_flow(hass: HomeAssistant, service: MagicMock): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options[CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL assert config_entry.options[CONF_TIMEOUT] == DEFAULT_TIMEOUT + assert config_entry.options[CONF_SNAPSHOT_QUALITY] == DEFAULT_SNAPSHOT_QUALITY # Manual result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 2, CONF_TIMEOUT: 30}, + user_input={CONF_SCAN_INTERVAL: 2, CONF_TIMEOUT: 30, CONF_SNAPSHOT_QUALITY: 0}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert config_entry.options[CONF_SCAN_INTERVAL] == 2 assert config_entry.options[CONF_TIMEOUT] == 30 + assert config_entry.options[CONF_SNAPSHOT_QUALITY] == 0 diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index d10ee9bbb5164a..8b9284a4b327dd 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -1,6 +1,5 @@ """Test system log component.""" import asyncio -from http import HTTPStatus import logging import queue from unittest.mock import MagicMock, patch @@ -11,6 +10,8 @@ from homeassistant.components import system_log from homeassistant.core import callback +from tests.common import async_capture_events + _LOGGER = logging.getLogger("test_logger") BASIC_CONFIG = {"system_log": {"max_entries": 2}} @@ -34,19 +35,19 @@ async def _async_block_until_queue_empty(hass, sq): hass.data[system_log.DOMAIN].acquire() hass.data[system_log.DOMAIN].release() await hass.async_block_till_done() + await hass.async_block_till_done() -async def get_error_log(hass, hass_client, expected_count): +async def get_error_log(hass_ws_client): """Fetch all entries from system_log via the API.""" + client = await hass_ws_client() + await client.send_json({"id": 5, "type": "system_log/list"}) - client = await hass_client() - resp = await client.get("/api/error/all") - assert resp.status == HTTPStatus.OK + msg = await client.receive_json() - data = await resp.json() - - assert len(data) == expected_count - return data + assert msg["id"] == 5 + assert msg["success"] + return msg["result"] def _generate_and_log_exception(exception, log): @@ -56,6 +57,18 @@ def _generate_and_log_exception(exception, log): _LOGGER.exception(log) +def find_log(logs, level): + """Return log with specific level.""" + if not isinstance(level, tuple): + level = (level,) + log = next( + (log for log in logs if log["level"] in level), + None, + ) + assert log is not None + return log + + def assert_log(log, exception, message, level): """Assert that specified values are in a specific log entry.""" if not isinstance(message, list): @@ -73,7 +86,7 @@ def get_frame(name): return (name, 5, None, None) -async def test_normal_logs(hass, simple_queue, hass_client): +async def test_normal_logs(hass, simple_queue, hass_ws_client): """Test that debug and info are not logged.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) @@ -82,36 +95,37 @@ async def test_normal_logs(hass, simple_queue, hass_client): await _async_block_until_queue_empty(hass, simple_queue) # Assert done by get_error_log - await get_error_log(hass, hass_client, 0) + logs = await get_error_log(hass_ws_client) + assert len([msg for msg in logs if msg["level"] in ("DEBUG", "INFO")]) == 0 -async def test_exception(hass, simple_queue, hass_client): +async def test_exception(hass, simple_queue, hass_ws_client): """Test that exceptions are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _generate_and_log_exception("exception message", "log message") await _async_block_until_queue_empty(hass, simple_queue) - - log = (await get_error_log(hass, hass_client, 1))[0] + log = find_log(await get_error_log(hass_ws_client), "ERROR") + assert log is not None assert_log(log, "exception message", "log message", "ERROR") -async def test_warning(hass, simple_queue, hass_client): +async def test_warning(hass, simple_queue, hass_ws_client): """Test that warning are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.warning("warning message") await _async_block_until_queue_empty(hass, simple_queue) - log = (await get_error_log(hass, hass_client, 1))[0] + log = find_log(await get_error_log(hass_ws_client), "WARNING") assert_log(log, "", "warning message", "WARNING") -async def test_error(hass, simple_queue, hass_client): +async def test_error(hass, simple_queue, hass_ws_client): """Test that errors are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error("error message") await _async_block_until_queue_empty(hass, simple_queue) - log = (await get_error_log(hass, hass_client, 1))[0] + log = find_log(await get_error_log(hass_ws_client), "ERROR") assert_log(log, "", "error message", "ERROR") @@ -138,14 +152,7 @@ async def test_error_posted_as_event(hass, simple_queue): await async_setup_component( hass, system_log.DOMAIN, {"system_log": {"max_entries": 2, "fire_event": True}} ) - events = [] - - @callback - def event_listener(event): - """Listen to events of type system_log_event.""" - events.append(event) - - hass.bus.async_listen(system_log.EVENT_SYSTEM_LOG, event_listener) + events = async_capture_events(hass, system_log.EVENT_SYSTEM_LOG) _LOGGER.error("error message") await _async_block_until_queue_empty(hass, simple_queue) @@ -154,17 +161,17 @@ def event_listener(event): assert_log(events[0].data, "", "error message", "ERROR") -async def test_critical(hass, simple_queue, hass_client): +async def test_critical(hass, simple_queue, hass_ws_client): """Test that critical are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.critical("critical message") await _async_block_until_queue_empty(hass, simple_queue) - log = (await get_error_log(hass, hass_client, 1))[0] + log = find_log(await get_error_log(hass_ws_client), "CRITICAL") assert_log(log, "", "critical message", "CRITICAL") -async def test_remove_older_logs(hass, simple_queue, hass_client): +async def test_remove_older_logs(hass, simple_queue, hass_ws_client): """Test that older logs are rotated out.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error("error message 1") @@ -172,7 +179,7 @@ async def test_remove_older_logs(hass, simple_queue, hass_client): _LOGGER.error("error message 3") await _async_block_until_queue_empty(hass, simple_queue) - log = await get_error_log(hass, hass_client, 2) + log = await get_error_log(hass_ws_client) assert_log(log[0], "", "error message 3", "ERROR") assert_log(log[1], "", "error message 2", "ERROR") @@ -182,7 +189,7 @@ def log_msg(nr=2): _LOGGER.error("error message %s", nr) -async def test_dedupe_logs(hass, simple_queue, hass_client): +async def test_dedupe_logs(hass, simple_queue, hass_ws_client): """Test that duplicate log entries are dedupe.""" await async_setup_component(hass, system_log.DOMAIN, {}) _LOGGER.error("error message 1") @@ -191,7 +198,7 @@ async def test_dedupe_logs(hass, simple_queue, hass_client): _LOGGER.error("error message 3") await _async_block_until_queue_empty(hass, simple_queue) - log = await get_error_log(hass, hass_client, 3) + log = await get_error_log(hass_ws_client) assert_log(log[0], "", "error message 3", "ERROR") assert log[1]["count"] == 2 assert_log(log[1], "", ["error message 2", "error message 2-2"], "ERROR") @@ -199,7 +206,7 @@ async def test_dedupe_logs(hass, simple_queue, hass_client): log_msg() await _async_block_until_queue_empty(hass, simple_queue) - log = await get_error_log(hass, hass_client, 3) + log = await get_error_log(hass_ws_client) assert_log(log[0], "", ["error message 2", "error message 2-2"], "ERROR") assert log[0]["timestamp"] > log[0]["first_occurred"] @@ -209,7 +216,7 @@ async def test_dedupe_logs(hass, simple_queue, hass_client): log_msg("2-6") await _async_block_until_queue_empty(hass, simple_queue) - log = await get_error_log(hass, hass_client, 3) + log = await get_error_log(hass_ws_client) assert_log( log[0], "", @@ -224,7 +231,7 @@ async def test_dedupe_logs(hass, simple_queue, hass_client): ) -async def test_clear_logs(hass, simple_queue, hass_client): +async def test_clear_logs(hass, simple_queue, hass_ws_client): """Test that the log can be cleared via a service call.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error("error message") @@ -234,7 +241,7 @@ async def test_clear_logs(hass, simple_queue, hass_client): await _async_block_until_queue_empty(hass, simple_queue) # Assert done by get_error_log - await get_error_log(hass, hass_client, 0) + await get_error_log(hass_ws_client) async def test_write_log(hass): @@ -277,13 +284,13 @@ async def test_write_choose_level(hass): assert logger.method_calls[0] == ("debug", ("test_message",)) -async def test_unknown_path(hass, simple_queue, hass_client): +async def test_unknown_path(hass, simple_queue, hass_ws_client): """Test error logged from unknown path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.findCaller = MagicMock(return_value=("unknown_path", 0, None, None)) _LOGGER.error("error message") await _async_block_until_queue_empty(hass, simple_queue) - log = (await get_error_log(hass, hass_client, 1))[0] + log = (await get_error_log(hass_ws_client))[0] assert log["source"] == ["unknown_path", 0] @@ -307,7 +314,7 @@ async def async_log_error_from_test_path(hass, path, sq): await _async_block_until_queue_empty(hass, sq) -async def test_homeassistant_path(hass, simple_queue, hass_client): +async def test_homeassistant_path(hass, simple_queue, hass_ws_client): """Test error logged from Home Assistant path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch( @@ -317,16 +324,16 @@ async def test_homeassistant_path(hass, simple_queue, hass_client): await async_log_error_from_test_path( hass, "venv_path/homeassistant/component/component.py", simple_queue ) - log = (await get_error_log(hass, hass_client, 1))[0] + log = (await get_error_log(hass_ws_client))[0] assert log["source"] == ["component/component.py", 5] -async def test_config_path(hass, simple_queue, hass_client): +async def test_config_path(hass, simple_queue, hass_ws_client): """Test error logged from config path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.object(hass.config, "config_dir", new="config"): await async_log_error_from_test_path( hass, "config/custom_component/test.py", simple_queue ) - log = (await get_error_log(hass, hass_client, 1))[0] + log = (await get_error_log(hass_ws_client))[0] assert log["source"] == ["custom_component/test.py", 5] diff --git a/tests/components/tailscale/test_binary_sensor.py b/tests/components/tailscale/test_binary_sensor.py index 9caeb7b8eba841..feb34c6d8a3401 100644 --- a/tests/components/tailscale/test_binary_sensor.py +++ b/tests/components/tailscale/test_binary_sensor.py @@ -28,7 +28,7 @@ async def test_tailscale_binary_sensors( assert entry.unique_id == "123456_update_available" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_ON - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Client" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "frencks-iphone Client" assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE assert ATTR_ICON not in state.attributes @@ -43,7 +43,7 @@ async def test_tailscale_binary_sensors( assert state.state == STATE_OFF assert ( state.attributes.get(ATTR_FRIENDLY_NAME) - == "Frencks-iPhone Supports Hairpinning" + == "frencks-iphone Supports Hairpinning" ) assert state.attributes.get(ATTR_ICON) == "mdi:wan" assert ATTR_DEVICE_CLASS not in state.attributes @@ -55,7 +55,7 @@ async def test_tailscale_binary_sensors( assert entry.unique_id == "123456_client_supports_ipv6" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_OFF - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Supports IPv6" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "frencks-iphone Supports IPv6" assert state.attributes.get(ATTR_ICON) == "mdi:wan" assert ATTR_DEVICE_CLASS not in state.attributes @@ -66,7 +66,7 @@ async def test_tailscale_binary_sensors( assert entry.unique_id == "123456_client_supports_pcp" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_OFF - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Supports PCP" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "frencks-iphone Supports PCP" assert state.attributes.get(ATTR_ICON) == "mdi:wan" assert ATTR_DEVICE_CLASS not in state.attributes @@ -77,7 +77,7 @@ async def test_tailscale_binary_sensors( assert entry.unique_id == "123456_client_supports_pmp" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_OFF - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Supports NAT-PMP" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "frencks-iphone Supports NAT-PMP" assert state.attributes.get(ATTR_ICON) == "mdi:wan" assert ATTR_DEVICE_CLASS not in state.attributes @@ -88,7 +88,7 @@ async def test_tailscale_binary_sensors( assert entry.unique_id == "123456_client_supports_udp" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_ON - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Supports UDP" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "frencks-iphone Supports UDP" assert state.attributes.get(ATTR_ICON) == "mdi:wan" assert ATTR_DEVICE_CLASS not in state.attributes @@ -99,7 +99,7 @@ async def test_tailscale_binary_sensors( assert entry.unique_id == "123456_client_supports_upnp" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_OFF - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frencks-iPhone Supports UPnP" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "frencks-iphone Supports UPnP" assert state.attributes.get(ATTR_ICON) == "mdi:wan" assert ATTR_DEVICE_CLASS not in state.attributes @@ -109,7 +109,7 @@ async def test_tailscale_binary_sensors( assert device_entry.identifiers == {(DOMAIN, "123456")} assert device_entry.manufacturer == "Tailscale Inc." assert device_entry.model == "iOS" - assert device_entry.name == "Frencks-iPhone" + assert device_entry.name == "frencks-iphone" assert device_entry.entry_type == dr.DeviceEntryType.SERVICE assert device_entry.sw_version == "1.12.3-td91ea7286-ge1bbbd90c" assert ( diff --git a/tests/components/tailscale/test_diagnostics.py b/tests/components/tailscale/test_diagnostics.py new file mode 100644 index 00000000000000..edea155c5967ab --- /dev/null +++ b/tests/components/tailscale/test_diagnostics.py @@ -0,0 +1,104 @@ +"""Tests for the diagnostics data provided by the Tailscale integration.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "devices": [ + { + "addresses": REDACTED, + "device_id": REDACTED, + "user": REDACTED, + "name": REDACTED, + "hostname": REDACTED, + "client_version": "1.12.3-td91ea7286-ge1bbbd90c", + "update_available": True, + "os": "iOS", + "created": "2021-08-19T09:25:22+00:00", + "last_seen": "2021-09-16T06:11:23+00:00", + "key_expiry_disabled": False, + "expires": "2022-02-15T09:25:22+00:00", + "authorized": True, + "is_external": False, + "machine_key": REDACTED, + "node_key": REDACTED, + "blocks_incoming_connections": False, + "enabled_routes": [], + "advertised_routes": [], + "client_connectivity": { + "endpoints": REDACTED, + "derp": "", + "mapping_varies_by_dest_ip": False, + "latency": {}, + "client_supports": { + "hair_pinning": False, + "ipv6": False, + "pcp": False, + "pmp": False, + "udp": True, + "upnp": False, + }, + }, + }, + { + "addresses": REDACTED, + "device_id": REDACTED, + "user": REDACTED, + "name": REDACTED, + "hostname": REDACTED, + "client_version": "1.14.0-t5cff36945-g809e87bba", + "update_available": True, + "os": "linux", + "created": "2021-08-29T09:49:06+00:00", + "last_seen": "2021-11-15T20:37:03+00:00", + "key_expiry_disabled": False, + "expires": "2022-02-25T09:49:06+00:00", + "authorized": True, + "is_external": False, + "machine_key": REDACTED, + "node_key": REDACTED, + "blocks_incoming_connections": False, + "enabled_routes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], + "advertised_routes": ["0.0.0.0/0", "10.10.10.0/23", "::/0"], + "client_connectivity": { + "endpoints": REDACTED, + "derp": "", + "mapping_varies_by_dest_ip": False, + "latency": { + "Bangalore": {"latencyMs": 143.42505599999998}, + "Chicago": {"latencyMs": 101.123646}, + "Dallas": {"latencyMs": 136.85886}, + "Frankfurt": {"latencyMs": 18.968314}, + "London": {"preferred": True, "latencyMs": 14.314574}, + "New York City": {"latencyMs": 83.078912}, + "San Francisco": {"latencyMs": 148.215522}, + "Seattle": {"latencyMs": 181.553595}, + "Singapore": {"latencyMs": 164.566539}, + "São Paulo": {"latencyMs": 207.250179}, + "Tokyo": {"latencyMs": 226.90714300000002}, + }, + "client_supports": { + "hair_pinning": True, + "ipv6": False, + "pcp": False, + "pmp": False, + "udp": True, + "upnp": False, + }, + }, + }, + ] + } diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 2ee404282936ad..94963234074ade 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -11,13 +11,14 @@ get_topic_tele_will, ) -from homeassistant.components import binary_sensor from homeassistant.components.tasmota.const import DEFAULT_PREFIX from homeassistant.const import ( ATTR_ASSUMED_STATE, EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, + STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha import homeassistant.util.dt as dt_util @@ -58,7 +59,7 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() state = hass.states.get("binary_sensor.tasmota_binary_sensor_1") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) # Test normal state update @@ -124,7 +125,7 @@ async def test_controlling_state_via_mqtt_switchname(hass, mqtt_mock, setup_tasm async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() state = hass.states.get("binary_sensor.custom_name") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) # Test normal state update @@ -183,7 +184,7 @@ async def test_pushon_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() state = hass.states.get("binary_sensor.tasmota_binary_sensor_1") - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) # Test normal state update @@ -260,14 +261,14 @@ def callback(event): async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - assert events == ["off"] + assert events == ["unknown"] async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", '{"Switch1":{"Action":"ON"}}' ) await hass.async_block_till_done() state = hass.states.get("binary_sensor.tasmota_binary_sensor_1") assert state.state == STATE_ON - assert events == ["off", "on"] + assert events == ["unknown", "on"] async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", '{"Switch1":{"Action":"ON"}}' @@ -275,13 +276,13 @@ def callback(event): await hass.async_block_till_done() state = hass.states.get("binary_sensor.tasmota_binary_sensor_1") assert state.state == STATE_ON - assert events == ["off", "on", "on"] + assert events == ["unknown", "on", "on"] async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() state = hass.states.get("binary_sensor.tasmota_binary_sensor_1") assert state.state == STATE_OFF - assert events == ["off", "on", "on", "off"] + assert events == ["unknown", "on", "on", "off"] async def test_availability_when_connection_lost( @@ -292,7 +293,7 @@ async def test_availability_when_connection_lost( config["swc"][0] = 1 config["swn"][0] = "Test" await help_test_availability_when_connection_lost( - hass, mqtt_client_mock, mqtt_mock, binary_sensor.DOMAIN, config + hass, mqtt_client_mock, mqtt_mock, Platform.BINARY_SENSOR, config ) @@ -301,7 +302,7 @@ async def test_availability(hass, mqtt_mock, setup_tasmota): config = copy.deepcopy(DEFAULT_CONFIG) config["swc"][0] = 1 config["swn"][0] = "Test" - await help_test_availability(hass, mqtt_mock, binary_sensor.DOMAIN, config) + await help_test_availability(hass, mqtt_mock, Platform.BINARY_SENSOR, config) async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): @@ -310,7 +311,7 @@ async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): config["swc"][0] = 1 config["swn"][0] = "Test" await help_test_availability_discovery_update( - hass, mqtt_mock, binary_sensor.DOMAIN, config + hass, mqtt_mock, Platform.BINARY_SENSOR, config ) @@ -326,7 +327,7 @@ async def test_availability_poll_state( hass, mqtt_client_mock, mqtt_mock, - binary_sensor.DOMAIN, + Platform.BINARY_SENSOR, config, poll_topic, "10", @@ -343,7 +344,7 @@ async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog, setup_ta config2["swn"][0] = "Test" await help_test_discovery_removal( - hass, mqtt_mock, caplog, binary_sensor.DOMAIN, config1, config2 + hass, mqtt_mock, caplog, Platform.BINARY_SENSOR, config1, config2 ) @@ -358,7 +359,7 @@ async def test_discovery_update_unchanged_binary_sensor( "homeassistant.components.tasmota.binary_sensor.TasmotaBinarySensor.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, binary_sensor.DOMAIN, config, discovery_update + hass, mqtt_mock, caplog, Platform.BINARY_SENSOR, config, discovery_update ) @@ -368,7 +369,7 @@ async def test_discovery_device_remove(hass, mqtt_mock, setup_tasmota): config["swc"][0] = 1 unique_id = f"{DEFAULT_CONFIG['mac']}_binary_sensor_switch_0" await help_test_discovery_device_remove( - hass, mqtt_mock, binary_sensor.DOMAIN, unique_id, config + hass, mqtt_mock, Platform.BINARY_SENSOR, unique_id, config ) @@ -384,7 +385,7 @@ async def test_entity_id_update_subscriptions(hass, mqtt_mock, setup_tasmota): get_topic_tele_will(config), ] await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, binary_sensor.DOMAIN, config, topics + hass, mqtt_mock, Platform.BINARY_SENSOR, config, topics ) @@ -394,5 +395,5 @@ async def test_entity_id_update_discovery_update(hass, mqtt_mock, setup_tasmota) config["swc"][0] = 1 config["swn"][0] = "Test" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, binary_sensor.DOMAIN, config + hass, mqtt_mock, Platform.BINARY_SENSOR, config ) diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index c036f490f6d5e4..80bf14943a9fd1 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -13,7 +13,7 @@ from homeassistant.components import cover from homeassistant.components.tasmota.const import DEFAULT_PREFIX -from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNKNOWN +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNKNOWN, Platform from .test_common import ( DEFAULT_CONFIG, @@ -392,7 +392,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async def call_service(hass, entity_id, service, **kwargs): """Call a fan service.""" await hass.services.async_call( - cover.DOMAIN, + Platform.COVER, service, {"entity_id": entity_id, **kwargs}, blocking=True, @@ -538,7 +538,7 @@ async def test_availability_when_connection_lost( hass, mqtt_client_mock, mqtt_mock, - cover.DOMAIN, + Platform.COVER, config, entity_id="test_cover_1", ) @@ -551,7 +551,7 @@ async def test_availability(hass, mqtt_mock, setup_tasmota): config["rl"][0] = 3 config["rl"][1] = 3 await help_test_availability( - hass, mqtt_mock, cover.DOMAIN, config, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, entity_id="test_cover_1" ) @@ -562,7 +562,7 @@ async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): config["rl"][0] = 3 config["rl"][1] = 3 await help_test_availability_discovery_update( - hass, mqtt_mock, cover.DOMAIN, config, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, entity_id="test_cover_1" ) @@ -575,7 +575,7 @@ async def test_availability_poll_state( config["rl"][1] = 3 poll_topic = "tasmota_49A3BC/cmnd/STATUS" await help_test_availability_poll_state( - hass, mqtt_client_mock, mqtt_mock, cover.DOMAIN, config, poll_topic, "10" + hass, mqtt_client_mock, mqtt_mock, Platform.COVER, config, poll_topic, "10" ) @@ -594,7 +594,7 @@ async def test_discovery_removal_cover(hass, mqtt_mock, caplog, setup_tasmota): hass, mqtt_mock, caplog, - cover.DOMAIN, + Platform.COVER, config1, config2, entity_id="test_cover_1", @@ -615,7 +615,7 @@ async def test_discovery_update_unchanged_cover(hass, mqtt_mock, caplog, setup_t hass, mqtt_mock, caplog, - cover.DOMAIN, + Platform.COVER, config, discovery_update, entity_id="test_cover_1", @@ -631,7 +631,7 @@ async def test_discovery_device_remove(hass, mqtt_mock, setup_tasmota): config["rl"][1] = 3 unique_id = f"{DEFAULT_CONFIG['mac']}_cover_shutter_0" await help_test_discovery_device_remove( - hass, mqtt_mock, cover.DOMAIN, unique_id, config + hass, mqtt_mock, Platform.COVER, unique_id, config ) @@ -648,7 +648,7 @@ async def test_entity_id_update_subscriptions(hass, mqtt_mock, setup_tasmota): get_topic_tele_will(config), ] await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, cover.DOMAIN, config, topics, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, topics, entity_id="test_cover_1" ) @@ -659,5 +659,5 @@ async def test_entity_id_update_discovery_update(hass, mqtt_mock, setup_tasmota) config["rl"][0] = 3 config["rl"][1] = 3 await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, cover.DOMAIN, config, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, entity_id="test_cover_1" ) diff --git a/tests/components/tasmota/test_device_trigger.py b/tests/components/tasmota/test_device_trigger.py index aba448bcbe5ae0..24467ed5359bed 100644 --- a/tests/components/tasmota/test_device_trigger.py +++ b/tests/components/tasmota/test_device_trigger.py @@ -7,6 +7,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.tasmota import _LOGGER from homeassistant.components.tasmota.const import DEFAULT_PREFIX, DOMAIN from homeassistant.helpers import device_registry as dr @@ -56,7 +57,9 @@ async def test_get_triggers_btn(hass, device_reg, entity_reg, mqtt_mock, setup_t "subtype": "button_2", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -82,7 +85,9 @@ async def test_get_triggers_swc(hass, device_reg, entity_reg, mqtt_mock, setup_t "subtype": "switch_1", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -125,7 +130,9 @@ async def test_get_unknown_triggers( }, ) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, []) @@ -144,7 +151,9 @@ async def test_get_non_existing_triggers( device_entry = device_reg.async_get_device( set(), {(dr.CONNECTION_NETWORK_MAC, mac)} ) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, []) @@ -170,7 +179,9 @@ async def test_discover_bad_triggers( device_entry = device_reg.async_get_device( set(), {(dr.CONNECTION_NETWORK_MAC, mac)} ) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, []) # Trigger an exception when the entity is discovered @@ -204,7 +215,9 @@ def is_active(self): device_entry = device_reg.async_get_device( set(), {(dr.CONNECTION_NETWORK_MAC, mac)} ) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, []) # Rediscover without exception @@ -221,7 +234,9 @@ def is_active(self): "subtype": "switch_1", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -270,7 +285,9 @@ async def test_update_remove_triggers( expected_triggers2 = copy.deepcopy(expected_triggers1) expected_triggers2[1]["type"] = "button_double_press" - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) for expected in expected_triggers1: assert expected in triggers @@ -278,7 +295,9 @@ async def test_update_remove_triggers( async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{mac}/config", json.dumps(config2)) await hass.async_block_till_done() - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) for expected in expected_triggers2: assert expected in triggers @@ -286,7 +305,9 @@ async def test_update_remove_triggers( async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{mac}/config", json.dumps(config3)) await hass.async_block_till_done() - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert triggers == [] diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index bb2610d466dfef..8c54e913f7c36d 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -13,7 +13,7 @@ from homeassistant.components import fan from homeassistant.components.tasmota.const import DEFAULT_PREFIX -from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, Platform from .test_common import ( DEFAULT_CONFIG, @@ -191,7 +191,7 @@ async def test_availability_when_connection_lost( config["dn"] = "Test" config["if"] = 1 await help_test_availability_when_connection_lost( - hass, mqtt_client_mock, mqtt_mock, fan.DOMAIN, config + hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config ) @@ -200,7 +200,7 @@ async def test_availability(hass, mqtt_mock, setup_tasmota): config = copy.deepcopy(DEFAULT_CONFIG) config["dn"] = "Test" config["if"] = 1 - await help_test_availability(hass, mqtt_mock, fan.DOMAIN, config) + await help_test_availability(hass, mqtt_mock, Platform.FAN, config) async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): @@ -208,7 +208,7 @@ async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): config = copy.deepcopy(DEFAULT_CONFIG) config["dn"] = "Test" config["if"] = 1 - await help_test_availability_discovery_update(hass, mqtt_mock, fan.DOMAIN, config) + await help_test_availability_discovery_update(hass, mqtt_mock, Platform.FAN, config) async def test_availability_poll_state( @@ -219,7 +219,7 @@ async def test_availability_poll_state( config["if"] = 1 poll_topic = "tasmota_49A3BC/cmnd/STATE" await help_test_availability_poll_state( - hass, mqtt_client_mock, mqtt_mock, fan.DOMAIN, config, poll_topic, "" + hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config, poll_topic, "" ) @@ -233,7 +233,7 @@ async def test_discovery_removal_fan(hass, mqtt_mock, caplog, setup_tasmota): config2["if"] = 0 await help_test_discovery_removal( - hass, mqtt_mock, caplog, fan.DOMAIN, config1, config2 + hass, mqtt_mock, caplog, Platform.FAN, config1, config2 ) @@ -246,7 +246,7 @@ async def test_discovery_update_unchanged_fan(hass, mqtt_mock, caplog, setup_tas "homeassistant.components.tasmota.fan.TasmotaFan.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, fan.DOMAIN, config, discovery_update + hass, mqtt_mock, caplog, Platform.FAN, config, discovery_update ) @@ -257,7 +257,7 @@ async def test_discovery_device_remove(hass, mqtt_mock, setup_tasmota): config["if"] = 1 unique_id = f"{DEFAULT_CONFIG['mac']}_fan_fan_ifan" await help_test_discovery_device_remove( - hass, mqtt_mock, fan.DOMAIN, unique_id, config + hass, mqtt_mock, Platform.FAN, unique_id, config ) @@ -272,7 +272,7 @@ async def test_entity_id_update_subscriptions(hass, mqtt_mock, setup_tasmota): get_topic_tele_will(config), ] await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, fan.DOMAIN, config, topics + hass, mqtt_mock, Platform.FAN, config, topics ) @@ -282,5 +282,5 @@ async def test_entity_id_update_discovery_update(hass, mqtt_mock, setup_tasmota) config["dn"] = "Test" config["if"] = 1 await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, fan.DOMAIN, config + hass, mqtt_mock, Platform.FAN, config ) diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index f85cf0d3c5b271..bdad1f8ceefa0a 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -10,10 +10,9 @@ get_topic_tele_will, ) -from homeassistant.components import light from homeassistant.components.light import SUPPORT_EFFECT, SUPPORT_TRANSITION from homeassistant.components.tasmota.const import DEFAULT_PREFIX -from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, Platform from .test_common import ( DEFAULT_CONFIG, @@ -1620,7 +1619,7 @@ async def test_availability_when_connection_lost( config["rl"][0] = 2 config["lt_st"] = 1 # 1 channel light (Dimmer) await help_test_availability_when_connection_lost( - hass, mqtt_client_mock, mqtt_mock, light.DOMAIN, config + hass, mqtt_client_mock, mqtt_mock, Platform.LIGHT, config ) @@ -1629,7 +1628,7 @@ async def test_availability(hass, mqtt_mock, setup_tasmota): config = copy.deepcopy(DEFAULT_CONFIG) config["rl"][0] = 2 config["lt_st"] = 1 # 1 channel light (Dimmer) - await help_test_availability(hass, mqtt_mock, light.DOMAIN, config) + await help_test_availability(hass, mqtt_mock, Platform.LIGHT, config) async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): @@ -1637,7 +1636,9 @@ async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): config = copy.deepcopy(DEFAULT_CONFIG) config["rl"][0] = 2 config["lt_st"] = 1 # 1 channel light (Dimmer) - await help_test_availability_discovery_update(hass, mqtt_mock, light.DOMAIN, config) + await help_test_availability_discovery_update( + hass, mqtt_mock, Platform.LIGHT, config + ) async def test_availability_poll_state( @@ -1649,7 +1650,7 @@ async def test_availability_poll_state( config["lt_st"] = 1 # 1 channel light (Dimmer) poll_topic = "tasmota_49A3BC/cmnd/STATE" await help_test_availability_poll_state( - hass, mqtt_client_mock, mqtt_mock, light.DOMAIN, config, poll_topic, "" + hass, mqtt_client_mock, mqtt_mock, Platform.LIGHT, config, poll_topic, "" ) @@ -1663,7 +1664,7 @@ async def test_discovery_removal_light(hass, mqtt_mock, caplog, setup_tasmota): config2["lt_st"] = 0 await help_test_discovery_removal( - hass, mqtt_mock, caplog, light.DOMAIN, config1, config2 + hass, mqtt_mock, caplog, Platform.LIGHT, config1, config2 ) @@ -1677,7 +1678,7 @@ async def test_discovery_removal_relay_as_light(hass, mqtt_mock, caplog, setup_t config2["so"]["30"] = 0 # Disable Home Assistant auto-discovery as light await help_test_discovery_removal( - hass, mqtt_mock, caplog, light.DOMAIN, config1, config2 + hass, mqtt_mock, caplog, Platform.LIGHT, config1, config2 ) @@ -1693,7 +1694,7 @@ async def test_discovery_removal_relay_as_light2( config2["so"]["30"] = 0 # Disable Home Assistant auto-discovery as light await help_test_discovery_removal( - hass, mqtt_mock, caplog, light.DOMAIN, config1, config2 + hass, mqtt_mock, caplog, Platform.LIGHT, config1, config2 ) @@ -1706,7 +1707,7 @@ async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog, setup_t "homeassistant.components.tasmota.light.TasmotaLight.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, light.DOMAIN, config, discovery_update + hass, mqtt_mock, caplog, Platform.LIGHT, config, discovery_update ) @@ -1717,7 +1718,7 @@ async def test_discovery_device_remove(hass, mqtt_mock, setup_tasmota): config["lt_st"] = 1 # 1 channel light (Dimmer) unique_id = f"{DEFAULT_CONFIG['mac']}_light_light_0" await help_test_discovery_device_remove( - hass, mqtt_mock, light.DOMAIN, unique_id, config + hass, mqtt_mock, Platform.LIGHT, unique_id, config ) @@ -1728,7 +1729,7 @@ async def test_discovery_device_remove_relay_as_light(hass, mqtt_mock, setup_tas config["so"]["30"] = 1 # Enforce Home Assistant auto-discovery as light unique_id = f"{DEFAULT_CONFIG['mac']}_light_relay_0" await help_test_discovery_device_remove( - hass, mqtt_mock, light.DOMAIN, unique_id, config + hass, mqtt_mock, Platform.LIGHT, unique_id, config ) @@ -1743,7 +1744,7 @@ async def test_entity_id_update_subscriptions(hass, mqtt_mock, setup_tasmota): get_topic_tele_will(config), ] await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, light.DOMAIN, config, topics + hass, mqtt_mock, Platform.LIGHT, config, topics ) @@ -1753,5 +1754,5 @@ async def test_entity_id_update_discovery_update(hass, mqtt_mock, setup_tasmota) config["rl"][0] = 2 config["lt_st"] = 1 # 1 channel light (Dimmer) await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, light.DOMAIN, config + hass, mqtt_mock, Platform.LIGHT, config ) diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 0cd18c89435d62..e2f5f1111e11c1 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -14,9 +14,9 @@ import pytest from homeassistant import config_entries -from homeassistant.components import sensor +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.components.tasmota.const import DEFAULT_PREFIX -from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNKNOWN +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNKNOWN, Platform from homeassistant.helpers import entity_registry as er from homeassistant.util import dt @@ -291,9 +291,7 @@ async def test_indexed_sensor_state_via_mqtt2(hass, mqtt_mock, setup_tasmota): state = hass.states.get("sensor.tasmota_energy_total") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert ( - state.attributes[sensor.ATTR_STATE_CLASS] == sensor.STATE_CLASS_TOTAL_INCREASING - ) + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() @@ -342,9 +340,7 @@ async def test_indexed_sensor_state_via_mqtt3(hass, mqtt_mock, setup_tasmota): state = hass.states.get("sensor.tasmota_energy_total_1") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert ( - state.attributes[sensor.ATTR_STATE_CLASS] == sensor.STATE_CLASS_TOTAL_INCREASING - ) + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() @@ -490,7 +486,7 @@ async def test_status_sensor_state_via_mqtt(hass, mqtt_mock, setup_tasmota): # Pre-enable the status sensor entity_reg.async_get_or_create( - sensor.DOMAIN, + Platform.SENSOR, "tasmota", "00000049A3BC_status_sensor_status_sensor_status_signal", suggested_object_id="tasmota_status", @@ -550,7 +546,7 @@ async def test_single_shot_status_sensor_state_via_mqtt(hass, mqtt_mock, setup_t # Pre-enable the status sensor entity_reg.async_get_or_create( - sensor.DOMAIN, + Platform.SENSOR, "tasmota", "00000049A3BC_status_sensor_status_sensor_status_restart_reason", suggested_object_id="tasmota_status", @@ -635,7 +631,7 @@ async def test_restart_time_status_sensor_state_via_mqtt( # Pre-enable the status sensor entity_reg.async_get_or_create( - sensor.DOMAIN, + Platform.SENSOR, "tasmota", "00000049A3BC_status_sensor_status_sensor_last_restart_time", suggested_object_id="tasmota_status", @@ -788,12 +784,12 @@ async def test_indexed_sensor_attributes(hass, mqtt_mock, setup_tasmota): @pytest.mark.parametrize( "sensor_name, disabled, disabled_by", [ - ("tasmota_firmware_version", True, er.DISABLED_INTEGRATION), - ("tasmota_ip", True, er.DISABLED_INTEGRATION), + ("tasmota_firmware_version", True, er.RegistryEntryDisabler.INTEGRATION), + ("tasmota_ip", True, er.RegistryEntryDisabler.INTEGRATION), ("tasmota_last_restart_time", False, None), ("tasmota_mqtt_connect_count", False, None), - ("tasmota_rssi", True, er.DISABLED_INTEGRATION), - ("tasmota_signal", True, er.DISABLED_INTEGRATION), + ("tasmota_rssi", True, er.RegistryEntryDisabler.INTEGRATION), + ("tasmota_signal", True, er.RegistryEntryDisabler.INTEGRATION), ("tasmota_ssid", False, None), ("tasmota_wifi_connect_count", False, None), ], @@ -819,7 +815,7 @@ async def test_diagnostic_sensors( assert bool(state) != disabled entry = entity_reg.async_get(f"sensor.{sensor_name}") assert entry.disabled == disabled - assert entry.disabled_by == disabled_by + assert entry.disabled_by is disabled_by assert entry.entity_category == "diagnostic" @@ -843,7 +839,7 @@ async def test_enable_status_sensor(hass, mqtt_mock, setup_tasmota): assert state is None entry = entity_reg.async_get("sensor.tasmota_signal") assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Enable the signal level status sensor updated_entry = entity_reg.async_update_entity( @@ -888,7 +884,7 @@ async def test_availability_when_connection_lost( hass, mqtt_client_mock, mqtt_mock, - sensor.DOMAIN, + Platform.SENSOR, config, sensor_config, "tasmota_dht11_temperature", @@ -902,7 +898,7 @@ async def test_availability(hass, mqtt_mock, setup_tasmota): await help_test_availability( hass, mqtt_mock, - sensor.DOMAIN, + Platform.SENSOR, config, sensor_config, "tasmota_dht11_temperature", @@ -916,7 +912,7 @@ async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): await help_test_availability_discovery_update( hass, mqtt_mock, - sensor.DOMAIN, + Platform.SENSOR, config, sensor_config, "tasmota_dht11_temperature", @@ -934,7 +930,7 @@ async def test_availability_poll_state( hass, mqtt_client_mock, mqtt_mock, - sensor.DOMAIN, + Platform.SENSOR, config, poll_topic, "10", @@ -951,7 +947,7 @@ async def test_discovery_removal_sensor(hass, mqtt_mock, caplog, setup_tasmota): hass, mqtt_mock, caplog, - sensor.DOMAIN, + Platform.SENSOR, config, config, sensor_config1, @@ -974,7 +970,7 @@ async def test_discovery_update_unchanged_sensor( hass, mqtt_mock, caplog, - sensor.DOMAIN, + Platform.SENSOR, config, discovery_update, sensor_config, @@ -989,7 +985,7 @@ async def test_discovery_device_remove(hass, mqtt_mock, setup_tasmota): sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) unique_id = f"{DEFAULT_CONFIG['mac']}_sensor_sensor_DHT11_Temperature" await help_test_discovery_device_remove( - hass, mqtt_mock, sensor.DOMAIN, unique_id, config, sensor_config + hass, mqtt_mock, Platform.SENSOR, unique_id, config, sensor_config ) @@ -1005,7 +1001,7 @@ async def test_entity_id_update_subscriptions(hass, mqtt_mock, setup_tasmota): await help_test_entity_id_update_subscriptions( hass, mqtt_mock, - sensor.DOMAIN, + Platform.SENSOR, config, topics, sensor_config, @@ -1020,7 +1016,7 @@ async def test_entity_id_update_discovery_update(hass, mqtt_mock, setup_tasmota) await help_test_entity_id_update_discovery_update( hass, mqtt_mock, - sensor.DOMAIN, + Platform.SENSOR, config, sensor_config, "tasmota_dht11_temperature", diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index 7c5bf66db45e7a..aa61908317165b 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -9,9 +9,8 @@ get_topic_tele_will, ) -from homeassistant.components import switch from homeassistant.components.tasmota.const import DEFAULT_PREFIX -from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, Platform from .test_common import ( DEFAULT_CONFIG, @@ -143,7 +142,7 @@ async def test_availability_when_connection_lost( config = copy.deepcopy(DEFAULT_CONFIG) config["rl"][0] = 1 await help_test_availability_when_connection_lost( - hass, mqtt_client_mock, mqtt_mock, switch.DOMAIN, config + hass, mqtt_client_mock, mqtt_mock, Platform.SWITCH, config ) @@ -151,7 +150,7 @@ async def test_availability(hass, mqtt_mock, setup_tasmota): """Test availability.""" config = copy.deepcopy(DEFAULT_CONFIG) config["rl"][0] = 1 - await help_test_availability(hass, mqtt_mock, switch.DOMAIN, config) + await help_test_availability(hass, mqtt_mock, Platform.SWITCH, config) async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): @@ -159,7 +158,7 @@ async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): config = copy.deepcopy(DEFAULT_CONFIG) config["rl"][0] = 1 await help_test_availability_discovery_update( - hass, mqtt_mock, switch.DOMAIN, config + hass, mqtt_mock, Platform.SWITCH, config ) @@ -171,7 +170,7 @@ async def test_availability_poll_state( config["rl"][0] = 1 poll_topic = "tasmota_49A3BC/cmnd/STATE" await help_test_availability_poll_state( - hass, mqtt_client_mock, mqtt_mock, switch.DOMAIN, config, poll_topic, "" + hass, mqtt_client_mock, mqtt_mock, Platform.SWITCH, config, poll_topic, "" ) @@ -183,7 +182,7 @@ async def test_discovery_removal_switch(hass, mqtt_mock, caplog, setup_tasmota): config2["rl"][0] = 0 await help_test_discovery_removal( - hass, mqtt_mock, caplog, switch.DOMAIN, config1, config2 + hass, mqtt_mock, caplog, Platform.SWITCH, config1, config2 ) @@ -197,7 +196,7 @@ async def test_discovery_removal_relay_as_light(hass, mqtt_mock, caplog, setup_t config2["so"]["30"] = 1 # Enforce Home Assistant auto-discovery as light await help_test_discovery_removal( - hass, mqtt_mock, caplog, switch.DOMAIN, config1, config2 + hass, mqtt_mock, caplog, Platform.SWITCH, config1, config2 ) @@ -211,7 +210,7 @@ async def test_discovery_update_unchanged_switch( "homeassistant.components.tasmota.switch.TasmotaSwitch.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, switch.DOMAIN, config, discovery_update + hass, mqtt_mock, caplog, Platform.SWITCH, config, discovery_update ) @@ -221,7 +220,7 @@ async def test_discovery_device_remove(hass, mqtt_mock, setup_tasmota): config["rl"][0] = 1 unique_id = f"{DEFAULT_CONFIG['mac']}_switch_relay_0" await help_test_discovery_device_remove( - hass, mqtt_mock, switch.DOMAIN, unique_id, config + hass, mqtt_mock, Platform.SWITCH, unique_id, config ) @@ -235,7 +234,7 @@ async def test_entity_id_update_subscriptions(hass, mqtt_mock, setup_tasmota): get_topic_tele_will(config), ] await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, switch.DOMAIN, config, topics + hass, mqtt_mock, Platform.SWITCH, config, topics ) @@ -244,5 +243,5 @@ async def test_entity_id_update_discovery_update(hass, mqtt_mock, setup_tasmota) config = copy.deepcopy(DEFAULT_CONFIG) config["rl"][0] = 1 await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, switch.DOMAIN, config + hass, mqtt_mock, Platform.SWITCH, config ) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 981ff63af50236..8f76caa2cb943a 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -6,13 +6,14 @@ import pytest from homeassistant import setup -from homeassistant.components import binary_sensor +from homeassistant.components import binary_sensor, template from homeassistant.const import ( ATTR_DEVICE_CLASS, EVENT_HOMEASSISTANT_START, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState from homeassistant.helpers import entity_registry @@ -24,56 +25,150 @@ OFF = "off" -@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + "config,domain,entity_id,name,attributes", [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ True }}", + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "value_template": "{{ True }}", + } + }, + }, + }, + binary_sensor.DOMAIN, + "binary_sensor.test", + "test", + {"friendly_name": "test"}, + ), + ( + { + "template": { + "binary_sensor": { + "state": "{{ True }}", + } + }, + }, + template.DOMAIN, + "binary_sensor.unnamed_device", + "unnamed device", + {}, + ), + ], +) +async def test_setup_minimal(hass, start_ha, entity_id, name, attributes): + """Test the setup.""" + state = hass.states.get(entity_id) + assert state is not None + assert state.name == name + assert state.state == ON + assert state.attributes == attributes + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "config,domain,entity_id", + [ + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "{{ True }}", + "device_class": "motion", + } + }, + }, + }, + binary_sensor.DOMAIN, + "binary_sensor.test", + ), + ( + { + "template": { + "binary_sensor": { + "name": "virtual thingy", + "state": "{{ True }}", "device_class": "motion", } }, }, - }, + template.DOMAIN, + "binary_sensor.virtual_thingy", + ), ], ) -async def test_setup_legacy(hass, start_ha): +async def test_setup(hass, start_ha, entity_id): """Test the setup.""" - state = hass.states.get("binary_sensor.test") + state = hass.states.get(entity_id) assert state is not None assert state.name == "virtual thingy" assert state.state == ON assert state.attributes["device_class"] == "motion" -@pytest.mark.parametrize("count,domain", [(0, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "config", + "config,domain", [ - {"binary_sensor": {"platform": "template"}}, - {"binary_sensor": {"platform": "template", "sensors": {"foo bar": {}}}}, - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "value_template": "{{ foo }}", + # No legacy binary sensors + ( + {"binary_sensor": {"platform": "template"}}, + binary_sensor.DOMAIN, + ), + # Legacy binary sensor missing mandatory config + ( + {"binary_sensor": {"platform": "template", "sensors": {"foo bar": {}}}}, + binary_sensor.DOMAIN, + ), + # Binary sensor missing mandatory config + ( + {"template": {"binary_sensor": {}}}, + template.DOMAIN, + ), + # Legacy binary sensor with invalid device class + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "value_template": "{{ foo }}", + "device_class": "foobarnotreal", + } + }, + } + }, + binary_sensor.DOMAIN, + ), + # Binary sensor with invalid device class + ( + { + "template": { + "binary_sensor": { + "state": "{{ foo }}", "device_class": "foobarnotreal", } - }, - } - }, - { - "binary_sensor": { - "platform": "template", - "sensors": {"test": {"device_class": "motion"}}, - } - }, + } + }, + template.DOMAIN, + ), + # Legacy binary sensor missing mandatory config + ( + { + "binary_sensor": { + "platform": "template", + "sensors": {"test": {"device_class": "motion"}}, + } + }, + binary_sensor.DOMAIN, + ), ], ) async def test_setup_invalid_sensors(hass, count, start_ha): @@ -81,17 +176,35 @@ async def test_setup_invalid_sensors(hass, count, start_ha): assert len(hass.states.async_entity_ids("binary_sensor")) == count -@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + "config,domain,entity_id", [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "icon_template": "{% if " + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.xyz.state }}", + "icon_template": "{% if " + "states.binary_sensor.test_state.state == " + "'Works' %}" + "mdi:check" + "{% endif %}", + }, + }, + }, + }, + binary_sensor.DOMAIN, + "binary_sensor.test_template_sensor", + ), + ( + { + "template": { + "binary_sensor": { + "state": "{{ states.sensor.xyz.state }}", + "icon": "{% if " "states.binary_sensor.test_state.state == " "'Works' %}" "mdi:check" @@ -99,31 +212,51 @@ async def test_setup_invalid_sensors(hass, count, start_ha): }, }, }, - }, + template.DOMAIN, + "binary_sensor.unnamed_device", + ), ], ) -async def test_icon_template(hass, start_ha): +async def test_icon_template(hass, start_ha, entity_id): """Test icon template.""" - state = hass.states.get("binary_sensor.test_template_sensor") + state = hass.states.get(entity_id) assert state.attributes.get("icon") == "" hass.states.async_set("binary_sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_template_sensor") + state = hass.states.get(entity_id) assert state.attributes["icon"] == "mdi:check" -@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + "config,domain,entity_id", [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "entity_picture_template": "{% if " + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.xyz.state }}", + "entity_picture_template": "{% if " + "states.binary_sensor.test_state.state == " + "'Works' %}" + "/local/sensor.png" + "{% endif %}", + }, + }, + }, + }, + binary_sensor.DOMAIN, + "binary_sensor.test_template_sensor", + ), + ( + { + "template": { + "binary_sensor": { + "state": "{{ states.sensor.xyz.state }}", + "picture": "{% if " "states.binary_sensor.test_state.state == " "'Works' %}" "/local/sensor.png" @@ -131,48 +264,68 @@ async def test_icon_template(hass, start_ha): }, }, }, - }, + template.DOMAIN, + "binary_sensor.unnamed_device", + ), ], ) -async def test_entity_picture_template(hass, start_ha): +async def test_entity_picture_template(hass, start_ha, entity_id): """Test entity_picture template.""" - state = hass.states.get("binary_sensor.test_template_sensor") + state = hass.states.get(entity_id) assert state.attributes.get("entity_picture") == "" hass.states.async_set("binary_sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_template_sensor") + state = hass.states.get(entity_id) assert state.attributes["entity_picture"] == "/local/sensor.png" -@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + "config,domain,entity_id", [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "attribute_templates": { + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_template_sensor": { + "value_template": "{{ states.sensor.xyz.state }}", + "attribute_templates": { + "test_attribute": "It {{ states.sensor.test_state.state }}." + }, + }, + }, + }, + }, + binary_sensor.DOMAIN, + "binary_sensor.test_template_sensor", + ), + ( + { + "template": { + "binary_sensor": { + "state": "{{ states.sensor.xyz.state }}", + "attributes": { "test_attribute": "It {{ states.sensor.test_state.state }}." }, }, }, }, - }, + template.DOMAIN, + "binary_sensor.unnamed_device", + ), ], ) -async def test_attribute_templates(hass, start_ha): +async def test_attribute_templates(hass, start_ha, entity_id): """Test attribute_templates template.""" - state = hass.states.get("binary_sensor.test_template_sensor") + state = hass.states.get(entity_id) assert state.attributes.get("test_attribute") == "It ." hass.states.async_set("sensor.test_state", "Works2") await hass.async_block_till_done() hass.states.async_set("sensor.test_state", "Works") await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_template_sensor") + state = hass.states.get(entity_id) assert state.attributes["test_attribute"] == "It Works." @@ -247,73 +400,110 @@ async def test_event(hass, start_ha): assert state.state == ON -@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + "config,count,domain", [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": 5, - }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": 5, + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_on": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_on": 5, + }, + "test_off": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_off": 5, + }, }, }, }, - }, - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 10 / 2 }) }}', + 1, + binary_sensor.DOMAIN, + ), + ( + { + "template": [ + { + "binary_sensor": { + "name": "test on", + "state": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_on": 5, + }, }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": 10 / 2 }) }}', + { + "binary_sensor": { + "name": "test off", + "state": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_off": 5, + }, }, - }, + ] }, - }, - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": states("input_number.delay")|int }) }}', + 2, + template.DOMAIN, + ), + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_on": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_on": '{{ ({ "seconds": 10 / 2 }) }}', + }, + "test_off": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_off": '{{ ({ "seconds": 10 / 2 }) }}', + }, }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": states("input_number.delay")|int }) }}', + }, + }, + 1, + binary_sensor.DOMAIN, + ), + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_on": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_on": '{{ ({ "seconds": states("input_number.delay")|int }) }}', + }, + "test_off": { + "friendly_name": "virtual thingy", + "value_template": "{{ states.sensor.test_state.state == 'on' }}", + "device_class": "motion", + "delay_off": '{{ ({ "seconds": states("input_number.delay")|int }) }}', + }, }, }, }, - }, + 1, + binary_sensor.DOMAIN, + ), ], ) async def test_template_delay_on_off(hass, start_ha): """Test binary sensor template delay on.""" - assert hass.states.get("binary_sensor.test_on").state == OFF - assert hass.states.get("binary_sensor.test_off").state == OFF + # Ensure the initial state is not on + assert hass.states.get("binary_sensor.test_on").state != ON + assert hass.states.get("binary_sensor.test_off").state != ON + hass.states.async_set("input_number.delay", 5) hass.states.async_set("sensor.test_state", ON) await hass.async_block_till_done() @@ -349,64 +539,101 @@ async def test_template_delay_on_off(hass, start_ha): assert hass.states.get("binary_sensor.test_off").state == OFF -@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + "config,domain,entity_id", [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "true", + "device_class": "motion", + "delay_off": 5, + }, + }, + }, + }, + binary_sensor.DOMAIN, + "binary_sensor.test", + ), + ( + { + "template": { + "binary_sensor": { + "name": "virtual thingy", + "state": "true", "device_class": "motion", "delay_off": 5, }, }, }, - }, + template.DOMAIN, + "binary_sensor.virtual_thingy", + ), ], ) -async def test_available_without_availability_template(hass, start_ha): +async def test_available_without_availability_template(hass, start_ha, entity_id): """Ensure availability is true without an availability_template.""" - state = hass.states.get("binary_sensor.test") + state = hass.states.get(entity_id) assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -@pytest.mark.parametrize("count,domain", [(1, binary_sensor.DOMAIN)]) +@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "config", + "config,domain,entity_id", [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "true", + "device_class": "motion", + "delay_off": 5, + "availability_template": "{{ is_state('sensor.test_state','on') }}", + }, + }, + }, + }, + binary_sensor.DOMAIN, + "binary_sensor.test", + ), + ( + { + "template": { + "binary_sensor": { + "name": "virtual thingy", + "state": "true", "device_class": "motion", "delay_off": 5, - "availability_template": "{{ is_state('sensor.test_state','on') }}", + "availability": "{{ is_state('sensor.test_state','on') }}", }, }, }, - }, + template.DOMAIN, + "binary_sensor.virtual_thingy", + ), ], ) -async def test_availability_template(hass, start_ha): +async def test_availability_template(hass, start_ha, entity_id): """Test availability template.""" hass.states.async_set("sensor.test_state", STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test").state == STATE_UNAVAILABLE + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE hass.states.async_set("sensor.test_state", STATE_ON) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(entity_id) assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" @@ -498,10 +725,10 @@ async def test_no_update_template_match_all(hass, caplog): hass.states.async_set("binary_sensor.test_sensor", "true") assert len(hass.states.async_all()) == 5 - assert hass.states.get("binary_sensor.all_state").state == OFF - assert hass.states.get("binary_sensor.all_icon").state == OFF - assert hass.states.get("binary_sensor.all_entity_picture").state == OFF - assert hass.states.get("binary_sensor.all_attribute").state == OFF + assert hass.states.get("binary_sensor.all_state").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.all_icon").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.all_entity_picture").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.all_attribute").state == STATE_UNKNOWN hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() @@ -626,6 +853,67 @@ async def test_template_validation_error(hass, caplog, start_ha): assert state.attributes.get("icon") is None +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "config,domain,entity_id", + [ + ( + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "availability_template": "{{ is_state('sensor.bla', 'available') }}", + "entity_picture_template": "{{ 'blib' + 'blub' }}", + "icon_template": "mdi:{{ 1+2 }}", + "friendly_name": "{{ 'My custom ' + 'sensor' }}", + "value_template": "{{ true }}", + }, + }, + }, + }, + binary_sensor.DOMAIN, + "binary_sensor.test", + ), + ( + { + "template": { + "binary_sensor": { + "availability": "{{ is_state('sensor.bla', 'available') }}", + "picture": "{{ 'blib' + 'blub' }}", + "icon": "mdi:{{ 1+2 }}", + "name": "{{ 'My custom ' + 'sensor' }}", + "state": "{{ true }}", + }, + }, + }, + template.DOMAIN, + "binary_sensor.my_custom_sensor", + ), + ], +) +async def test_availability_icon_picture(hass, start_ha, entity_id): + """Test name, icon and picture templates are rendered at setup.""" + state = hass.states.get(entity_id) + assert state.state == "unavailable" + assert state.attributes == { + "entity_picture": "blibblub", + "friendly_name": "My custom sensor", + "icon": "mdi:3", + } + + hass.states.async_set("sensor.bla", "available") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "on" + assert state.attributes == { + "entity_picture": "blibblub", + "friendly_name": "My custom sensor", + "icon": "mdi:3", + } + + @pytest.mark.parametrize("count,domain", [(2, "template")]) @pytest.mark.parametrize( "config", @@ -682,11 +970,11 @@ async def test_trigger_entity(hass, start_ha): await hass.async_block_till_done() state = hass.states.get("binary_sensor.hello_name") assert state is not None - assert state.state == OFF + assert state.state == STATE_UNKNOWN state = hass.states.get("binary_sensor.bare_minimum") assert state is not None - assert state.state == OFF + assert state.state == STATE_UNKNOWN context = Context() hass.bus.async_fire("test_event", {"beer": 2}, context=context) @@ -749,7 +1037,7 @@ async def test_trigger_entity(hass, start_ha): async def test_template_with_trigger_templated_delay_on(hass, start_ha): """Test binary sensor template with template delay on.""" state = hass.states.get("binary_sensor.test") - assert state.state == OFF + assert state.state == STATE_UNKNOWN context = Context() hass.bus.async_fire("test_event", {"beer": 2}, context=context) diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py new file mode 100644 index 00000000000000..aa671bebd89a6f --- /dev/null +++ b/tests/components/template/test_button.py @@ -0,0 +1,197 @@ +"""The tests for the Template button platform.""" +import datetime as dt +from unittest.mock import patch + +import pytest + +from homeassistant import setup +from homeassistant.components.button.const import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.template.button import DEFAULT_NAME +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_ENTITY_ID, + CONF_FRIENDLY_NAME, + CONF_ICON, + STATE_UNKNOWN, +) +from homeassistant.helpers.entity_registry import async_get + +from tests.common import assert_setup_component, async_mock_service + +_TEST_BUTTON = "button.template_button" +_TEST_OPTIONS_BUTTON = "button.test" + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_missing_optional_config(hass, calls): + """Test: missing optional template is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "button": { + "press": {"service": "script.press"}, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_UNKNOWN) + + +async def test_missing_required_keys(hass, calls): + """Test: missing required fields will fail.""" + with assert_setup_component(0, "template"): + assert await setup.async_setup_component( + hass, + "template", + {"template": {"button": {}}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all("button") == [] + + +async def test_all_optional_config(hass, calls): + """Test: including all optional templates is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "unique_id": "test", + "button": { + "press": {"service": "test.automation"}, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify( + hass, + STATE_UNKNOWN, + { + CONF_DEVICE_CLASS: "restart", + CONF_FRIENDLY_NAME: "test", + CONF_ICON: "mdi:test", + }, + _TEST_OPTIONS_BUTTON, + ) + + now = dt.datetime.now(dt.timezone.utc) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, + blocking=True, + ) + + assert len(calls) == 1 + + _verify( + hass, + now.isoformat(), + { + CONF_DEVICE_CLASS: "restart", + CONF_FRIENDLY_NAME: "test", + CONF_ICON: "mdi:test", + }, + _TEST_OPTIONS_BUTTON, + ) + + er = async_get(hass) + assert er.async_get_entity_id("button", "template", "test-test") + + +async def test_name_template(hass, calls): + """Test: name template.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "button": { + "press": {"service": "script.press"}, + "name": "Button {{ 1 + 1 }}", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify( + hass, + STATE_UNKNOWN, + { + CONF_FRIENDLY_NAME: "Button 2", + }, + "button.button_2", + ) + + +async def test_unique_id(hass, calls): + """Test: unique id is ok.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "unique_id": "test", + "button": { + "press": {"service": "script.press"}, + "unique_id": "test", + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_UNKNOWN) + + +def _verify( + hass, + expected_value, + attributes=None, + entity_id=_TEST_BUTTON, +): + """Verify button's state.""" + attributes = attributes or {} + if CONF_FRIENDLY_NAME not in attributes: + attributes[CONF_FRIENDLY_NAME] = DEFAULT_NAME + state = hass.states.get(entity_id) + assert state.state == expected_value + assert state.attributes == attributes diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 8ec771a05f321e..1f1947183de340 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -18,6 +18,7 @@ SPEED_OFF, SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, + NotValidSpeedError, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE @@ -29,8 +30,6 @@ _STATE_INPUT_BOOLEAN = "input_boolean.state" # Represent for fan's state _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" -# Represent for fan's speed -_SPEED_INPUT_SELECT = "input_select.speed" # Represent for fan's preset mode _PRESET_MODE_INPUT_SELECT = "input_select.preset_mode" # Represent for fan's speed percentage @@ -148,7 +147,6 @@ async def test_wrong_template_config(hass, start_ha): {% endif %} """, "percentage_template": "{{ states('input_number.percentage') }}", - "speed_template": "{{ states('input_select.speed') }}", "preset_mode_template": "{{ states('input_select.preset_mode') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", @@ -170,7 +168,7 @@ async def test_templates_with_entities(hass, start_ha): _verify(hass, STATE_OFF, None, 0, None, None, None) hass.states.async_set(_STATE_INPUT_BOOLEAN, True) - hass.states.async_set(_SPEED_INPUT_SELECT, SPEED_MEDIUM) + hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 66) hass.states.async_set(_OSC_INPUT, "True") for set_state, set_value, speed, value in [ @@ -262,7 +260,6 @@ async def test_templates_with_entities2(hass, entity, tests, start_ha): "test_fan": { "availability_template": "{{ is_state('availability_boolean.state', 'on') }}", "value_template": "{{ 'on' }}", - "speed_template": "{{ 'medium' }}", "oscillating_template": "{{ 1 == 1 }}", "direction_template": "{{ 'forward' }}", "turn_on": {"service": "script.fan_on"}, @@ -307,9 +304,9 @@ async def test_availability_template_with_entities(hass, start_ha): "fans": { "test_fan": { "value_template": "{{ 'on' }}", - "speed_template": "{{ 'unavailable' }}", "oscillating_template": "{{ 'unavailable' }}", "direction_template": "{{ 'unavailable' }}", + "percentage_template": "{{ 0 }}", "turn_on": {"service": "script.fan_on"}, "turn_off": {"service": "script.fan_off"}, } @@ -325,9 +322,9 @@ async def test_availability_template_with_entities(hass, start_ha): "fans": { "test_fan": { "value_template": "{{ 'on' }}", - "speed_template": "{{ 'medium' }}", "oscillating_template": "{{ 1 == 1 }}", "direction_template": "{{ 'forward' }}", + "percentage_template": "{{ 66 }}", "turn_on": {"service": "script.fan_on"}, "turn_off": {"service": "script.fan_off"}, } @@ -343,9 +340,9 @@ async def test_availability_template_with_entities(hass, start_ha): "fans": { "test_fan": { "value_template": "{{ 'abc' }}", - "speed_template": "{{ '0' }}", "oscillating_template": "{{ 'xyz' }}", "direction_template": "{{ 'right' }}", + "percentage_template": "{{ 0 }}", "turn_on": {"service": "script.fan_on"}, "turn_off": {"service": "script.fan_off"}, } @@ -372,7 +369,6 @@ async def test_template_with_unavailable_entities(hass, states, start_ha): "test_fan": { "value_template": "{{ 'on' }}", "availability_template": "{{ x - 12 }}", - "speed_template": "{{ states('input_select.speed') }}", "preset_mode_template": "{{ states('input_select.preset_mode') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", @@ -411,38 +407,34 @@ async def test_set_speed(hass): await _register_components(hass, preset_modes=["auto", "smart"]) await common.async_turn_on(hass, _TEST_FAN) - for cmd, t_state, type, state, value in [ - (SPEED_HIGH, SPEED_HIGH, SPEED_HIGH, STATE_ON, 100), - (SPEED_MEDIUM, SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), - (SPEED_OFF, SPEED_OFF, SPEED_OFF, STATE_OFF, 0), - (SPEED_MEDIUM, SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), - ("invalid", SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), + for cmd, type, state, value in [ + (SPEED_HIGH, SPEED_HIGH, STATE_ON, 100), + (SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), + (SPEED_OFF, SPEED_OFF, STATE_OFF, 0), + (SPEED_MEDIUM, SPEED_MEDIUM, STATE_ON, 66), ]: await common.async_set_speed(hass, _TEST_FAN, cmd) - assert hass.states.get(_SPEED_INPUT_SELECT).state == t_state + assert float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state) == value _verify(hass, state, type, value, None, None, None) + with pytest.raises(NotValidSpeedError): + await common.async_set_speed(hass, _TEST_FAN, "invalid") + + assert float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state) == 66 + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) + async def test_set_invalid_speed(hass): """Test set invalid speed when fan has valid speed.""" await _register_components(hass) await common.async_turn_on(hass, _TEST_FAN) - for extra in [SPEED_HIGH, "invalid"]: - await common.async_set_speed(hass, _TEST_FAN, extra) - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - - -async def test_custom_speed_list(hass): - """Test set custom speed list.""" - await _register_components(hass, ["1", "2", "3"]) + await common.async_set_speed(hass, _TEST_FAN, SPEED_HIGH) + assert float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state) == 100 + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) - await common.async_turn_on(hass, _TEST_FAN) - for extra in ["1", SPEED_MEDIUM]: - await common.async_set_speed(hass, _TEST_FAN, extra) - assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" - _verify(hass, STATE_ON, "1", 33, None, None, None) + with pytest.raises(NotValidSpeedError): + await common.async_set_speed(hass, _TEST_FAN, "invalid") async def test_set_invalid_direction_from_initial_stage(hass, calls): @@ -610,7 +602,7 @@ def _verify( state = hass.states.get(_TEST_FAN) attributes = state.attributes assert state.state == str(expected_state) - assert attributes.get(ATTR_SPEED) == expected_speed + assert attributes.get(ATTR_SPEED) == expected_speed or SPEED_OFF assert attributes.get(ATTR_PERCENTAGE) == expected_percentage assert attributes.get(ATTR_OSCILLATING) == expected_oscillating assert attributes.get(ATTR_DIRECTION) == expected_direction @@ -643,27 +635,12 @@ async def _register_components( }, ) - with assert_setup_component(4, "input_select"): + with assert_setup_component(3, "input_select"): assert await setup.async_setup_component( hass, "input_select", { "input_select": { - "speed": { - "name": "Speed", - "options": [ - "", - SPEED_OFF, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_HIGH, - "1", - "2", - "3", - "auto", - "smart", - ], - }, "preset_mode": { "name": "Preset Mode", "options": ["auto", "smart"], @@ -688,7 +665,6 @@ async def _register_components( test_fan_config = { "value_template": value_template, - "speed_template": "{{ states('input_select.speed') }}", "preset_mode_template": "{{ states('input_select.preset_mode') }}", "percentage_template": "{{ states('input_number.percentage') }}", "oscillating_template": "{{ states('input_select.osc') }}", @@ -697,17 +673,19 @@ async def _register_components( "service": "input_boolean.turn_on", "entity_id": _STATE_INPUT_BOOLEAN, }, - "turn_off": { - "service": "input_boolean.turn_off", - "entity_id": _STATE_INPUT_BOOLEAN, - }, - "set_speed": { - "service": "input_select.select_option", - "data_template": { - "entity_id": _SPEED_INPUT_SELECT, - "option": "{{ speed }}", + "turn_off": [ + { + "service": "input_boolean.turn_off", + "entity_id": _STATE_INPUT_BOOLEAN, }, - }, + { + "service": "input_number.set_value", + "data_template": { + "entity_id": _PERCENTAGE_INPUT_NUMBER, + "value": 0, + }, + }, + ], "set_preset_mode": { "service": "input_select.select_option", "data_template": { @@ -738,9 +716,6 @@ async def _register_components( }, } - if speed_list: - test_fan_config["speeds"] = speed_list - if preset_modes: test_fan_config["preset_modes"] = preset_modes @@ -940,71 +915,3 @@ async def test_implemented_preset_mode(hass, start_ha): attributes = state.attributes assert attributes.get("percentage") is None assert attributes.get("supported_features") & SUPPORT_PRESET_MODE - - -@pytest.mark.parametrize("count,domain", [(1, DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - DOMAIN: { - "platform": "template", - "fans": { - "mechanical_ventilation": { - "friendly_name": "Mechanische ventilatie", - "unique_id": "a2fd2e38-674b-4b47-b5ef-cc2362211a72", - "value_template": "{{ states('light.mv_snelheid') }}", - "speed_template": "{{ 'fast' }}", - "speeds": ["slow", "fast"], - "set_preset_mode": [ - { - "service": "light.turn_on", - "target": { - "entity_id": "light.mv_snelheid", - }, - "data": {"brightness_pct": "{{ percentage }}"}, - } - ], - "turn_on": [ - { - "service": "switch.turn_off", - "target": { - "entity_id": "switch.mv_automatisch", - }, - }, - { - "service": "light.turn_on", - "target": { - "entity_id": "light.mv_snelheid", - }, - "data": {"brightness_pct": 40}, - }, - ], - "turn_off": [ - { - "service": "light.turn_off", - "target": { - "entity_id": "light.mv_snelheid", - }, - }, - { - "service": "switch.turn_on", - "target": { - "entity_id": "switch.mv_automatisch", - }, - }, - ], - }, - }, - } - }, - ], -) -async def test_implemented_speed(hass, start_ha): - """Test a fan that implements speed.""" - assert len(hass.states.async_all()) == 1 - - state = hass.states.get("fan.mechanical_ventilation") - attributes = state.attributes - assert attributes["percentage"] == 100 - assert attributes["speed"] == "fast" diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index ae812370d936ba..e4e797d6bcd4c9 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -5,9 +5,9 @@ from homeassistant.helpers import template -async def test_template_entity_requires_hass_set(): +async def test_template_entity_requires_hass_set(hass): """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity() + entity = template_entity.TemplateEntity(hass) with pytest.raises(AssertionError): entity.add_template_attribute("_hello", template.Template("Hello")) diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py new file mode 100644 index 00000000000000..3fa503ed002ae0 --- /dev/null +++ b/tests/components/tibber/conftest.py @@ -0,0 +1,19 @@ +"""Test helpers for Tibber.""" +import pytest + +from homeassistant.components.tibber.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry(hass): + """Tibber config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCESS_TOKEN: "token"}, + unique_id="tibber", + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/tibber/test_common.py b/tests/components/tibber/test_common.py new file mode 100644 index 00000000000000..716fa8ce47bc4a --- /dev/null +++ b/tests/components/tibber/test_common.py @@ -0,0 +1,36 @@ +"""Test common.""" +import datetime as dt +from unittest.mock import AsyncMock + +CONSUMPTION_DATA_1 = [ + { + "from": "2022-01-03T00:00:00.000+01:00", + "totalCost": 1.1, + "consumption": 2.1, + }, + { + "from": "2022-01-03T01:00:00.000+01:00", + "totalCost": 1.2, + "consumption": 2.2, + }, + { + "from": "2022-01-03T02:00:00.000+01:00", + "totalCost": 1.3, + "consumption": 2.3, + }, +] + + +def mock_get_homes(only_active=True): + """Return a list of mocked Tibber homes.""" + tibber_home = AsyncMock() + tibber_home.name = "Name" + tibber_home.home_id = "home_id" + tibber_home.currency = "NOK" + tibber_home.has_active_subscription = True + tibber_home.has_real_time_consumption = False + tibber_home.country = "NO" + tibber_home.last_cons_data_timestamp = dt.datetime(2016, 1, 1, 12, 44, 57) + tibber_home.last_data_timestamp = dt.datetime(2016, 1, 1, 12, 48, 57) + tibber_home.get_historic_data.return_value = CONSUMPTION_DATA_1 + return [tibber_home] diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py index 6eaa52ac103440..198ff8ccfc47eb 100644 --- a/tests/components/tibber/test_config_flow.py +++ b/tests/components/tibber/test_config_flow.py @@ -7,7 +7,7 @@ from homeassistant.components.tibber.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN -from tests.common import MockConfigEntry +from tests.common import async_init_recorder_component @pytest.fixture(name="tibber_setup", autouse=True) @@ -19,6 +19,8 @@ def tibber_setup_fixture(): async def test_show_config_form(hass): """Test show configuration form.""" + await async_init_recorder_component(hass) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -29,6 +31,8 @@ async def test_show_config_form(hass): async def test_create_entry(hass): """Test create entry from user input.""" + await async_init_recorder_component(hass) + test_data = { CONF_ACCESS_TOKEN: "valid", } @@ -51,14 +55,9 @@ async def test_create_entry(hass): assert result["data"] == test_data -async def test_flow_entry_already_exists(hass): +async def test_flow_entry_already_exists(hass, config_entry): """Test user input for config_entry that already exists.""" - first_entry = MockConfigEntry( - domain="tibber", - data={CONF_ACCESS_TOKEN: "valid"}, - unique_id="tibber", - ) - first_entry.add_to_hass(hass) + await async_init_recorder_component(hass) test_data = { CONF_ACCESS_TOKEN: "valid", diff --git a/tests/components/tibber/test_diagnostics.py b/tests/components/tibber/test_diagnostics.py new file mode 100644 index 00000000000000..706f86b63aca0e --- /dev/null +++ b/tests/components/tibber/test_diagnostics.py @@ -0,0 +1,50 @@ +"""Test the Netatmo diagnostics.""" +from unittest.mock import patch + +from homeassistant.setup import async_setup_component + +from .test_common import mock_get_homes + +from tests.common import async_init_recorder_component +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, hass_client, config_entry): + """Test config entry diagnostics.""" + await async_init_recorder_component(hass) + + with patch( + "tibber.Tibber.update_info", + return_value=None, + ), patch("homeassistant.components.tibber.discovery.async_load_platform"): + assert await async_setup_component(hass, "tibber", {}) + + await hass.async_block_till_done() + + with patch( + "tibber.Tibber.get_homes", + return_value=[], + ): + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == { + "homes": {}, + } + + with patch( + "tibber.Tibber.get_homes", + side_effect=mock_get_homes, + ): + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == { + "homes": { + "home_id": { + "last_data_timestamp": "2016-01-01T12:48:57", + "has_active_subscription": True, + "has_real_time_consumption": False, + "last_cons_data_timestamp": "2016-01-01T12:44:57", + "country": "NO", + } + }, + } diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py new file mode 100644 index 00000000000000..7fbc03279109f4 --- /dev/null +++ b/tests/components/tibber/test_statistics.py @@ -0,0 +1,79 @@ +"""Test adding external statistics from Tibber.""" +from unittest.mock import AsyncMock + +from homeassistant.components.recorder.statistics import statistics_during_period +from homeassistant.components.tibber.sensor import TibberDataCoordinator +from homeassistant.util import dt as dt_util + +from .test_common import CONSUMPTION_DATA_1, mock_get_homes + +from tests.common import async_init_recorder_component +from tests.components.recorder.common import async_wait_recording_done_without_instance + + +async def test_async_setup_entry(hass): + """Test setup Tibber.""" + await async_init_recorder_component(hass) + + tibber_connection = AsyncMock() + tibber_connection.name = "tibber" + tibber_connection.fetch_consumption_data_active_homes.return_value = None + tibber_connection.get_homes = mock_get_homes + + coordinator = TibberDataCoordinator(hass, tibber_connection) + await coordinator._async_update_data() + await async_wait_recording_done_without_instance(hass) + + # Validate consumption + statistic_id = "tibber:energy_consumption_home_id" + + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.parse_datetime(CONSUMPTION_DATA_1[0]["from"]), + None, + [statistic_id], + "hour", + True, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + _sum = 0 + for k, stat in enumerate(stats[statistic_id]): + assert stat["start"] == dt_util.parse_datetime(CONSUMPTION_DATA_1[k]["from"]) + assert stat["state"] == CONSUMPTION_DATA_1[k]["consumption"] + assert stat["mean"] is None + assert stat["min"] is None + assert stat["max"] is None + assert stat["last_reset"] is None + + _sum += CONSUMPTION_DATA_1[k]["consumption"] + assert stat["sum"] == _sum + + # Validate cost + statistic_id = "tibber:energy_totalcost_home_id" + + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.parse_datetime(CONSUMPTION_DATA_1[0]["from"]), + None, + [statistic_id], + "hour", + True, + ) + + assert len(stats) == 1 + assert len(stats[statistic_id]) == 3 + _sum = 0 + for k, stat in enumerate(stats[statistic_id]): + assert stat["start"] == dt_util.parse_datetime(CONSUMPTION_DATA_1[k]["from"]) + assert stat["state"] == CONSUMPTION_DATA_1[k]["totalCost"] + assert stat["mean"] is None + assert stat["min"] is None + assert stat["max"] is None + assert stat["last_reset"] is None + + _sum += CONSUMPTION_DATA_1[k]["totalCost"] + assert stat["sum"] == _sum diff --git a/tests/components/tile/conftest.py b/tests/components/tile/conftest.py new file mode 100644 index 00000000000000..0cb9a0080f6e23 --- /dev/null +++ b/tests/components/tile/conftest.py @@ -0,0 +1,67 @@ +"""Define test fixtures for Tile.""" +import json +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from pytile.tile import Tile + +from homeassistant.components.tile.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="api") +def api_fixture(hass, data_tile_details): + """Define a pytile API object.""" + tile = Tile(None, data_tile_details) + tile.async_update = AsyncMock() + + return Mock( + async_get_tiles=AsyncMock( + return_value={data_tile_details["result"]["tile_uuid"]: tile} + ) + ) + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config, unique_id): + """Define a config entry fixture.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(hass): + """Define a config entry data fixture.""" + return { + CONF_USERNAME: "user@host.com", + CONF_PASSWORD: "123abc", + } + + +@pytest.fixture(name="data_tile_details", scope="session") +def data_tile_details_fixture(): + """Define a Tile details data payload.""" + return json.loads(load_fixture("tile_details_data.json", "tile")) + + +@pytest.fixture(name="setup_tile") +async def setup_tile_fixture(hass, api, config): + """Define a fixture to set up Tile.""" + with patch( + "homeassistant.components.tile.config_flow.async_login", return_value=api + ), patch("homeassistant.components.tile.async_login", return_value=api), patch( + "homeassistant.components.tile.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="unique_id") +def unique_id_fixture(hass): + """Define a config entry unique ID fixture.""" + return "user@host.com" diff --git a/tests/components/tile/fixtures/tile_details_data.json b/tests/components/tile/fixtures/tile_details_data.json new file mode 100644 index 00000000000000..bfcc27f3c1850b --- /dev/null +++ b/tests/components/tile/fixtures/tile_details_data.json @@ -0,0 +1,95 @@ +{ + "version": 1, + "revision": 1, + "timestamp": "2020-08-12T21:46:52.962Z", + "timestamp_ms": 1597268812962, + "result_code": 0, + "result": { + "group": null, + "parents": [], + "user_node_relationships": null, + "owner_user_uuid": "fd0c10a5-d0f7-4619-9bce-5b2cb7a6754b", + "node_type": null, + "name": "Wallet", + "description": null, + "image_url": "https://local-tile-pub.s3.amazonaws.com/images/wallet.jpeg", + "product": "DUTCH1", + "archetype": "WALLET", + "visible": true, + "user_node_data": {}, + "permissions_mask": null, + "tile_uuid": "19264d2dffdbca32", + "firmware_version": "01.12.14.0", + "category": null, + "superseded_tile_uuid": null, + "is_dead": false, + "hw_version": "02.09", + "configuration": { + "fw10_advertising_interval": null + }, + "last_tile_state": { + "uuid": "19264d2dffdbca32", + "connectionStateCode": 0, + "ringStateCode": 0, + "tile_uuid": "19264d2dffdbca32", + "client_uuid": "2cc56adc-b96a-4293-9b94-eda716e0aa17", + "timestamp": 1597254926000, + "advertised_rssi": -89, + "client_rssi": 0, + "battery_level": 0, + "latitude": 51.528308, + "longitude": -0.3817765, + "altitude": 0.4076319168123, + "raw_h_accuracy": 13.496111, + "v_accuracy": 9.395408, + "speed": 0.167378, + "course": 147.42035, + "authentication": null, + "owned": false, + "has_authentication": null, + "lost_timestamp": -1, + "connection_client_uuid": null, + "connection_event_timestamp": 0, + "last_owner_update": 0, + "partner_id": null, + "partner_client_id": null, + "speed_accuracy": null, + "course_accuracy": null, + "discovery_timestamp": 1597254933661, + "connection_state": "DISCONNECTED", + "ring_state": "STOPPED", + "is_lost": false, + "h_accuracy": 13.496111, + "voip_state": "OFFLINE" + }, + "firmware": { + "expected_firmware_version": "01.19.01.0", + "expected_firmware_imagename": "Tile_FW_Image_01.19.01.0.bin", + "expected_firmware_urlprefix": "https://s3.amazonaws.com/tile-tofu-fw/prod/", + "expected_firmware_publish_date": 1574380800000, + "expected_ppm": null, + "expected_advertising_interval": null, + "security_level": 1, + "expiry_timestamp": 1597290412960, + "expected_tdt_cmd_config": "xxxxxxxx" + }, + "auth_key": "xxxxxxxxxxxxxxxxxxxxxxxx", + "renewal_status": "NONE", + "metadata": { + "battery_state": "10" + }, + "battery_status": "NONE", + "serial_number": null, + "auto_retile": false, + "all_user_node_relationships": null, + "tile_type": "TILE", + "registration_timestamp": 1569634958090, + "is_lost": false, + "auth_timestamp": 1569634958090, + "status": "ACTIVATED", + "activation_timestamp": 1569634959186, + "thumbnail_image": "https://local-tile-pub.s3.amazonaws.com/images/thumb.jpeg", + "last_modified_timestamp": 1597268811531 + } +} + diff --git a/tests/components/tile/test_config_flow.py b/tests/components/tile/test_config_flow.py index e5561133a35f2d..7c623de4dedb45 100644 --- a/tests/components/tile/test_config_flow.py +++ b/tests/components/tile/test_config_flow.py @@ -1,96 +1,90 @@ """Define tests for the Tile config flow.""" from unittest.mock import patch -from pytile.errors import TileError +import pytest +from pytile.errors import InvalidAuthError, TileError from homeassistant import data_entry_flow from homeassistant.components.tile import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from tests.common import MockConfigEntry - -async def test_duplicate_error(hass): +async def test_duplicate_error(hass, config, config_entry): """Test that errors are shown when duplicates are added.""" - conf = { - CONF_USERNAME: "user@host.com", - CONF_PASSWORD: "123abc", - } - - MockConfigEntry(domain=DOMAIN, unique_id="user@host.com", data=conf).add_to_hass( - hass - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -async def test_invalid_credentials(hass): - """Test that invalid credentials key throws an error.""" - conf = { - CONF_USERNAME: "user@host.com", - CONF_PASSWORD: "123abc", - } - +@pytest.mark.parametrize( + "err,err_string", + [ + (InvalidAuthError, "invalid_auth"), + (TileError, "unknown"), + ], +) +async def test_errors(hass, config, err, err_string): + """Test that errors are handled correctly.""" with patch( "homeassistant.components.tile.config_flow.async_login", - side_effect=TileError, + side_effect=err, ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": SOURCE_USER}, data=config ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": err_string} -async def test_step_import(hass): +async def test_step_import(hass, config, setup_tile): """Test that the import step works.""" - conf = { + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user@host.com" + assert result["data"] == { CONF_USERNAME: "user@host.com", CONF_PASSWORD: "123abc", } - with patch( - "homeassistant.components.tile.async_setup_entry", return_value=True - ), patch("homeassistant.components.tile.config_flow.async_login"): - 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"] == "user@host.com" - assert result["data"] == { - CONF_USERNAME: "user@host.com", - CONF_PASSWORD: "123abc", - } +async def test_step_reauth(hass, config, config_entry, setup_tile): + """Test that the reauth step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=config + ) + assert result["step_id"] == "reauth_confirm" -async def test_step_user(hass): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "password"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_step_user(hass, config, setup_tile): """Test that the user step works.""" - conf = { + 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" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=config + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user@host.com" + assert result["data"] == { CONF_USERNAME: "user@host.com", CONF_PASSWORD: "123abc", } - - with patch( - "homeassistant.components.tile.async_setup_entry", return_value=True - ), patch("homeassistant.components.tile.config_flow.async_login"): - 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" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "user@host.com" - assert result["data"] == { - CONF_USERNAME: "user@host.com", - CONF_PASSWORD: "123abc", - } diff --git a/tests/components/tile/test_diagnostics.py b/tests/components/tile/test_diagnostics.py new file mode 100644 index 00000000000000..e9cf0d34d774b0 --- /dev/null +++ b/tests/components/tile/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Test Tile diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_tile): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "tiles": [ + { + "accuracy": 13.496111, + "altitude": REDACTED, + "archetype": "WALLET", + "dead": False, + "firmware_version": "01.12.14.0", + "hardware_version": "02.09", + "kind": "TILE", + "last_timestamp": "2020-08-12T17:55:26", + "latitude": REDACTED, + "longitude": REDACTED, + "lost": False, + "lost_timestamp": "1969-12-31T23:59:59.999000", + "name": "Wallet", + "ring_state": "STOPPED", + "uuid": REDACTED, + "visible": True, + "voip_state": "OFFLINE", + } + ] + } diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index 368ebf93a04fae..c623b82645982d 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -5,6 +5,11 @@ import pytest from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN +from homeassistant.components.totalconnect import DOMAIN +from homeassistant.components.totalconnect.alarm_control_panel import ( + SERVICE_ALARM_ARM_AWAY_INSTANT, + SERVICE_ALARM_ARM_HOME_INSTANT, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, @@ -121,6 +126,82 @@ async def test_arm_home_failure(hass: HomeAssistant) -> None: assert mock_request.call_count == 2 +async def test_arm_home_instant_success(hass: HomeAssistant) -> None: + """Test arm home instant method success.""" + responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_STAY] + with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + await setup_platform(hass, ALARM_DOMAIN) + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + assert hass.states.get(ENTITY_ID_2).state == STATE_ALARM_DISARMED + assert mock_request.call_count == 1 + + await hass.services.async_call( + DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True + ) + assert mock_request.call_count == 2 + + async_fire_time_changed(hass, dt.utcnow() + DELAY) + await hass.async_block_till_done() + assert mock_request.call_count == 3 + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_HOME + + +async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: + """Test arm home instant method failure.""" + responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE] + with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + await setup_platform(hass, ALARM_DOMAIN) + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + assert mock_request.call_count == 1 + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True + ) + await hass.async_block_till_done() + assert f"{err.value}" == "TotalConnect failed to arm home instant test." + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + assert mock_request.call_count == 2 + + +async def test_arm_away_instant_success(hass: HomeAssistant) -> None: + """Test arm home instant method success.""" + responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_AWAY] + with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + await setup_platform(hass, ALARM_DOMAIN) + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + assert hass.states.get(ENTITY_ID_2).state == STATE_ALARM_DISARMED + assert mock_request.call_count == 1 + + await hass.services.async_call( + DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True + ) + assert mock_request.call_count == 2 + + async_fire_time_changed(hass, dt.utcnow() + DELAY) + await hass.async_block_till_done() + assert mock_request.call_count == 3 + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY + + +async def test_arm_away_instant_failure(hass: HomeAssistant) -> None: + """Test arm home instant method failure.""" + responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE] + with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + await setup_platform(hass, ALARM_DOMAIN) + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + assert mock_request.call_count == 1 + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True + ) + await hass.async_block_till_done() + assert f"{err.value}" == "TotalConnect failed to arm away instant test." + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + assert mock_request.call_count == 2 + + async def test_arm_home_invalid_usercode(hass: HomeAssistant) -> None: """Test arm home method with invalid usercode.""" responses = [RESPONSE_DISARMED, RESPONSE_USER_CODE_INVALID] diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 5a4e672a3842a8..fdc9fcea83ec87 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -3,7 +3,7 @@ import pytest -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.tplink import DOMAIN from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME @@ -175,52 +175,6 @@ async def test_discovery_no_device(hass: HomeAssistant): assert result2["reason"] == "no_devices_found" -async def test_import(hass: HomeAssistant): - """Test import from yaml.""" - config = { - CONF_HOST: IP_ADDRESS, - } - - # Cannot connect - with _patch_discovery(no_device=True), _patch_single_discovery(no_device=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config - ) - await hass.async_block_till_done() - - assert result["type"] == "abort" - assert result["reason"] == "cannot_connect" - - # Success - with _patch_discovery(), _patch_single_discovery(), patch( - f"{MODULE}.async_setup", return_value=True - ) as mock_setup, patch( - f"{MODULE}.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == DEFAULT_ENTRY_TITLE - assert result["data"] == { - CONF_HOST: IP_ADDRESS, - } - mock_setup.assert_called_once() - mock_setup_entry.assert_called_once() - - # Duplicate - with _patch_discovery(), _patch_single_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config - ) - await hass.async_block_till_done() - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - async def test_manual(hass: HomeAssistant): """Test manually setup.""" result = await hass.config_entries.flow.async_init( @@ -406,76 +360,3 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device(hass, source await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "cannot_connect" - - -async def test_migration_device_online(hass: HomeAssistant): - """Test migration from single config entry.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) - config_entry.add_to_hass(hass) - config = {CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS, CONF_HOST: IP_ADDRESS} - - with _patch_discovery(), _patch_single_discovery(), patch( - f"{MODULE}.async_setup_entry", return_value=True - ) as mock_setup_entry: - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "migration"}, data=config - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == ALIAS - assert result["data"] == { - CONF_HOST: IP_ADDRESS, - } - assert len(mock_setup_entry.mock_calls) == 2 - - # Duplicate - with _patch_discovery(), _patch_single_discovery(): - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "migration"}, data=config - ) - await hass.async_block_till_done() - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - -async def test_migration_device_offline(hass: HomeAssistant): - """Test migration from single config entry.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) - config_entry.add_to_hass(hass) - config = {CONF_MAC: MAC_ADDRESS, CONF_NAME: ALIAS, CONF_HOST: None} - - with _patch_discovery(no_device=True), _patch_single_discovery( - no_device=True - ), patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry: - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "migration"}, data=config - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == ALIAS - new_entry = result["result"] - assert result["data"] == { - CONF_HOST: None, - } - assert len(mock_setup_entry.mock_calls) == 2 - - # Ensure a manual import updates the missing host - config = {CONF_HOST: IP_ADDRESS} - with _patch_discovery(no_device=True), _patch_single_discovery(): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config - ) - await hass.async_block_till_done() - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - assert new_entry.data[CONF_HOST] == IP_ADDRESS diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 73edc63e28cc4d..d6812f6c993e2a 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -74,37 +74,6 @@ async def test_config_entry_retry(hass): assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY -async def test_dimmer_switch_unique_id_fix_original_entity_was_deleted( - hass: HomeAssistant, entity_reg: EntityRegistry -): - """Test that roll out unique id entity id changed to the original unique id.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=MAC_ADDRESS) - config_entry.add_to_hass(hass) - dimmer = _mocked_dimmer() - rollout_unique_id = MAC_ADDRESS.replace(":", "").upper() - original_unique_id = tplink.legacy_device_id(dimmer) - rollout_dimmer_entity_reg = entity_reg.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="light", - unique_id=rollout_unique_id, - original_name="Rollout dimmer", - ) - - with _patch_discovery(device=dimmer), _patch_single_discovery(device=dimmer): - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - migrated_dimmer_entity_reg = entity_reg.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="light", - unique_id=original_unique_id, - original_name="Migrated dimmer", - ) - assert migrated_dimmer_entity_reg.entity_id == rollout_dimmer_entity_reg.entity_id - - async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( hass: HomeAssistant, entity_reg: EntityRegistry ): diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 1017ad38eae652..c3b82045ef09e8 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -1,6 +1,6 @@ """Tests for light platform.""" +from __future__ import annotations -from typing import Optional from unittest.mock import PropertyMock import pytest @@ -48,7 +48,7 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: @pytest.mark.parametrize("transition", [2.0, None]) -async def test_color_light(hass: HomeAssistant, transition: Optional[float]) -> None: +async def test_color_light(hass: HomeAssistant, transition: float | None) -> None: """Test a color light and that all transitions are correctly passed.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={}, unique_id=MAC_ADDRESS diff --git a/tests/components/tplink/test_migration.py b/tests/components/tplink/test_migration.py deleted file mode 100644 index a1cd581e21112b..00000000000000 --- a/tests/components/tplink/test_migration.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Test the tplink config flow.""" - -from homeassistant import setup -from homeassistant.components.tplink import CONF_DISCOVERY, CONF_SWITCH, DOMAIN -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceRegistry -from homeassistant.helpers.entity_registry import EntityRegistry - -from . import ALIAS, IP_ADDRESS, MAC_ADDRESS, _patch_discovery, _patch_single_discovery - -from tests.common import MockConfigEntry - - -async def test_migration_device_online_end_to_end( - hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry -): - """Test migration from single config entry.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) - config_entry.add_to_hass(hass) - device = device_reg.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, - name=ALIAS, - ) - switch_entity_reg = entity_reg.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="switch", - unique_id=MAC_ADDRESS, - original_name=ALIAS, - device_id=device.id, - ) - light_entity_reg = entity_reg.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="light", - unique_id=dr.format_mac(MAC_ADDRESS), - original_name=ALIAS, - device_id=device.id, - ) - power_sensor_entity_reg = entity_reg.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="sensor", - unique_id=f"{MAC_ADDRESS}_sensor", - original_name=ALIAS, - device_id=device.id, - ) - - with _patch_discovery(), _patch_single_discovery(): - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - migrated_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == DOMAIN: - migrated_entry = entry - break - - assert migrated_entry is not None - - assert device.config_entries == {migrated_entry.entry_id} - assert light_entity_reg.config_entry_id == migrated_entry.entry_id - assert switch_entity_reg.config_entry_id == migrated_entry.entry_id - assert power_sensor_entity_reg.config_entry_id == migrated_entry.entry_id - assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - legacy_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == DOMAIN: - legacy_entry = entry - break - - assert legacy_entry is None - - -async def test_migration_device_online_end_to_end_after_downgrade( - hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry -): - """Test migration from single config entry can happen again after a downgrade.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) - config_entry.add_to_hass(hass) - - already_migrated_config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS - ) - already_migrated_config_entry.add_to_hass(hass) - device = device_reg.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, - name=ALIAS, - ) - light_entity_reg = entity_reg.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="light", - unique_id=MAC_ADDRESS, - original_name=ALIAS, - device_id=device.id, - ) - power_sensor_entity_reg = entity_reg.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="sensor", - unique_id=f"{MAC_ADDRESS}_sensor", - original_name=ALIAS, - device_id=device.id, - ) - - with _patch_discovery(), _patch_single_discovery(): - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - assert device.config_entries == {config_entry.entry_id} - assert light_entity_reg.config_entry_id == config_entry.entry_id - assert power_sensor_entity_reg.config_entry_id == config_entry.entry_id - assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - legacy_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == DOMAIN: - legacy_entry = entry - break - - assert legacy_entry is None - - -async def test_migration_device_online_end_to_end_ignores_other_devices( - hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry -): - """Test migration from single config entry.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) - config_entry.add_to_hass(hass) - - other_domain_config_entry = MockConfigEntry( - domain="other_domain", data={}, unique_id="other_domain" - ) - other_domain_config_entry.add_to_hass(hass) - device = device_reg.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, - name=ALIAS, - ) - other_device = device_reg.async_get_or_create( - config_entry_id=other_domain_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "556655665566")}, - name=ALIAS, - ) - light_entity_reg = entity_reg.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="light", - unique_id=MAC_ADDRESS, - original_name=ALIAS, - device_id=device.id, - ) - power_sensor_entity_reg = entity_reg.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="sensor", - unique_id=f"{MAC_ADDRESS}_sensor", - original_name=ALIAS, - device_id=device.id, - ) - ignored_entity_reg = entity_reg.async_get_or_create( - config_entry=other_domain_config_entry, - platform=DOMAIN, - domain="sensor", - unique_id="00:00:00:00:00:00_sensor", - original_name=ALIAS, - device_id=device.id, - ) - garbage_entity_reg = entity_reg.async_get_or_create( - config_entry=config_entry, - platform=DOMAIN, - domain="sensor", - unique_id="garbage", - original_name=ALIAS, - device_id=other_device.id, - ) - - with _patch_discovery(), _patch_single_discovery(): - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - migrated_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == DOMAIN: - migrated_entry = entry - break - - assert migrated_entry is not None - - assert device.config_entries == {migrated_entry.entry_id} - assert light_entity_reg.config_entry_id == migrated_entry.entry_id - assert power_sensor_entity_reg.config_entry_id == migrated_entry.entry_id - assert ignored_entity_reg.config_entry_id == other_domain_config_entry.entry_id - assert garbage_entity_reg.config_entry_id == config_entry.entry_id - - assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - legacy_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == DOMAIN: - legacy_entry = entry - break - - assert legacy_entry is not None - - -async def test_migrate_from_yaml(hass: HomeAssistant): - """Test migrate from yaml.""" - config = { - DOMAIN: { - CONF_DISCOVERY: False, - CONF_SWITCH: [{CONF_HOST: IP_ADDRESS}], - } - } - with _patch_discovery(), _patch_single_discovery(): - await setup.async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - migrated_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == MAC_ADDRESS: - migrated_entry = entry - break - - assert migrated_entry is not None - assert migrated_entry.data[CONF_HOST] == IP_ADDRESS - - -async def test_migrate_from_legacy_entry(hass: HomeAssistant): - """Test migrate from legacy entry that was already imported from yaml.""" - data = { - CONF_DISCOVERY: False, - CONF_SWITCH: [{CONF_HOST: IP_ADDRESS}], - } - config_entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=DOMAIN) - config_entry.add_to_hass(hass) - with _patch_discovery(), _patch_single_discovery(): - await setup.async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - migrated_entry = None - for entry in hass.config_entries.async_entries(DOMAIN): - if entry.unique_id == MAC_ADDRESS: - migrated_entry = entry - break - - assert migrated_entry is not None - assert migrated_entry.data[CONF_HOST] == IP_ADDRESS diff --git a/tests/components/tradfri/common.py b/tests/components/tradfri/common.py new file mode 100644 index 00000000000000..5e28bdcd55c4e3 --- /dev/null +++ b/tests/components/tradfri/common.py @@ -0,0 +1,24 @@ +"""Common tools used for the Tradfri test suite.""" +from homeassistant.components import tradfri + +from . import GATEWAY_ID + +from tests.common import MockConfigEntry + + +async def setup_integration(hass): + """Load the Tradfri integration with a mock gateway.""" + entry = MockConfigEntry( + domain=tradfri.DOMAIN, + data={ + "host": "mock-host", + "identity": "mock-identity", + "key": "mock-key", + "import_groups": True, + "gateway_id": GATEWAY_ID, + }, + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/tradfri/test_cover.py b/tests/components/tradfri/test_cover.py new file mode 100644 index 00000000000000..663e7209619cb5 --- /dev/null +++ b/tests/components/tradfri/test_cover.py @@ -0,0 +1,154 @@ +"""Tradfri cover (recognised as blinds in the IKEA ecosystem) platform tests.""" + +from unittest.mock import MagicMock, Mock, PropertyMock, patch + +import pytest +from pytradfri.device import Device +from pytradfri.device.blind import Blind +from pytradfri.device.blind_control import BlindControl + +from .common import setup_integration + + +@pytest.fixture(autouse=True, scope="module") +def setup(request): + """Set up patches for pytradfri methods.""" + with patch( + "pytradfri.device.BlindControl.raw", + new_callable=PropertyMock, + return_value=[{"mock": "mock"}], + ), patch( + "pytradfri.device.BlindControl.blinds", + ): + yield + + +def mock_cover(test_features=None, test_state=None, device_number=0): + """Mock a tradfri cover/blind.""" + if test_features is None: + test_features = {} + if test_state is None: + test_state = {} + mock_cover_data = Mock(**test_state) + + dev_info_mock = MagicMock() + dev_info_mock.manufacturer = "manufacturer" + dev_info_mock.model_number = "model" + dev_info_mock.firmware_version = "1.2.3" + _mock_cover = Mock( + id=f"mock-cover-id-{device_number}", + reachable=True, + observe=Mock(), + device_info=dev_info_mock, + has_light_control=False, + has_socket_control=False, + has_blind_control=True, + has_signal_repeater_control=False, + has_air_purifier_control=False, + ) + _mock_cover.name = f"tradfri_cover_{device_number}" + + # Set supported features for the covers. + blind_control = BlindControl(_mock_cover) + + # Store the initial state. + setattr(blind_control, "blinds", [mock_cover_data]) + _mock_cover.blind_control = blind_control + return _mock_cover + + +async def test_cover(hass, mock_gateway, mock_api_factory): + """Test that covers are correctly added.""" + state = { + "current_cover_position": 40, + } + + mock_gateway.mock_devices.append(mock_cover(test_state=state)) + await setup_integration(hass) + + cover_1 = hass.states.get("cover.tradfri_cover_0") + assert cover_1 is not None + assert cover_1.state == "open" + assert cover_1.attributes["current_position"] == 60 + + +async def test_cover_observed(hass, mock_gateway, mock_api_factory): + """Test that covers are correctly observed.""" + state = { + "current_cover_position": 1, + } + + cover = mock_cover(test_state=state) + mock_gateway.mock_devices.append(cover) + await setup_integration(hass) + assert len(cover.observe.mock_calls) > 0 + + +async def test_cover_available(hass, mock_gateway, mock_api_factory): + """Test cover available property.""" + + cover = mock_cover(test_state={"current_cover_position": 1}, device_number=1) + cover.reachable = True + + cover2 = mock_cover(test_state={"current_cover_position": 1}, device_number=2) + cover2.reachable = False + + mock_gateway.mock_devices.append(cover) + mock_gateway.mock_devices.append(cover2) + await setup_integration(hass) + + assert hass.states.get("cover.tradfri_cover_1").state == "open" + assert hass.states.get("cover.tradfri_cover_2").state == "unavailable" + + +@pytest.mark.parametrize( + "test_data, expected_result", + [({"position": 100}, "open"), ({"position": 0}, "closed")], +) +async def test_set_cover_position( + hass, + mock_gateway, + mock_api_factory, + test_data, + expected_result, +): + """Test setting position of a cover.""" + # Note pytradfri style, not hass. Values not really important. + initial_state = { + "current_cover_position": 0, + } + + # Setup the gateway with a mock cover. + cover = mock_cover(test_state=initial_state, device_number=0) + mock_gateway.mock_devices.append(cover) + await setup_integration(hass) + + # Use the turn_on service call to change the cover state. + await hass.services.async_call( + "cover", + "set_cover_position", + {"entity_id": "cover.tradfri_cover_0", **test_data}, + blocking=True, + ) + await hass.async_block_till_done() + + # Check that the cover is observed. + mock_func = cover.observe + assert len(mock_func.mock_calls) > 0 + _, callkwargs = mock_func.call_args + assert "callback" in callkwargs + # Callback function to refresh cover state. + callback = callkwargs["callback"] + + responses = mock_gateway.mock_responses + + # Use the callback function to update the cover state. + dev = Device(responses[0]) + cover_data = Blind(dev, 0) + cover.blind_control.blinds[0] = cover_data + callback(cover) + await hass.async_block_till_done() + + # Check that the state is correct. + state = hass.states.get("cover.tradfri_cover_0") + assert state.state == expected_result diff --git a/tests/components/tradfri/test_fan.py b/tests/components/tradfri/test_fan.py new file mode 100644 index 00000000000000..13b7e59e10348d --- /dev/null +++ b/tests/components/tradfri/test_fan.py @@ -0,0 +1,162 @@ +"""Tradfri fan (recognised as air purifiers in the IKEA ecosystem) platform tests.""" + +from unittest.mock import MagicMock, Mock, PropertyMock, patch + +import pytest +from pytradfri.device import Device +from pytradfri.device.air_purifier import AirPurifier +from pytradfri.device.air_purifier_control import AirPurifierControl + +from .common import setup_integration + + +@pytest.fixture(autouse=True, scope="module") +def setup(request): + """Set up patches for pytradfri methods.""" + with patch( + "pytradfri.device.AirPurifierControl.raw", + new_callable=PropertyMock, + return_value=[{"mock": "mock"}], + ), patch( + "pytradfri.device.AirPurifierControl.air_purifiers", + ): + yield + + +def mock_fan(test_features=None, test_state=None, device_number=0): + """Mock a tradfri fan/air purifier.""" + if test_features is None: + test_features = {} + if test_state is None: + test_state = {} + mock_fan_data = Mock(**test_state) + + dev_info_mock = MagicMock() + dev_info_mock.manufacturer = "manufacturer" + dev_info_mock.model_number = "model" + dev_info_mock.firmware_version = "1.2.3" + _mock_fan = Mock( + id=f"mock-fan-id-{device_number}", + reachable=True, + observe=Mock(), + device_info=dev_info_mock, + has_light_control=False, + has_socket_control=False, + has_blind_control=False, + has_signal_repeater_control=False, + has_air_purifier_control=True, + ) + _mock_fan.name = f"tradfri_fan_{device_number}" + air_purifier_control = AirPurifierControl(_mock_fan) + + # Store the initial state. + setattr(air_purifier_control, "air_purifiers", [mock_fan_data]) + _mock_fan.air_purifier_control = air_purifier_control + return _mock_fan + + +async def test_fan(hass, mock_gateway, mock_api_factory): + """Test that fans are correctly added.""" + state = { + "fan_speed": 10, + } + + mock_gateway.mock_devices.append(mock_fan(test_state=state)) + await setup_integration(hass) + + fan_1 = hass.states.get("fan.tradfri_fan_0") + assert fan_1 is not None + assert fan_1.state == "on" + assert fan_1.attributes["percentage"] == 18 + assert fan_1.attributes["preset_modes"] == ["Auto"] + assert fan_1.attributes["supported_features"] == 9 + + +async def test_fan_observed(hass, mock_gateway, mock_api_factory): + """Test that fans are correctly observed.""" + state = { + "fan_speed": 10, + } + + fan = mock_fan(test_state=state) + mock_gateway.mock_devices.append(fan) + await setup_integration(hass) + assert len(fan.observe.mock_calls) > 0 + + +async def test_fan_available(hass, mock_gateway, mock_api_factory): + """Test fan available property.""" + + fan = mock_fan(test_state={"fan_speed": 10}, device_number=1) + fan.reachable = True + + fan2 = mock_fan(test_state={"fan_speed": 10}, device_number=2) + fan2.reachable = False + + mock_gateway.mock_devices.append(fan) + mock_gateway.mock_devices.append(fan2) + await setup_integration(hass) + + assert hass.states.get("fan.tradfri_fan_1").state == "on" + assert hass.states.get("fan.tradfri_fan_2").state == "unavailable" + + +@pytest.mark.parametrize( + "test_data, expected_result", + [ + ( + {"percentage": 50}, + "on", + ), + ({"percentage": 0}, "off"), + ], +) +async def test_set_percentage( + hass, + mock_gateway, + mock_api_factory, + test_data, + expected_result, +): + """Test setting speed of a fan.""" + # Note pytradfri style, not hass. Values not really important. + initial_state = {"percentage": 10, "fan_speed": 3} + + # Setup the gateway with a mock fan. + fan = mock_fan(test_state=initial_state, device_number=0) + mock_gateway.mock_devices.append(fan) + await setup_integration(hass) + + # Use the turn_on service call to change the fan state. + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.tradfri_fan_0", **test_data}, + blocking=True, + ) + await hass.async_block_till_done() + + # Check that the fan is observed. + mock_func = fan.observe + assert len(mock_func.mock_calls) > 0 + _, callkwargs = mock_func.call_args + assert "callback" in callkwargs + # Callback function to refresh fan state. + callback = callkwargs["callback"] + + responses = mock_gateway.mock_responses + mock_gateway_response = responses[0] + + # A KeyError is raised if we don't add the 5908 response code + mock_gateway_response["15025"][0].update({"5908": 10}) + + # Use the callback function to update the fan state. + dev = Device(mock_gateway_response) + fan_data = AirPurifier(dev, 0) + fan.air_purifier_control.air_purifiers[0] = fan_data + callback(fan) + await hass.async_block_till_done() + + # Check that the state is correct. + state = hass.states.get("fan.tradfri_fan_0") + assert state.state == expected_result diff --git a/tests/components/tradfri/test_light.py b/tests/components/tradfri/test_light.py index 85e7f8dc03707c..7de2c4dcb37fb4 100644 --- a/tests/components/tradfri/test_light.py +++ b/tests/components/tradfri/test_light.py @@ -8,11 +8,7 @@ from pytradfri.device.light import Light from pytradfri.device.light_control import LightControl -from homeassistant.components import tradfri - -from . import GATEWAY_ID - -from tests.common import MockConfigEntry +from .common import setup_integration DEFAULT_TEST_FEATURES = { "can_set_dimmer": False, @@ -100,24 +96,6 @@ async def generate_psk(self, code): return "mock" -async def setup_integration(hass): - """Load the Tradfri platform with a mock gateway.""" - entry = MockConfigEntry( - domain=tradfri.DOMAIN, - data={ - "host": "mock-host", - "identity": "mock-identity", - "key": "mock-key", - "import_groups": True, - "gateway_id": GATEWAY_ID, - }, - ) - - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - def mock_light(test_features=None, test_state=None, light_number=0): """Mock a tradfri light.""" if test_features is None: diff --git a/tests/components/tradfri/test_sensor.py b/tests/components/tradfri/test_sensor.py new file mode 100644 index 00000000000000..1e9ae718285340 --- /dev/null +++ b/tests/components/tradfri/test_sensor.py @@ -0,0 +1,71 @@ +"""Tradfri sensor platform tests.""" + +from unittest.mock import MagicMock, Mock + +from .common import setup_integration + + +def mock_sensor(state_name: str, state_value: str, device_number=0): + """Mock a tradfri sensor.""" + dev_info_mock = MagicMock() + dev_info_mock.manufacturer = "manufacturer" + dev_info_mock.model_number = "model" + dev_info_mock.firmware_version = "1.2.3" + + # Set state value, eg battery_level = 50 + setattr(dev_info_mock, state_name, state_value) + + _mock_sensor = Mock( + id=f"mock-sensor-id-{device_number}", + reachable=True, + observe=Mock(), + device_info=dev_info_mock, + has_light_control=False, + has_socket_control=False, + has_blind_control=False, + has_signal_repeater_control=False, + has_air_purifier_control=False, + ) + _mock_sensor.name = f"tradfri_sensor_{device_number}" + + return _mock_sensor + + +async def test_battery_sensor(hass, mock_gateway, mock_api_factory): + """Test that a battery sensor is correctly added.""" + mock_gateway.mock_devices.append( + mock_sensor(state_name="battery_level", state_value=60) + ) + await setup_integration(hass) + + sensor_1 = hass.states.get("sensor.tradfri_sensor_0") + assert sensor_1 is not None + assert sensor_1.state == "60" + assert sensor_1.attributes["unit_of_measurement"] == "%" + assert sensor_1.attributes["device_class"] == "battery" + + +async def test_sensor_observed(hass, mock_gateway, mock_api_factory): + """Test that sensors are correctly observed.""" + + sensor = mock_sensor(state_name="battery_level", state_value=60) + mock_gateway.mock_devices.append(sensor) + await setup_integration(hass) + assert len(sensor.observe.mock_calls) > 0 + + +async def test_sensor_available(hass, mock_gateway, mock_api_factory): + """Test sensor available property.""" + + sensor = mock_sensor(state_name="battery_level", state_value=60, device_number=1) + sensor.reachable = True + + sensor2 = mock_sensor(state_name="battery_level", state_value=60, device_number=2) + sensor2.reachable = False + + mock_gateway.mock_devices.append(sensor) + mock_gateway.mock_devices.append(sensor2) + await setup_integration(hass) + + assert hass.states.get("sensor.tradfri_sensor_1").state == "60" + assert hass.states.get("sensor.tradfri_sensor_2").state == "unavailable" diff --git a/tests/components/tradfri/test_switch.py b/tests/components/tradfri/test_switch.py new file mode 100644 index 00000000000000..11903dc9a42c40 --- /dev/null +++ b/tests/components/tradfri/test_switch.py @@ -0,0 +1,160 @@ +"""Tradfri switch (recognised as sockets in the IKEA ecosystem) platform tests.""" + +from unittest.mock import MagicMock, Mock, PropertyMock, patch + +import pytest +from pytradfri.device import Device +from pytradfri.device.socket import Socket +from pytradfri.device.socket_control import SocketControl + +from .common import setup_integration + + +@pytest.fixture(autouse=True, scope="module") +def setup(request): + """Set up patches for pytradfri methods.""" + with patch( + "pytradfri.device.SocketControl.raw", + new_callable=PropertyMock, + return_value=[{"mock": "mock"}], + ), patch( + "pytradfri.device.SocketControl.sockets", + ): + yield + + +def mock_switch(test_features=None, test_state=None, device_number=0): + """Mock a tradfri switch/socket.""" + if test_features is None: + test_features = {} + if test_state is None: + test_state = {} + mock_switch_data = Mock(**test_state) + + dev_info_mock = MagicMock() + dev_info_mock.manufacturer = "manufacturer" + dev_info_mock.model_number = "model" + dev_info_mock.firmware_version = "1.2.3" + _mock_switch = Mock( + id=f"mock-switch-id-{device_number}", + reachable=True, + observe=Mock(), + device_info=dev_info_mock, + has_light_control=False, + has_socket_control=True, + has_blind_control=False, + has_signal_repeater_control=False, + has_air_purifier_control=False, + ) + _mock_switch.name = f"tradfri_switch_{device_number}" + socket_control = SocketControl(_mock_switch) + + # Store the initial state. + setattr(socket_control, "sockets", [mock_switch_data]) + _mock_switch.socket_control = socket_control + return _mock_switch + + +async def test_switch(hass, mock_gateway, mock_api_factory): + """Test that switches are correctly added.""" + state = { + "state": True, + } + + mock_gateway.mock_devices.append(mock_switch(test_state=state)) + await setup_integration(hass) + + switch_1 = hass.states.get("switch.tradfri_switch_0") + assert switch_1 is not None + assert switch_1.state == "on" + + +async def test_switch_observed(hass, mock_gateway, mock_api_factory): + """Test that switches are correctly observed.""" + state = { + "state": True, + } + + switch = mock_switch(test_state=state) + mock_gateway.mock_devices.append(switch) + await setup_integration(hass) + assert len(switch.observe.mock_calls) > 0 + + +async def test_switch_available(hass, mock_gateway, mock_api_factory): + """Test switch available property.""" + + switch = mock_switch(test_state={"state": True}, device_number=1) + switch.reachable = True + + switch2 = mock_switch(test_state={"state": True}, device_number=2) + switch2.reachable = False + + mock_gateway.mock_devices.append(switch) + mock_gateway.mock_devices.append(switch2) + await setup_integration(hass) + + assert hass.states.get("switch.tradfri_switch_1").state == "on" + assert hass.states.get("switch.tradfri_switch_2").state == "unavailable" + + +@pytest.mark.parametrize( + "test_data, expected_result", + [ + ( + "turn_on", + "on", + ), + ("turn_off", "off"), + ], +) +async def test_turn_on_off( + hass, + mock_gateway, + mock_api_factory, + test_data, + expected_result, +): + """Test turning switch on/off.""" + # Note pytradfri style, not hass. Values not really important. + initial_state = { + "state": True, + } + + # Setup the gateway with a mock switch. + switch = mock_switch(test_state=initial_state, device_number=0) + mock_gateway.mock_devices.append(switch) + await setup_integration(hass) + + # Use the turn_on/turn_off service call to change the switch state. + await hass.services.async_call( + "switch", + test_data, + { + "entity_id": "switch.tradfri_switch_0", + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Check that the switch is observed. + mock_func = switch.observe + assert len(mock_func.mock_calls) > 0 + _, callkwargs = mock_func.call_args + assert "callback" in callkwargs + # Callback function to refresh switch state. + callback = callkwargs["callback"] + + responses = mock_gateway.mock_responses + mock_gateway_response = responses[0] + + # Use the callback function to update the switch state. + dev = Device(mock_gateway_response) + switch_data = Socket(dev, 0) + switch.socket_control.sockets[0] = switch_data + callback(switch) + await hass.async_block_till_done() + + # Check that the state is correct. + state = hass.states.get("switch.tradfri_switch_0") + assert state.state == expected_result diff --git a/tests/components/tradfri/test_util.py b/tests/components/tradfri/test_util.py index 3dbdf801f893ee..67d5b95f5d8c57 100644 --- a/tests/components/tradfri/test_util.py +++ b/tests/components/tradfri/test_util.py @@ -1,22 +1,31 @@ """Tradfri utility function tests.""" +import pytest -from homeassistant.components.tradfri.fan import _from_fan_speed, _from_percentage +from homeassistant.components.tradfri.fan import _from_fan_percentage, _from_fan_speed -def test_from_fan_speed(): +@pytest.mark.parametrize( + "fan_speed, expected_result", + [ + (0, 0), + (2, 2), + (25, 49), + (50, 100), + ], +) +def test_from_fan_speed(fan_speed, expected_result): """Test that we can convert fan speed to percentage value.""" - assert _from_fan_speed(41) == 80 - - -def test_from_percentage(): + assert _from_fan_speed(fan_speed) == expected_result + + +@pytest.mark.parametrize( + "percentage, expected_result", + [ + (1, 2), + (100, 50), + (50, 26), + ], +) +def test_from_percentage(percentage, expected_result): """Test that we can convert percentage value to fan speed.""" - assert _from_percentage(84) == 40 - - -def test_from_percentage_limit(): - """ - Test that we can convert percentage value to fan speed. - - Handle special case of percent value being below 20. - """ - assert _from_percentage(10) == 0 + assert _from_fan_percentage(percentage) == expected_result diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index 4716762d2e7752..f7ccee97b29d0f 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -4,7 +4,7 @@ from homeassistant import config as hass_config, setup from homeassistant.components.trend import DOMAIN -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN import homeassistant.util.dt as dt_util from tests.common import ( @@ -307,7 +307,7 @@ def test_non_numeric(self): self.hass.states.set("sensor.test_state", "Numeric") self.hass.block_till_done() state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" + assert state.state == STATE_UNKNOWN def test_missing_attribute(self): """Test attribute down trend.""" @@ -333,7 +333,7 @@ def test_missing_attribute(self): self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) self.hass.block_till_done() state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" + assert state.state == STATE_UNKNOWN def test_invalid_name_does_not_create(self): """Test invalid name.""" diff --git a/tests/components/twentemilieu/test_diagnostics.py b/tests/components/twentemilieu/test_diagnostics.py new file mode 100644 index 00000000000000..2f5ffcd5eb96c8 --- /dev/null +++ b/tests/components/twentemilieu/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Tests for the diagnostics data provided by the TwenteMilieu integration.""" +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "0": "2021-11-01", + "1": "2021-11-02", + "2": None, + "6": "2022-01-06", + "10": "2021-11-03", + } diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index 96f9f450b8a7bb..d5440ddb74a7d2 100644 --- a/tests/components/twinkly/__init__.py +++ b/tests/components/twinkly/__init__.py @@ -14,13 +14,14 @@ class ClientMock: - """A mock of the twinkly_client.TwinklyClient.""" + """A mock of the ttls.client.Twinkly.""" def __init__(self) -> None: """Create a mocked client.""" self.is_offline = False - self.is_on = True - self.brightness = 10 + self.state = True + self.brightness = {"mode": "enabled", "value": 10} + self.color = None self.id = str(uuid4()) self.device_info = { @@ -34,23 +35,29 @@ def host(self) -> str: """Get the mocked host.""" return TEST_HOST - async def get_device_info(self): + async def get_details(self): """Get the mocked device info.""" if self.is_offline: raise ClientConnectionError() return self.device_info - async def get_is_on(self) -> bool: + async def is_on(self) -> bool: """Get the mocked on/off state.""" if self.is_offline: raise ClientConnectionError() - return self.is_on + return self.state - async def set_is_on(self, is_on: bool) -> None: - """Set the mocked on/off state.""" + async def turn_on(self) -> None: + """Set the mocked on state.""" if self.is_offline: raise ClientConnectionError() - self.is_on = is_on + self.state = True + + async def turn_off(self) -> None: + """Set the mocked off state.""" + if self.is_offline: + raise ClientConnectionError() + self.state = False async def get_brightness(self) -> int: """Get the mocked brightness.""" @@ -62,8 +69,15 @@ async def set_brightness(self, brightness: int) -> None: """Set the mocked brightness.""" if self.is_offline: raise ClientConnectionError() - self.brightness = brightness + self.brightness = {"mode": "enabled", "value": brightness} def change_name(self, new_name: str) -> None: """Change the name of this virtual device.""" self.device_info[DEV_NAME] = new_name + + async def set_static_colour(self, colour) -> None: + """Set static color.""" + self.color = colour + + async def interview(self) -> None: + """Interview.""" diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 46566bdf54b6cd..e29bd1c3fc1856 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -1,24 +1,28 @@ """Tests for the config_flow of the twinly component.""" - from unittest.mock import patch from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.components.twinkly.const import ( - CONF_ENTRY_HOST, - CONF_ENTRY_ID, - CONF_ENTRY_MODEL, - CONF_ENTRY_NAME, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, DOMAIN as TWINKLY_DOMAIN, ) from . import TEST_MODEL, ClientMock +from tests.common import MockConfigEntry + async def test_invalid_host(hass): """Test the failure when invalid host provided.""" client = ClientMock() client.is_offline = True - with patch("twinkly_client.TwinklyClient", return_value=client): + with patch( + "homeassistant.components.twinkly.config_flow.Twinkly", return_value=client + ): result = await hass.config_entries.flow.async_init( TWINKLY_DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -27,18 +31,20 @@ async def test_invalid_host(hass): assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_ENTRY_HOST: "dummy"}, + {CONF_HOST: "dummy"}, ) assert result["type"] == "form" assert result["step_id"] == "user" - assert result["errors"] == {CONF_ENTRY_HOST: "cannot_connect"} + assert result["errors"] == {CONF_HOST: "cannot_connect"} async def test_success_flow(hass): """Test that an entity is created when the flow completes.""" client = ClientMock() - with patch("twinkly_client.TwinklyClient", return_value=client): + with patch( + "homeassistant.components.twinkly.config_flow.Twinkly", return_value=client + ), patch("homeassistant.components.twinkly.async_setup_entry", return_value=True): result = await hass.config_entries.flow.async_init( TWINKLY_DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -49,14 +55,101 @@ async def test_success_flow(hass): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_ENTRY_HOST: "dummy"}, + {CONF_HOST: "dummy"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == client.id + assert result["data"] == { + CONF_HOST: "dummy", + CONF_ID: client.id, + CONF_NAME: client.id, + CONF_MODEL: TEST_MODEL, + } + + +async def test_dhcp_can_confirm(hass): + """Test DHCP discovery flow can confirm right away.""" + client = ClientMock() + with patch( + "homeassistant.components.twinkly.config_flow.Twinkly", return_value=client + ): + result = await hass.config_entries.flow.async_init( + TWINKLY_DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="Twinkly_XYZ", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["step_id"] == "discovery_confirm" + + +async def test_dhcp_success(hass): + """Test DHCP discovery flow success.""" + client = ClientMock() + with patch( + "homeassistant.components.twinkly.config_flow.Twinkly", return_value=client + ), patch("homeassistant.components.twinkly.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + TWINKLY_DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="Twinkly_XYZ", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "create_entry" assert result["title"] == client.id assert result["data"] == { - CONF_ENTRY_HOST: "dummy", - CONF_ENTRY_ID: client.id, - CONF_ENTRY_NAME: client.id, - CONF_ENTRY_MODEL: TEST_MODEL, + CONF_HOST: "1.2.3.4", + CONF_ID: client.id, + CONF_NAME: client.id, + CONF_MODEL: TEST_MODEL, } + + +async def test_dhcp_already_exists(hass): + """Test DHCP discovery flow that fails to connect.""" + client = ClientMock() + + entry = MockConfigEntry( + domain=TWINKLY_DOMAIN, + data={ + CONF_HOST: "1.2.3.4", + CONF_ID: client.id, + CONF_NAME: client.id, + CONF_MODEL: TEST_MODEL, + }, + unique_id=client.id, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.twinkly.config_flow.Twinkly", return_value=client + ): + result = await hass.config_entries.flow.async_init( + TWINKLY_DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="Twinkly_XYZ", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/twinkly/test_init.py b/tests/components/twinkly/test_init.py index 3f55d2ffdf0131..573bf5fbc86abd 100644 --- a/tests/components/twinkly/test_init.py +++ b/tests/components/twinkly/test_init.py @@ -3,65 +3,71 @@ from unittest.mock import patch from uuid import uuid4 -from homeassistant.components.twinkly import async_setup_entry, async_unload_entry from homeassistant.components.twinkly.const import ( - CONF_ENTRY_HOST, - CONF_ENTRY_ID, - CONF_ENTRY_MODEL, - CONF_ENTRY_NAME, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, DOMAIN as TWINKLY_DOMAIN, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -from tests.components.twinkly import TEST_HOST, TEST_MODEL, TEST_NAME_ORIGINAL +from tests.components.twinkly import ( + TEST_HOST, + TEST_MODEL, + TEST_NAME_ORIGINAL, + ClientMock, +) -async def test_setup_entry(hass: HomeAssistant): +async def test_load_unload_entry(hass: HomeAssistant): """Validate that setup entry also configure the client.""" + client = ClientMock() id = str(uuid4()) config_entry = MockConfigEntry( domain=TWINKLY_DOMAIN, data={ - CONF_ENTRY_HOST: TEST_HOST, - CONF_ENTRY_ID: id, - CONF_ENTRY_NAME: TEST_NAME_ORIGINAL, - CONF_ENTRY_MODEL: TEST_MODEL, + CONF_HOST: TEST_HOST, + CONF_ID: id, + CONF_NAME: TEST_NAME_ORIGINAL, + CONF_MODEL: TEST_MODEL, }, entry_id=id, ) - def setup_mock(_, __): - return True + config_entry.add_to_hass(hass) - with patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", - side_effect=setup_mock, - ): - await async_setup_entry(hass, config_entry) + with patch("homeassistant.components.twinkly.Twinkly", return_value=client): + await hass.config_entries.async_setup(config_entry.entry_id) - assert hass.data[TWINKLY_DOMAIN][id] is not None + assert config_entry.state == ConfigEntryState.LOADED + await hass.config_entries.async_unload(config_entry.entry_id) -async def test_unload_entry(hass: HomeAssistant): - """Validate that unload entry also clear the client.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready(hass: HomeAssistant): + """Validate that config entry is retried.""" + client = ClientMock() + client.is_offline = True - id = str(uuid4()) config_entry = MockConfigEntry( domain=TWINKLY_DOMAIN, data={ - CONF_ENTRY_HOST: TEST_HOST, - CONF_ENTRY_ID: id, - CONF_ENTRY_NAME: TEST_NAME_ORIGINAL, - CONF_ENTRY_MODEL: TEST_MODEL, + CONF_HOST: TEST_HOST, + CONF_ID: id, + CONF_NAME: TEST_NAME_ORIGINAL, + CONF_MODEL: TEST_MODEL, }, - entry_id=id, ) - # Put random content at the location where the client should have been placed by setup - hass.data.setdefault(TWINKLY_DOMAIN, {})[id] = config_entry + config_entry.add_to_hass(hass) - await async_unload_entry(hass, config_entry) + with patch("homeassistant.components.twinkly.Twinkly", return_value=client): + await hass.config_entries.async_setup(config_entry.entry_id) - assert hass.data[TWINKLY_DOMAIN].get(id) is None + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/twinkly/test_twinkly.py b/tests/components/twinkly/test_light.py similarity index 58% rename from tests/components/twinkly/test_twinkly.py rename to tests/components/twinkly/test_light.py index fcbbdb035c7a67..7072d7c2eecbc1 100644 --- a/tests/components/twinkly/test_twinkly.py +++ b/tests/components/twinkly/test_light.py @@ -3,58 +3,33 @@ from unittest.mock import patch +from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.twinkly.const import ( - CONF_ENTRY_HOST, - CONF_ENTRY_ID, - CONF_ENTRY_MODEL, - CONF_ENTRY_NAME, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, DOMAIN as TWINKLY_DOMAIN, ) -from homeassistant.components.twinkly.light import TwinklyLight from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry from tests.common import MockConfigEntry -from tests.components.twinkly import ( - TEST_HOST, - TEST_ID, - TEST_MODEL, - TEST_NAME_ORIGINAL, - ClientMock, -) - - -async def test_missing_client(hass: HomeAssistant): - """Validate that if client has not been setup, it fails immediately in setup.""" - try: - config_entry = MockConfigEntry( - data={ - CONF_ENTRY_HOST: TEST_HOST, - CONF_ENTRY_ID: TEST_ID, - CONF_ENTRY_NAME: TEST_NAME_ORIGINAL, - CONF_ENTRY_MODEL: TEST_MODEL, - } - ) - TwinklyLight(config_entry, hass) - except ValueError: - return - - assert False +from tests.components.twinkly import TEST_MODEL, TEST_NAME_ORIGINAL, ClientMock async def test_initial_state(hass: HomeAssistant): """Validate that entity and device states are updated on startup.""" - entity, device, _ = await _create_entries(hass) + entity, device, _, _ = await _create_entries(hass) state = hass.states.get(entity.entity_id) # Basic state properties assert state.name == entity.unique_id assert state.state == "on" - assert state.attributes["host"] == TEST_HOST - assert state.attributes["brightness"] == 26 + assert state.attributes[ATTR_BRIGHTNESS] == 26 assert state.attributes["friendly_name"] == entity.unique_id assert state.attributes["icon"] == "mdi:string-lights" @@ -69,72 +44,108 @@ async def test_initial_state(hass: HomeAssistant): assert device.manufacturer == "LEDWORKS" -async def test_initial_state_offline(hass: HomeAssistant): - """Validate that entity and device are restored from config is offline on startup.""" +async def test_turn_on_off(hass: HomeAssistant): + """Test support of the light.turn_on service.""" client = ClientMock() - client.is_offline = True - entity, device, _ = await _create_entries(hass, client) + client.state = False + client.brightness = {"mode": "enabled", "value": 20} + entity, _, _, _ = await _create_entries(hass, client) + + assert hass.states.get(entity.entity_id).state == "off" + + await hass.services.async_call( + "light", "turn_on", service_data={"entity_id": entity.entity_id} + ) + await hass.async_block_till_done() state = hass.states.get(entity.entity_id) - assert state.name == TEST_NAME_ORIGINAL - assert state.state == "unavailable" - assert state.attributes["friendly_name"] == TEST_NAME_ORIGINAL - assert state.attributes["icon"] == "mdi:string-lights" + assert state.state == "on" + assert state.attributes[ATTR_BRIGHTNESS] == 51 - assert entity.original_name == TEST_NAME_ORIGINAL - assert entity.original_icon == "mdi:string-lights" - assert device.name == TEST_NAME_ORIGINAL - assert device.model == TEST_MODEL - assert device.manufacturer == "LEDWORKS" +async def test_turn_on_with_brightness(hass: HomeAssistant): + """Test support of the light.turn_on service with a brightness parameter.""" + client = ClientMock() + client.state = False + client.brightness = {"mode": "enabled", "value": 20} + entity, _, _, _ = await _create_entries(hass, client) + assert hass.states.get(entity.entity_id).state == "off" -async def test_turn_on(hass: HomeAssistant): - """Test support of the light.turn_on service.""" + await hass.services.async_call( + "light", + "turn_on", + service_data={"entity_id": entity.entity_id, "brightness": 255}, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + + assert state.state == "on" + assert state.attributes[ATTR_BRIGHTNESS] == 255 + + await hass.services.async_call( + "light", + "turn_on", + service_data={"entity_id": entity.entity_id, "brightness": 1}, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + + assert state.state == "off" + + +async def test_turn_on_with_color_rgbw(hass: HomeAssistant): + """Test support of the light.turn_on service with a brightness parameter.""" client = ClientMock() - client.is_on = False - client.brightness = 20 - entity, _, _ = await _create_entries(hass, client) + client.state = False + client.device_info["led_profile"] = "RGBW" + client.brightness = {"mode": "enabled", "value": 255} + entity, _, _, _ = await _create_entries(hass, client) assert hass.states.get(entity.entity_id).state == "off" await hass.services.async_call( - "light", "turn_on", service_data={"entity_id": entity.entity_id} + "light", + "turn_on", + service_data={"entity_id": entity.entity_id, "rgbw_color": (128, 64, 32, 0)}, ) await hass.async_block_till_done() state = hass.states.get(entity.entity_id) assert state.state == "on" - assert state.attributes["brightness"] == 51 + assert client.color == (0, 128, 64, 32) -async def test_turn_on_with_brightness(hass: HomeAssistant): +async def test_turn_on_with_color_rgb(hass: HomeAssistant): """Test support of the light.turn_on service with a brightness parameter.""" client = ClientMock() - client.is_on = False - client.brightness = 20 - entity, _, _ = await _create_entries(hass, client) + client.state = False + client.device_info["led_profile"] = "RGB" + client.brightness = {"mode": "enabled", "value": 255} + entity, _, _, _ = await _create_entries(hass, client) assert hass.states.get(entity.entity_id).state == "off" await hass.services.async_call( "light", "turn_on", - service_data={"entity_id": entity.entity_id, "brightness": 255}, + service_data={"entity_id": entity.entity_id, "rgb_color": (128, 64, 32)}, ) await hass.async_block_till_done() state = hass.states.get(entity.entity_id) assert state.state == "on" - assert state.attributes["brightness"] == 255 + assert client.color == (128, 64, 32) async def test_turn_off(hass: HomeAssistant): """Test support of the light.turn_off service.""" - entity, _, _ = await _create_entries(hass) + entity, _, _, _ = await _create_entries(hass) assert hass.states.get(entity.entity_id).state == "on" @@ -146,7 +157,6 @@ async def test_turn_off(hass: HomeAssistant): state = hass.states.get(entity.entity_id) assert state.state == "off" - assert state.attributes["brightness"] == 0 async def test_update_name(hass: HomeAssistant): @@ -157,15 +167,7 @@ async def test_update_name(hass: HomeAssistant): then the name of the entity is updated and it's also persisted, so it can be restored when starting HA while Twinkly is offline. """ - entity, _, client = await _create_entries(hass) - - updated_config_entry = None - - async def on_update(ha, co): - nonlocal updated_config_entry - updated_config_entry = co - - hass.config_entries.async_get_entry(entity.unique_id).add_update_listener(on_update) + entity, _, client, config_entry = await _create_entries(hass) client.change_name("new_device_name") await hass.services.async_call( @@ -175,15 +177,14 @@ async def on_update(ha, co): state = hass.states.get(entity.entity_id) - assert updated_config_entry is not None - assert updated_config_entry.data[CONF_ENTRY_NAME] == "new_device_name" + assert config_entry.data[CONF_NAME] == "new_device_name" assert state.attributes["friendly_name"] == "new_device_name" async def test_unload(hass: HomeAssistant): """Validate that entities can be unloaded from the UI.""" - _, _, client = await _create_entries(hass) + _, _, client, _ = await _create_entries(hass) entry_id = client.id assert await hass.config_entries.async_unload(entry_id) @@ -194,17 +195,14 @@ async def _create_entries( ) -> tuple[RegistryEntry, DeviceEntry, ClientMock]: client = ClientMock() if client is None else client - def get_client_mock(client, _): - return client - - with patch("twinkly_client.TwinklyClient", side_effect=get_client_mock): + with patch("homeassistant.components.twinkly.Twinkly", return_value=client): config_entry = MockConfigEntry( domain=TWINKLY_DOMAIN, data={ - CONF_ENTRY_HOST: client, - CONF_ENTRY_ID: client.id, - CONF_ENTRY_NAME: TEST_NAME_ORIGINAL, - CONF_ENTRY_MODEL: TEST_MODEL, + CONF_HOST: client, + CONF_ID: client.id, + CONF_NAME: TEST_NAME_ORIGINAL, + CONF_MODEL: TEST_MODEL, }, entry_id=client.id, ) @@ -216,10 +214,10 @@ def get_client_mock(client, _): entity_registry = er.async_get(hass) entity_id = entity_registry.async_get_entity_id("light", TWINKLY_DOMAIN, client.id) - entity = entity_registry.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) device = device_registry.async_get_device({(TWINKLY_DOMAIN, client.id)}) - assert entity is not None + assert entity_entry is not None assert device is not None - return entity, device, client + return entity_entry, device, client, config_entry diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 42e9db6b958b26..e21c458386fdd4 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -6,6 +6,10 @@ from aiounifi.websocket import SIGNAL_CONNECTION_STATE, SIGNAL_DATA import pytest +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def mock_unifi_websocket(): @@ -34,3 +38,27 @@ def mock_discovery(): return_value=None, ) as mock: yield mock + + +@pytest.fixture +def mock_device_registry(hass): + """Mock device registry.""" + dev_reg = dr.async_get(hass) + config_entry = MockConfigEntry(domain="something_else") + + for idx, device in enumerate( + ( + "00:00:00:00:00:01", + "00:00:00:00:00:02", + "00:00:00:00:00:03", + "00:00:00:00:00:04", + "00:00:00:00:00:05", + "00:00:00:00:01:01", + "00:00:00:00:02:02", + ) + ): + dev_reg.async_get_or_create( + name=f"Device {idx}", + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, device)}, + ) diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 8e0e687345dd34..4c4ff7006fd264 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -562,6 +562,15 @@ async def test_form_ssdp(hass): assert result["type"] == "form" assert result["step_id"] == "user" assert result["errors"] == {} + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + assert ( + flows[0].get("context", {}).get("configuration_url") + == "https://192.168.208.1:443" + ) + context = next( flow["context"] for flow in hass.config_entries.flow.async_progress() diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 738cb28e1e3dc2..8a41ada9b6234b 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -346,7 +346,9 @@ async def test_reset_fails(hass, aioclient_mock): assert result is False -async def test_connection_state_signalling(hass, aioclient_mock, mock_unifi_websocket): +async def test_connection_state_signalling( + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry +): """Verify connection statesignalling and connection state are working.""" client = { "hostname": "client", @@ -471,49 +473,22 @@ async def test_get_controller_verify_ssl_false(hass): assert await get_controller(hass, **controller_data) -async def test_get_controller_login_failed(hass): - """Check that get_controller can handle a failed login.""" - with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch( - "aiounifi.Controller.login", side_effect=aiounifi.Unauthorized - ), pytest.raises(AuthenticationRequired): - await get_controller(hass, **CONTROLLER_DATA) - - -async def test_get_controller_controller_bad_gateway(hass): - """Check that get_controller can handle controller being unavailable.""" - with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch( - "aiounifi.Controller.login", side_effect=aiounifi.BadGateway - ), pytest.raises(CannotConnect): - await get_controller(hass, **CONTROLLER_DATA) - - -async def test_get_controller_controller_service_unavailable(hass): - """Check that get_controller can handle controller being unavailable.""" - with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch( - "aiounifi.Controller.login", side_effect=aiounifi.ServiceUnavailable - ), pytest.raises(CannotConnect): - await get_controller(hass, **CONTROLLER_DATA) - - -async def test_get_controller_controller_unavailable(hass): +@pytest.mark.parametrize( + "side_effect,raised_exception", + [ + (asyncio.TimeoutError, CannotConnect), + (aiounifi.BadGateway, CannotConnect), + (aiounifi.ServiceUnavailable, CannotConnect), + (aiounifi.RequestError, CannotConnect), + (aiounifi.ResponseError, CannotConnect), + (aiounifi.Unauthorized, AuthenticationRequired), + (aiounifi.LoginRequired, AuthenticationRequired), + (aiounifi.AiounifiException, AuthenticationRequired), + ], +) +async def test_get_controller_fails_to_connect(hass, side_effect, raised_exception): """Check that get_controller can handle controller being unavailable.""" with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch( - "aiounifi.Controller.login", side_effect=aiounifi.RequestError - ), pytest.raises(CannotConnect): - await get_controller(hass, **CONTROLLER_DATA) - - -async def test_get_controller_login_required(hass): - """Check that get_controller can handle unknown errors.""" - with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch( - "aiounifi.Controller.login", side_effect=aiounifi.LoginRequired - ), pytest.raises(AuthenticationRequired): - await get_controller(hass, **CONTROLLER_DATA) - - -async def test_get_controller_unknown_error(hass): - """Check that get_controller can handle unknown errors.""" - with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch( - "aiounifi.Controller.login", side_effect=aiounifi.AiounifiException - ), pytest.raises(AuthenticationRequired): + "aiounifi.Controller.login", side_effect=side_effect + ), pytest.raises(raised_exception): await get_controller(hass, **CONTROLLER_DATA) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 4014062ee27f8a..b490d43fffdf5f 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -3,12 +3,7 @@ from datetime import timedelta from unittest.mock import patch -from aiounifi.controller import ( - MESSAGE_CLIENT, - MESSAGE_CLIENT_REMOVED, - MESSAGE_DEVICE, - MESSAGE_EVENT, -) +from aiounifi.controller import MESSAGE_CLIENT, MESSAGE_CLIENT_REMOVED, MESSAGE_DEVICE from aiounifi.websocket import STATE_DISCONNECTED, STATE_RUNNING from homeassistant import config_entries @@ -23,7 +18,7 @@ DOMAIN as UNIFI_DOMAIN, ) from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .test_controller import ENTRY_CONFIG, setup_unifi_integration @@ -38,7 +33,9 @@ async def test_no_entities(hass, aioclient_mock): assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 0 -async def test_tracked_wireless_clients(hass, aioclient_mock, mock_unifi_websocket): +async def test_tracked_wireless_clients( + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry +): """Verify tracking of wireless clients.""" client = { "ap_mac": "00:00:00:00:02:01", @@ -68,35 +65,19 @@ async def test_tracked_wireless_clients(hass, aioclient_mock, mock_unifi_websock await hass.async_block_till_done() client_state = hass.states.get("device_tracker.client") - assert client_state.state == STATE_HOME + assert client_state.state == STATE_NOT_HOME assert client_state.attributes["ip"] == "10.0.0.1" assert client_state.attributes["mac"] == "00:00:00:00:00:01" assert client_state.attributes["hostname"] == "client" assert client_state.attributes["host_name"] == "client" - # State change signalling works with events - - # Disconnected event - - event = { - "user": client["mac"], - "ssid": client["essid"], - "hostname": client["hostname"], - "ap": client["ap_mac"], - "duration": 467, - "bytes": 459039, - "key": "EVT_WU_Disconnected", - "subsystem": "wlan", - "site_id": "name", - "time": 1587752927000, - "datetime": "2020-04-24T18:28:47Z", - "msg": f'User{[client["mac"]]} disconnected from "{client["essid"]}" (7m 47s connected, 448.28K bytes, last AP[{client["ap_mac"]}])', - "_id": "5ea32ff730c49e00f90dca1a", - } + # Updated timestamp marks client as home + + client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) mock_unifi_websocket( data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [event], + "meta": {"message": MESSAGE_CLIENT}, + "data": [client], } ) await hass.async_block_till_done() @@ -112,12 +93,7 @@ async def test_tracked_wireless_clients(hass, aioclient_mock, mock_unifi_websock assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME - # To limit false positives in client tracker - # data sources other than events are only used to update state - # until the first event has been received. - # This control will be reset if controller connection has been lost. - - # New data doesn't change state + # Same timestamp again means client is away mock_unifi_websocket( data={ @@ -129,35 +105,10 @@ async def test_tracked_wireless_clients(hass, aioclient_mock, mock_unifi_websock assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME - # Connected event - - event = { - "user": client["mac"], - "ssid": client["essid"], - "ap": client["ap_mac"], - "radio": "na", - "channel": "44", - "hostname": client["hostname"], - "key": "EVT_WU_Connected", - "subsystem": "wlan", - "site_id": "name", - "time": 1587753456179, - "datetime": "2020-04-24T18:37:36Z", - "msg": f'User{[client["mac"]]} has connected to AP[{client["ap_mac"]}] with SSID "{client["essid"]}" on "channel 44(na)"', - "_id": "5ea331fa30c49e00f90ddc1a", - } - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [event], - } - ) - await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME - - -async def test_tracked_clients(hass, aioclient_mock, mock_unifi_websocket): +async def test_tracked_clients( + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry +): """Test the update_items function with some clients.""" client_1 = { "ap_mac": "00:00:00:00:02:01", @@ -223,6 +174,7 @@ async def test_tracked_clients(hass, aioclient_mock, mock_unifi_websocket): # State change signalling works + client_1["last_seen"] += 1 mock_unifi_websocket( data={ "meta": {"message": MESSAGE_CLIENT}, @@ -234,7 +186,9 @@ async def test_tracked_clients(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("device_tracker.client_1").state == STATE_HOME -async def test_tracked_devices(hass, aioclient_mock, mock_unifi_websocket): +async def test_tracked_devices( + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry +): """Test the update_items function with some devices.""" device_1 = { "board_rev": 3, @@ -321,45 +275,10 @@ async def test_tracked_devices(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("device_tracker.device_1").state == STATE_UNAVAILABLE assert hass.states.get("device_tracker.device_2").state == STATE_HOME - # Update device registry when device is upgraded - - event = { - "_id": "5eae7fe02ab79c00f9d38960", - "datetime": "2020-05-09T20:06:37Z", - "key": "EVT_SW_Upgraded", - "msg": f'Switch[{device_2["mac"]}] was upgraded from "{device_2["version"]}" to "4.3.13.11253"', - "subsystem": "lan", - "sw": device_2["mac"], - "sw_name": device_2["name"], - "time": 1589054797635, - "version_from": {device_2["version"]}, - "version_to": "4.3.13.11253", - } - - device_2["version"] = event["version_to"] - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_DEVICE}, - "data": [device_2], - } - ) - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [event], - } - ) - await hass.async_block_till_done() - - # Verify device registry has been updated - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("device_tracker.device_2") - device_registry = dr.async_get(hass) - device = device_registry.async_get(entry.device_id) - assert device.sw_version == event["version_to"] - -async def test_remove_clients(hass, aioclient_mock, mock_unifi_websocket): +async def test_remove_clients( + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry +): """Test the remove_items function with some clients.""" client_1 = { "essid": "ssid", @@ -398,50 +317,9 @@ async def test_remove_clients(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("device_tracker.client_2") -async def test_remove_client_but_keep_device_entry( - hass, aioclient_mock, mock_unifi_websocket +async def test_controller_state_change( + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry ): - """Test that unifi entity base remove config entry id from a multi integration device registry entry.""" - client_1 = { - "essid": "ssid", - "hostname": "client_1", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - await setup_unifi_integration(hass, aioclient_mock, clients_response=[client_1]) - - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id="other", - connections={("mac", "00:00:00:00:00:01")}, - ) - - entity_registry = er.async_get(hass) - other_entity = entity_registry.async_get_or_create( - TRACKER_DOMAIN, - "other", - "unique_id", - device_id=device_entry.id, - ) - assert len(device_entry.config_entries) == 2 - - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT_REMOVED}, - "data": [client_1], - } - ) - await hass.async_block_till_done() - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 0 - - device_entry = device_registry.async_get(other_entity.device_id) - assert len(device_entry.config_entries) == 1 - - -async def test_controller_state_change(hass, aioclient_mock, mock_unifi_websocket): """Verify entities state reflect on controller becoming unavailable.""" client = { "essid": "ssid", @@ -494,92 +372,7 @@ async def test_controller_state_change(hass, aioclient_mock, mock_unifi_websocke assert hass.states.get("device_tracker.device").state == STATE_HOME -async def test_controller_state_change_client_to_listen_on_all_state_changes( - hass, aioclient_mock, mock_unifi_websocket -): - """Verify entities state reflect on controller becoming unavailable.""" - client = { - "ap_mac": "00:00:00:00:02:01", - "essid": "ssid", - "hostname": "client", - "ip": "10.0.0.1", - "is_wired": False, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:01", - } - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_response=[client] - ) - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 - assert hass.states.get("device_tracker.client").state == STATE_HOME - - # Disconnected event - - event = { - "user": client["mac"], - "ssid": client["essid"], - "hostname": client["hostname"], - "ap": client["ap_mac"], - "duration": 467, - "bytes": 459039, - "key": "EVT_WU_Disconnected", - "subsystem": "wlan", - "site_id": "name", - "time": 1587752927000, - "datetime": "2020-04-24T18:28:47Z", - "msg": f'User{[client["mac"]]} disconnected from "{client["essid"]}" (7m 47s connected, 448.28K bytes, last AP[{client["ap_mac"]}])', - "_id": "5ea32ff730c49e00f90dca1a", - } - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_EVENT}, - "data": [event], - } - ) - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.client").state == STATE_HOME - - # Change time to mark client as away - - new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME - - # Controller unavailable - mock_unifi_websocket(state=STATE_DISCONNECTED) - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.client").state == STATE_UNAVAILABLE - - # Controller available - mock_unifi_websocket(state=STATE_RUNNING) - await hass.async_block_till_done() - - # To limit false positives in client tracker - # data sources other than events are only used to update state - # until the first event has been received. - # This control will be reset if controller connection has been lost. - - # New data can change state - - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client], - } - ) - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.client").state == STATE_HOME - - -async def test_option_track_clients(hass, aioclient_mock): +async def test_option_track_clients(hass, aioclient_mock, mock_device_registry): """Test the tracking of clients can be turned off.""" wireless_client = { "essid": "ssid", @@ -645,7 +438,7 @@ async def test_option_track_clients(hass, aioclient_mock): assert hass.states.get("device_tracker.device") -async def test_option_track_wired_clients(hass, aioclient_mock): +async def test_option_track_wired_clients(hass, aioclient_mock, mock_device_registry): """Test the tracking of wired clients can be turned off.""" wireless_client = { "essid": "ssid", @@ -711,7 +504,7 @@ async def test_option_track_wired_clients(hass, aioclient_mock): assert hass.states.get("device_tracker.device") -async def test_option_track_devices(hass, aioclient_mock): +async def test_option_track_devices(hass, aioclient_mock, mock_device_registry): """Test the tracking of devices can be turned off.""" client = { "hostname": "client", @@ -764,7 +557,9 @@ async def test_option_track_devices(hass, aioclient_mock): assert hass.states.get("device_tracker.device") -async def test_option_ssid_filter(hass, aioclient_mock, mock_unifi_websocket): +async def test_option_ssid_filter( + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry +): """Test the SSID filter works. Client will travel from a supported SSID to an unsupported ssid. @@ -839,6 +634,8 @@ async def test_option_ssid_filter(hass, aioclient_mock, mock_unifi_websocket): ) await hass.async_block_till_done() + client["last_seen"] += 1 + client_on_ssid2["last_seen"] += 1 mock_unifi_websocket( data={ "meta": {"message": MESSAGE_CLIENT}, @@ -865,6 +662,7 @@ async def test_option_ssid_filter(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME + client_on_ssid2["last_seen"] += 1 mock_unifi_websocket( data={ "meta": {"message": MESSAGE_CLIENT}, @@ -877,6 +675,7 @@ async def test_option_ssid_filter(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("device_tracker.client_on_ssid2").state == STATE_HOME # Trigger update to get client marked as away + client_on_ssid2["last_seen"] += 1 mock_unifi_websocket( data={ "meta": {"message": MESSAGE_CLIENT}, @@ -896,7 +695,7 @@ async def test_option_ssid_filter(hass, aioclient_mock, mock_unifi_websocket): async def test_wireless_client_go_wired_issue( - hass, aioclient_mock, mock_unifi_websocket + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry ): """Test the solution to catch wireless device go wired UniFi issue. @@ -924,6 +723,7 @@ async def test_wireless_client_go_wired_issue( assert client_state.attributes["is_wired"] is False # Trigger wired bug + client["last_seen"] += 1 client["is_wired"] = True mock_unifi_websocket( data={ @@ -950,6 +750,7 @@ async def test_wireless_client_go_wired_issue( assert client_state.attributes["is_wired"] is False # Try to mark client as connected + client["last_seen"] += 1 mock_unifi_websocket( data={ "meta": {"message": MESSAGE_CLIENT}, @@ -964,6 +765,7 @@ async def test_wireless_client_go_wired_issue( assert client_state.attributes["is_wired"] is False # Make client wireless + client["last_seen"] += 1 client["is_wired"] = False mock_unifi_websocket( data={ @@ -979,7 +781,9 @@ async def test_wireless_client_go_wired_issue( assert client_state.attributes["is_wired"] is False -async def test_option_ignore_wired_bug(hass, aioclient_mock, mock_unifi_websocket): +async def test_option_ignore_wired_bug( + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry +): """Test option to ignore wired bug.""" client = { "ap_mac": "00:00:00:00:02:01", @@ -1032,6 +836,7 @@ async def test_option_ignore_wired_bug(hass, aioclient_mock, mock_unifi_websocke assert client_state.attributes["is_wired"] is True # Mark client as connected again + client["last_seen"] += 1 mock_unifi_websocket( data={ "meta": {"message": MESSAGE_CLIENT}, @@ -1046,6 +851,7 @@ async def test_option_ignore_wired_bug(hass, aioclient_mock, mock_unifi_websocke assert client_state.attributes["is_wired"] is True # Make client wireless + client["last_seen"] += 1 client["is_wired"] = False mock_unifi_websocket( data={ @@ -1061,7 +867,7 @@ async def test_option_ignore_wired_bug(hass, aioclient_mock, mock_unifi_websocke assert client_state.attributes["is_wired"] is False -async def test_restoring_client(hass, aioclient_mock): +async def test_restoring_client(hass, aioclient_mock, mock_device_registry): """Verify clients are restored from clients_all if they ever was registered to entity registry.""" client = { "hostname": "client", @@ -1115,7 +921,7 @@ async def test_restoring_client(hass, aioclient_mock): assert not hass.states.get("device_tracker.not_restored") -async def test_dont_track_clients(hass, aioclient_mock): +async def test_dont_track_clients(hass, aioclient_mock, mock_device_registry): """Test don't track clients config works.""" wireless_client = { "essid": "ssid", @@ -1175,7 +981,7 @@ async def test_dont_track_clients(hass, aioclient_mock): assert hass.states.get("device_tracker.device") -async def test_dont_track_devices(hass, aioclient_mock): +async def test_dont_track_devices(hass, aioclient_mock, mock_device_registry): """Test don't track devices config works.""" client = { "hostname": "client", @@ -1224,7 +1030,7 @@ async def test_dont_track_devices(hass, aioclient_mock): assert hass.states.get("device_tracker.device") -async def test_dont_track_wired_clients(hass, aioclient_mock): +async def test_dont_track_wired_clients(hass, aioclient_mock, mock_device_registry): """Test don't track wired clients config works.""" wireless_client = { "essid": "ssid", diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py new file mode 100644 index 00000000000000..ccec7fcb48adb1 --- /dev/null +++ b/tests/components/unifi/test_diagnostics.py @@ -0,0 +1,253 @@ +"""Test UniFi Network diagnostics.""" + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.unifi.const import ( + CONF_ALLOW_BANDWIDTH_SENSORS, + CONF_ALLOW_UPTIME_SENSORS, + CONF_BLOCK_CLIENT, +) +from homeassistant.components.unifi.device_tracker import CLIENT_TRACKER, DEVICE_TRACKER +from homeassistant.components.unifi.sensor import RX_SENSOR, TX_SENSOR, UPTIME_SENSOR +from homeassistant.components.unifi.switch import ( + BLOCK_SWITCH, + DPI_SWITCH, + OUTLET_SWITCH, + POE_SWITCH, +) +from homeassistant.const import Platform + +from .test_controller import setup_unifi_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, hass_client, aioclient_mock): + """Test config entry diagnostics.""" + client = { + "blocked": False, + "hostname": "client_1", + "ip": "10.0.0.1", + "is_wired": True, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:01", + "name": "POE Client 1", + "oui": "Producer", + "sw_mac": "00:00:00:00:01:01", + "sw_port": 1, + "wired-rx_bytes": 1234000000, + "wired-tx_bytes": 5678000000, + } + device = { + "ethernet_table": [ + { + "mac": "22:22:22:22:22:22", + "num_port": 2, + "name": "eth0", + } + ], + "device_id": "mock-id", + "ip": "10.0.1.1", + "mac": "00:00:00:00:01:01", + "last_seen": 1562600145, + "model": "US16P150", + "name": "mock-name", + "port_overrides": [], + "port_table": [ + { + "mac_table": [ + { + "age": 1, + "mac": "00:00:00:00:00:01", + "static": False, + "uptime": 3971792, + "vlan": 1, + }, + { + "age": 1, + "mac": "11:11:11:11:11:11", + "static": True, + "uptime": 0, + "vlan": 0, + }, + ], + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_class": "Class 4", + "poe_enable": True, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": True, + "up": True, + }, + ], + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + } + dpi_app = { + "_id": "5f976f62e3c58f018ec7e17d", + "apps": [], + "blocked": True, + "cats": ["4"], + "enabled": True, + "log": True, + "site_id": "name", + } + dpi_group = { + "_id": "5f976f4ae3c58f018ec7dff6", + "name": "Block Media Streaming", + "site_id": "name", + "dpiapp_ids": ["5f976f62e3c58f018ec7e17d"], + } + + options = { + CONF_ALLOW_BANDWIDTH_SENSORS: True, + CONF_ALLOW_UPTIME_SENSORS: True, + CONF_BLOCK_CLIENT: ["00:00:00:00:00:01"], + } + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + options=options, + clients_response=[client], + devices_response=[device], + dpiapp_response=[dpi_app], + dpigroup_response=[dpi_group], + ) + + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "config": { + "data": { + "controller": REDACTED, + "host": REDACTED, + "password": REDACTED, + "port": 1234, + "site": "site_id", + "username": REDACTED, + "verify_ssl": False, + }, + "disabled_by": None, + "domain": "unifi", + "entry_id": "1", + "options": { + "allow_bandwidth_sensors": True, + "allow_uptime_sensors": True, + "block_client": ["00:00:00:00:00:00"], + }, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "title": "Mock Title", + "unique_id": "1", + "version": 1, + }, + "site_role": "admin", + "entities": { + str(Platform.DEVICE_TRACKER): { + CLIENT_TRACKER: ["00:00:00:00:00:00"], + DEVICE_TRACKER: ["00:00:00:00:00:01"], + }, + str(Platform.SENSOR): { + RX_SENSOR: ["00:00:00:00:00:00"], + TX_SENSOR: ["00:00:00:00:00:00"], + UPTIME_SENSOR: ["00:00:00:00:00:00"], + }, + str(Platform.SWITCH): { + BLOCK_SWITCH: ["00:00:00:00:00:00"], + DPI_SWITCH: ["5f976f4ae3c58f018ec7dff6"], + POE_SWITCH: ["00:00:00:00:00:00"], + OUTLET_SWITCH: [], + }, + }, + "clients": { + "00:00:00:00:00:00": { + "blocked": False, + "hostname": "client_1", + "ip": "10.0.0.1", + "is_wired": True, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:00", + "name": "POE Client 1", + "oui": "Producer", + "sw_mac": "00:00:00:00:00:01", + "sw_port": 1, + "wired-rx_bytes": 1234000000, + "wired-tx_bytes": 5678000000, + } + }, + "devices": { + "00:00:00:00:00:01": { + "ethernet_table": [ + { + "mac": "00:00:00:00:00:02", + "num_port": 2, + "name": "eth0", + } + ], + "device_id": "mock-id", + "ip": "10.0.1.1", + "mac": "00:00:00:00:00:01", + "last_seen": 1562600145, + "model": "US16P150", + "name": "mock-name", + "port_overrides": [], + "port_table": [ + { + "mac_table": [ + { + "age": 1, + "mac": "00:00:00:00:00:00", + "static": False, + "uptime": 3971792, + "vlan": 1, + }, + { + "age": 1, + "mac": REDACTED, + "static": True, + "uptime": 0, + "vlan": 0, + }, + ], + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_class": "Class 4", + "poe_enable": True, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": True, + "up": True, + }, + ], + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + } + }, + "dpi_apps": { + "5f976f62e3c58f018ec7e17d": { + "_id": "5f976f62e3c58f018ec7e17d", + "apps": [], + "blocked": True, + "cats": ["4"], + "enabled": True, + "log": True, + "site_id": "name", + } + }, + "dpi_groups": { + "5f976f4ae3c58f018ec7dff6": { + "_id": "5f976f4ae3c58f018ec7dff6", + "name": "Block Media Streaming", + "site_id": "name", + "dpiapp_ids": ["5f976f62e3c58f018ec7e17d"], + } + }, + "wlans": {}, + } diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 3794c46988d674..398c6c6c3f57e7 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -15,9 +15,9 @@ CONF_TRACK_DEVICES, DOMAIN as UNIFI_DOMAIN, ) -from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import EntityCategory import homeassistant.util.dt as dt_util from .test_controller import setup_unifi_integration @@ -44,16 +44,16 @@ async def test_bandwidth_sensors(hass, aioclient_mock, mock_unifi_websocket): "is_wired": True, "mac": "00:00:00:00:00:01", "oui": "Producer", - "wired-rx_bytes": 1234000000, - "wired-tx_bytes": 5678000000, + "wired-rx_bytes-r": 1234000000, + "wired-tx_bytes-r": 5678000000, } wireless_client = { "is_wired": False, "mac": "00:00:00:00:00:02", "name": "Wireless client", "oui": "Producer", - "rx_bytes": 2345000000, - "tx_bytes": 6789000000, + "rx_bytes-r": 2345000000, + "tx_bytes-r": 6789000000, } options = { CONF_ALLOW_BANDWIDTH_SENSORS: True, @@ -79,13 +79,13 @@ async def test_bandwidth_sensors(hass, aioclient_mock, mock_unifi_websocket): ent_reg = er.async_get(hass) assert ( ent_reg.async_get("sensor.wired_client_rx").entity_category - == ENTITY_CATEGORY_DIAGNOSTIC + is EntityCategory.DIAGNOSTIC ) # Verify state update - wireless_client["rx_bytes"] = 3456000000 - wireless_client["tx_bytes"] = 7891000000 + wireless_client["rx_bytes-r"] = 3456000000 + wireless_client["tx_bytes-r"] = 7891000000 mock_unifi_websocket( data={ @@ -190,7 +190,7 @@ async def test_uptime_sensors( ent_reg = er.async_get(hass) assert ( ent_reg.async_get("sensor.client1_uptime").entity_category - == ENTITY_CATEGORY_DIAGNOSTIC + is EntityCategory.DIAGNOSTIC ) # Verify normal new event doesn't change uptime diff --git a/tests/components/unifi/test_services.py b/tests/components/unifi/test_services.py index 8fe41d7a856f4e..27e4ddea930345 100644 --- a/tests/components/unifi/test_services.py +++ b/tests/components/unifi/test_services.py @@ -77,15 +77,26 @@ async def test_reconnect_client(hass, aioclient_mock): assert aioclient_mock.call_count == 1 +async def test_reconnect_non_existant_device(hass, aioclient_mock): + """Verify no call is made if device does not exist.""" + await setup_unifi_integration(hass, aioclient_mock) + + aioclient_mock.clear_requests() + + await hass.services.async_call( + UNIFI_DOMAIN, + SERVICE_RECONNECT_CLIENT, + service_data={ATTR_DEVICE_ID: "device_entry.id"}, + blocking=True, + ) + assert aioclient_mock.call_count == 0 + + async def test_reconnect_device_without_mac(hass, aioclient_mock): """Verify no call is made if device does not have a known mac.""" config_entry = await setup_unifi_integration(hass, aioclient_mock) - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] aioclient_mock.clear_requests() - aioclient_mock.post( - f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", - ) device_registry = await hass.helpers.device_registry.async_get_registry() device_entry = device_registry.async_get_or_create( @@ -139,12 +150,8 @@ async def test_reconnect_client_controller_unavailable(hass, aioclient_mock): async def test_reconnect_client_unknown_mac(hass, aioclient_mock): """Verify no call is made if trying to reconnect a mac unknown to controller.""" config_entry = await setup_unifi_integration(hass, aioclient_mock) - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] aioclient_mock.clear_requests() - aioclient_mock.post( - f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", - ) device_registry = await hass.helpers.device_registry.async_get_registry() device_entry = device_registry.async_get_or_create( @@ -172,12 +179,8 @@ async def test_reconnect_wired_client(hass, aioclient_mock): config_entry = await setup_unifi_integration( hass, aioclient_mock, clients_response=clients ) - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] aioclient_mock.clear_requests() - aioclient_mock.post( - f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", - ) device_registry = await hass.helpers.device_registry.async_get_registry() device_entry = device_registry.async_get_or_create( @@ -197,6 +200,9 @@ async def test_reconnect_wired_client(hass, aioclient_mock): async def test_remove_clients(hass, aioclient_mock): """Verify removing different variations of clients work.""" clients = [ + { + "mac": "00:00:00:00:00:00", + }, { "first_seen": 100, "last_seen": 500, @@ -239,7 +245,7 @@ async def test_remove_clients(hass, aioclient_mock): await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) assert aioclient_mock.mock_calls[0][2] == { "cmd": "forget-sta", - "macs": ["00:00:00:00:00:01"], + "macs": ["00:00:00:00:00:00", "00:00:00:00:00:01"], } assert await hass.config_entries.async_unload(config_entry.entry_id) @@ -261,9 +267,6 @@ async def test_remove_clients_controller_unavailable(hass, aioclient_mock): controller.available = False aioclient_mock.clear_requests() - aioclient_mock.post( - f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", - ) await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) assert aioclient_mock.call_count == 0 @@ -278,15 +281,9 @@ async def test_remove_clients_no_call_on_empty_list(hass, aioclient_mock): "mac": "00:00:00:00:00:01", } ] - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_all_response=clients - ) - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + await setup_unifi_integration(hass, aioclient_mock, clients_all_response=clients) aioclient_mock.clear_requests() - aioclient_mock.post( - f"https://{controller.host}:1234/api/s/{controller.site}/cmd/stamgr", - ) await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) assert aioclient_mock.call_count == 0 diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 29640b7a4b4c84..8e2802738afa9f 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -2,11 +2,14 @@ from copy import deepcopy from unittest.mock import patch -from aiounifi.controller import MESSAGE_CLIENT_REMOVED, MESSAGE_EVENT +from aiounifi.controller import MESSAGE_CLIENT_REMOVED, MESSAGE_DEVICE, MESSAGE_EVENT from homeassistant import config_entries, core -from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.unifi.const import ( CONF_BLOCK_CLIENT, CONF_DPI_RESTRICTIONS, @@ -16,9 +19,10 @@ DOMAIN as UNIFI_DOMAIN, ) from homeassistant.components.unifi.switch import POE_SWITCH -from homeassistant.const import ENTITY_CATEGORY_CONFIG, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import EntityCategory from .test_controller import ( CONTROLLER_HOST, @@ -288,7 +292,42 @@ "data": [ { "_id": "5f976f4ae3c58f018ec7dff6", - "name": "dpi group", + "name": "Block Media Streaming", + "site_id": "name", + "dpiapp_ids": [], + } + ], +} + +DPI_GROUP_CREATED_EVENT = { + "meta": {"rc": "ok", "message": "dpigroup:add"}, + "data": [ + { + "name": "Block Media Streaming", + "site_id": "name", + "_id": "5f976f4ae3c58f018ec7dff6", + } + ], +} + +DPI_GROUP_ADDED_APP = { + "meta": {"rc": "ok", "message": "dpigroup:sync"}, + "data": [ + { + "_id": "5f976f4ae3c58f018ec7dff6", + "name": "Block Media Streaming", + "site_id": "name", + "dpiapp_ids": ["5f976f62e3c58f018ec7e17d"], + } + ], +} + +DPI_GROUP_REMOVE_APP = { + "meta": {"rc": "ok", "message": "dpigroup:sync"}, + "data": [ + { + "_id": "5f976f4ae3c58f018ec7dff6", + "name": "Block Media Streaming", "site_id": "name", "dpiapp_ids": [], } @@ -310,6 +349,213 @@ ], } +OUTLET_UP1 = { + "_id": "600c8356942a6ade50707b56", + "ip": "192.168.0.189", + "mac": "fc:ec:da:76:4f:5f", + "model": "UP1", + "model_in_lts": False, + "model_in_eol": False, + "type": "uap", + "version": "2.2.1.511", + "adopted": True, + "site_id": "545eb1f0e4b0205d14c4e548", + "x_authkey": "345678976545678", + "cfgversion": "4c62f1e663783447", + "syslog_key": "41c4bcefcbc842d6eefb05b8fd9b78faa1841d10a09cebb170ce3e2f474b43b3", + "config_network": {"type": "dhcp"}, + "setup_id": "a8730d36-8fdd-44f9-8678-1e89676f36c1", + "x_vwirekey": "2dabb7e23b048c88b60123456789", + "vwire_table": [], + "dot1x_portctrl_enabled": False, + "outlet_overrides": [], + "outlet_enabled": True, + "license_state": "registered", + "x_aes_gcm": True, + "inform_url": "http://192.168.0.5:8080/inform", + "inform_ip": "192.168.0.5", + "required_version": "2.1.3", + "anon_id": "d2744a31-1c26-92fe-423d-6b9ba204abc7", + "board_rev": 2, + "manufacturer_id": 72, + "model_incompatible": False, + "antenna_table": [], + "radio_table": [], + "scan_radio_table": [], + "ethernet_table": [], + "port_table": [], + "switch_caps": {}, + "has_speaker": False, + "has_eth1": False, + "fw_caps": 0, + "hw_caps": 128, + "wifi_caps": 0, + "sys_error_caps": 0, + "has_fan": False, + "has_temperature": False, + "country_code": 10752, + "outlet_table": [ + { + "index": 1, + "has_relay": True, + "has_metering": False, + "relay_state": False, + "name": "Outlet 1", + } + ], + "element_ap_serial": "44:d9:e7:90:f4:24", + "connected_at": 1641678609, + "provisioned_at": 1642054077, + "led_override": "default", + "led_override_color": "#0000ff", + "led_override_color_brightness": 100, + "outdoor_mode_override": "default", + "lcm_brightness_override": False, + "lcm_idle_timeout_override": False, + "name": "Plug", + "unsupported": False, + "unsupported_reason": 0, + "two_phase_adopt": False, + "serial": "FCECDA764F5F", + "lcm_tracker_enabled": False, + "wlangroup_id_ng": "545eb1f0e4b0205d14c4e555", + "supports_fingerprint_ml": False, + "last_uplink": { + "uplink_mac": "78:45:58:87:93:16", + "uplink_device_name": "U6-Pro", + "type": "wireless", + }, + "device_id": "600c8356942a6ade50707b56", + "uplink": { + "uplink_mac": "78:45:58:87:93:16", + "uplink_device_name": "U6-Pro", + "type": "wireless", + "up": True, + "ap_mac": "78:45:58:87:93:16", + "tx_rate": 54000, + "rx_rate": 72200, + "rssi": 60, + "is_11ax": False, + "is_11ac": False, + "is_11n": True, + "is_11b": False, + "radio": "ng", + "essid": "Network Name", + "channel": 11, + "tx_packets": 1586746, + "rx_packets": 362176, + "tx_bytes": 397773, + "rx_bytes": 24423980, + "tx_bytes-r": 0, + "rx_bytes-r": 45, + "uplink_source": "legacy", + }, + "state": 1, + "start_disconnected_millis": 1641679166349, + "last_seen": 1642055273, + "next_interval": 40, + "known_cfgversion": "4c62f1e663783447", + "start_connected_millis": 1641679166355, + "upgradable": False, + "adoptable_when_upgraded": False, + "rollupgrade": False, + "uptime": 376083, + "_uptime": 376083, + "locating": False, + "connect_request_ip": "192.168.0.189", + "connect_request_port": "49155", + "sys_stats": {"mem_total": 98304, "mem_used": 87736}, + "system-stats": {}, + "lldp_table": [], + "displayable_version": "2.2.1", + "connection_network_name": "LAN", + "startup_timestamp": 1641679190, + "scanning": False, + "spectrum_scanning": False, + "meshv3_peer_mac": "", + "element_peer_mac": "", + "satisfaction": 100, + "uplink_bssid": "78:45:58:87:93:17", + "hide_ch_width": "none", + "isolated": False, + "radio_table_stats": [], + "port_stats": [], + "vap_table": [], + "downlink_table": [], + "vwire_vap_table": [], + "bytes-d": 0, + "tx_bytes-d": 0, + "rx_bytes-d": 0, + "bytes-r": 0, + "element_uplink_ap_mac": "78:45:58:87:93:16", + "prev_non_busy_state": 1, + "stat": { + "ap": { + "site_id": "5a32aa4ee4b0412345678910", + "o": "ap", + "oid": "fc:ec:da:76:4f:5f", + "ap": "fc:ec:da:76:4f:5f", + "time": 1641678600000, + "datetime": "2022-01-08T21:50:00Z", + "user-rx_packets": 0.0, + "guest-rx_packets": 0.0, + "rx_packets": 0.0, + "user-rx_bytes": 0.0, + "guest-rx_bytes": 0.0, + "rx_bytes": 0.0, + "user-rx_errors": 0.0, + "guest-rx_errors": 0.0, + "rx_errors": 0.0, + "user-rx_dropped": 0.0, + "guest-rx_dropped": 0.0, + "rx_dropped": 0.0, + "user-rx_crypts": 0.0, + "guest-rx_crypts": 0.0, + "rx_crypts": 0.0, + "user-rx_frags": 0.0, + "guest-rx_frags": 0.0, + "rx_frags": 0.0, + "user-tx_packets": 0.0, + "guest-tx_packets": 0.0, + "tx_packets": 0.0, + "user-tx_bytes": 0.0, + "guest-tx_bytes": 0.0, + "tx_bytes": 0.0, + "user-tx_errors": 0.0, + "guest-tx_errors": 0.0, + "tx_errors": 0.0, + "user-tx_dropped": 0.0, + "guest-tx_dropped": 0.0, + "tx_dropped": 0.0, + "user-tx_retries": 0.0, + "guest-tx_retries": 0.0, + "tx_retries": 0.0, + "user-mac_filter_rejections": 0.0, + "guest-mac_filter_rejections": 0.0, + "mac_filter_rejections": 0.0, + "user-wifi_tx_attempts": 0.0, + "guest-wifi_tx_attempts": 0.0, + "wifi_tx_attempts": 0.0, + "user-wifi_tx_dropped": 0.0, + "guest-wifi_tx_dropped": 0.0, + "wifi_tx_dropped": 0.0, + "bytes": 0.0, + "duration": 376663000.0, + } + }, + "tx_bytes": 0, + "rx_bytes": 0, + "bytes": 0, + "vwireEnabled": True, + "uplink_table": [], + "num_sta": 0, + "user-num_sta": 0, + "user-wlan-num_sta": 0, + "guest-num_sta": 0, + "guest-wlan-num_sta": 0, + "x_has_ssh_hostkey": False, +} + async def test_no_clients(hass, aioclient_mock): """Test the update_clients function when no clients are found.""" @@ -408,7 +654,7 @@ async def test_switches(hass, aioclient_mock): "switch.block_client_1", "switch.block_media_streaming", ): - assert ent_reg.async_get(entry_id).entity_category == ENTITY_CATEGORY_CONFIG + assert ent_reg.async_get(entry_id).entity_category is EntityCategory.CONFIG # Block and unblock client @@ -599,6 +845,161 @@ async def test_dpi_switches(hass, aioclient_mock, mock_unifi_websocket): assert hass.states.get("switch.block_media_streaming").state == STATE_OFF + mock_unifi_websocket(data=DPI_GROUP_REMOVE_APP) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass.states.get("switch.block_media_streaming") is None + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + +async def test_dpi_switches_add_second_app(hass, aioclient_mock, mock_unifi_websocket): + """Test the update_items function with some clients.""" + await setup_unifi_integration( + hass, + aioclient_mock, + dpigroup_response=DPI_GROUPS, + dpiapp_response=DPI_APPS, + ) + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + assert hass.states.get("switch.block_media_streaming").state == STATE_ON + + second_app_event = { + "meta": {"rc": "ok", "message": "dpiapp:add"}, + "data": [ + { + "apps": [524292], + "blocked": False, + "cats": [], + "enabled": False, + "log": False, + "site_id": "name", + "_id": "61783e89c1773a18c0c61f00", + } + ], + } + mock_unifi_websocket(data=second_app_event) + await hass.async_block_till_done() + + assert hass.states.get("switch.block_media_streaming").state == STATE_ON + + add_second_app_to_group = { + "meta": {"rc": "ok", "message": "dpigroup:sync"}, + "data": [ + { + "_id": "5f976f4ae3c58f018ec7dff6", + "name": "Block Media Streaming", + "site_id": "name", + "dpiapp_ids": ["5f976f62e3c58f018ec7e17d", "61783e89c1773a18c0c61f00"], + } + ], + } + + mock_unifi_websocket(data=add_second_app_to_group) + await hass.async_block_till_done() + + assert hass.states.get("switch.block_media_streaming").state == STATE_OFF + + second_app_event_enabled = { + "meta": {"rc": "ok", "message": "dpiapp:sync"}, + "data": [ + { + "apps": [524292], + "blocked": False, + "cats": [], + "enabled": True, + "log": False, + "site_id": "name", + "_id": "61783e89c1773a18c0c61f00", + } + ], + } + mock_unifi_websocket(data=second_app_event_enabled) + await hass.async_block_till_done() + + assert hass.states.get("switch.block_media_streaming").state == STATE_ON + + +async def test_outlet_switches(hass, aioclient_mock, mock_unifi_websocket): + """Test the update_items function with some clients.""" + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + options={CONF_TRACK_DEVICES: False}, + devices_response=[OUTLET_UP1], + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + outlet = hass.states.get("switch.plug_outlet_1") + assert outlet is not None + assert outlet.state == STATE_OFF + + # State change + + outlet_up1 = deepcopy(OUTLET_UP1) + outlet_up1["outlet_table"][0]["relay_state"] = True + + mock_unifi_websocket( + data={ + "meta": {"message": MESSAGE_DEVICE}, + "data": [outlet_up1], + } + ) + await hass.async_block_till_done() + + outlet = hass.states.get("switch.plug_outlet_1") + assert outlet.state == STATE_ON + + # Turn on and off outlet + + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/600c8356942a6ade50707b56", + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { + "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}] + } + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, + blocking=True, + ) + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == { + "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": False}] + } + + # Changes to config entry options shouldn't affect outlets + hass.config_entries.async_update_entry( + config_entry, + options={CONF_BLOCK_CLIENT: []}, + ) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + # Unload config entry + await hass.config_entries.async_unload(config_entry.entry_id) + assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + + # Remove config entry + await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("switch.plug_outlet_1") is None + async def test_new_client_discovered_on_block_control( hass, aioclient_mock, mock_unifi_websocket @@ -783,8 +1184,6 @@ async def test_ignore_multiple_poe_clients_on_same_port(hass, aioclient_mock): devices_response=[DEVICE_1], ) - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 3 - switch_1 = hass.states.get("switch.poe_client_1") switch_2 = hass.states.get("switch.poe_client_2") assert switch_1 is None diff --git a/tests/components/unifiprotect/__init__.py b/tests/components/unifiprotect/__init__.py new file mode 100644 index 00000000000000..1bbe9fb435dc84 --- /dev/null +++ b/tests/components/unifiprotect/__init__.py @@ -0,0 +1,45 @@ +"""Tests for the UniFi Protect integration.""" + +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from unifi_discovery import AIOUnifiScanner, UnifiDevice, UnifiService + +DEVICE_HOSTNAME = "unvr" +DEVICE_IP_ADDRESS = "127.0.0.1" +DEVICE_MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +DIRECT_CONNECT_DOMAIN = "x.ui.direct" + + +UNIFI_DISCOVERY = UnifiDevice( + source_ip=DEVICE_IP_ADDRESS, + hw_addr=DEVICE_MAC_ADDRESS, + platform=DEVICE_HOSTNAME, + hostname=DEVICE_HOSTNAME, + services={UnifiService.Protect: True}, + direct_connect_domain=DIRECT_CONNECT_DOMAIN, +) + + +UNIFI_DISCOVERY_PARTIAL = UnifiDevice( + source_ip=DEVICE_IP_ADDRESS, + hw_addr=DEVICE_MAC_ADDRESS, + services={UnifiService.Protect: True}, +) + + +def _patch_discovery(device=None, no_device=False): + mock_aio_discovery = MagicMock(auto_spec=AIOUnifiScanner) + scanner_return = [] if no_device else [device or UNIFI_DISCOVERY] + mock_aio_discovery.async_scan = AsyncMock(return_value=scanner_return) + mock_aio_discovery.found_devices = scanner_return + + @contextmanager + def _patcher(): + with patch( + "homeassistant.components.unifiprotect.discovery.AIOUnifiScanner", + return_value=mock_aio_discovery, + ): + yield + + return _patcher() diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py new file mode 100644 index 00000000000000..f495b2bc8f742b --- /dev/null +++ b/tests/components/unifiprotect/conftest.py @@ -0,0 +1,282 @@ +"""Fixtures and test data for UniFi Protect methods.""" +# pylint: disable=protected-access +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +from ipaddress import IPv4Address +import json +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from pyunifiprotect.data import ( + NVR, + Camera, + Doorlock, + Light, + Liveview, + Sensor, + Viewer, + WSSubscriptionMessage, +) +from pyunifiprotect.data.base import ProtectAdoptableDeviceModel + +from homeassistant.components.unifiprotect.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityDescription +import homeassistant.util.dt as dt_util + +from . import _patch_discovery + +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture + +MAC_ADDR = "aa:bb:cc:dd:ee:ff" + + +@dataclass +class MockBootstrap: + """Mock for Bootstrap.""" + + nvr: NVR + cameras: dict[str, Any] + lights: dict[str, Any] + sensors: dict[str, Any] + viewers: dict[str, Any] + liveviews: dict[str, Any] + events: dict[str, Any] + doorlocks: dict[str, Any] + + def reset_objects(self) -> None: + """Reset all devices on bootstrap for tests.""" + self.cameras = {} + self.lights = {} + self.sensors = {} + self.viewers = {} + self.liveviews = {} + self.events = {} + self.doorlocks = {} + + def process_ws_packet(self, msg: WSSubscriptionMessage) -> None: + """Fake process method for tests.""" + pass + + +@dataclass +class MockEntityFixture: + """Mock for NVR.""" + + entry: MockConfigEntry + api: Mock + + +@pytest.fixture(name="mock_nvr") +def mock_nvr_fixture(): + """Mock UniFi Protect Camera device.""" + + data = json.loads(load_fixture("sample_nvr.json", integration=DOMAIN)) + nvr = NVR.from_unifi_dict(**data) + + # disable pydantic validation so mocking can happen + NVR.__config__.validate_assignment = False + + yield nvr + + NVR.__config__.validate_assignment = True + + +@pytest.fixture(name="mock_ufp_config_entry") +def mock_ufp_config_entry(): + """Mock the unifiprotect config entry.""" + + return MockConfigEntry( + domain=DOMAIN, + data={ + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + }, + version=2, + ) + + +@pytest.fixture(name="mock_old_nvr") +def mock_old_nvr_fixture(): + """Mock UniFi Protect Camera device.""" + + data = json.loads(load_fixture("sample_nvr.json", integration=DOMAIN)) + data["version"] = "1.19.0" + return NVR.from_unifi_dict(**data) + + +@pytest.fixture(name="mock_bootstrap") +def mock_bootstrap_fixture(mock_nvr: NVR): + """Mock Bootstrap fixture.""" + return MockBootstrap( + nvr=mock_nvr, + cameras={}, + lights={}, + sensors={}, + viewers={}, + liveviews={}, + events={}, + doorlocks={}, + ) + + +@pytest.fixture +def mock_client(mock_bootstrap: MockBootstrap): + """Mock ProtectApiClient for testing.""" + client = Mock() + client.bootstrap = mock_bootstrap + + nvr = mock_bootstrap.nvr + nvr._api = client + + client.base_url = "https://127.0.0.1" + client.connection_host = IPv4Address("127.0.0.1") + client.get_nvr = AsyncMock(return_value=nvr) + client.update = AsyncMock(return_value=mock_bootstrap) + client.async_disconnect_ws = AsyncMock() + + def subscribe(ws_callback: Callable[[WSSubscriptionMessage], None]) -> Any: + client.ws_subscription = ws_callback + + return Mock() + + client.subscribe_websocket = subscribe + return client + + +@pytest.fixture +def mock_entry( + hass: HomeAssistant, + mock_ufp_config_entry: MockConfigEntry, + mock_client, # pylint: disable=redefined-outer-name +): + """Mock ProtectApiClient for testing.""" + + with _patch_discovery(no_device=True), patch( + "homeassistant.components.unifiprotect.ProtectApiClient" + ) as mock_api: + mock_ufp_config_entry.add_to_hass(hass) + + mock_api.return_value = mock_client + + yield MockEntityFixture(mock_ufp_config_entry, mock_client) + + +@pytest.fixture +def mock_liveview(): + """Mock UniFi Protect Liveview.""" + + data = json.loads(load_fixture("sample_liveview.json", integration=DOMAIN)) + return Liveview.from_unifi_dict(**data) + + +@pytest.fixture +def mock_camera(): + """Mock UniFi Protect Camera device.""" + + data = json.loads(load_fixture("sample_camera.json", integration=DOMAIN)) + return Camera.from_unifi_dict(**data) + + +@pytest.fixture +def mock_light(): + """Mock UniFi Protect Light device.""" + + data = json.loads(load_fixture("sample_light.json", integration=DOMAIN)) + return Light.from_unifi_dict(**data) + + +@pytest.fixture +def mock_viewer(): + """Mock UniFi Protect Viewport device.""" + + data = json.loads(load_fixture("sample_viewport.json", integration=DOMAIN)) + return Viewer.from_unifi_dict(**data) + + +@pytest.fixture +def mock_sensor(): + """Mock UniFi Protect Sensor device.""" + + data = json.loads(load_fixture("sample_sensor.json", integration=DOMAIN)) + return Sensor.from_unifi_dict(**data) + + +@pytest.fixture +def mock_doorlock(): + """Mock UniFi Protect Doorlock device.""" + + data = json.loads(load_fixture("sample_doorlock.json", integration=DOMAIN)) + return Doorlock.from_unifi_dict(**data) + + +@pytest.fixture +def now(): + """Return datetime object that will be consistent throughout test.""" + return dt_util.utcnow() + + +async def time_changed(hass: HomeAssistant, seconds: int) -> None: + """Trigger time changed.""" + next_update = dt_util.utcnow() + timedelta(seconds) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + +async def enable_entity( + hass: HomeAssistant, entry_id: str, entity_id: str +) -> er.RegistryEntry: + """Enable a disabled entity.""" + entity_registry = er.async_get(hass) + + updated_entity = entity_registry.async_update_entity(entity_id, disabled_by=None) + assert not updated_entity.disabled + await hass.config_entries.async_reload(entry_id) + await hass.async_block_till_done() + + return updated_entity + + +def assert_entity_counts( + hass: HomeAssistant, platform: Platform, total: int, enabled: int +) -> None: + """Assert entity counts for a given platform.""" + + entity_registry = er.async_get(hass) + + entities = [ + e for e in entity_registry.entities if split_entity_id(e)[0] == platform.value + ] + + assert len(entities) == total + assert len(hass.states.async_all(platform.value)) == enabled + + +def ids_from_device_description( + platform: Platform, + device: ProtectAdoptableDeviceModel, + description: EntityDescription, +) -> tuple[str, str]: + """Return expected unique_id and entity_id for a give platform/device/description combination.""" + + entity_name = ( + device.name.lower().replace(":", "").replace(" ", "_").replace("-", "_") + ) + description_entity_name = ( + description.name.lower().replace(":", "").replace(" ", "_").replace("-", "_") + ) + + unique_id = f"{device.id}_{description.key}" + entity_id = f"{platform.value}.{entity_name}_{description_entity_name}" + + return unique_id, entity_id diff --git a/tests/components/unifiprotect/fixtures/sample_camera.json b/tests/components/unifiprotect/fixtures/sample_camera.json new file mode 100644 index 00000000000000..7cc660f428b250 --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_camera.json @@ -0,0 +1,482 @@ +{ + "isDeleting": false, + "mac": "72C7836A47DC", + "host": "192.168.6.90", + "connectionHost": "192.168.178.217", + "type": "UVC G4 Instant", + "name": "Fufail Qqjx", + "upSince": 1640020678036, + "uptime": 3203, + "lastSeen": 1640023881036, + "connectedSince": 1640020710448, + "state": "CONNECTED", + "hardwareRevision": "11", + "firmwareVersion": "4.47.13", + "latestFirmwareVersion": "4.47.13", + "firmwareBuild": "0a55423.211124.718", + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": true, + "isRebooting": false, + "isSshEnabled": true, + "canAdopt": false, + "isAttemptingToConnect": false, + "lastMotion": 1640021213927, + "micVolume": 100, + "isMicEnabled": true, + "isRecording": false, + "isWirelessUplinkEnabled": true, + "isMotionDetected": false, + "isSmartDetected": false, + "phyRate": 72, + "hdrMode": true, + "videoMode": "default", + "isProbingForWifi": false, + "apMac": null, + "apRssi": null, + "elementInfo": null, + "chimeDuration": 0, + "isDark": false, + "lastPrivacyZonePositionId": null, + "lastRing": null, + "isLiveHeatmapEnabled": false, + "anonymousDeviceId": "7722b5e7-ecfa-468c-a385-3eafea917b0c", + "eventStats": { + "motion": { + "today": 10, + "average": 39, + "lastDays": [ + 48, + 45, + 33, + 41, + 44, + 60, + 6 + ], + "recentHours": [ + 0, + 4, + 1, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 0 + ] + }, + "smart": { + "today": 0, + "average": 0, + "lastDays": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "videoReconfigurationInProgress": false, + "voltage": null, + "wiredConnectionState": { + "phyRate": null + }, + "channels": [ + { + "id": 0, + "videoId": "video1", + "name": "Jzi Bftu", + "enabled": true, + "isRtspEnabled": true, + "rtspAlias": "ANOAPfoKMW7VixG1", + "width": 2688, + "height": 1512, + "fps": 30, + "bitrate": 10000000, + "minBitrate": 32000, + "maxBitrate": 10000000, + "minClientAdaptiveBitRate": 0, + "minMotionAdaptiveBitRate": 2000000, + "fpsValues": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 12, + 15, + 16, + 18, + 20, + 24, + 25, + 30 + ], + "idrInterval": 5 + }, + { + "id": 1, + "videoId": "video2", + "name": "Rgcpxsf Xfwt", + "enabled": true, + "isRtspEnabled": true, + "rtspAlias": "XHXAdHVKGVEzMNTP", + "width": 1280, + "height": 720, + "fps": 30, + "bitrate": 1500000, + "minBitrate": 32000, + "maxBitrate": 2000000, + "minClientAdaptiveBitRate": 150000, + "minMotionAdaptiveBitRate": 750000, + "fpsValues": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 12, + 15, + 16, + 18, + 20, + 24, + 25, + 30 + ], + "idrInterval": 5 + }, + { + "id": 2, + "videoId": "video3", + "name": "Umefvk Fug", + "enabled": true, + "isRtspEnabled": false, + "rtspAlias": null, + "width": 640, + "height": 360, + "fps": 30, + "bitrate": 200000, + "minBitrate": 32000, + "maxBitrate": 1000000, + "minClientAdaptiveBitRate": 0, + "minMotionAdaptiveBitRate": 200000, + "fpsValues": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 12, + 15, + 16, + 18, + 20, + 24, + 25, + 30 + ], + "idrInterval": 5 + } + ], + "ispSettings": { + "aeMode": "auto", + "irLedMode": "auto", + "irLedLevel": 255, + "wdr": 1, + "icrSensitivity": 0, + "brightness": 50, + "contrast": 50, + "hue": 50, + "saturation": 50, + "sharpness": 50, + "denoise": 50, + "isFlippedVertical": false, + "isFlippedHorizontal": false, + "isAutoRotateEnabled": true, + "isLdcEnabled": true, + "is3dnrEnabled": true, + "isExternalIrEnabled": false, + "isAggressiveAntiFlickerEnabled": false, + "isPauseMotionEnabled": false, + "dZoomCenterX": 50, + "dZoomCenterY": 50, + "dZoomScale": 0, + "dZoomStreamId": 4, + "focusMode": "ztrig", + "focusPosition": 0, + "touchFocusX": 1001, + "touchFocusY": 1001, + "zoomPosition": 0, + "mountPosition": "wall" + }, + "talkbackSettings": { + "typeFmt": "aac", + "typeIn": "serverudp", + "bindAddr": "0.0.0.0", + "bindPort": 7004, + "filterAddr": "", + "filterPort": 0, + "channels": 1, + "samplingRate": 22050, + "bitsPerSample": 16, + "quality": 100 + }, + "osdSettings": { + "isNameEnabled": true, + "isDateEnabled": true, + "isLogoEnabled": false, + "isDebugEnabled": false + }, + "ledSettings": { + "isEnabled": false, + "blinkRate": 0 + }, + "speakerSettings": { + "isEnabled": true, + "areSystemSoundsEnabled": false, + "volume": 100 + }, + "recordingSettings": { + "prePaddingSecs": 10, + "postPaddingSecs": 10, + "minMotionEventTrigger": 1000, + "endMotionEventDelay": 3000, + "suppressIlluminationSurge": false, + "mode": "detections", + "geofencing": "off", + "motionAlgorithm": "enhanced", + "enablePirTimelapse": false, + "useNewMotionAlgorithm": true + }, + "smartDetectSettings": { + "objectTypes": [] + }, + "recordingSchedules": [], + "motionZones": [ + { + "id": 1, + "name": "Default", + "color": "#AB46BC", + "points": [ + [ + 0, + 0 + ], + [ + 1, + 0 + ], + [ + 1, + 1 + ], + [ + 0, + 1 + ] + ], + "sensitivity": 50 + } + ], + "privacyZones": [], + "smartDetectZones": [ + { + "id": 1, + "name": "Default", + "color": "#AB46BC", + "points": [ + [ + 0, + 0 + ], + [ + 1, + 0 + ], + [ + 1, + 1 + ], + [ + 0, + 1 + ] + ], + "sensitivity": 50, + "objectTypes": [] + } + ], + "smartDetectLines": [], + "stats": { + "rxBytes": 33684237, + "txBytes": 1208318620, + "wifi": { + "channel": 6, + "frequency": 2437, + "linkSpeedMbps": null, + "signalQuality": 100, + "signalStrength": -35 + }, + "battery": { + "percentage": null, + "isCharging": false, + "sleepState": "disconnected" + }, + "video": { + "recordingStart": 1639219284079, + "recordingEnd": 1640021215245, + "recordingStartLQ": 1639219283987, + "recordingEndLQ": 1640021217213, + "timelapseStart": 1639219284030, + "timelapseEnd": 1640023738713, + "timelapseStartLQ": 1639219284030, + "timelapseEndLQ": 1640021765237 + }, + "storage": { + "used": 20401094656, + "rate": 693.424269097809 + }, + "wifiQuality": 100, + "wifiStrength": -35 + }, + "featureFlags": { + "canAdjustIrLedLevel": false, + "canMagicZoom": false, + "canOpticalZoom": false, + "canTouchFocus": false, + "hasAccelerometer": true, + "hasAec": true, + "hasBattery": false, + "hasBluetooth": true, + "hasChime": false, + "hasExternalIr": false, + "hasIcrSensitivity": true, + "hasLdc": false, + "hasLedIr": true, + "hasLedStatus": true, + "hasLineIn": false, + "hasMic": true, + "hasPrivacyMask": true, + "hasRtc": false, + "hasSdCard": false, + "hasSpeaker": true, + "hasWifi": true, + "hasHdr": true, + "hasAutoICROnly": true, + "videoModes": [ + "default" + ], + "videoModeMaxFps": [], + "hasMotionZones": true, + "hasLcdScreen": false, + "mountPositions": [], + "smartDetectTypes": [], + "motionAlgorithms": [ + "enhanced" + ], + "hasSquareEventThumbnail": true, + "hasPackageCamera": false, + "privacyMaskCapability": { + "maxMasks": 4, + "rectangleOnly": true + }, + "focus": { + "steps": { + "max": null, + "min": null, + "step": null + }, + "degrees": { + "max": null, + "min": null, + "step": null + } + }, + "pan": { + "steps": { + "max": null, + "min": null, + "step": null + }, + "degrees": { + "max": null, + "min": null, + "step": null + } + }, + "tilt": { + "steps": { + "max": null, + "min": null, + "step": null + }, + "degrees": { + "max": null, + "min": null, + "step": null + } + }, + "zoom": { + "steps": { + "max": null, + "min": null, + "step": null + }, + "degrees": { + "max": null, + "min": null, + "step": null + } + }, + "hasSmartDetect": false + }, + "pirSettings": { + "pirSensitivity": 100, + "pirMotionClipLength": 15, + "timelapseFrameInterval": 15, + "timelapseTransferInterval": 600 + }, + "lcdMessage": {}, + "wifiConnectionState": { + "channel": 6, + "frequency": 2437, + "phyRate": 72, + "signalQuality": 100, + "signalStrength": -35, + "ssid": "Mortis Camera" + }, + "lenses": [], + "id": "0de062b4f6922d489d3b312d", + "isConnected": true, + "platform": "sav530q", + "hasSpeaker": true, + "hasWifi": true, + "audioBitrate": 64000, + "canManage": false, + "isManaged": true, + "marketName": "G4 Instant", + "modelKey": "camera" +} diff --git a/tests/components/unifiprotect/fixtures/sample_doorlock.json b/tests/components/unifiprotect/fixtures/sample_doorlock.json new file mode 100644 index 00000000000000..babfe826763721 --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_doorlock.json @@ -0,0 +1,52 @@ +{ + "mac": "F10599AB6955", + "host": null, + "connectionHost": "192.168.102.63", + "type": "UFP-LOCK-R", + "name": "Wkltg Qcjxv", + "upSince": 1643050461849, + "uptime": null, + "lastSeen": 1643052750858, + "connectedSince": 1643052765849, + "state": "CONNECTED", + "hardwareRevision": 7, + "firmwareVersion": "1.2.0", + "latestFirmwareVersion": "1.2.0", + "firmwareBuild": null, + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "credentials": "955756200c7f43936df9d5f7865f058e1528945aac0f0cb27cef960eb58f17db", + "lockStatus": "CLOSING", + "enableHomekit": false, + "autoCloseTimeMs": 15000, + "wiredConnectionState": { + "phyRate": null + }, + "ledSettings": { + "isEnabled": true + }, + "bluetoothConnectionState": { + "signalQuality": 62, + "signalStrength": -65 + }, + "batteryStatus": { + "percentage": 100, + "isLow": false + }, + "bridge": "61b3f5c90050a703e700042a", + "camera": "e2ff0ade6be0f2a2beb61869", + "bridgeCandidates": [], + "id": "1c812e80fd693ab51535be38", + "isConnected": true, + "hasHomekit": false, + "marketName": "UP DoorLock", + "modelKey": "doorlock", + "privateToken": "MsjIV0UUpMWuAQZvJnCOfC1K9UAfgqDKCIcWtANWIuW66OXLwSgMbNEG2MEkL2TViSkMbJvFxAQEyHU0EJeVCWzY6dGHGuKXFXZMqJWZivBGDC8JoXiRxNIBqHZtXQKXZIoXWKLmhBL7SDxLoFNYEYNNLUGKGFBBGX2oNLi8KRW3SDSUTTWJZNwAUs8GKeJJ" +} diff --git a/tests/components/unifiprotect/fixtures/sample_light.json b/tests/components/unifiprotect/fixtures/sample_light.json new file mode 100644 index 00000000000000..599c26f4f0c942 --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_light.json @@ -0,0 +1,53 @@ +{ + "mac": "D7F1C8D3FCDD", + "host": "192.168.10.86", + "connectionHost": "192.168.178.217", + "type": "UP FloodLight", + "name": "Byyfbpe Ufoka", + "upSince": 1638128991022, + "uptime": 1894890, + "lastSeen": 1640023881022, + "connectedSince": 1640020579711, + "state": "CONNECTED", + "hardwareRevision": null, + "firmwareVersion": "1.9.3", + "latestFirmwareVersion": "1.9.3", + "firmwareBuild": "g990c553.211105.251", + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": true, + "canAdopt": false, + "isAttemptingToConnect": false, + "isPirMotionDetected": false, + "lastMotion": 1640022006069, + "isDark": false, + "isLightOn": false, + "isLocating": false, + "wiredConnectionState": { + "phyRate": 100 + }, + "lightDeviceSettings": { + "isIndicatorEnabled": true, + "ledLevel": 6, + "luxSensitivity": "medium", + "pirDuration": 120000, + "pirSensitivity": 46 + }, + "lightOnSettings": { + "isLedForceOn": false + }, + "lightModeSettings": { + "mode": "off", + "enableAt": "fulltime" + }, + "camera": "193be66559c03ec5629f54cd", + "id": "37dd610720816cfb5c547967", + "isConnected": true, + "isCameraPaired": true, + "marketName": "UP FloodLight", + "modelKey": "light" +} diff --git a/tests/components/unifiprotect/fixtures/sample_liveview.json b/tests/components/unifiprotect/fixtures/sample_liveview.json new file mode 100644 index 00000000000000..70e641285bb512 --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_liveview.json @@ -0,0 +1,72 @@ +{ + "name": "Default", + "isDefault": true, + "isGlobal": true, + "layout": 9, + "slots": [ + { + "cameras": [ + "0488c1538efb5cc9f73f77ca" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "0de062b4f6922d489d3b312d" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "193be66559c03ec5629f54cd" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "16b0c551e36d872806f2806b" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "5becd64d90f1cae3a4146a0f" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "4f5fab885aca3f7c226b22b9" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "cc7a572a0a8677baae933873" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [ + "f4e9f4421209908c51284e67" + ], + "cycleMode": "time", + "cycleInterval": 10 + }, + { + "cameras": [], + "cycleMode": "time", + "cycleInterval": 10 + } + ], + "owner": "5a839670ad0a929bf8271c26", + "id": "ecb21f15e6d8fae65fea82f8", + "modelKey": "liveview" +} diff --git a/tests/components/unifiprotect/fixtures/sample_nvr.json b/tests/components/unifiprotect/fixtures/sample_nvr.json new file mode 100644 index 00000000000000..f8962836045635 --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_nvr.json @@ -0,0 +1,236 @@ +{ + "mac": "A1E00C826924", + "host": "192.168.216.198", + "name": "UnifiProtect", + "canAutoUpdate": true, + "isStatsGatheringEnabled": true, + "timezone": "America/New_York", + "version": "1.21.0-beta.2", + "ucoreVersion": "2.3.26", + "firmwareVersion": "2.3.10", + "uiVersion": null, + "hardwarePlatform": "al324", + "ports": { + "ump": 7449, + "http": 7080, + "https": 7443, + "rtsp": 7447, + "rtsps": 7441, + "rtmp": 1935, + "devicesWss": 7442, + "cameraHttps": 7444, + "cameraTcp": 7877, + "liveWs": 7445, + "liveWss": 7446, + "tcpStreams": 7448, + "playback": 7450, + "emsCLI": 7440, + "emsLiveFLV": 7550, + "cameraEvents": 7551, + "tcpBridge": 7888, + "ucore": 11081, + "discoveryClient": 0 + }, + "uptime": 1191516000, + "lastSeen": 1641269019283, + "isUpdating": false, + "lastUpdateAt": null, + "isStation": false, + "enableAutomaticBackups": true, + "enableStatsReporting": false, + "isSshEnabled": false, + "errorCode": null, + "releaseChannel": "beta", + "ssoChannel": null, + "hosts": [ + "192.168.216.198" + ], + "enableBridgeAutoAdoption": true, + "hardwareId": "baf4878d-df21-4427-9fbe-c2ef15301412", + "hardwareRevision": "113-03137-22", + "hostType": 59936, + "hostShortname": "UNVRPRO", + "isHardware": true, + "isWirelessUplinkEnabled": false, + "timeFormat": "24h", + "temperatureUnit": "C", + "recordingRetentionDurationMs": null, + "enableCrashReporting": true, + "disableAudio": false, + "analyticsData": "anonymous", + "anonymousDeviceId": "65257f7d-874c-498a-8f1b-00b2dd0a7ae1", + "cameraUtilization": 30, + "isRecycling": false, + "avgMotions": [ + 21.29, + 14, + 5.43, + 2.29, + 6.43, + 7.43, + 16.86, + 17, + 24.71, + 36.86, + 46.43, + 47.57, + 51.57, + 52.71, + 63.86, + 80.86, + 86.71, + 91.71, + 96.57, + 71.14, + 57, + 53.71, + 39.57, + 21.29 + ], + "disableAutoLink": false, + "skipFirmwareUpdate": false, + "wifiSettings": { + "useThirdPartyWifi": false, + "ssid": null, + "password": null + }, + "locationSettings": { + "isAway": true, + "isGeofencingEnabled": false, + "latitude": 41.4519, + "longitude": -81.921, + "radius": 200 + }, + "featureFlags": { + "beta": false, + "dev": false, + "notificationsV2": true + }, + "systemInfo": { + "cpu": { + "averageLoad": 5, + "temperature": 70 + }, + "memory": { + "available": 6481504, + "free": 87080, + "total": 8163024 + }, + "storage": { + "available": 21796939214848, + "isRecycling": false, + "size": 31855989432320, + "type": "raid", + "used": 8459815895040, + "devices": [ + { + "model": "ST16000VE000-2L2103", + "size": 16000900661248, + "healthy": true + }, + { + "model": "ST16000VE000-2L2103", + "size": 16000900661248, + "healthy": true + }, + { + "model": "ST16000VE000-2L2103", + "size": 16000900661248, + "healthy": true + } + ] + }, + "tmpfs": { + "available": 934204, + "total": 1048576, + "used": 114372, + "path": "/var/opt/unifi-protect/tmp" + } + }, + "doorbellSettings": { + "defaultMessageText": "Welcome", + "defaultMessageResetTimeoutMs": 60000, + "customMessages": [ + "Come In!", + "Use Other Door" + ], + "allMessages": [ + { + "type": "LEAVE_PACKAGE_AT_DOOR", + "text": "LEAVE PACKAGE AT DOOR" + }, + { + "type": "DO_NOT_DISTURB", + "text": "DO NOT DISTURB" + }, + { + "type": "CUSTOM_MESSAGE", + "text": "Test" + } + ] + }, + "smartDetectAgreement": { + "status": "agreed", + "lastUpdateAt": 1606964227734 + }, + "storageStats": { + "utilization": 26.61384533704469, + "capacity": 5706909122, + "remainingCapacity": 4188081155, + "recordingSpace": { + "total": 31787269955584, + "used": 8459814862848, + "available": 23327455092736 + }, + "storageDistribution": { + "recordingTypeDistributions": [ + { + "recordingType": "rotating", + "size": 7736989099040, + "percentage": 91.47686438351941 + }, + { + "recordingType": "timelapse", + "size": 21474836480, + "percentage": 0.2539037704709915 + }, + { + "recordingType": "detections", + "size": 699400412128, + "percentage": 8.269231846009593 + } + ], + "resolutionDistributions": [ + { + "resolution": "HD", + "size": 2896955441152, + "percentage": 9.113571077981481 + }, + { + "resolution": "4K", + "size": 5560908906496, + "percentage": 17.494138107066746 + }, + { + "resolution": "free", + "size": 23329405607936, + "percentage": 73.39229081495176 + } + ] + } + }, + "id": "test_id", + "isAway": true, + "isSetup": true, + "network": "Ethernet", + "type": "UNVR-PRO", + "upSince": 1640077503063, + "isRecordingDisabled": false, + "isRecordingMotionOnly": false, + "maxCameraCapacity": { + "4K": 20, + "2K": 30, + "HD": 60 + }, + "modelKey": "nvr" +} diff --git a/tests/components/unifiprotect/fixtures/sample_sensor.json b/tests/components/unifiprotect/fixtures/sample_sensor.json new file mode 100644 index 00000000000000..ef9e8253b91d9d --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_sensor.json @@ -0,0 +1,92 @@ +{ + "mac": "26DBAFF133A4", + "connectionHost": "192.168.216.198", + "type": "UFP-SENSE", + "name": "Egdczv Urg", + "upSince": 1641256963255, + "uptime": null, + "lastSeen": 1641259127934, + "connectedSince": 1641259139255, + "state": "CONNECTED", + "hardwareRevision": 6, + "firmwareVersion": "1.0.2", + "latestFirmwareVersion": "1.0.2", + "firmwareBuild": null, + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "isMotionDetected": false, + "mountType": "door", + "leakDetectedAt": null, + "tamperingDetectedAt": null, + "isOpened": true, + "openStatusChangedAt": 1641269036582, + "alarmTriggeredAt": null, + "motionDetectedAt": 1641269044824, + "wiredConnectionState": { + "phyRate": null + }, + "stats": { + "light": { + "value": 0, + "status": "neutral" + }, + "humidity": { + "value": 35, + "status": "neutral" + }, + "temperature": { + "value": 17.23, + "status": "neutral" + } + }, + "bluetoothConnectionState": { + "signalQuality": 15, + "signalStrength": -84 + }, + "batteryStatus": { + "percentage": 100, + "isLow": false + }, + "alarmSettings": { + "isEnabled": false + }, + "lightSettings": { + "isEnabled": true, + "lowThreshold": null, + "highThreshold": null, + "margin": 10 + }, + "motionSettings": { + "isEnabled": true, + "sensitivity": 100 + }, + "temperatureSettings": { + "isEnabled": true, + "lowThreshold": null, + "highThreshold": null, + "margin": 0.1 + }, + "humiditySettings": { + "isEnabled": true, + "lowThreshold": null, + "highThreshold": null, + "margin": 1 + }, + "ledSettings": { + "isEnabled": true + }, + "bridge": "61b3f5c90050a703e700042a", + "camera": "2f9beb2e6f79af3c32c22d49", + "bridgeCandidates": [], + "id": "f6ecbe829f81cc79ad6e0c9a", + "isConnected": true, + "marketName": "UP Sense", + "modelKey": "sensor" +} diff --git a/tests/components/unifiprotect/fixtures/sample_viewport.json b/tests/components/unifiprotect/fixtures/sample_viewport.json new file mode 100644 index 00000000000000..001abd86417ffb --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_viewport.json @@ -0,0 +1,35 @@ +{ + "mac": "4EDC1B6D2F76", + "host": "192.168.34.145", + "connectionHost": "192.168.178.217", + "type": "UP Viewport", + "name": "Yfptv Ttklkw", + "upSince": 1639845760126, + "uptime": 178121, + "lastSeen": 1640023881126, + "connectedSince": 1640020660049, + "state": "CONNECTED", + "hardwareRevision": null, + "firmwareVersion": "1.2.54", + "latestFirmwareVersion": "1.2.54", + "firmwareBuild": "dcfb16f3.210907.625", + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "streamLimit": 16, + "softwareVersion": "1.2.54", + "wiredConnectionState": { + "phyRate": 1000 + }, + "liveview": "ecb21f15e6d8fae65fea82f8", + "id": "5ec2a22846047eeb6e976922", + "isConnected": true, + "marketName": "UP ViewPort", + "modelKey": "viewer" +} diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py new file mode 100644 index 00000000000000..88f19e59d7da44 --- /dev/null +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -0,0 +1,529 @@ +"""Test the UniFi Protect binary_sensor platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from copy import copy +from datetime import datetime, timedelta +from unittest.mock import Mock + +import pytest +from pyunifiprotect.data import Camera, Event, EventType, Light, MountType, Sensor +from pyunifiprotect.data.nvr import EventMetadata + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.unifiprotect.binary_sensor import ( + CAMERA_SENSORS, + LIGHT_SENSORS, + MOTION_SENSORS, + SENSE_SENSORS, +) +from homeassistant.components.unifiprotect.const import ( + ATTR_EVENT_SCORE, + DEFAULT_ATTRIBUTION, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, + ATTR_LAST_TRIP_TIME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + MockEntityFixture, + assert_entity_counts, + ids_from_device_description, +) + + +@pytest.fixture(name="camera") +async def camera_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_camera: Camera, + now: datetime, +): + """Fixture for a single camera for testing the binary_sensor platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.has_chime = True + camera_obj.last_ring = now - timedelta(hours=1) + camera_obj.is_dark = False + camera_obj.is_motion_detected = False + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +@pytest.fixture(name="light") +async def light_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light, now: datetime +): + """Fixture for a single light for testing the binary_sensor platform.""" + + # disable pydantic validation so mocking can happen + Light.__config__.validate_assignment = False + + light_obj = mock_light.copy(deep=True) + light_obj._api = mock_entry.api + light_obj.name = "Test Light" + light_obj.is_dark = False + light_obj.is_pir_motion_detected = False + light_obj.last_motion = now - timedelta(hours=1) + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] + mock_entry.api.bootstrap.lights = { + light_obj.id: light_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) + + yield light_obj + + Light.__config__.validate_assignment = True + + +@pytest.fixture(name="camera_none") +async def camera_none_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the binary_sensor platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.has_chime = False + camera_obj.is_dark = False + camera_obj.is_motion_detected = False + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +@pytest.fixture(name="sensor") +async def sensor_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_sensor: Sensor, + now: datetime, +): + """Fixture for a single sensor for testing the binary_sensor platform.""" + + # disable pydantic validation so mocking can happen + Sensor.__config__.validate_assignment = False + + sensor_obj = mock_sensor.copy(deep=True) + sensor_obj._api = mock_entry.api + sensor_obj.name = "Test Sensor" + sensor_obj.mount_type = MountType.DOOR + sensor_obj.is_opened = False + sensor_obj.battery_status.is_low = False + sensor_obj.is_motion_detected = False + sensor_obj.alarm_settings.is_enabled = True + sensor_obj.motion_detected_at = now - timedelta(hours=1) + sensor_obj.open_status_changed_at = now - timedelta(hours=1) + sensor_obj.alarm_triggered_at = now - timedelta(hours=1) + sensor_obj.tampering_detected_at = now - timedelta(hours=1) + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] + mock_entry.api.bootstrap.sensors = { + sensor_obj.id: sensor_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + + yield sensor_obj + + Sensor.__config__.validate_assignment = True + + +@pytest.fixture(name="sensor_none") +async def sensor_none_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_sensor: Sensor, + now: datetime, +): + """Fixture for a single sensor for testing the binary_sensor platform.""" + + # disable pydantic validation so mocking can happen + Sensor.__config__.validate_assignment = False + + sensor_obj = mock_sensor.copy(deep=True) + sensor_obj._api = mock_entry.api + sensor_obj.name = "Test Sensor" + sensor_obj.mount_type = MountType.LEAK + sensor_obj.battery_status.is_low = False + sensor_obj.alarm_settings.is_enabled = False + sensor_obj.tampering_detected_at = now - timedelta(hours=1) + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] + mock_entry.api.bootstrap.sensors = { + sensor_obj.id: sensor_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + + yield sensor_obj + + Sensor.__config__.validate_assignment = True + + +async def test_binary_sensor_setup_light( + hass: HomeAssistant, light: Light, now: datetime +): + """Test binary_sensor entity setup for light devices.""" + + entity_registry = er.async_get(hass) + + for index, description in enumerate(LIGHT_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, light, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + if index == 1: + assert state.attributes[ATTR_LAST_TRIP_TIME] == now - timedelta(hours=1) + + +async def test_binary_sensor_setup_camera_all( + hass: HomeAssistant, camera: Camera, now: datetime +): + """Test binary_sensor entity setup for camera devices (all features).""" + + entity_registry = er.async_get(hass) + + description = CAMERA_SENSORS[0] + unique_id, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, camera, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + assert state.attributes[ATTR_LAST_TRIP_TIME] == now - timedelta(hours=1) + + # Is Dark + description = CAMERA_SENSORS[1] + unique_id, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, camera, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + # Motion + description = MOTION_SENSORS[0] + unique_id, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, camera, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_SCORE] == 0 + + +async def test_binary_sensor_setup_camera_none( + hass: HomeAssistant, + camera_none: Camera, +): + """Test binary_sensor entity setup for camera devices (no features).""" + + entity_registry = er.async_get(hass) + description = CAMERA_SENSORS[1] + + unique_id, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, camera_none, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_binary_sensor_setup_sensor( + hass: HomeAssistant, sensor: Sensor, now: datetime +): + """Test binary_sensor entity setup for sensor devices.""" + + entity_registry = er.async_get(hass) + + expected_trip_time = now - timedelta(hours=1) + for index, description in enumerate(SENSE_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, sensor, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + if index != 1: + assert state.attributes[ATTR_LAST_TRIP_TIME] == expected_trip_time + + +async def test_binary_sensor_setup_sensor_none( + hass: HomeAssistant, sensor_none: Sensor +): + """Test binary_sensor entity setup for sensor with most sensors disabled.""" + + entity_registry = er.async_get(hass) + + expected = [ + STATE_UNAVAILABLE, + STATE_OFF, + STATE_UNAVAILABLE, + STATE_OFF, + ] + for index, description in enumerate(SENSE_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, sensor_none, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + print(entity_id) + assert state.state == expected[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_binary_sensor_update_motion( + hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime +): + """Test binary_sensor motion entity.""" + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, camera, MOTION_SENSORS[0] + ) + + event = Event( + id="test_event_id", + type=EventType.MOTION, + start=now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=camera.id, + ) + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_camera = camera.copy() + new_camera.is_motion_detected = True + new_camera.last_motion_event_id = event.id + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + new_bootstrap.cameras = {new_camera.id: new_camera} + new_bootstrap.events = {event.id: event} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_SCORE] == 100 + + +async def test_binary_sensor_update_light_motion( + hass: HomeAssistant, mock_entry: MockEntityFixture, light: Light, now: datetime +): + """Test binary_sensor motion entity.""" + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, light, LIGHT_SENSORS[1] + ) + + event_metadata = EventMetadata(light_id=light.id) + event = Event( + id="test_event_id", + type=EventType.MOTION_LIGHT, + start=now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + metadata=event_metadata, + api=mock_entry.api, + ) + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_light = light.copy() + new_light.is_pir_motion_detected = True + new_light.last_motion_event_id = event.id + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + + new_bootstrap.lights = {new_light.id: new_light} + new_bootstrap.events = {event.id: event} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + +async def test_binary_sensor_update_mount_type_window( + hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor +): + """Test binary_sensor motion entity.""" + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, sensor, SENSE_SENSORS[0] + ) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_sensor = sensor.copy() + new_sensor.mount_type = MountType.WINDOW + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_sensor + + new_bootstrap.sensors = {new_sensor.id: new_sensor} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.WINDOW.value + + +async def test_binary_sensor_update_mount_type_garage( + hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor +): + """Test binary_sensor motion entity.""" + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, sensor, SENSE_SENSORS[0] + ) + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_sensor = sensor.copy() + new_sensor.mount_type = MountType.GARAGE + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_sensor + + new_bootstrap.sensors = {new_sensor.id: new_sensor} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert ( + state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GARAGE_DOOR.value + ) diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py new file mode 100644 index 00000000000000..0064781c6ced3b --- /dev/null +++ b/tests/components/unifiprotect/test_button.py @@ -0,0 +1,69 @@ +"""Test the UniFi Protect button platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from pyunifiprotect.data import Camera + +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MockEntityFixture, assert_entity_counts, enable_entity + + +@pytest.fixture(name="camera") +async def camera_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the button platform.""" + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.BUTTON, 1, 0) + + return (camera_obj, "button.test_camera_reboot_device") + + +async def test_button( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[Camera, str], +): + """Test button entity.""" + + mock_entry.api.reboot_device = AsyncMock() + + unique_id = f"{camera[0].id}" + entity_id = camera[1] + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + await hass.services.async_call( + "button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_entry.api.reboot_device.assert_called_once() diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py new file mode 100644 index 00000000000000..362481bfb035e0 --- /dev/null +++ b/tests/components/unifiprotect/test_camera.py @@ -0,0 +1,576 @@ +"""Test the UniFi Protect camera platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from copy import copy +from unittest.mock import AsyncMock, Mock + +import pytest +from pyunifiprotect.data import Camera as ProtectCamera +from pyunifiprotect.data.devices import CameraChannel +from pyunifiprotect.data.types import StateType +from pyunifiprotect.exceptions import NvrError + +from homeassistant.components.camera import ( + SUPPORT_STREAM, + Camera, + async_get_image, + async_get_stream_source, +) +from homeassistant.components.unifiprotect.const import ( + ATTR_BITRATE, + ATTR_CHANNEL_ID, + ATTR_FPS, + ATTR_HEIGHT, + ATTR_WIDTH, + DEFAULT_ATTRIBUTION, + DEFAULT_SCAN_INTERVAL, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .conftest import ( + MockEntityFixture, + assert_entity_counts, + enable_entity, + time_changed, +) + + +@pytest.fixture(name="camera") +async def camera_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the camera platform.""" + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.channels[0].is_rtsp_enabled = True + camera_obj.channels[0].name = "High" + camera_obj.channels[1].is_rtsp_enabled = False + camera_obj.channels[2].is_rtsp_enabled = False + + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + + return (camera_obj, "camera.test_camera_high") + + +@pytest.fixture(name="camera_package") +async def camera_package_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the camera platform.""" + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.has_package_camera = True + camera_obj.channels[0].is_rtsp_enabled = True + camera_obj.channels[0].name = "High" + camera_obj.channels[0].rtsp_alias = "test_high_alias" + camera_obj.channels[1].is_rtsp_enabled = False + camera_obj.channels[2].is_rtsp_enabled = False + package_channel = camera_obj.channels[0].copy(deep=True) + package_channel.is_rtsp_enabled = False + package_channel.name = "Package Camera" + package_channel.id = 3 + package_channel.fps = 2 + package_channel.rtsp_alias = "test_package_alias" + camera_obj.channels.append(package_channel) + + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.CAMERA, 3, 2) + + return (camera_obj, "camera.test_camera_package_camera") + + +def validate_default_camera_entity( + hass: HomeAssistant, + camera_obj: ProtectCamera, + channel_id: int, +) -> str: + """Validate a camera entity.""" + + channel = camera_obj.channels[channel_id] + + entity_name = f"{camera_obj.name} {channel.name}" + unique_id = f"{camera_obj.id}_{channel.id}" + entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is False + assert entity.unique_id == unique_id + + return entity_id + + +def validate_rtsps_camera_entity( + hass: HomeAssistant, + camera_obj: ProtectCamera, + channel_id: int, +) -> str: + """Validate a disabled RTSPS camera entity.""" + + channel = camera_obj.channels[channel_id] + + entity_name = f"{camera_obj.name} {channel.name}" + unique_id = f"{camera_obj.id}_{channel.id}" + entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + return entity_id + + +def validate_rtsp_camera_entity( + hass: HomeAssistant, + camera_obj: ProtectCamera, + channel_id: int, +) -> str: + """Validate a disabled RTSP camera entity.""" + + channel = camera_obj.channels[channel_id] + + entity_name = f"{camera_obj.name} {channel.name} Insecure" + unique_id = f"{camera_obj.id}_{channel.id}_insecure" + entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + return entity_id + + +def validate_common_camera_state( + hass: HomeAssistant, + channel: CameraChannel, + entity_id: str, + features: int = SUPPORT_STREAM, +): + """Validate state that is common to all camera entity, regradless of type.""" + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert entity_state.attributes[ATTR_SUPPORTED_FEATURES] == features + assert entity_state.attributes[ATTR_WIDTH] == channel.width + assert entity_state.attributes[ATTR_HEIGHT] == channel.height + assert entity_state.attributes[ATTR_FPS] == channel.fps + assert entity_state.attributes[ATTR_BITRATE] == channel.bitrate + assert entity_state.attributes[ATTR_CHANNEL_ID] == channel.id + + +async def validate_rtsps_camera_state( + hass: HomeAssistant, + camera_obj: ProtectCamera, + channel_id: int, + entity_id: str, + features: int = SUPPORT_STREAM, +): + """Validate a camera's state.""" + channel = camera_obj.channels[channel_id] + + assert await async_get_stream_source(hass, entity_id) == channel.rtsps_url + validate_common_camera_state(hass, channel, entity_id, features) + + +async def validate_rtsp_camera_state( + hass: HomeAssistant, + camera_obj: ProtectCamera, + channel_id: int, + entity_id: str, + features: int = SUPPORT_STREAM, +): + """Validate a camera's state.""" + channel = camera_obj.channels[channel_id] + + assert await async_get_stream_source(hass, entity_id) == channel.rtsp_url + validate_common_camera_state(hass, channel, entity_id, features) + + +async def validate_no_stream_camera_state( + hass: HomeAssistant, + camera_obj: ProtectCamera, + channel_id: int, + entity_id: str, + features: int = SUPPORT_STREAM, +): + """Validate a camera's state.""" + channel = camera_obj.channels[channel_id] + + assert await async_get_stream_source(hass, entity_id) is None + validate_common_camera_state(hass, channel, entity_id, features) + + +async def test_basic_setup( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: ProtectCamera +): + """Test working setup of unifiprotect entry.""" + + camera_high_only = mock_camera.copy(deep=True) + camera_high_only._api = mock_entry.api + camera_high_only.channels[0]._api = mock_entry.api + camera_high_only.channels[1]._api = mock_entry.api + camera_high_only.channels[2]._api = mock_entry.api + camera_high_only.name = "Test Camera 1" + camera_high_only.id = "test_high" + camera_high_only.channels[0].is_rtsp_enabled = True + camera_high_only.channels[0].name = "High" + camera_high_only.channels[0].rtsp_alias = "test_high_alias" + camera_high_only.channels[1].is_rtsp_enabled = False + camera_high_only.channels[2].is_rtsp_enabled = False + + camera_medium_only = mock_camera.copy(deep=True) + camera_medium_only._api = mock_entry.api + camera_medium_only.channels[0]._api = mock_entry.api + camera_medium_only.channels[1]._api = mock_entry.api + camera_medium_only.channels[2]._api = mock_entry.api + camera_medium_only.name = "Test Camera 2" + camera_medium_only.id = "test_medium" + camera_medium_only.channels[0].is_rtsp_enabled = False + camera_medium_only.channels[1].is_rtsp_enabled = True + camera_medium_only.channels[1].name = "Medium" + camera_medium_only.channels[1].rtsp_alias = "test_medium_alias" + camera_medium_only.channels[2].is_rtsp_enabled = False + + camera_all_channels = mock_camera.copy(deep=True) + camera_all_channels._api = mock_entry.api + camera_all_channels.channels[0]._api = mock_entry.api + camera_all_channels.channels[1]._api = mock_entry.api + camera_all_channels.channels[2]._api = mock_entry.api + camera_all_channels.name = "Test Camera 3" + camera_all_channels.id = "test_all" + camera_all_channels.channels[0].is_rtsp_enabled = True + camera_all_channels.channels[0].name = "High" + camera_all_channels.channels[0].rtsp_alias = "test_high_alias" + camera_all_channels.channels[1].is_rtsp_enabled = True + camera_all_channels.channels[1].name = "Medium" + camera_all_channels.channels[1].rtsp_alias = "test_medium_alias" + camera_all_channels.channels[2].is_rtsp_enabled = True + camera_all_channels.channels[2].name = "Low" + camera_all_channels.channels[2].rtsp_alias = "test_low_alias" + + camera_no_channels = mock_camera.copy(deep=True) + camera_no_channels._api = mock_entry.api + camera_no_channels.channels[0]._api = mock_entry.api + camera_no_channels.channels[1]._api = mock_entry.api + camera_no_channels.channels[2]._api = mock_entry.api + camera_no_channels.name = "Test Camera 4" + camera_no_channels.id = "test_none" + camera_no_channels.channels[0].is_rtsp_enabled = False + camera_no_channels.channels[0].name = "High" + camera_no_channels.channels[1].is_rtsp_enabled = False + camera_no_channels.channels[2].is_rtsp_enabled = False + + camera_package = mock_camera.copy(deep=True) + camera_package._api = mock_entry.api + camera_package.channels[0]._api = mock_entry.api + camera_package.channels[1]._api = mock_entry.api + camera_package.channels[2]._api = mock_entry.api + camera_package.name = "Test Camera 5" + camera_package.id = "test_package" + camera_package.channels[0].is_rtsp_enabled = True + camera_package.channels[0].name = "High" + camera_package.channels[0].rtsp_alias = "test_high_alias" + camera_package.channels[1].is_rtsp_enabled = False + camera_package.channels[2].is_rtsp_enabled = False + package_channel = camera_package.channels[0].copy(deep=True) + package_channel.is_rtsp_enabled = False + package_channel.name = "Package Camera" + package_channel.id = 3 + package_channel.fps = 2 + package_channel.rtsp_alias = "test_package_alias" + camera_package.channels.append(package_channel) + + mock_entry.api.bootstrap.cameras = { + camera_high_only.id: camera_high_only, + camera_medium_only.id: camera_medium_only, + camera_all_channels.id: camera_all_channels, + camera_no_channels.id: camera_no_channels, + camera_package.id: camera_package, + } + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.CAMERA, 14, 6) + + # test camera 1 + entity_id = validate_default_camera_entity(hass, camera_high_only, 0) + await validate_rtsps_camera_state(hass, camera_high_only, 0, entity_id) + + entity_id = validate_rtsp_camera_entity(hass, camera_high_only, 0) + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_high_only, 0, entity_id) + + # test camera 2 + entity_id = validate_default_camera_entity(hass, camera_medium_only, 1) + await validate_rtsps_camera_state(hass, camera_medium_only, 1, entity_id) + + entity_id = validate_rtsp_camera_entity(hass, camera_medium_only, 1) + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_medium_only, 1, entity_id) + + # test camera 3 + entity_id = validate_default_camera_entity(hass, camera_all_channels, 0) + await validate_rtsps_camera_state(hass, camera_all_channels, 0, entity_id) + + entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 0) + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_all_channels, 0, entity_id) + + entity_id = validate_rtsps_camera_entity(hass, camera_all_channels, 1) + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await validate_rtsps_camera_state(hass, camera_all_channels, 1, entity_id) + + entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 1) + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_all_channels, 1, entity_id) + + entity_id = validate_rtsps_camera_entity(hass, camera_all_channels, 2) + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await validate_rtsps_camera_state(hass, camera_all_channels, 2, entity_id) + + entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 2) + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_all_channels, 2, entity_id) + + # test camera 4 + entity_id = validate_default_camera_entity(hass, camera_no_channels, 0) + await validate_no_stream_camera_state( + hass, camera_no_channels, 0, entity_id, features=0 + ) + + # test camera 5 + entity_id = validate_default_camera_entity(hass, camera_package, 0) + await validate_rtsps_camera_state(hass, camera_package, 0, entity_id) + + entity_id = validate_rtsp_camera_entity(hass, camera_package, 0) + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_package, 0, entity_id) + + entity_id = validate_default_camera_entity(hass, camera_package, 3) + await validate_no_stream_camera_state( + hass, camera_package, 3, entity_id, features=0 + ) + + +async def test_missing_channels( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: ProtectCamera +): + """Test setting up camera with no camera channels.""" + + camera = mock_camera.copy(deep=True) + camera.channels = [] + + mock_entry.api.bootstrap.cameras = {camera.id: camera} + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + assert len(hass.states.async_all()) == 0 + assert len(entity_registry.entities) == 0 + + +async def test_camera_image( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[Camera, str], +): + """Test retrieving camera image.""" + + mock_entry.api.get_camera_snapshot = AsyncMock() + + await async_get_image(hass, camera[1]) + mock_entry.api.get_camera_snapshot.assert_called_once() + + +async def test_package_camera_image( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera_package: tuple[Camera, str], +): + """Test retrieving package camera image.""" + + mock_entry.api.get_package_camera_snapshot = AsyncMock() + + await async_get_image(hass, camera_package[1]) + mock_entry.api.get_package_camera_snapshot.assert_called_once() + + +async def test_camera_generic_update( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[ProtectCamera, str], +): + """Tests generic entity update service.""" + + assert await async_setup_component(hass, "homeassistant", {}) + + state = hass.states.get(camera[1]) + assert state and state.state == "idle" + + mock_entry.api.update = AsyncMock(return_value=None) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: camera[1]}, + blocking=True, + ) + + state = hass.states.get(camera[1]) + assert state and state.state == "idle" + + +async def test_camera_interval_update( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[ProtectCamera, str], +): + """Interval updates updates camera entity.""" + + state = hass.states.get(camera[1]) + assert state and state.state == "idle" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_camera = camera[0].copy() + new_camera.is_recording = True + + new_bootstrap.cameras = {new_camera.id: new_camera} + mock_entry.api.update = AsyncMock(return_value=new_bootstrap) + mock_entry.api.bootstrap = new_bootstrap + await time_changed(hass, DEFAULT_SCAN_INTERVAL) + + state = hass.states.get(camera[1]) + assert state and state.state == "recording" + + +async def test_camera_bad_interval_update( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[Camera, str], +): + """Interval updates marks camera unavailable.""" + + state = hass.states.get(camera[1]) + assert state and state.state == "idle" + + # update fails + mock_entry.api.update = AsyncMock(side_effect=NvrError) + await time_changed(hass, DEFAULT_SCAN_INTERVAL) + + state = hass.states.get(camera[1]) + assert state and state.state == "unavailable" + + # next update succeeds + mock_entry.api.update = AsyncMock(return_value=mock_entry.api.bootstrap) + await time_changed(hass, DEFAULT_SCAN_INTERVAL) + + state = hass.states.get(camera[1]) + assert state and state.state == "idle" + + +async def test_camera_ws_update( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[ProtectCamera, str], +): + """WS update updates camera entity.""" + + state = hass.states.get(camera[1]) + assert state and state.state == "idle" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_camera = camera[0].copy() + new_camera.is_recording = True + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + new_bootstrap.cameras = {new_camera.id: new_camera} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(camera[1]) + assert state and state.state == "recording" + + +async def test_camera_ws_update_offline( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[ProtectCamera, str], +): + """WS updates marks camera unavailable.""" + + state = hass.states.get(camera[1]) + assert state and state.state == "idle" + + # camera goes offline + new_bootstrap = copy(mock_entry.api.bootstrap) + new_camera = camera[0].copy() + new_camera.state = StateType.DISCONNECTED + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + new_bootstrap.cameras = {new_camera.id: new_camera} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(camera[1]) + assert state and state.state == "unavailable" + + # camera comes back online + new_camera.state = StateType.CONNECTED + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + new_bootstrap.cameras = {new_camera.id: new_camera} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(camera[1]) + assert state and state.state == "idle" diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py new file mode 100644 index 00000000000000..a1609984be302e --- /dev/null +++ b/tests/components/unifiprotect/test_config_flow.py @@ -0,0 +1,746 @@ +"""Test the UniFi Protect config flow.""" +from __future__ import annotations + +from dataclasses import asdict +import socket +from unittest.mock import patch + +import pytest +from pyunifiprotect import NotAuthorized, NvrError +from pyunifiprotect.data.nvr import NVR + +from homeassistant import config_entries +from homeassistant.components import dhcp, ssdp +from homeassistant.components.unifiprotect.const import ( + CONF_ALL_UPDATES, + CONF_DISABLE_RTSP, + CONF_OVERRIDE_CHOST, + DOMAIN, +) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.helpers import device_registry as dr + +from . import ( + DEVICE_HOSTNAME, + DEVICE_IP_ADDRESS, + DEVICE_MAC_ADDRESS, + DIRECT_CONNECT_DOMAIN, + UNIFI_DISCOVERY, + UNIFI_DISCOVERY_PARTIAL, + _patch_discovery, +) +from .conftest import MAC_ADDR + +from tests.common import MockConfigEntry + +DHCP_DISCOVERY = dhcp.DhcpServiceInfo( + hostname=DEVICE_HOSTNAME, + ip=DEVICE_IP_ADDRESS, + macaddress=DEVICE_MAC_ADDRESS, +) +SSDP_DISCOVERY = ( + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://{DEVICE_IP_ADDRESS}:41417/rootDesc.xml", + upnp={ + "friendlyName": "UniFi Dream Machine", + "modelDescription": "UniFi Dream Machine Pro", + "serialNumber": DEVICE_MAC_ADDRESS, + }, + ), +) + +UNIFI_DISCOVERY_DICT = asdict(UNIFI_DISCOVERY) +UNIFI_DISCOVERY_DICT_PARTIAL = asdict(UNIFI_DISCOVERY_PARTIAL) + + +async def test_form(hass: HomeAssistant, mock_nvr: NVR) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + with patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", + return_value=mock_nvr, + ), patch( + "homeassistant.components.unifiprotect.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "UnifiProtect" + assert result2["data"] == { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_version_too_old(hass: HomeAssistant, mock_old_nvr: NVR) -> None: + """Test we handle the version being too old.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", + return_value=mock_old_nvr, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "protect_version"} + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", + side_effect=NotAuthorized, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"password": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """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.unifiprotect.config_flow.ProtectApiClient.get_nvr", + side_effect=NvrError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_reauth_auth(hass: HomeAssistant, mock_nvr: NVR) -> None: + """Test we handle reauth auth.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + }, + unique_id=dr.format_mac(MAC_ADDR), + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config.entry_id, + }, + ) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + + with patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", + side_effect=NotAuthorized, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"password": "invalid_auth"} + assert result2["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", + return_value=mock_nvr, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "reauth_successful" + + +async def test_form_options(hass: HomeAssistant, mock_client) -> None: + """Test we handle options flows.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + }, + version=2, + unique_id=dr.format_mac(MAC_ADDR), + ) + mock_config.add_to_hass(hass) + + with _patch_discovery(), patch( + "homeassistant.components.unifiprotect.ProtectApiClient" + ) as mock_api: + mock_api.return_value = mock_client + + await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + assert mock_config.state == config_entries.ConfigEntryState.LOADED + + result = await hass.config_entries.options.async_init(mock_config.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert not result["errors"] + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_DISABLE_RTSP: True, CONF_ALL_UPDATES: True, CONF_OVERRIDE_CHOST: True}, + ) + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + "all_updates": True, + "disable_rtsp": True, + "override_connection_host": True, + } + + +@pytest.mark.parametrize( + "source, data", + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_SSDP, SSDP_DISCOVERY), + ], +) +async def test_discovered_by_ssdp_or_dhcp( + hass: HomeAssistant, source: str, data: dhcp.DhcpServiceInfo | ssdp.SsdpServiceInfo +) -> None: + """Test we handoff to unifi-discovery when discovered via ssdp or dhcp.""" + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": source}, + data=data, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "discovery_started" + + +async def test_discovered_by_unifi_discovery_direct_connect( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery.""" + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "discovery_confirm" + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert flows[0]["context"]["title_placeholders"] == { + "ip_address": DEVICE_IP_ADDRESS, + "name": DEVICE_HOSTNAME, + } + + assert not result["errors"] + + with patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", + return_value=mock_nvr, + ), patch( + "homeassistant.components.unifiprotect.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "UnifiProtect" + assert result2["data"] == { + "host": DIRECT_CONNECT_DOMAIN, + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_discovered_by_unifi_discovery_direct_connect_updated( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery updates the direct connect host.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "y.ui.direct", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": True, + }, + version=2, + unique_id=DEVICE_MAC_ADDRESS.replace(":", "").upper(), + ) + mock_config.add_to_hass(hass) + + with _patch_discovery(), patch( + "homeassistant.components.unifiprotect.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_config.data[CONF_HOST] == DIRECT_CONNECT_DOMAIN + + +async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_using_direct_connect( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery updates the host but not direct connect if its not in use.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "1.2.2.2", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + }, + version=2, + unique_id=DEVICE_MAC_ADDRESS.replace(":", "").upper(), + ) + mock_config.add_to_hass(hass) + + with _patch_discovery(), patch( + "homeassistant.components.unifiprotect.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_config.data[CONF_HOST] == "127.0.0.1" + + +async def test_discovered_by_unifi_discovery( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery.""" + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "discovery_confirm" + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert flows[0]["context"]["title_placeholders"] == { + "ip_address": DEVICE_IP_ADDRESS, + "name": DEVICE_HOSTNAME, + } + + assert not result["errors"] + + with patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", + side_effect=[NotAuthorized, mock_nvr], + ), patch( + "homeassistant.components.unifiprotect.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "UnifiProtect" + assert result2["data"] == { + "host": DEVICE_IP_ADDRESS, + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_discovered_by_unifi_discovery_partial( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery partial.""" + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT_PARTIAL, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "discovery_confirm" + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert flows[0]["context"]["title_placeholders"] == { + "ip_address": DEVICE_IP_ADDRESS, + "name": "NVR DDEEFF", + } + + assert not result["errors"] + + with patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", + return_value=mock_nvr, + ), patch( + "homeassistant.components.unifiprotect.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "UnifiProtect" + assert result2["data"] == { + "host": DEVICE_IP_ADDRESS, + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery from an alternate interface.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": DIRECT_CONNECT_DOMAIN, + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": True, + }, + unique_id="FFFFFFAAAAAA", + ) + mock_config.add_to_hass(hass) + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_ip_matches( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery from an alternate interface when the ip matches.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "127.0.0.1", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": True, + }, + unique_id="FFFFFFAAAAAA", + ) + mock_config.add_to_hass(hass) + + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery from an alternate interface when direct connect domain resolves to host ip.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "y.ui.direct", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": True, + }, + unique_id="FFFFFFAAAAAA", + ) + mock_config.add_to_hass(hass) + + other_ip_dict = UNIFI_DISCOVERY_DICT.copy() + other_ip_dict["source_ip"] = "127.0.0.1" + other_ip_dict["direct_connect_domain"] = "nomatchsameip.ui.direct" + + with _patch_discovery(), patch.object( + hass.loop, + "getaddrinfo", + return_value=[(socket.AF_INET, None, None, None, ("127.0.0.1", 443))], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=other_ip_dict, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver_fails( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test we can still configure if the resolver fails.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "y.ui.direct", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": True, + }, + unique_id="FFFFFFAAAAAA", + ) + mock_config.add_to_hass(hass) + + other_ip_dict = UNIFI_DISCOVERY_DICT.copy() + other_ip_dict["source_ip"] = "127.0.0.2" + other_ip_dict["direct_connect_domain"] = "nomatchsameip.ui.direct" + + with _patch_discovery(), patch.object( + hass.loop, "getaddrinfo", side_effect=OSError + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=other_ip_dict, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "discovery_confirm" + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert flows[0]["context"]["title_placeholders"] == { + "ip_address": "127.0.0.2", + "name": "unvr", + } + + assert not result["errors"] + + with patch( + "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", + return_value=mock_nvr, + ), patch( + "homeassistant.components.unifiprotect.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "UnifiProtect" + assert result2["data"] == { + "host": "nomatchsameip.ui.direct", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver_no_result( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery from an alternate interface when direct connect domain resolve has no result.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "y.ui.direct", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": True, + }, + unique_id="FFFFFFAAAAAA", + ) + mock_config.add_to_hass(hass) + + other_ip_dict = UNIFI_DISCOVERY_DICT.copy() + other_ip_dict["source_ip"] = "127.0.0.2" + other_ip_dict["direct_connect_domain"] = "y.ui.direct" + + with _patch_discovery(), patch.object(hass.loop, "getaddrinfo", return_value=[]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=other_ip_dict, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_discovery_can_be_ignored(hass: HomeAssistant, mock_nvr: NVR) -> None: + """Test a discovery can be ignored.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={}, + unique_id=DEVICE_MAC_ADDRESS.upper().replace(":", ""), + source=config_entries.SOURCE_IGNORE, + ) + mock_config.add_to_hass(hass) + with _patch_discovery(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py new file mode 100644 index 00000000000000..77bf900d87ea37 --- /dev/null +++ b/tests/components/unifiprotect/test_init.py @@ -0,0 +1,177 @@ +"""Test the UniFi Protect setup flow.""" +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +from pyunifiprotect import NotAuthorized, NvrError +from pyunifiprotect.data import NVR + +from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import _patch_discovery +from .conftest import MockBootstrap, MockEntityFixture + +from tests.common import MockConfigEntry + + +async def test_setup(hass: HomeAssistant, mock_entry: MockEntityFixture): + """Test working setup of unifiprotect entry.""" + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.entry.state == ConfigEntryState.LOADED + assert mock_entry.api.update.called + assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + + +async def test_setup_multiple( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_client, + mock_bootstrap: MockBootstrap, +): + """Test working setup of unifiprotect entry.""" + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.entry.state == ConfigEntryState.LOADED + assert mock_entry.api.update.called + assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + + nvr = mock_bootstrap.nvr + nvr._api = mock_client + nvr.mac = "A1E00C826983" + nvr.id + mock_client.get_nvr = AsyncMock(return_value=nvr) + + with patch("homeassistant.components.unifiprotect.ProtectApiClient") as mock_api: + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + }, + version=2, + ) + mock_config.add_to_hass(hass) + + mock_api.return_value = mock_client + + await hass.config_entries.async_setup(mock_config.entry_id) + await hass.async_block_till_done() + + assert mock_config.state == ConfigEntryState.LOADED + assert mock_client.update.called + assert mock_config.unique_id == mock_client.bootstrap.nvr.mac + + +async def test_reload(hass: HomeAssistant, mock_entry: MockEntityFixture): + """Test updating entry reload entry.""" + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + assert mock_entry.entry.state == ConfigEntryState.LOADED + + options = dict(mock_entry.entry.options) + options[CONF_DISABLE_RTSP] = True + hass.config_entries.async_update_entry(mock_entry.entry, options=options) + await hass.async_block_till_done() + + assert mock_entry.entry.state == ConfigEntryState.LOADED + assert mock_entry.api.async_disconnect_ws.called + + +async def test_unload(hass: HomeAssistant, mock_entry: MockEntityFixture): + """Test unloading of unifiprotect entry.""" + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + assert mock_entry.entry.state == ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_entry.entry.entry_id) + assert mock_entry.entry.state == ConfigEntryState.NOT_LOADED + assert mock_entry.api.async_disconnect_ws.called + + +async def test_setup_too_old( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_old_nvr: NVR +): + """Test setup of unifiprotect entry with too old of version of UniFi Protect.""" + + mock_entry.api.get_nvr.return_value = mock_old_nvr + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + assert mock_entry.entry.state == ConfigEntryState.SETUP_ERROR + assert not mock_entry.api.update.called + + +async def test_setup_failed_update(hass: HomeAssistant, mock_entry: MockEntityFixture): + """Test setup of unifiprotect entry with failed update.""" + + mock_entry.api.update = AsyncMock(side_effect=NvrError) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY + assert mock_entry.api.update.called + + +async def test_setup_failed_update_reauth( + hass: HomeAssistant, mock_entry: MockEntityFixture +): + """Test setup of unifiprotect entry with update that gives unauthroized error.""" + + mock_entry.api.update = AsyncMock(side_effect=NotAuthorized) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY + assert mock_entry.api.update.called + + +async def test_setup_failed_error(hass: HomeAssistant, mock_entry: MockEntityFixture): + """Test setup of unifiprotect entry with generic error.""" + + mock_entry.api.get_nvr = AsyncMock(side_effect=NvrError) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY + assert not mock_entry.api.update.called + + +async def test_setup_failed_auth(hass: HomeAssistant, mock_entry: MockEntityFixture): + """Test setup of unifiprotect entry with unauthorized error.""" + + mock_entry.api.get_nvr = AsyncMock(side_effect=NotAuthorized) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + assert mock_entry.entry.state == ConfigEntryState.SETUP_ERROR + assert not mock_entry.api.update.called + + +async def test_setup_starts_discovery( + hass: HomeAssistant, mock_ufp_config_entry: ConfigEntry, mock_client +): + """Test setting up will start discovery.""" + with _patch_discovery(), patch( + "homeassistant.components.unifiprotect.ProtectApiClient" + ) as mock_api: + mock_ufp_config_entry.add_to_hass(hass) + mock_api.return_value = mock_client + mock_entry = MockEntityFixture(mock_ufp_config_entry, mock_client) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + assert mock_entry.entry.state == ConfigEntryState.LOADED + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py new file mode 100644 index 00000000000000..8f4dc4f8fcf8ab --- /dev/null +++ b/tests/components/unifiprotect/test_light.py @@ -0,0 +1,138 @@ +"""Test the UniFi Protect light platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from copy import copy +from unittest.mock import AsyncMock, Mock + +import pytest +from pyunifiprotect.data import Light + +from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MockEntityFixture, assert_entity_counts + + +@pytest.fixture(name="light") +async def light_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Fixture for a single light for testing the light platform.""" + + # disable pydantic validation so mocking can happen + Light.__config__.validate_assignment = False + + light_obj = mock_light.copy(deep=True) + light_obj._api = mock_entry.api + light_obj.name = "Test Light" + light_obj.is_light_on = False + + mock_entry.api.bootstrap.lights = { + light_obj.id: light_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + yield (light_obj, "light.test_light") + + Light.__config__.validate_assignment = True + + +async def test_light_setup( + hass: HomeAssistant, + light: tuple[Light, str], +): + """Test light entity setup.""" + + unique_id = light[0].id + entity_id = light[1] + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_light_update( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + light: tuple[Light, str], +): + """Test light entity update.""" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_light = light[0].copy() + new_light.is_light_on = True + new_light.light_device_settings.led_level = 3 + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_light + + new_bootstrap.lights = {new_light.id: new_light} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(light[1]) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] == 128 + + +async def test_light_turn_on( + hass: HomeAssistant, + light: tuple[Light, str], +): + """Test light entity turn off.""" + + entity_id = light[1] + light[0].__fields__["set_light"] = Mock() + light[0].set_light = AsyncMock() + + await hass.services.async_call( + "light", + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + light[0].set_light.assert_called_once_with(True, 3) + + +async def test_light_turn_off( + hass: HomeAssistant, + light: tuple[Light, str], +): + """Test light entity turn on.""" + + entity_id = light[1] + light[0].__fields__["set_light"] = Mock() + light[0].set_light = AsyncMock() + + await hass.services.async_call( + "light", + "turn_off", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + light[0].set_light.assert_called_once_with(False) diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py new file mode 100644 index 00000000000000..740cb61b3c4802 --- /dev/null +++ b/tests/components/unifiprotect/test_lock.py @@ -0,0 +1,252 @@ +"""Test the UniFi Protect lock platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from copy import copy +from unittest.mock import AsyncMock, Mock + +import pytest +from pyunifiprotect.data import Doorlock +from pyunifiprotect.data.types import LockStatusType + +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNAVAILABLE, + STATE_UNLOCKED, + STATE_UNLOCKING, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MockEntityFixture, assert_entity_counts + + +@pytest.fixture(name="doorlock") +async def doorlock_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_doorlock: Doorlock +): + """Fixture for a single doorlock for testing the lock platform.""" + + # disable pydantic validation so mocking can happen + Doorlock.__config__.validate_assignment = False + + lock_obj = mock_doorlock.copy(deep=True) + lock_obj._api = mock_entry.api + lock_obj.name = "Test Lock" + lock_obj.lock_status = LockStatusType.OPEN + + mock_entry.api.bootstrap.doorlocks = { + lock_obj.id: lock_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + yield (lock_obj, "lock.test_lock_lock") + + Doorlock.__config__.validate_assignment = True + + +async def test_lock_setup( + hass: HomeAssistant, + doorlock: tuple[Doorlock, str], +): + """Test lock entity setup.""" + + unique_id = f"{doorlock[0].id}_lock" + entity_id = doorlock[1] + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNLOCKED + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_lock_locked( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + doorlock: tuple[Doorlock, str], +): + """Test lock entity locked.""" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_lock = doorlock[0].copy() + new_lock.lock_status = LockStatusType.CLOSED + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_lock + + new_bootstrap.doorlocks = {new_lock.id: new_lock} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(doorlock[1]) + assert state + assert state.state == STATE_LOCKED + + +async def test_lock_unlocking( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + doorlock: tuple[Doorlock, str], +): + """Test lock entity unlocking.""" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_lock = doorlock[0].copy() + new_lock.lock_status = LockStatusType.OPENING + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_lock + + new_bootstrap.doorlocks = {new_lock.id: new_lock} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(doorlock[1]) + assert state + assert state.state == STATE_UNLOCKING + + +async def test_lock_locking( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + doorlock: tuple[Doorlock, str], +): + """Test lock entity locking.""" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_lock = doorlock[0].copy() + new_lock.lock_status = LockStatusType.CLOSING + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_lock + + new_bootstrap.doorlocks = {new_lock.id: new_lock} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(doorlock[1]) + assert state + assert state.state == STATE_LOCKING + + +async def test_lock_jammed( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + doorlock: tuple[Doorlock, str], +): + """Test lock entity jammed.""" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_lock = doorlock[0].copy() + new_lock.lock_status = LockStatusType.JAMMED_WHILE_CLOSING + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_lock + + new_bootstrap.doorlocks = {new_lock.id: new_lock} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(doorlock[1]) + assert state + assert state.state == STATE_JAMMED + + +async def test_lock_unavailable( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + doorlock: tuple[Doorlock, str], +): + """Test lock entity unavailable.""" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_lock = doorlock[0].copy() + new_lock.lock_status = LockStatusType.NOT_CALIBRATED + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_lock + + new_bootstrap.doorlocks = {new_lock.id: new_lock} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(doorlock[1]) + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_lock_do_lock( + hass: HomeAssistant, + doorlock: tuple[Doorlock, str], +): + """Test lock entity lock service.""" + + doorlock[0].__fields__["close_lock"] = Mock() + doorlock[0].close_lock = AsyncMock() + + await hass.services.async_call( + "lock", + "lock", + {ATTR_ENTITY_ID: doorlock[1]}, + blocking=True, + ) + + doorlock[0].close_lock.assert_called_once() + + +async def test_lock_do_unlock( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + doorlock: tuple[Doorlock, str], +): + """Test lock entity unlock service.""" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_lock = doorlock[0].copy() + new_lock.lock_status = LockStatusType.CLOSED + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_lock + + new_bootstrap.doorlocks = {new_lock.id: new_lock} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + new_lock.__fields__["open_lock"] = Mock() + new_lock.open_lock = AsyncMock() + + await hass.services.async_call( + "lock", + "unlock", + {ATTR_ENTITY_ID: doorlock[1]}, + blocking=True, + ) + + new_lock.open_lock.assert_called_once() diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py new file mode 100644 index 00000000000000..4d83da3fabb92d --- /dev/null +++ b/tests/components/unifiprotect/test_media_player.py @@ -0,0 +1,240 @@ +"""Test the UniFi Protect media_player platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from copy import copy +from unittest.mock import AsyncMock, Mock + +import pytest +from pyunifiprotect.data import Camera +from pyunifiprotect.exceptions import StreamError + +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_VOLUME_LEVEL, +) +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_IDLE, + STATE_PLAYING, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import MockEntityFixture, assert_entity_counts + + +@pytest.fixture(name="camera") +async def camera_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the media_player platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.has_speaker = True + + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + yield (camera_obj, "media_player.test_camera_speaker") + + Camera.__config__.validate_assignment = True + + +async def test_media_player_setup( + hass: HomeAssistant, + camera: tuple[Camera, str], +): + """Test media_player entity setup.""" + + unique_id = f"{camera[0].id}_speaker" + entity_id = camera[1] + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + expected_volume = float(camera[0].speaker_settings.volume / 100) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_IDLE + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 5636 + assert state.attributes[ATTR_MEDIA_CONTENT_TYPE] == "music" + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == expected_volume + + +async def test_media_player_update( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[Camera, str], +): + """Test media_player entity update.""" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_camera = camera[0].copy() + new_camera.talkback_stream = Mock() + new_camera.talkback_stream.is_running = True + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + new_bootstrap.cameras = {new_camera.id: new_camera} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(camera[1]) + assert state + assert state.state == STATE_PLAYING + + +async def test_media_player_set_volume( + hass: HomeAssistant, + camera: tuple[Camera, str], +): + """Test media_player entity test set_volume_level.""" + + camera[0].__fields__["set_speaker_volume"] = Mock() + camera[0].set_speaker_volume = AsyncMock() + + await hass.services.async_call( + "media_player", + "volume_set", + {ATTR_ENTITY_ID: camera[1], "volume_level": 0.5}, + blocking=True, + ) + + camera[0].set_speaker_volume.assert_called_once_with(50) + + +async def test_media_player_stop( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[Camera, str], +): + """Test media_player entity test media_stop.""" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_camera = camera[0].copy() + new_camera.talkback_stream = AsyncMock() + new_camera.talkback_stream.is_running = True + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + new_bootstrap.cameras = {new_camera.id: new_camera} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + await hass.services.async_call( + "media_player", + "media_stop", + {ATTR_ENTITY_ID: camera[1]}, + blocking=True, + ) + + new_camera.talkback_stream.stop.assert_called_once() + + +async def test_media_player_play( + hass: HomeAssistant, + camera: tuple[Camera, str], +): + """Test media_player entity test play_media.""" + + camera[0].__fields__["stop_audio"] = Mock() + camera[0].__fields__["play_audio"] = Mock() + camera[0].__fields__["wait_until_audio_completes"] = Mock() + camera[0].stop_audio = AsyncMock() + camera[0].play_audio = AsyncMock() + camera[0].wait_until_audio_completes = AsyncMock() + + await hass.services.async_call( + "media_player", + "play_media", + { + ATTR_ENTITY_ID: camera[1], + "media_content_id": "/test.mp3", + "media_content_type": "music", + }, + blocking=True, + ) + + camera[0].play_audio.assert_called_once_with("/test.mp3", blocking=False) + camera[0].wait_until_audio_completes.assert_called_once() + + +async def test_media_player_play_invalid( + hass: HomeAssistant, + camera: tuple[Camera, str], +): + """Test media_player entity test play_media, not music.""" + + camera[0].__fields__["play_audio"] = Mock() + camera[0].play_audio = AsyncMock() + + with pytest.raises(ValueError): + await hass.services.async_call( + "media_player", + "play_media", + { + ATTR_ENTITY_ID: camera[1], + "media_content_id": "/test.png", + "media_content_type": "image", + }, + blocking=True, + ) + + assert not camera[0].play_audio.called + + +async def test_media_player_play_error( + hass: HomeAssistant, + camera: tuple[Camera, str], +): + """Test media_player entity test play_media, not music.""" + + camera[0].__fields__["play_audio"] = Mock() + camera[0].__fields__["wait_until_audio_completes"] = Mock() + camera[0].play_audio = AsyncMock(side_effect=StreamError) + camera[0].wait_until_audio_completes = AsyncMock() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "media_player", + "play_media", + { + ATTR_ENTITY_ID: camera[1], + "media_content_id": "/test.mp3", + "media_content_type": "music", + }, + blocking=True, + ) + + assert camera[0].play_audio.called + assert not camera[0].wait_until_audio_completes.called diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py new file mode 100644 index 00000000000000..f516ad64a0bb29 --- /dev/null +++ b/tests/components/unifiprotect/test_number.py @@ -0,0 +1,298 @@ +"""Test the UniFi Protect number platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock, Mock + +import pytest +from pyunifiprotect.data import Camera, Doorlock, Light + +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.components.unifiprotect.number import ( + CAMERA_NUMBERS, + DOORLOCK_NUMBERS, + LIGHT_NUMBERS, + ProtectNumberEntityDescription, +) +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + MockEntityFixture, + assert_entity_counts, + ids_from_device_description, +) + + +@pytest.fixture(name="light") +async def light_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Fixture for a single light for testing the number platform.""" + + # disable pydantic validation so mocking can happen + Light.__config__.validate_assignment = False + + light_obj = mock_light.copy(deep=True) + light_obj._api = mock_entry.api + light_obj.name = "Test Light" + light_obj.light_device_settings.pir_sensitivity = 45 + light_obj.light_device_settings.pir_duration = timedelta(seconds=45) + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.lights = { + light_obj.id: light_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.NUMBER, 2, 2) + + yield light_obj + + Light.__config__.validate_assignment = True + + +@pytest.fixture(name="camera") +async def camera_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the number platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.can_optical_zoom = True + camera_obj.feature_flags.has_mic = True + # has_wdr is an the inverse of has HDR + camera_obj.feature_flags.has_hdr = False + camera_obj.isp_settings.wdr = 0 + camera_obj.mic_volume = 0 + camera_obj.isp_settings.zoom_position = 0 + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.NUMBER, 3, 3) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +@pytest.fixture(name="doorlock") +async def doorlock_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_doorlock: Doorlock +): + """Fixture for a single doorlock for testing the number platform.""" + + # disable pydantic validation so mocking can happen + Doorlock.__config__.validate_assignment = False + + lock_obj = mock_doorlock.copy(deep=True) + lock_obj._api = mock_entry.api + lock_obj.name = "Test Lock" + lock_obj.auto_close_time = timedelta(seconds=45) + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.doorlocks = { + lock_obj.id: lock_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.NUMBER, 1, 1) + + yield lock_obj + + Doorlock.__config__.validate_assignment = True + + +async def test_number_setup_light( + hass: HomeAssistant, + light: Light, +): + """Test number entity setup for light devices.""" + + entity_registry = er.async_get(hass) + + for description in LIGHT_NUMBERS: + unique_id, entity_id = ids_from_device_description( + Platform.NUMBER, light, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == "45" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_number_setup_camera_all( + hass: HomeAssistant, + camera: Camera, +): + """Test number entity setup for camera devices (all features).""" + + entity_registry = er.async_get(hass) + + for description in CAMERA_NUMBERS: + unique_id, entity_id = ids_from_device_description( + Platform.NUMBER, camera, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == "0" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_number_setup_camera_none( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Test number entity setup for camera devices (no features).""" + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.can_optical_zoom = False + camera_obj.feature_flags.has_mic = False + # has_wdr is an the inverse of has HDR + camera_obj.feature_flags.has_hdr = True + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.NUMBER, 0, 0) + + +async def test_number_setup_camera_missing_attr( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Test number entity setup for camera devices (no features, bad attrs).""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags = None + + Camera.__config__.validate_assignment = True + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.NUMBER, 0, 0) + + +async def test_number_light_sensitivity(hass: HomeAssistant, light: Light): + """Test sensitivity number entity for lights.""" + + description = LIGHT_NUMBERS[0] + assert description.ufp_set_method is not None + + light.__fields__["set_sensitivity"] = Mock() + light.set_sensitivity = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) + + await hass.services.async_call( + "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True + ) + + light.set_sensitivity.assert_called_once_with(15.0) + + +async def test_number_light_duration(hass: HomeAssistant, light: Light): + """Test auto-shutoff duration number entity for lights.""" + + description = LIGHT_NUMBERS[1] + + light.__fields__["set_duration"] = Mock() + light.set_duration = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.NUMBER, light, description) + + await hass.services.async_call( + "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True + ) + + light.set_duration.assert_called_once_with(timedelta(seconds=15.0)) + + +@pytest.mark.parametrize("description", CAMERA_NUMBERS) +async def test_number_camera_simple( + hass: HomeAssistant, camera: Camera, description: ProtectNumberEntityDescription +): + """Tests all simple numbers for cameras.""" + + assert description.ufp_set_method is not None + + camera.__fields__[description.ufp_set_method] = Mock() + setattr(camera, description.ufp_set_method, AsyncMock()) + set_method = getattr(camera, description.ufp_set_method) + + _, entity_id = ids_from_device_description(Platform.NUMBER, camera, description) + + await hass.services.async_call( + "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 1.0}, blocking=True + ) + + set_method.assert_called_once_with(1.0) + + +async def test_number_lock_auto_close(hass: HomeAssistant, doorlock: Doorlock): + """Test auto-lock timeout for locks.""" + + description = DOORLOCK_NUMBERS[0] + + doorlock.__fields__["set_auto_close_time"] = Mock() + doorlock.set_auto_close_time = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.NUMBER, doorlock, description) + + await hass.services.async_call( + "number", "set_value", {ATTR_ENTITY_ID: entity_id, "value": 15.0}, blocking=True + ) + + doorlock.set_auto_close_time.assert_called_once_with(timedelta(seconds=15.0)) diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py new file mode 100644 index 00000000000000..d09fa421ec32c1 --- /dev/null +++ b/tests/components/unifiprotect/test_select.py @@ -0,0 +1,690 @@ +"""Test the UniFi Protect select platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from copy import copy +from datetime import timedelta +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from pyunifiprotect.data import Camera, Light +from pyunifiprotect.data.devices import LCDMessage, Viewer +from pyunifiprotect.data.nvr import DoorbellMessage, Liveview +from pyunifiprotect.data.types import ( + DoorbellMessageType, + IRLEDMode, + LightModeEnableType, + LightModeType, + RecordingMode, +) + +from homeassistant.components.select.const import ATTR_OPTIONS +from homeassistant.components.unifiprotect.const import ( + ATTR_DURATION, + ATTR_MESSAGE, + DEFAULT_ATTRIBUTION, +) +from homeassistant.components.unifiprotect.select import ( + CAMERA_SELECTS, + LIGHT_MODE_OFF, + LIGHT_SELECTS, + SERVICE_SET_DOORBELL_MESSAGE, + VIEWER_SELECTS, +) +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow + +from .conftest import ( + MockEntityFixture, + assert_entity_counts, + ids_from_device_description, +) + + +@pytest.fixture(name="viewer") +async def viewer_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_viewer: Viewer, + mock_liveview: Liveview, +): + """Fixture for a single viewport for testing the select platform.""" + + # disable pydantic validation so mocking can happen + Viewer.__config__.validate_assignment = False + + viewer_obj = mock_viewer.copy(deep=True) + viewer_obj._api = mock_entry.api + viewer_obj.name = "Test Viewer" + viewer_obj.liveview_id = mock_liveview.id + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.viewers = { + viewer_obj.id: viewer_obj, + } + mock_entry.api.bootstrap.liveviews = {mock_liveview.id: mock_liveview} + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SELECT, 1, 1) + + yield viewer_obj + + Viewer.__config__.validate_assignment = True + + +@pytest.fixture(name="camera") +async def camera_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the select platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.has_lcd_screen = True + camera_obj.feature_flags.has_chime = True + camera_obj.recording_settings.mode = RecordingMode.ALWAYS + camera_obj.isp_settings.ir_led_mode = IRLEDMode.AUTO + camera_obj.lcd_message = None + camera_obj.chime_duration = 0 + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SELECT, 4, 4) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +@pytest.fixture(name="light") +async def light_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_light: Light, + camera: Camera, +): + """Fixture for a single light for testing the select platform.""" + + # disable pydantic validation so mocking can happen + Light.__config__.validate_assignment = False + + light_obj = mock_light.copy(deep=True) + light_obj._api = mock_entry.api + light_obj.name = "Test Light" + light_obj.camera_id = None + light_obj.light_mode_settings.mode = LightModeType.MOTION + light_obj.light_mode_settings.enable_at = LightModeEnableType.DARK + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = {camera.id: camera} + mock_entry.api.bootstrap.lights = { + light_obj.id: light_obj, + } + + await hass.config_entries.async_reload(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SELECT, 6, 6) + + yield light_obj + + Light.__config__.validate_assignment = True + + +@pytest.fixture(name="camera_none") +async def camera_none_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the select platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.has_lcd_screen = False + camera_obj.feature_flags.has_chime = False + camera_obj.recording_settings.mode = RecordingMode.ALWAYS + camera_obj.isp_settings.ir_led_mode = IRLEDMode.AUTO + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SELECT, 2, 2) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +async def test_select_setup_light( + hass: HomeAssistant, + light: Light, +): + """Test select entity setup for light devices.""" + + entity_registry = er.async_get(hass) + expected_values = ("On Motion - When Dark", "Not Paired") + + for index, description in enumerate(LIGHT_SELECTS): + unique_id, entity_id = ids_from_device_description( + Platform.SELECT, light, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_select_setup_viewer( + hass: HomeAssistant, + viewer: Viewer, +): + """Test select entity setup for light devices.""" + + entity_registry = er.async_get(hass) + description = VIEWER_SELECTS[0] + + unique_id, entity_id = ids_from_device_description( + Platform.SELECT, viewer, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == viewer.liveview.name + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_select_setup_camera_all( + hass: HomeAssistant, + camera: Camera, +): + """Test select entity setup for camera devices (all features).""" + + entity_registry = er.async_get(hass) + expected_values = ("Always", "Auto", "Default Message (Welcome)", "None") + + for index, description in enumerate(CAMERA_SELECTS): + unique_id, entity_id = ids_from_device_description( + Platform.SELECT, camera, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_select_setup_camera_none( + hass: HomeAssistant, + camera_none: Camera, +): + """Test select entity setup for camera devices (no features).""" + + entity_registry = er.async_get(hass) + expected_values = ("Always", "Auto", "Default Message (Welcome)") + + for index, description in enumerate(CAMERA_SELECTS): + if index == 2: + return + + unique_id, entity_id = ids_from_device_description( + Platform.SELECT, camera_none, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_select_update_liveview( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + viewer: Viewer, + mock_liveview: Liveview, +): + """Test select entity update (new Liveview).""" + + _, entity_id = ids_from_device_description( + Platform.SELECT, viewer, VIEWER_SELECTS[0] + ) + + state = hass.states.get(entity_id) + assert state + expected_options = state.attributes[ATTR_OPTIONS] + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_liveview = copy(mock_liveview) + new_liveview.id = "test_id" + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_liveview + + new_bootstrap.liveviews = {**new_bootstrap.liveviews, new_liveview.id: new_liveview} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes[ATTR_OPTIONS] == expected_options + + +async def test_select_update_doorbell_settings( + hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera +): + """Test select entity update (new Doorbell Message).""" + + expected_length = ( + len(mock_entry.api.bootstrap.nvr.doorbell_settings.all_messages) + 1 + ) + + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + state = hass.states.get(entity_id) + assert state + assert len(state.attributes[ATTR_OPTIONS]) == expected_length + + expected_length += 1 + new_nvr = copy(mock_entry.api.bootstrap.nvr) + new_nvr.__fields__["update_all_messages"] = Mock() + new_nvr.update_all_messages = Mock() + + new_nvr.doorbell_settings.all_messages = [ + *new_nvr.doorbell_settings.all_messages, + DoorbellMessage( + type=DoorbellMessageType.CUSTOM_MESSAGE, + text="Test2", + ), + ] + + mock_msg = Mock() + mock_msg.changed_data = {"doorbell_settings": {}} + mock_msg.new_obj = new_nvr + + mock_entry.api.bootstrap.nvr = new_nvr + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + new_nvr.update_all_messages.assert_called_once() + + state = hass.states.get(entity_id) + assert state + assert len(state.attributes[ATTR_OPTIONS]) == expected_length + + +async def test_select_update_doorbell_message( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: Camera, +): + """Test select entity update (change doorbell message).""" + + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "Default Message (Welcome)" + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_camera = camera.copy() + new_camera.lcd_message = LCDMessage( + type=DoorbellMessageType.CUSTOM_MESSAGE, text="Test" + ) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + new_bootstrap.cameras = {new_camera.id: new_camera} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "Test" + + +async def test_select_set_option_light_motion( + hass: HomeAssistant, + light: Light, +): + """Test Light Mode select.""" + _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[0]) + + light.__fields__["set_light_settings"] = Mock() + light.set_light_settings = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LIGHT_MODE_OFF}, + blocking=True, + ) + + light.set_light_settings.assert_called_once_with( + LightModeType.MANUAL, enable_at=None + ) + + +async def test_select_set_option_light_camera( + hass: HomeAssistant, + light: Light, +): + """Test Paired Camera select.""" + _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[1]) + + light.__fields__["set_paired_camera"] = Mock() + light.set_paired_camera = AsyncMock() + + camera = list(light.api.bootstrap.cameras.values())[0] + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: camera.name}, + blocking=True, + ) + + light.set_paired_camera.assert_called_once_with(camera) + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Not Paired"}, + blocking=True, + ) + + light.set_paired_camera.assert_called_with(None) + + +async def test_select_set_option_camera_recording( + hass: HomeAssistant, + camera: Camera, +): + """Test Recording Mode select.""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[0] + ) + + camera.__fields__["set_recording_mode"] = Mock() + camera.set_recording_mode = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Never"}, + blocking=True, + ) + + camera.set_recording_mode.assert_called_once_with(RecordingMode.NEVER) + + +async def test_select_set_option_camera_ir( + hass: HomeAssistant, + camera: Camera, +): + """Test Infrared Mode select.""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[1] + ) + + camera.__fields__["set_ir_led_model"] = Mock() + camera.set_ir_led_model = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Always Enable"}, + blocking=True, + ) + + camera.set_ir_led_model.assert_called_once_with(IRLEDMode.ON) + + +async def test_select_set_option_camera_doorbell_custom( + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text select (user defined message).""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Test"}, + blocking=True, + ) + + camera.set_lcd_text.assert_called_once_with( + DoorbellMessageType.CUSTOM_MESSAGE, text="Test" + ) + + +async def test_select_set_option_camera_doorbell_unifi( + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text select (unifi message).""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "LEAVE PACKAGE AT DOOR", + }, + blocking=True, + ) + + camera.set_lcd_text.assert_called_once_with( + DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR + ) + + await hass.services.async_call( + "select", + "select_option", + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "Default Message (Welcome)", + }, + blocking=True, + ) + + camera.set_lcd_text.assert_called_with(None) + + +async def test_select_set_option_camera_doorbell_default( + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text select (default message).""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + await hass.services.async_call( + "select", + "select_option", + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "Default Message (Welcome)", + }, + blocking=True, + ) + + camera.set_lcd_text.assert_called_once_with(None) + + +async def test_select_set_option_viewer( + hass: HomeAssistant, + viewer: Viewer, +): + """Test Liveview select.""" + _, entity_id = ids_from_device_description( + Platform.SELECT, viewer, VIEWER_SELECTS[0] + ) + + viewer.__fields__["set_liveview"] = Mock() + viewer.set_liveview = AsyncMock() + + liveview = list(viewer.api.bootstrap.liveviews.values())[0] + + await hass.services.async_call( + "select", + "select_option", + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: liveview.name}, + blocking=True, + ) + + viewer.set_liveview.assert_called_once_with(liveview) + + +async def test_select_service_doorbell_invalid( + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text service (invalid).""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[1] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "unifiprotect", + SERVICE_SET_DOORBELL_MESSAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_MESSAGE: "Test"}, + blocking=True, + ) + + assert not camera.set_lcd_text.called + + +async def test_select_service_doorbell_success( + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text service (success).""" + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + await hass.services.async_call( + "unifiprotect", + SERVICE_SET_DOORBELL_MESSAGE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MESSAGE: "Test", + }, + blocking=True, + ) + + camera.set_lcd_text.assert_called_once_with( + DoorbellMessageType.CUSTOM_MESSAGE, "Test", reset_at=None + ) + + +@patch("homeassistant.components.unifiprotect.select.utcnow") +async def test_select_service_doorbell_with_reset( + mock_now, + hass: HomeAssistant, + camera: Camera, +): + """Test Doorbell Text service (success with reset time).""" + now = utcnow() + mock_now.return_value = now + + _, entity_id = ids_from_device_description( + Platform.SELECT, camera, CAMERA_SELECTS[2] + ) + + camera.__fields__["set_lcd_text"] = Mock() + camera.set_lcd_text = AsyncMock() + + await hass.services.async_call( + "unifiprotect", + SERVICE_SET_DOORBELL_MESSAGE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MESSAGE: "Test", + ATTR_DURATION: 60, + }, + blocking=True, + ) + + camera.set_lcd_text.assert_called_once_with( + DoorbellMessageType.CUSTOM_MESSAGE, + "Test", + reset_at=now + timedelta(minutes=60), + ) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py new file mode 100644 index 00000000000000..1f5624c30a90ab --- /dev/null +++ b/tests/components/unifiprotect/test_sensor.py @@ -0,0 +1,573 @@ +"""Test the UniFi Protect sensor platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from copy import copy +from datetime import datetime, timedelta +from unittest.mock import Mock + +import pytest +from pyunifiprotect.data import NVR, Camera, Event, Sensor +from pyunifiprotect.data.base import WifiConnectionState, WiredConnectionState +from pyunifiprotect.data.nvr import EventMetadata +from pyunifiprotect.data.types import EventType, SmartDetectObjectType + +from homeassistant.components.unifiprotect.const import ( + ATTR_EVENT_SCORE, + DEFAULT_ATTRIBUTION, +) +from homeassistant.components.unifiprotect.sensor import ( + ALL_DEVICES_SENSORS, + CAMERA_DISABLED_SENSORS, + CAMERA_SENSORS, + MOTION_SENSORS, + NVR_DISABLED_SENSORS, + NVR_SENSORS, + OBJECT_TYPE_NONE, + SENSE_SENSORS, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + MockEntityFixture, + assert_entity_counts, + enable_entity, + ids_from_device_description, + time_changed, +) + + +@pytest.fixture(name="sensor") +async def sensor_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_sensor: Sensor, + now: datetime, +): + """Fixture for a single sensor for testing the sensor platform.""" + + # disable pydantic validation so mocking can happen + Sensor.__config__.validate_assignment = False + + sensor_obj = mock_sensor.copy(deep=True) + sensor_obj._api = mock_entry.api + sensor_obj.name = "Test Sensor" + sensor_obj.battery_status.percentage = 10.0 + sensor_obj.light_settings.is_enabled = True + sensor_obj.humidity_settings.is_enabled = True + sensor_obj.temperature_settings.is_enabled = True + sensor_obj.alarm_settings.is_enabled = True + sensor_obj.stats.light.value = 10.0 + sensor_obj.stats.humidity.value = 10.0 + sensor_obj.stats.temperature.value = 10.0 + sensor_obj.up_since = now + sensor_obj.bluetooth_connection_state.signal_strength = -50.0 + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.sensors = { + sensor_obj.id: sensor_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + # 2 from all, 4 from sense, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 19, 14) + + yield sensor_obj + + Sensor.__config__.validate_assignment = True + + +@pytest.fixture(name="sensor_none") +async def sensor_none_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_sensor: Sensor, + now: datetime, +): + """Fixture for a single sensor for testing the sensor platform.""" + + # disable pydantic validation so mocking can happen + Sensor.__config__.validate_assignment = False + + sensor_obj = mock_sensor.copy(deep=True) + sensor_obj._api = mock_entry.api + sensor_obj.name = "Test Sensor" + sensor_obj.battery_status.percentage = 10.0 + sensor_obj.light_settings.is_enabled = False + sensor_obj.humidity_settings.is_enabled = False + sensor_obj.temperature_settings.is_enabled = False + sensor_obj.alarm_settings.is_enabled = False + sensor_obj.up_since = now + sensor_obj.bluetooth_connection_state.signal_strength = -50.0 + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.sensors = { + sensor_obj.id: sensor_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + # 2 from all, 4 from sense, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 19, 14) + + yield sensor_obj + + Sensor.__config__.validate_assignment = True + + +@pytest.fixture(name="camera") +async def camera_fixture( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_camera: Camera, + now: datetime, +): + """Fixture for a single camera for testing the sensor platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.feature_flags.has_smart_detect = True + camera_obj.is_smart_detected = False + camera_obj.wired_connection_state = WiredConnectionState(phy_rate=1000) + camera_obj.wifi_connection_state = WifiConnectionState( + signal_quality=100, signal_strength=-50 + ) + camera_obj.stats.rx_bytes = 100.0 + camera_obj.stats.tx_bytes = 100.0 + camera_obj.stats.video.recording_start = now + camera_obj.stats.storage.used = 100.0 + camera_obj.stats.storage.used = 100.0 + camera_obj.stats.storage.rate = 100.0 + camera_obj.voltage = 20.0 + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + # 3 from all, 6 from camera, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 22, 14) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +async def test_sensor_setup_sensor( + hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor +): + """Test sensor entity setup for sensor devices.""" + + entity_registry = er.async_get(hass) + + expected_values = ("10", "10.0", "10.0", "10.0", "none") + for index, description in enumerate(SENSE_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, sensor, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + # BLE signal + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, sensor, ALL_DEVICES_SENSORS[1] + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == "-50" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_sensor_setup_sensor_none( + hass: HomeAssistant, mock_entry: MockEntityFixture, sensor_none: Sensor +): + """Test sensor entity setup for sensor devices with no sensors enabled.""" + + entity_registry = er.async_get(hass) + + expected_values = ( + "10", + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + ) + for index, description in enumerate(SENSE_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, sensor_none, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_sensor_setup_nvr( + hass: HomeAssistant, mock_entry: MockEntityFixture, now: datetime +): + """Test sensor entity setup for NVR device.""" + + mock_entry.api.bootstrap.reset_objects() + nvr: NVR = mock_entry.api.bootstrap.nvr + nvr.up_since = now + nvr.system_info.cpu.average_load = 50.0 + nvr.system_info.cpu.temperature = 50.0 + nvr.storage_stats.utilization = 50.0 + nvr.system_info.memory.available = 50.0 + nvr.system_info.memory.total = 100.0 + nvr.storage_stats.storage_distribution.timelapse_recordings.percentage = 50.0 + nvr.storage_stats.storage_distribution.continuous_recordings.percentage = 50.0 + nvr.storage_stats.storage_distribution.detections_recordings.percentage = 50.0 + nvr.storage_stats.storage_distribution.hd_usage.percentage = 50.0 + nvr.storage_stats.storage_distribution.uhd_usage.percentage = 50.0 + nvr.storage_stats.storage_distribution.free.percentage = 50.0 + nvr.storage_stats.capacity = 50.0 + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + # 2 from all, 4 from sense, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 12, 9) + + entity_registry = er.async_get(hass) + + expected_values = ( + now.replace(second=0, microsecond=0).isoformat(), + "50.0", + "50.0", + "50.0", + "50.0", + "50.0", + "50.0", + "50.0", + "50", + ) + for index, description in enumerate(NVR_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, nvr, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is not description.entity_registry_enabled_default + assert entity.unique_id == unique_id + + if not description.entity_registry_enabled_default: + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + expected_values = ("50.0", "50.0", "50.0") + for index, description in enumerate(NVR_DISABLED_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, nvr, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is not description.entity_registry_enabled_default + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_sensor_nvr_missing_values( + hass: HomeAssistant, mock_entry: MockEntityFixture, now: datetime +): + """Test NVR sensor sensors if no data available.""" + + mock_entry.api.bootstrap.reset_objects() + nvr: NVR = mock_entry.api.bootstrap.nvr + nvr.system_info.memory.available = None + nvr.system_info.memory.total = None + nvr.up_since = None + nvr.storage_stats.capacity = None + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + # 2 from all, 4 from sense, 12 NVR + assert_entity_counts(hass, Platform.SENSOR, 12, 9) + + entity_registry = er.async_get(hass) + + # Uptime + description = NVR_SENSORS[0] + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, nvr, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + # Memory + description = NVR_SENSORS[8] + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, nvr, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == "0" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + # Memory + description = NVR_DISABLED_SENSORS[2] + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, nvr, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_sensor_setup_camera( + hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime +): + """Test sensor entity setup for camera devices.""" + + entity_registry = er.async_get(hass) + + expected_values = ( + now.replace(microsecond=0).isoformat(), + "100", + "100.0", + "20.0", + ) + for index, description in enumerate(CAMERA_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, camera, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is not description.entity_registry_enabled_default + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + expected_values = ("100", "100") + for index, description in enumerate(CAMERA_DISABLED_SENSORS): + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, camera, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is not description.entity_registry_enabled_default + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + # Wired signal + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, camera, ALL_DEVICES_SENSORS[2] + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == "1000" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + # WiFi signal + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, camera, ALL_DEVICES_SENSORS[3] + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == "-50" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + # Detected Object + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, camera, MOTION_SENSORS[0] + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == OBJECT_TYPE_NONE + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_SCORE] == 0 + + +async def test_sensor_update_motion( + hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime +): + """Test sensor motion entity.""" + + _, entity_id = ids_from_device_description( + Platform.SENSOR, camera, MOTION_SENSORS[0] + ) + + event = Event( + id="test_event_id", + type=EventType.SMART_DETECT, + start=now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[SmartDetectObjectType.PERSON], + smart_detect_event_ids=[], + camera_id=camera.id, + api=mock_entry.api, + ) + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_camera = camera.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_id = event.id + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + + new_bootstrap.cameras = {new_camera.id: new_camera} + new_bootstrap.events = {event.id: event} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == SmartDetectObjectType.PERSON.value + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_SCORE] == 100 + + +async def test_sensor_update_alarm( + hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor, now: datetime +): + """Test sensor motion entity.""" + + _, entity_id = ids_from_device_description( + Platform.SENSOR, sensor, SENSE_SENSORS[4] + ) + + event_metadata = EventMetadata(sensor_id=sensor.id, alarm_type="smoke") + event = Event( + id="test_event_id", + type=EventType.SENSOR_ALARM, + start=now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + metadata=event_metadata, + api=mock_entry.api, + ) + + new_bootstrap = copy(mock_entry.api.bootstrap) + new_sensor = sensor.copy() + new_sensor.set_alarm_timeout() + new_sensor.last_alarm_event_id = event.id + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + + new_bootstrap.sensors = {new_sensor.id: new_sensor} + new_bootstrap.events = {event.id: event} + mock_entry.api.bootstrap = new_bootstrap + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "smoke" + await time_changed(hass, 10) diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py new file mode 100644 index 00000000000000..82e4434ad089a0 --- /dev/null +++ b/tests/components/unifiprotect/test_services.py @@ -0,0 +1,145 @@ +"""Test the UniFi Protect global services.""" +# pylint: disable=protected-access +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest +from pyunifiprotect.data import Light +from pyunifiprotect.exceptions import BadRequest + +from homeassistant.components.unifiprotect.const import ATTR_MESSAGE, DOMAIN +from homeassistant.components.unifiprotect.services import ( + SERVICE_ADD_DOORBELL_TEXT, + SERVICE_REMOVE_DOORBELL_TEXT, + SERVICE_SET_DEFAULT_DOORBELL_TEXT, +) +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr + +from .conftest import MockEntityFixture + + +@pytest.fixture(name="device") +async def device_fixture(hass: HomeAssistant, mock_entry: MockEntityFixture): + """Fixture with entry setup to call services with.""" + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + device_registry = await dr.async_get_registry(hass) + + return list(device_registry.devices.values())[0] + + +@pytest.fixture(name="subdevice") +async def subdevice_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Fixture with entry setup to call services with.""" + + mock_light._api = mock_entry.api + mock_entry.api.bootstrap.lights = { + mock_light.id: mock_light, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + device_registry = await dr.async_get_registry(hass) + + return [d for d in device_registry.devices.values() if d.name != "UnifiProtect"][0] + + +async def test_global_service_bad_device( + hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture +): + """Test global service, invalid device ID.""" + + nvr = mock_entry.api.bootstrap.nvr + nvr.__fields__["add_custom_doorbell_message"] = Mock() + nvr.add_custom_doorbell_message = AsyncMock() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_DOORBELL_TEXT, + {ATTR_DEVICE_ID: "bad_device_id", ATTR_MESSAGE: "Test Message"}, + blocking=True, + ) + assert not nvr.add_custom_doorbell_message.called + + +async def test_global_service_exception( + hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture +): + """Test global service, unexpected error.""" + + nvr = mock_entry.api.bootstrap.nvr + nvr.__fields__["add_custom_doorbell_message"] = Mock() + nvr.add_custom_doorbell_message = AsyncMock(side_effect=BadRequest) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_DOORBELL_TEXT, + {ATTR_DEVICE_ID: device.id, ATTR_MESSAGE: "Test Message"}, + blocking=True, + ) + assert nvr.add_custom_doorbell_message.called + + +async def test_add_doorbell_text( + hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture +): + """Test add_doorbell_text service.""" + + nvr = mock_entry.api.bootstrap.nvr + nvr.__fields__["add_custom_doorbell_message"] = Mock() + nvr.add_custom_doorbell_message = AsyncMock() + + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_DOORBELL_TEXT, + {ATTR_DEVICE_ID: device.id, ATTR_MESSAGE: "Test Message"}, + blocking=True, + ) + nvr.add_custom_doorbell_message.assert_called_once_with("Test Message") + + +async def test_remove_doorbell_text( + hass: HomeAssistant, subdevice: dr.DeviceEntry, mock_entry: MockEntityFixture +): + """Test remove_doorbell_text service.""" + + nvr = mock_entry.api.bootstrap.nvr + nvr.__fields__["remove_custom_doorbell_message"] = Mock() + nvr.remove_custom_doorbell_message = AsyncMock() + + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_DOORBELL_TEXT, + {ATTR_DEVICE_ID: subdevice.id, ATTR_MESSAGE: "Test Message"}, + blocking=True, + ) + nvr.remove_custom_doorbell_message.assert_called_once_with("Test Message") + + +async def test_set_default_doorbell_text( + hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture +): + """Test set_default_doorbell_text service.""" + + nvr = mock_entry.api.bootstrap.nvr + nvr.__fields__["set_default_doorbell_message"] = Mock() + nvr.set_default_doorbell_message = AsyncMock() + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DEFAULT_DOORBELL_TEXT, + {ATTR_DEVICE_ID: device.id, ATTR_MESSAGE: "Test Message"}, + blocking=True, + ) + nvr.set_default_doorbell_message.assert_called_once_with("Test Message") diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py new file mode 100644 index 00000000000000..87ffc5683c0a21 --- /dev/null +++ b/tests/components/unifiprotect/test_switch.py @@ -0,0 +1,474 @@ +"""Test the UniFi Protect switch platform.""" +# pylint: disable=protected-access +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest +from pyunifiprotect.data import Camera, Light +from pyunifiprotect.data.types import RecordingMode, VideoMode + +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.components.unifiprotect.switch import ( + ALL_DEVICES_SWITCHES, + CAMERA_SWITCHES, + LIGHT_SWITCHES, + ProtectSwitchEntityDescription, +) +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_OFF, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + MockEntityFixture, + assert_entity_counts, + enable_entity, + ids_from_device_description, +) + + +@pytest.fixture(name="light") +async def light_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Fixture for a single light for testing the switch platform.""" + + # disable pydantic validation so mocking can happen + Light.__config__.validate_assignment = False + + light_obj = mock_light.copy(deep=True) + light_obj._api = mock_entry.api + light_obj.name = "Test Light" + light_obj.is_ssh_enabled = False + light_obj.light_device_settings.is_indicator_enabled = False + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.lights = { + light_obj.id: light_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SWITCH, 2, 1) + + yield light_obj + + Light.__config__.validate_assignment = True + + +@pytest.fixture(name="camera") +async def camera_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the switch platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.recording_settings.mode = RecordingMode.DETECTIONS + camera_obj.feature_flags.has_led_status = True + camera_obj.feature_flags.has_hdr = True + camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT, VideoMode.HIGH_FPS] + camera_obj.feature_flags.has_privacy_mask = True + camera_obj.feature_flags.has_speaker = True + camera_obj.feature_flags.has_smart_detect = True + camera_obj.is_ssh_enabled = False + camera_obj.led_settings.is_enabled = False + camera_obj.hdr_mode = False + camera_obj.video_mode = VideoMode.DEFAULT + camera_obj.remove_privacy_zone() + camera_obj.speaker_settings.are_system_sounds_enabled = False + camera_obj.osd_settings.is_name_enabled = False + camera_obj.osd_settings.is_date_enabled = False + camera_obj.osd_settings.is_logo_enabled = False + camera_obj.osd_settings.is_debug_enabled = False + camera_obj.smart_detect_settings.object_types = [] + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SWITCH, 12, 11) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +@pytest.fixture(name="camera_none") +async def camera_none_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the switch platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.recording_settings.mode = RecordingMode.DETECTIONS + camera_obj.feature_flags.has_led_status = False + camera_obj.feature_flags.has_hdr = False + camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT] + camera_obj.feature_flags.has_privacy_mask = False + camera_obj.feature_flags.has_speaker = False + camera_obj.feature_flags.has_smart_detect = False + camera_obj.is_ssh_enabled = False + camera_obj.osd_settings.is_name_enabled = False + camera_obj.osd_settings.is_date_enabled = False + camera_obj.osd_settings.is_logo_enabled = False + camera_obj.osd_settings.is_debug_enabled = False + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SWITCH, 5, 4) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +@pytest.fixture(name="camera_privacy") +async def camera_privacy_fixture( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera +): + """Fixture for a single camera for testing the switch platform.""" + + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + + camera_obj = mock_camera.copy(deep=True) + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + camera_obj.name = "Test Camera" + camera_obj.recording_settings.mode = RecordingMode.NEVER + camera_obj.feature_flags.has_led_status = False + camera_obj.feature_flags.has_hdr = False + camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT] + camera_obj.feature_flags.has_privacy_mask = True + camera_obj.feature_flags.has_speaker = False + camera_obj.feature_flags.has_smart_detect = False + camera_obj.add_privacy_zone() + camera_obj.is_ssh_enabled = False + camera_obj.osd_settings.is_name_enabled = False + camera_obj.osd_settings.is_date_enabled = False + camera_obj.osd_settings.is_logo_enabled = False + camera_obj.osd_settings.is_debug_enabled = False + + mock_entry.api.bootstrap.reset_objects() + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SWITCH, 6, 5) + + yield camera_obj + + Camera.__config__.validate_assignment = True + + +async def test_switch_setup_light( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + light: Light, +): + """Test switch entity setup for light devices.""" + + entity_registry = er.async_get(hass) + + description = LIGHT_SWITCHES[0] + + unique_id, entity_id = ids_from_device_description( + Platform.SWITCH, light, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + description = ALL_DEVICES_SWITCHES[0] + + unique_id = f"{light.id}_{description.key}" + entity_id = f"switch.test_light_{description.name.lower().replace(' ', '_')}" + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_switch_setup_camera_all( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: Camera, +): + """Test switch entity setup for camera devices (all enabled feature flags).""" + + entity_registry = er.async_get(hass) + + for description in CAMERA_SWITCHES: + unique_id, entity_id = ids_from_device_description( + Platform.SWITCH, camera, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + description = ALL_DEVICES_SWITCHES[0] + + description_entity_name = ( + description.name.lower().replace(":", "").replace(" ", "_") + ) + unique_id = f"{camera.id}_{description.key}" + entity_id = f"switch.test_camera_{description_entity_name}" + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_switch_setup_camera_none( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera_none: Camera, +): + """Test switch entity setup for camera devices (no enabled feature flags).""" + + entity_registry = er.async_get(hass) + + for description in CAMERA_SWITCHES: + if description.ufp_required_field is not None: + continue + + unique_id, entity_id = ids_from_device_description( + Platform.SWITCH, camera_none, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + description = ALL_DEVICES_SWITCHES[0] + + description_entity_name = ( + description.name.lower().replace(":", "").replace(" ", "_") + ) + unique_id = f"{camera_none.id}_{description.key}" + entity_id = f"switch.test_camera_{description_entity_name}" + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_switch_light_status(hass: HomeAssistant, light: Light): + """Tests status light switch for lights.""" + + description = LIGHT_SWITCHES[0] + + light.__fields__["set_status_light"] = Mock() + light.set_status_light = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.SWITCH, light, description) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + light.set_status_light.assert_called_once_with(True) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + light.set_status_light.assert_called_with(False) + + +async def test_switch_camera_ssh( + hass: HomeAssistant, camera: Camera, mock_entry: MockEntityFixture +): + """Tests SSH switch for cameras.""" + + description = ALL_DEVICES_SWITCHES[0] + + camera.__fields__["set_ssh"] = Mock() + camera.set_ssh = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_ssh.assert_called_once_with(True) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_ssh.assert_called_with(False) + + +@pytest.mark.parametrize("description", CAMERA_SWITCHES) +async def test_switch_camera_simple( + hass: HomeAssistant, camera: Camera, description: ProtectSwitchEntityDescription +): + """Tests all simple switches for cameras.""" + + if description.name in ("High FPS", "Privacy Mode"): + return + + assert description.ufp_set_method is not None + + camera.__fields__[description.ufp_set_method] = Mock() + setattr(camera, description.ufp_set_method, AsyncMock()) + set_method = getattr(camera, description.ufp_set_method) + + _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + set_method.assert_called_once_with(True) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + set_method.assert_called_with(False) + + +async def test_switch_camera_highfps(hass: HomeAssistant, camera: Camera): + """Tests High FPS switch for cameras.""" + + description = CAMERA_SWITCHES[2] + + camera.__fields__["set_video_mode"] = Mock() + camera.set_video_mode = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_video_mode.assert_called_once_with(VideoMode.HIGH_FPS) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_video_mode.assert_called_with(VideoMode.DEFAULT) + + +async def test_switch_camera_privacy(hass: HomeAssistant, camera: Camera): + """Tests Privacy Mode switch for cameras.""" + + description = CAMERA_SWITCHES[3] + + camera.__fields__["set_privacy"] = Mock() + camera.set_privacy = AsyncMock() + + _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + + await hass.services.async_call( + "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_privacy.assert_called_once_with(True, 0, RecordingMode.NEVER) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera.set_privacy.assert_called_with( + False, camera.mic_volume, camera.recording_settings.mode + ) + + +async def test_switch_camera_privacy_already_on( + hass: HomeAssistant, camera_privacy: Camera +): + """Tests Privacy Mode switch for cameras with privacy mode defaulted on.""" + + description = CAMERA_SWITCHES[3] + + camera_privacy.__fields__["set_privacy"] = Mock() + camera_privacy.set_privacy = AsyncMock() + + _, entity_id = ids_from_device_description( + Platform.SWITCH, camera_privacy, description + ) + + await hass.services.async_call( + "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + camera_privacy.set_privacy.assert_called_once_with(False, 100, RecordingMode.ALWAYS) diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 4c8b125cbce075..87c3abbb65a1ee 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -1,9 +1,8 @@ """The tests for the Universal Media player platform.""" -import asyncio from copy import copy -import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch +import pytest from voluptuous.error import MultipleInvalid from homeassistant import config as hass_config @@ -21,9 +20,18 @@ STATE_UNKNOWN, ) from homeassistant.core import Context, callback -from homeassistant.setup import async_setup_component, setup_component +from homeassistant.setup import async_setup_component -from tests.common import get_fixture_path, get_test_home_assistant, mock_service +from tests.common import async_mock_service, get_fixture_path + +CONFIG_CHILDREN_ONLY = { + "name": "test", + "platform": "universal", + "children": [ + media_player.ENTITY_ID_FORMAT.format("mock1"), + media_player.ENTITY_ID_FORMAT.format("mock2"), + ], +} def validate_config(config): @@ -53,64 +61,64 @@ def __init__(self, hass, name): self._sound_mode = None self.service_calls = { - "turn_on": mock_service( + "turn_on": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_TURN_ON ), - "turn_off": mock_service( + "turn_off": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_TURN_OFF ), - "mute_volume": mock_service( + "mute_volume": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_MUTE ), - "set_volume_level": mock_service( + "set_volume_level": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET ), - "media_play": mock_service( + "media_play": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PLAY ), - "media_pause": mock_service( + "media_pause": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PAUSE ), - "media_stop": mock_service( + "media_stop": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_STOP ), - "media_previous_track": mock_service( + "media_previous_track": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PREVIOUS_TRACK ), - "media_next_track": mock_service( + "media_next_track": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_NEXT_TRACK ), - "media_seek": mock_service( + "media_seek": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_SEEK ), - "play_media": mock_service( + "play_media": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_PLAY_MEDIA ), - "volume_up": mock_service( + "volume_up": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_UP ), - "volume_down": mock_service( + "volume_down": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_DOWN ), - "media_play_pause": mock_service( + "media_play_pause": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PLAY_PAUSE ), - "select_sound_mode": mock_service( + "select_sound_mode": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOUND_MODE ), - "select_source": mock_service( + "select_source": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE ), - "toggle": mock_service( + "toggle": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_TOGGLE ), - "clear_playlist": mock_service( + "clear_playlist": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_CLEAR_PLAYLIST ), - "repeat_set": mock_service( + "repeat_set": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_REPEAT_SET ), - "shuffle_set": mock_service( + "shuffle_set": async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SHUFFLE_SET ), } @@ -199,826 +207,830 @@ def set_repeat(self, repeat): self._repeat = repeat -class TestMediaPlayer(unittest.TestCase): - """Test the media_player module.""" +@pytest.fixture +async def mock_states(hass): + """Set mock states used in tests.""" + result = Mock() - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + result.mock_mp_1 = MockMediaPlayer(hass, "mock1") + result.mock_mp_1.async_schedule_update_ha_state() - self.mock_mp_1 = MockMediaPlayer(self.hass, "mock1") - self.mock_mp_1.schedule_update_ha_state() + result.mock_mp_2 = MockMediaPlayer(hass, "mock2") + result.mock_mp_2.async_schedule_update_ha_state() - self.mock_mp_2 = MockMediaPlayer(self.hass, "mock2") - self.mock_mp_2.schedule_update_ha_state() + await hass.async_block_till_done() - self.hass.block_till_done() + result.mock_mute_switch_id = switch.ENTITY_ID_FORMAT.format("mute") + hass.states.async_set(result.mock_mute_switch_id, STATE_OFF) - self.mock_mute_switch_id = switch.ENTITY_ID_FORMAT.format("mute") - self.hass.states.set(self.mock_mute_switch_id, STATE_OFF) + result.mock_state_switch_id = switch.ENTITY_ID_FORMAT.format("state") + hass.states.async_set(result.mock_state_switch_id, STATE_OFF) - self.mock_state_switch_id = switch.ENTITY_ID_FORMAT.format("state") - self.hass.states.set(self.mock_state_switch_id, STATE_OFF) + result.mock_volume_id = f"{input_number.DOMAIN}.volume_level" + hass.states.async_set(result.mock_volume_id, 0) - self.mock_volume_id = f"{input_number.DOMAIN}.volume_level" - self.hass.states.set(self.mock_volume_id, 0) + result.mock_source_list_id = f"{input_select.DOMAIN}.source_list" + hass.states.async_set(result.mock_source_list_id, ["dvd", "htpc"]) - self.mock_source_list_id = f"{input_select.DOMAIN}.source_list" - self.hass.states.set(self.mock_source_list_id, ["dvd", "htpc"]) + result.mock_source_id = f"{input_select.DOMAIN}.source" + hass.states.async_set(result.mock_source_id, "dvd") - self.mock_source_id = f"{input_select.DOMAIN}.source" - self.hass.states.set(self.mock_source_id, "dvd") + result.mock_sound_mode_list_id = f"{input_select.DOMAIN}.sound_mode_list" + hass.states.async_set(result.mock_sound_mode_list_id, ["music", "movie"]) - self.mock_sound_mode_list_id = f"{input_select.DOMAIN}.sound_mode_list" - self.hass.states.set(self.mock_sound_mode_list_id, ["music", "movie"]) + result.mock_sound_mode_id = f"{input_select.DOMAIN}.sound_mode" + hass.states.async_set(result.mock_sound_mode_id, "music") - self.mock_sound_mode_id = f"{input_select.DOMAIN}.sound_mode" - self.hass.states.set(self.mock_sound_mode_id, "music") + result.mock_shuffle_switch_id = switch.ENTITY_ID_FORMAT.format("shuffle") + hass.states.async_set(result.mock_shuffle_switch_id, STATE_OFF) - self.mock_shuffle_switch_id = switch.ENTITY_ID_FORMAT.format("shuffle") - self.hass.states.set(self.mock_shuffle_switch_id, STATE_OFF) + result.mock_repeat_switch_id = switch.ENTITY_ID_FORMAT.format("repeat") + hass.states.async_set(result.mock_repeat_switch_id, STATE_OFF) - self.mock_repeat_switch_id = switch.ENTITY_ID_FORMAT.format("repeat") - self.hass.states.set(self.mock_repeat_switch_id, STATE_OFF) + return result - self.config_children_only = { - "name": "test", - "platform": "universal", - "children": [ - media_player.ENTITY_ID_FORMAT.format("mock1"), - media_player.ENTITY_ID_FORMAT.format("mock2"), - ], - } - self.config_children_and_attr = { - "name": "test", - "platform": "universal", - "children": [ - media_player.ENTITY_ID_FORMAT.format("mock1"), - media_player.ENTITY_ID_FORMAT.format("mock2"), - ], - "attributes": { - "is_volume_muted": self.mock_mute_switch_id, - "volume_level": self.mock_volume_id, - "source": self.mock_source_id, - "source_list": self.mock_source_list_id, - "state": self.mock_state_switch_id, - "shuffle": self.mock_shuffle_switch_id, - "repeat": self.mock_repeat_switch_id, - "sound_mode_list": self.mock_sound_mode_list_id, - "sound_mode": self.mock_sound_mode_id, - }, - } - self.addCleanup(self.tear_down_cleanup) - - def tear_down_cleanup(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_config_children_only(self): - """Check config with only children.""" - config_start = copy(self.config_children_only) - del config_start["platform"] - config_start["commands"] = {} - config_start["attributes"] = {} - - config = validate_config(self.config_children_only) - assert config_start == config - - def test_config_children_and_attr(self): - """Check config with children and attributes.""" - config_start = copy(self.config_children_and_attr) - del config_start["platform"] - config_start["commands"] = {} - - config = validate_config(self.config_children_and_attr) - assert config_start == config - - def test_config_no_name(self): - """Check config with no Name entry.""" - response = True - try: - validate_config({"platform": "universal"}) - except MultipleInvalid: - response = False - assert not response - - def test_config_bad_children(self): - """Check config with bad children entry.""" - config_no_children = {"name": "test", "platform": "universal"} - config_bad_children = {"name": "test", "children": {}, "platform": "universal"} - - config_no_children = validate_config(config_no_children) - assert [] == config_no_children["children"] - - config_bad_children = validate_config(config_bad_children) - assert [] == config_bad_children["children"] - - def test_config_bad_commands(self): - """Check config with bad commands entry.""" - config = {"name": "test", "platform": "universal"} - - config = validate_config(config) - assert {} == config["commands"] - - def test_config_bad_attributes(self): - """Check config with bad attributes.""" - config = {"name": "test", "platform": "universal"} - - config = validate_config(config) - assert {} == config["attributes"] - - def test_config_bad_key(self): - """Check config with bad key.""" - config = {"name": "test", "asdf": 5, "platform": "universal"} - - config = validate_config(config) - assert "asdf" not in config - - def test_platform_setup(self): - """Test platform setup.""" - config = {"name": "test", "platform": "universal"} - bad_config = {"platform": "universal"} - entities = [] - - def add_entities(new_entities): - """Add devices to list.""" - for dev in new_entities: - entities.append(dev) - - setup_ok = True - try: - asyncio.run_coroutine_threadsafe( - universal.async_setup_platform( - self.hass, validate_config(bad_config), add_entities - ), - self.hass.loop, - ).result() - except MultipleInvalid: - setup_ok = False - assert not setup_ok - assert len(entities) == 0 - - asyncio.run_coroutine_threadsafe( - universal.async_setup_platform( - self.hass, validate_config(config), add_entities - ), - self.hass.loop, - ).result() - assert len(entities) == 1 - assert entities[0].name == "test" - def test_master_state(self): - """Test master state property.""" - config = validate_config(self.config_children_only) +@pytest.fixture +def config_children_and_attr(mock_states): + """Return configuration that references the mock states.""" + return { + "name": "test", + "platform": "universal", + "children": [ + media_player.ENTITY_ID_FORMAT.format("mock1"), + media_player.ENTITY_ID_FORMAT.format("mock2"), + ], + "attributes": { + "is_volume_muted": mock_states.mock_mute_switch_id, + "volume_level": mock_states.mock_volume_id, + "source": mock_states.mock_source_id, + "source_list": mock_states.mock_source_list_id, + "state": mock_states.mock_state_switch_id, + "shuffle": mock_states.mock_shuffle_switch_id, + "repeat": mock_states.mock_repeat_switch_id, + "sound_mode_list": mock_states.mock_sound_mode_list_id, + "sound_mode": mock_states.mock_sound_mode_id, + }, + } - ump = universal.UniversalMediaPlayer(self.hass, **config) - assert ump.master_state is None +async def test_config_children_only(hass): + """Check config with only children.""" + config_start = copy(CONFIG_CHILDREN_ONLY) + del config_start["platform"] + config_start["commands"] = {} + config_start["attributes"] = {} - def test_master_state_with_attrs(self): - """Test master state property.""" - config = validate_config(self.config_children_and_attr) + config = validate_config(CONFIG_CHILDREN_ONLY) + assert config_start == config - ump = universal.UniversalMediaPlayer(self.hass, **config) - assert ump.master_state == STATE_OFF - self.hass.states.set(self.mock_state_switch_id, STATE_ON) - assert ump.master_state == STATE_ON +async def test_config_children_and_attr(hass, config_children_and_attr): + """Check config with children and attributes.""" + config_start = copy(config_children_and_attr) + del config_start["platform"] + config_start["commands"] = {} - def test_master_state_with_bad_attrs(self): - """Test master state property.""" - config = copy(self.config_children_and_attr) - config["attributes"]["state"] = "bad.entity_id" - config = validate_config(config) + config = validate_config(config_children_and_attr) + assert config_start == config - ump = universal.UniversalMediaPlayer(self.hass, **config) - assert ump.master_state == STATE_OFF +async def test_config_no_name(hass): + """Check config with no Name entry.""" + response = True + try: + validate_config({"platform": "universal"}) + except MultipleInvalid: + response = False + assert not response - def test_active_child_state(self): - """Test active child state property.""" - config = validate_config(self.config_children_only) - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() +async def test_config_bad_children(hass): + """Check config with bad children entry.""" + config_no_children = {"name": "test", "platform": "universal"} + config_bad_children = {"name": "test", "children": {}, "platform": "universal"} - assert ump._child_state is None + config_no_children = validate_config(config_no_children) + assert [] == config_no_children["children"] - self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert self.mock_mp_1.entity_id == ump._child_state.entity_id + config_bad_children = validate_config(config_bad_children) + assert [] == config_bad_children["children"] - self.mock_mp_2._state = STATE_PLAYING - self.mock_mp_2.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert self.mock_mp_1.entity_id == ump._child_state.entity_id - self.mock_mp_1._state = STATE_OFF - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert self.mock_mp_2.entity_id == ump._child_state.entity_id +async def test_config_bad_commands(hass): + """Check config with bad commands entry.""" + config = {"name": "test", "platform": "universal"} - def test_name(self): - """Test name property.""" - config = validate_config(self.config_children_only) + config = validate_config(config) + assert {} == config["commands"] - ump = universal.UniversalMediaPlayer(self.hass, **config) - assert config["name"] == ump.name +async def test_config_bad_attributes(hass): + """Check config with bad attributes.""" + config = {"name": "test", "platform": "universal"} - def test_polling(self): - """Test should_poll property.""" - config = validate_config(self.config_children_only) + config = validate_config(config) + assert {} == config["attributes"] - ump = universal.UniversalMediaPlayer(self.hass, **config) - assert ump.should_poll is False +async def test_config_bad_key(hass): + """Check config with bad key.""" + config = {"name": "test", "asdf": 5, "platform": "universal"} - def test_state_children_only(self): - """Test media player state with only children.""" - config = validate_config(self.config_children_only) + config = validate_config(config) + assert "asdf" not in config - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert ump.state, STATE_OFF +async def test_platform_setup(hass): + """Test platform setup.""" + config = {"name": "test", "platform": "universal"} + bad_config = {"platform": "universal"} + entities = [] - self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert ump.state == STATE_PLAYING + def add_entities(new_entities): + """Add devices to list.""" + for dev in new_entities: + entities.append(dev) - def test_state_with_children_and_attrs(self): - """Test media player with children and master state.""" - config = validate_config(self.config_children_and_attr) + setup_ok = True + try: + await universal.async_setup_platform( + hass, validate_config(bad_config), add_entities + ) + except MultipleInvalid: + setup_ok = False + assert not setup_ok + assert len(entities) == 0 - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() + await universal.async_setup_platform(hass, validate_config(config), add_entities) + assert len(entities) == 1 + assert entities[0].name == "test" - assert ump.state == STATE_OFF - self.hass.states.set(self.mock_state_switch_id, STATE_ON) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert ump.state == STATE_ON +async def test_master_state(hass): + """Test master state property.""" + config = validate_config(CONFIG_CHILDREN_ONLY) - self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert ump.state == STATE_PLAYING + ump = universal.UniversalMediaPlayer(hass, **config) - self.hass.states.set(self.mock_state_switch_id, STATE_OFF) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert ump.state == STATE_OFF + assert ump.master_state is None - def test_volume_level(self): - """Test volume level property.""" - config = validate_config(self.config_children_only) - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() +async def test_master_state_with_attrs(hass, config_children_and_attr, mock_states): + """Test master state property.""" + config = validate_config(config_children_and_attr) - assert ump.volume_level is None + ump = universal.UniversalMediaPlayer(hass, **config) - self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert ump.volume_level == 0 + assert ump.master_state == STATE_OFF + hass.states.async_set(mock_states.mock_state_switch_id, STATE_ON) + assert ump.master_state == STATE_ON - self.mock_mp_1._volume_level = 1 - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert ump.volume_level == 1 - def test_media_image_url(self): - """Test media_image_url property.""" - test_url = "test_url" - config = validate_config(self.config_children_only) +async def test_master_state_with_bad_attrs(hass, config_children_and_attr): + """Test master state property.""" + config = copy(config_children_and_attr) + config["attributes"]["state"] = "bad.entity_id" + config = validate_config(config) - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() + ump = universal.UniversalMediaPlayer(hass, **config) - assert ump.media_image_url is None + assert ump.master_state == STATE_OFF - self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1._media_image_url = test_url - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - # mock_mp_1 will convert the url to the api proxy url. This test - # ensures ump passes through the same url without an additional proxy. - assert self.mock_mp_1.entity_picture == ump.entity_picture - def test_is_volume_muted_children_only(self): - """Test is volume muted property w/ children only.""" - config = validate_config(self.config_children_only) +async def test_active_child_state(hass, mock_states): + """Test active child state property.""" + config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() - assert not ump.is_volume_muted + assert ump._child_state is None - self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert not ump.is_volume_muted + mock_states.mock_mp_1._state = STATE_PLAYING + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert mock_states.mock_mp_1.entity_id == ump._child_state.entity_id - self.mock_mp_1._is_volume_muted = True - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert ump.is_volume_muted + mock_states.mock_mp_2._state = STATE_PLAYING + mock_states.mock_mp_2.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert mock_states.mock_mp_1.entity_id == ump._child_state.entity_id - def test_sound_mode_list_children_and_attr(self): - """Test sound mode list property w/ children and attrs.""" - config = validate_config(self.config_children_and_attr) + mock_states.mock_mp_1._state = STATE_OFF + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert mock_states.mock_mp_2.entity_id == ump._child_state.entity_id - ump = universal.UniversalMediaPlayer(self.hass, **config) - assert ump.sound_mode_list == "['music', 'movie']" +async def test_name(hass): + """Test name property.""" + config = validate_config(CONFIG_CHILDREN_ONLY) - self.hass.states.set(self.mock_sound_mode_list_id, ["music", "movie", "game"]) - assert ump.sound_mode_list == "['music', 'movie', 'game']" + ump = universal.UniversalMediaPlayer(hass, **config) - def test_source_list_children_and_attr(self): - """Test source list property w/ children and attrs.""" - config = validate_config(self.config_children_and_attr) + assert config["name"] == ump.name - ump = universal.UniversalMediaPlayer(self.hass, **config) - assert ump.source_list == "['dvd', 'htpc']" +async def test_polling(hass): + """Test should_poll property.""" + config = validate_config(CONFIG_CHILDREN_ONLY) - self.hass.states.set(self.mock_source_list_id, ["dvd", "htpc", "game"]) - assert ump.source_list == "['dvd', 'htpc', 'game']" + ump = universal.UniversalMediaPlayer(hass, **config) - def test_sound_mode_children_and_attr(self): - """Test sound modeproperty w/ children and attrs.""" - config = validate_config(self.config_children_and_attr) + assert ump.should_poll is False - ump = universal.UniversalMediaPlayer(self.hass, **config) - assert ump.sound_mode == "music" +async def test_state_children_only(hass, mock_states): + """Test media player state with only children.""" + config = validate_config(CONFIG_CHILDREN_ONLY) - self.hass.states.set(self.mock_sound_mode_id, "movie") - assert ump.sound_mode == "movie" + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() - def test_source_children_and_attr(self): - """Test source property w/ children and attrs.""" - config = validate_config(self.config_children_and_attr) + assert ump.state, STATE_OFF - ump = universal.UniversalMediaPlayer(self.hass, **config) + mock_states.mock_mp_1._state = STATE_PLAYING + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert ump.state == STATE_PLAYING - assert ump.source == "dvd" - self.hass.states.set(self.mock_source_id, "htpc") - assert ump.source == "htpc" +async def test_state_with_children_and_attrs( + hass, config_children_and_attr, mock_states +): + """Test media player with children and master state.""" + config = validate_config(config_children_and_attr) - def test_volume_level_children_and_attr(self): - """Test volume level property w/ children and attrs.""" - config = validate_config(self.config_children_and_attr) + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() - ump = universal.UniversalMediaPlayer(self.hass, **config) + assert ump.state == STATE_OFF - assert ump.volume_level == 0 + hass.states.async_set(mock_states.mock_state_switch_id, STATE_ON) + await ump.async_update() + assert ump.state == STATE_ON - self.hass.states.set(self.mock_volume_id, 100) - assert ump.volume_level == 100 + mock_states.mock_mp_1._state = STATE_PLAYING + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert ump.state == STATE_PLAYING - def test_is_volume_muted_children_and_attr(self): - """Test is volume muted property w/ children and attrs.""" - config = validate_config(self.config_children_and_attr) + hass.states.async_set(mock_states.mock_state_switch_id, STATE_OFF) + await ump.async_update() + assert ump.state == STATE_OFF - ump = universal.UniversalMediaPlayer(self.hass, **config) - assert not ump.is_volume_muted +async def test_volume_level(hass, mock_states): + """Test volume level property.""" + config = validate_config(CONFIG_CHILDREN_ONLY) - self.hass.states.set(self.mock_mute_switch_id, STATE_ON) - assert ump.is_volume_muted + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() - def test_supported_features_children_only(self): - """Test supported media commands with only children.""" - config = validate_config(self.config_children_only) + assert ump.volume_level is None - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() + mock_states.mock_mp_1._state = STATE_PLAYING + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert ump.volume_level == 0 - assert ump.supported_features == 0 + mock_states.mock_mp_1._volume_level = 1 + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert ump.volume_level == 1 - self.mock_mp_1._supported_features = 512 - self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - assert ump.supported_features == 512 - def test_supported_features_children_and_cmds(self): - """Test supported media commands with children and attrs.""" - config = copy(self.config_children_and_attr) - excmd = {"service": "media_player.test", "data": {}} - config["commands"] = { - "turn_on": excmd, - "turn_off": excmd, - "volume_up": excmd, - "volume_down": excmd, - "volume_mute": excmd, - "volume_set": excmd, - "select_sound_mode": excmd, - "select_source": excmd, - "repeat_set": excmd, - "shuffle_set": excmd, - "media_play": excmd, - "media_pause": excmd, - "media_stop": excmd, - "media_next_track": excmd, - "media_previous_track": excmd, - "toggle": excmd, - "play_media": excmd, - "clear_playlist": excmd, - } - config = validate_config(config) - - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - - self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - - check_flags = ( - universal.SUPPORT_TURN_ON - | universal.SUPPORT_TURN_OFF - | universal.SUPPORT_VOLUME_STEP - | universal.SUPPORT_VOLUME_MUTE - | universal.SUPPORT_SELECT_SOUND_MODE - | universal.SUPPORT_SELECT_SOURCE - | universal.SUPPORT_REPEAT_SET - | universal.SUPPORT_SHUFFLE_SET - | universal.SUPPORT_VOLUME_SET - | universal.SUPPORT_PLAY - | universal.SUPPORT_PAUSE - | universal.SUPPORT_STOP - | universal.SUPPORT_NEXT_TRACK - | universal.SUPPORT_PREVIOUS_TRACK - | universal.SUPPORT_PLAY_MEDIA - | universal.SUPPORT_CLEAR_PLAYLIST - ) +async def test_media_image_url(hass, mock_states): + """Test media_image_url property.""" + test_url = "test_url" + config = validate_config(CONFIG_CHILDREN_ONLY) - assert check_flags == ump.supported_features - - def test_overrides(self): - """Test overrides.""" - config = copy(self.config_children_and_attr) - excmd = {"service": "test.override", "data": {}} - config["name"] = "overridden" - config["commands"] = { - "turn_on": excmd, - "turn_off": excmd, - "volume_up": excmd, - "volume_down": excmd, - "volume_mute": excmd, - "volume_set": excmd, - "select_sound_mode": excmd, - "select_source": excmd, - "repeat_set": excmd, - "shuffle_set": excmd, - "media_play": excmd, - "media_play_pause": excmd, - "media_pause": excmd, - "media_stop": excmd, - "media_next_track": excmd, - "media_previous_track": excmd, - "clear_playlist": excmd, - "play_media": excmd, - "toggle": excmd, - } - setup_component(self.hass, "media_player", {"media_player": config}) + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() - service = mock_service(self.hass, "test", "override") - self.hass.services.call( - "media_player", - "turn_on", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 1 - self.hass.services.call( - "media_player", - "turn_off", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 2 - self.hass.services.call( - "media_player", - "volume_up", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 3 - self.hass.services.call( - "media_player", - "volume_down", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 4 - self.hass.services.call( - "media_player", - "volume_mute", - service_data={ - "entity_id": "media_player.overridden", - "is_volume_muted": True, - }, - blocking=True, - ) - assert len(service) == 5 - self.hass.services.call( - "media_player", - "volume_set", - service_data={"entity_id": "media_player.overridden", "volume_level": 1}, - blocking=True, - ) - assert len(service) == 6 - self.hass.services.call( - "media_player", - "select_sound_mode", - service_data={ - "entity_id": "media_player.overridden", - "sound_mode": "music", - }, - blocking=True, - ) - assert len(service) == 7 - self.hass.services.call( - "media_player", - "select_source", - service_data={"entity_id": "media_player.overridden", "source": "video1"}, - blocking=True, - ) - assert len(service) == 8 - self.hass.services.call( - "media_player", - "repeat_set", - service_data={"entity_id": "media_player.overridden", "repeat": "all"}, - blocking=True, - ) - assert len(service) == 9 - self.hass.services.call( - "media_player", - "shuffle_set", - service_data={"entity_id": "media_player.overridden", "shuffle": True}, - blocking=True, - ) - assert len(service) == 10 - self.hass.services.call( - "media_player", - "media_play", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 11 - self.hass.services.call( - "media_player", - "media_pause", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 12 - self.hass.services.call( - "media_player", - "media_stop", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 13 - self.hass.services.call( - "media_player", - "media_next_track", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 14 - self.hass.services.call( - "media_player", - "media_previous_track", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 15 - self.hass.services.call( - "media_player", - "clear_playlist", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 16 - self.hass.services.call( - "media_player", - "media_play_pause", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 17 - self.hass.services.call( - "media_player", - "play_media", - service_data={ - "entity_id": "media_player.overridden", - "media_content_id": 1, - "media_content_type": "channel", - }, - blocking=True, - ) - assert len(service) == 18 - self.hass.services.call( - "media_player", - "toggle", - service_data={"entity_id": "media_player.overridden"}, - blocking=True, - ) - assert len(service) == 19 - - def test_supported_features_play_pause(self): - """Test supported media commands with play_pause function.""" - config = copy(self.config_children_and_attr) - excmd = {"service": "media_player.test", "data": {"entity_id": "test"}} - config["commands"] = {"media_play_pause": excmd} - config = validate_config(config) - - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - - self.mock_mp_1._state = STATE_PLAYING - self.mock_mp_1.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - - check_flags = universal.SUPPORT_PLAY | universal.SUPPORT_PAUSE - - assert check_flags == ump.supported_features - - def test_service_call_no_active_child(self): - """Test a service call to children with no active child.""" - config = validate_config(self.config_children_and_attr) - - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - - self.mock_mp_1._state = STATE_OFF - self.mock_mp_1.schedule_update_ha_state() - self.mock_mp_2._state = STATE_OFF - self.mock_mp_2.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - - asyncio.run_coroutine_threadsafe(ump.async_turn_off(), self.hass.loop).result() - assert len(self.mock_mp_1.service_calls["turn_off"]) == 0 - assert len(self.mock_mp_2.service_calls["turn_off"]) == 0 - - def test_service_call_to_child(self): - """Test service calls that should be routed to a child.""" - config = validate_config(self.config_children_only) - - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - - self.mock_mp_2._state = STATE_PLAYING - self.mock_mp_2.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - - asyncio.run_coroutine_threadsafe(ump.async_turn_off(), self.hass.loop).result() - assert len(self.mock_mp_2.service_calls["turn_off"]) == 1 - - asyncio.run_coroutine_threadsafe(ump.async_turn_on(), self.hass.loop).result() - assert len(self.mock_mp_2.service_calls["turn_on"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_mute_volume(True), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["mute_volume"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_set_volume_level(0.5), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["set_volume_level"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_media_play(), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["media_play"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_media_pause(), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["media_pause"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_media_stop(), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["media_stop"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_media_previous_track(), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["media_previous_track"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_media_next_track(), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["media_next_track"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_media_seek(100), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["media_seek"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_play_media("movie", "batman"), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["play_media"]) == 1 - - asyncio.run_coroutine_threadsafe(ump.async_volume_up(), self.hass.loop).result() - assert len(self.mock_mp_2.service_calls["volume_up"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_volume_down(), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["volume_down"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_media_play_pause(), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["media_play_pause"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_select_sound_mode("music"), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["select_sound_mode"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_select_source("dvd"), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["select_source"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_clear_playlist(), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["clear_playlist"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_set_repeat(True), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["repeat_set"]) == 1 - - asyncio.run_coroutine_threadsafe( - ump.async_set_shuffle(True), self.hass.loop - ).result() - assert len(self.mock_mp_2.service_calls["shuffle_set"]) == 1 - - asyncio.run_coroutine_threadsafe(ump.async_toggle(), self.hass.loop).result() - # Delegate to turn_off - assert len(self.mock_mp_2.service_calls["turn_off"]) == 2 - - def test_service_call_to_command(self): - """Test service call to command.""" - config = copy(self.config_children_only) - config["commands"] = {"turn_off": {"service": "test.turn_off", "data": {}}} - config = validate_config(config) - - service = mock_service(self.hass, "test", "turn_off") - - ump = universal.UniversalMediaPlayer(self.hass, **config) - ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - - self.mock_mp_2._state = STATE_PLAYING - self.mock_mp_2.schedule_update_ha_state() - self.hass.block_till_done() - asyncio.run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result() - - asyncio.run_coroutine_threadsafe(ump.async_turn_off(), self.hass.loop).result() - assert len(service) == 1 + assert ump.media_image_url is None + + mock_states.mock_mp_1._state = STATE_PLAYING + mock_states.mock_mp_1._media_image_url = test_url + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + # mock_mp_1 will convert the url to the api proxy url. This test + # ensures ump passes through the same url without an additional proxy. + assert mock_states.mock_mp_1.entity_picture == ump.entity_picture + + +async def test_is_volume_muted_children_only(hass, mock_states): + """Test is volume muted property w/ children only.""" + config = validate_config(CONFIG_CHILDREN_ONLY) + + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + assert not ump.is_volume_muted + + mock_states.mock_mp_1._state = STATE_PLAYING + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert not ump.is_volume_muted + + mock_states.mock_mp_1._is_volume_muted = True + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert ump.is_volume_muted + + +async def test_sound_mode_list_children_and_attr( + hass, config_children_and_attr, mock_states +): + """Test sound mode list property w/ children and attrs.""" + config = validate_config(config_children_and_attr) + + ump = universal.UniversalMediaPlayer(hass, **config) + + assert ump.sound_mode_list == "['music', 'movie']" + + hass.states.async_set( + mock_states.mock_sound_mode_list_id, ["music", "movie", "game"] + ) + assert ump.sound_mode_list == "['music', 'movie', 'game']" + + +async def test_source_list_children_and_attr( + hass, config_children_and_attr, mock_states +): + """Test source list property w/ children and attrs.""" + config = validate_config(config_children_and_attr) + + ump = universal.UniversalMediaPlayer(hass, **config) + + assert ump.source_list == "['dvd', 'htpc']" + + hass.states.async_set(mock_states.mock_source_list_id, ["dvd", "htpc", "game"]) + assert ump.source_list == "['dvd', 'htpc', 'game']" + + +async def test_sound_mode_children_and_attr( + hass, config_children_and_attr, mock_states +): + """Test sound modeproperty w/ children and attrs.""" + config = validate_config(config_children_and_attr) + + ump = universal.UniversalMediaPlayer(hass, **config) + + assert ump.sound_mode == "music" + + hass.states.async_set(mock_states.mock_sound_mode_id, "movie") + assert ump.sound_mode == "movie" + + +async def test_source_children_and_attr(hass, config_children_and_attr, mock_states): + """Test source property w/ children and attrs.""" + config = validate_config(config_children_and_attr) + + ump = universal.UniversalMediaPlayer(hass, **config) + + assert ump.source == "dvd" + + hass.states.async_set(mock_states.mock_source_id, "htpc") + assert ump.source == "htpc" + + +async def test_volume_level_children_and_attr( + hass, config_children_and_attr, mock_states +): + """Test volume level property w/ children and attrs.""" + config = validate_config(config_children_and_attr) + + ump = universal.UniversalMediaPlayer(hass, **config) + + assert ump.volume_level == 0 + + hass.states.async_set(mock_states.mock_volume_id, 100) + assert ump.volume_level == 100 + + +async def test_is_volume_muted_children_and_attr( + hass, config_children_and_attr, mock_states +): + """Test is volume muted property w/ children and attrs.""" + config = validate_config(config_children_and_attr) + + ump = universal.UniversalMediaPlayer(hass, **config) + + assert not ump.is_volume_muted + + hass.states.async_set(mock_states.mock_mute_switch_id, STATE_ON) + assert ump.is_volume_muted + + +async def test_supported_features_children_only(hass, mock_states): + """Test supported media commands with only children.""" + config = validate_config(CONFIG_CHILDREN_ONLY) + + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + assert ump.supported_features == 0 + + mock_states.mock_mp_1._supported_features = 512 + mock_states.mock_mp_1._state = STATE_PLAYING + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert ump.supported_features == 512 + + +async def test_supported_features_children_and_cmds( + hass, config_children_and_attr, mock_states +): + """Test supported media commands with children and attrs.""" + config = copy(config_children_and_attr) + excmd = {"service": "media_player.test", "data": {}} + config["commands"] = { + "turn_on": excmd, + "turn_off": excmd, + "volume_up": excmd, + "volume_down": excmd, + "volume_mute": excmd, + "volume_set": excmd, + "select_sound_mode": excmd, + "select_source": excmd, + "repeat_set": excmd, + "shuffle_set": excmd, + "media_play": excmd, + "media_pause": excmd, + "media_stop": excmd, + "media_next_track": excmd, + "media_previous_track": excmd, + "toggle": excmd, + "play_media": excmd, + "clear_playlist": excmd, + } + config = validate_config(config) + + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + mock_states.mock_mp_1._state = STATE_PLAYING + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + + check_flags = ( + universal.SUPPORT_TURN_ON + | universal.SUPPORT_TURN_OFF + | universal.SUPPORT_VOLUME_STEP + | universal.SUPPORT_VOLUME_MUTE + | universal.SUPPORT_SELECT_SOUND_MODE + | universal.SUPPORT_SELECT_SOURCE + | universal.SUPPORT_REPEAT_SET + | universal.SUPPORT_SHUFFLE_SET + | universal.SUPPORT_VOLUME_SET + | universal.SUPPORT_PLAY + | universal.SUPPORT_PAUSE + | universal.SUPPORT_STOP + | universal.SUPPORT_NEXT_TRACK + | universal.SUPPORT_PREVIOUS_TRACK + | universal.SUPPORT_PLAY_MEDIA + | universal.SUPPORT_CLEAR_PLAYLIST + ) + + assert check_flags == ump.supported_features + + +async def test_overrides(hass, config_children_and_attr): + """Test overrides.""" + config = copy(config_children_and_attr) + excmd = {"service": "test.override", "data": {}} + config["name"] = "overridden" + config["commands"] = { + "turn_on": excmd, + "turn_off": excmd, + "volume_up": excmd, + "volume_down": excmd, + "volume_mute": excmd, + "volume_set": excmd, + "select_sound_mode": excmd, + "select_source": excmd, + "repeat_set": excmd, + "shuffle_set": excmd, + "media_play": excmd, + "media_play_pause": excmd, + "media_pause": excmd, + "media_stop": excmd, + "media_next_track": excmd, + "media_previous_track": excmd, + "clear_playlist": excmd, + "play_media": excmd, + "toggle": excmd, + } + await async_setup_component(hass, "media_player", {"media_player": config}) + await hass.async_block_till_done() + + service = async_mock_service(hass, "test", "override") + await hass.services.async_call( + "media_player", + "turn_on", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 1 + await hass.services.async_call( + "media_player", + "turn_off", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 2 + await hass.services.async_call( + "media_player", + "volume_up", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 3 + await hass.services.async_call( + "media_player", + "volume_down", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 4 + await hass.services.async_call( + "media_player", + "volume_mute", + service_data={ + "entity_id": "media_player.overridden", + "is_volume_muted": True, + }, + blocking=True, + ) + assert len(service) == 5 + await hass.services.async_call( + "media_player", + "volume_set", + service_data={"entity_id": "media_player.overridden", "volume_level": 1}, + blocking=True, + ) + assert len(service) == 6 + await hass.services.async_call( + "media_player", + "select_sound_mode", + service_data={ + "entity_id": "media_player.overridden", + "sound_mode": "music", + }, + blocking=True, + ) + assert len(service) == 7 + await hass.services.async_call( + "media_player", + "select_source", + service_data={"entity_id": "media_player.overridden", "source": "video1"}, + blocking=True, + ) + assert len(service) == 8 + await hass.services.async_call( + "media_player", + "repeat_set", + service_data={"entity_id": "media_player.overridden", "repeat": "all"}, + blocking=True, + ) + assert len(service) == 9 + await hass.services.async_call( + "media_player", + "shuffle_set", + service_data={"entity_id": "media_player.overridden", "shuffle": True}, + blocking=True, + ) + assert len(service) == 10 + await hass.services.async_call( + "media_player", + "media_play", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 11 + await hass.services.async_call( + "media_player", + "media_pause", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 12 + await hass.services.async_call( + "media_player", + "media_stop", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 13 + await hass.services.async_call( + "media_player", + "media_next_track", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 14 + await hass.services.async_call( + "media_player", + "media_previous_track", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 15 + await hass.services.async_call( + "media_player", + "clear_playlist", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 16 + await hass.services.async_call( + "media_player", + "media_play_pause", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 17 + await hass.services.async_call( + "media_player", + "play_media", + service_data={ + "entity_id": "media_player.overridden", + "media_content_id": 1, + "media_content_type": "channel", + }, + blocking=True, + ) + assert len(service) == 18 + await hass.services.async_call( + "media_player", + "toggle", + service_data={"entity_id": "media_player.overridden"}, + blocking=True, + ) + assert len(service) == 19 + + +async def test_supported_features_play_pause( + hass, config_children_and_attr, mock_states +): + """Test supported media commands with play_pause function.""" + config = copy(config_children_and_attr) + excmd = {"service": "media_player.test", "data": {"entity_id": "test"}} + config["commands"] = {"media_play_pause": excmd} + config = validate_config(config) + + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + mock_states.mock_mp_1._state = STATE_PLAYING + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + + check_flags = universal.SUPPORT_PLAY | universal.SUPPORT_PAUSE + + assert check_flags == ump.supported_features + + +async def test_service_call_no_active_child( + hass, config_children_and_attr, mock_states +): + """Test a service call to children with no active child.""" + config = validate_config(config_children_and_attr) + + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + mock_states.mock_mp_1._state = STATE_OFF + mock_states.mock_mp_1.async_schedule_update_ha_state() + mock_states.mock_mp_2._state = STATE_OFF + mock_states.mock_mp_2.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + + await ump.async_turn_off() + assert len(mock_states.mock_mp_1.service_calls["turn_off"]) == 0 + assert len(mock_states.mock_mp_2.service_calls["turn_off"]) == 0 + + +async def test_service_call_to_child(hass, mock_states): + """Test service calls that should be routed to a child.""" + config = validate_config(CONFIG_CHILDREN_ONLY) + + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + mock_states.mock_mp_2._state = STATE_PLAYING + mock_states.mock_mp_2.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + + await ump.async_turn_off() + assert len(mock_states.mock_mp_2.service_calls["turn_off"]) == 1 + + await ump.async_turn_on() + assert len(mock_states.mock_mp_2.service_calls["turn_on"]) == 1 + + await ump.async_mute_volume(True) + assert len(mock_states.mock_mp_2.service_calls["mute_volume"]) == 1 + + await ump.async_set_volume_level(0.5) + assert len(mock_states.mock_mp_2.service_calls["set_volume_level"]) == 1 + + await ump.async_media_play() + assert len(mock_states.mock_mp_2.service_calls["media_play"]) == 1 + + await ump.async_media_pause() + assert len(mock_states.mock_mp_2.service_calls["media_pause"]) == 1 + + await ump.async_media_stop() + assert len(mock_states.mock_mp_2.service_calls["media_stop"]) == 1 + + await ump.async_media_previous_track() + assert len(mock_states.mock_mp_2.service_calls["media_previous_track"]) == 1 + + await ump.async_media_next_track() + assert len(mock_states.mock_mp_2.service_calls["media_next_track"]) == 1 + + await ump.async_media_seek(100) + assert len(mock_states.mock_mp_2.service_calls["media_seek"]) == 1 + + await ump.async_play_media("movie", "batman") + assert len(mock_states.mock_mp_2.service_calls["play_media"]) == 1 + + await ump.async_volume_up() + assert len(mock_states.mock_mp_2.service_calls["volume_up"]) == 1 + + await ump.async_volume_down() + assert len(mock_states.mock_mp_2.service_calls["volume_down"]) == 1 + + await ump.async_media_play_pause() + assert len(mock_states.mock_mp_2.service_calls["media_play_pause"]) == 1 + + await ump.async_select_sound_mode("music") + assert len(mock_states.mock_mp_2.service_calls["select_sound_mode"]) == 1 + + await ump.async_select_source("dvd") + assert len(mock_states.mock_mp_2.service_calls["select_source"]) == 1 + + await ump.async_clear_playlist() + assert len(mock_states.mock_mp_2.service_calls["clear_playlist"]) == 1 + + await ump.async_set_repeat(True) + assert len(mock_states.mock_mp_2.service_calls["repeat_set"]) == 1 + + await ump.async_set_shuffle(True) + assert len(mock_states.mock_mp_2.service_calls["shuffle_set"]) == 1 + + await ump.async_toggle() + # Delegate to turn_off + assert len(mock_states.mock_mp_2.service_calls["turn_off"]) == 2 + + +async def test_service_call_to_command(hass, mock_states): + """Test service call to command.""" + config = copy(CONFIG_CHILDREN_ONLY) + config["commands"] = {"turn_off": {"service": "test.turn_off", "data": {}}} + config = validate_config(config) + + service = async_mock_service(hass, "test", "turn_off") + + ump = universal.UniversalMediaPlayer(hass, **config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + mock_states.mock_mp_2._state = STATE_PLAYING + mock_states.mock_mp_2.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + + await ump.async_turn_off() + assert len(service) == 1 async def test_state_template(hass): diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index d2b1a849ba59dd..479cd9000504c4 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -1,8 +1,13 @@ """Configuration for SSDP tests.""" -from typing import Any, Mapping +from __future__ import annotations + +from collections.abc import Sequence from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import urlparse +from async_upnp_client.client import UpnpDevice +from async_upnp_client.event_handler import UpnpEventHandler +from async_upnp_client.profiles.igd import StatusInfo import pytest from homeassistant.components import ssdp @@ -16,7 +21,6 @@ PACKETS_SENT, ROUTER_IP, ROUTER_UPTIME, - TIMESTAMP, WAN_STATUS, ) from homeassistant.core import HomeAssistant @@ -29,17 +33,20 @@ TEST_USN = f"{TEST_UDN}::{TEST_ST}" TEST_LOCATION = "http://192.168.1.1/desc.xml" TEST_HOSTNAME = urlparse(TEST_LOCATION).hostname -TEST_FRIENDLY_NAME = "friendly name" +TEST_FRIENDLY_NAME = "mock-name" TEST_DISCOVERY = ssdp.SsdpServiceInfo( ssdp_usn=TEST_USN, ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, upnp={ - ssdp.ATTR_UPNP_UDN: TEST_UDN, - "usn": TEST_USN, - "location": TEST_LOCATION, "_udn": TEST_UDN, - "friendlyName": TEST_FRIENDLY_NAME, + "location": TEST_LOCATION, + "usn": TEST_USN, + ssdp.ATTR_UPNP_DEVICE_TYPE: TEST_ST, + ssdp.ATTR_UPNP_FRIENDLY_NAME: TEST_FRIENDLY_NAME, + ssdp.ATTR_UPNP_MANUFACTURER: "mock-manufacturer", + ssdp.ATTR_UPNP_MODEL_NAME: "mock-model-name", + ssdp.ATTR_UPNP_UDN: TEST_UDN, }, ssdp_headers={ "_host": TEST_HOSTNAME, @@ -47,52 +54,37 @@ ) -class MockDevice: - """Mock device for Device.""" +class MockUpnpDevice: + """Mock async_upnp_client UpnpDevice.""" - def __init__(self, hass: HomeAssistant, udn: str) -> None: - """Initialize mock device.""" - self.hass = hass - self._udn = udn - self.traffic_times_polled = 0 - self.status_times_polled = 0 - self._timestamp = dt.utcnow() - - @classmethod - async def async_create_device(cls, hass, ssdp_location) -> "MockDevice": - """Return self.""" - return cls(hass, TEST_UDN) - - async def async_ssdp_callback( - self, headers: Mapping[str, Any], change: ssdp.SsdpChange - ) -> None: - """SSDP callback, update if needed.""" - pass - - @property - def udn(self) -> str: - """Get the UDN.""" - return self._udn + def __init__(self, location: str) -> None: + """Initialize.""" + self.device_url = location @property def manufacturer(self) -> str: """Get manufacturer.""" - return "mock-manufacturer" + return TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_MANUFACTURER] @property def name(self) -> str: """Get name.""" - return "mock-name" + return TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] @property def model_name(self) -> str: """Get the model name.""" - return "mock-model-name" + return TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_MODEL_NAME] @property def device_type(self) -> str: """Get the device type.""" - return "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + return TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_DEVICE_TYPE] + + @property + def udn(self) -> str: + """Get the UDN.""" + return TEST_DISCOVERY.upnp[ssdp.ATTR_UPNP_UDN] @property def usn(self) -> str: @@ -104,39 +96,118 @@ def unique_id(self) -> str: """Get the unique id.""" return self.usn - @property - def hostname(self) -> str: - """Get the hostname.""" - return "mock-hostname" + def reinit(self, new_upnp_device: UpnpDevice) -> None: + """Reinitialize.""" + self.device_url = new_upnp_device.device_url - async def async_get_traffic_data(self) -> Mapping[str, Any]: - """Get traffic data.""" - self.traffic_times_polled += 1 - return { - TIMESTAMP: self._timestamp, + +class MockIgdDevice: + """Mock async_upnp_client IgdDevice.""" + + def __init__(self, device: MockUpnpDevice, event_handler: UpnpEventHandler) -> None: + """Initialize mock device.""" + self.device = device + self.profile_device = device + + self._timestamp = dt.utcnow() + self.traffic_times_polled = 0 + self.status_times_polled = 0 + + self.traffic_data = { BYTES_RECEIVED: 0, BYTES_SENT: 0, PACKETS_RECEIVED: 0, PACKETS_SENT: 0, } - - async def async_get_status(self) -> Mapping[str, Any]: - """Get connection status, uptime, and external IP.""" - self.status_times_polled += 1 - return { + self.status_data = { WAN_STATUS: "Connected", ROUTER_UPTIME: 10, ROUTER_IP: "8.9.10.11", } + @property + def name(self) -> str: + """Get the name of the device.""" + return self.profile_device.name + + @property + def manufacturer(self) -> str: + """Get the manufacturer of this device.""" + return self.profile_device.manufacturer + + @property + def model_name(self) -> str: + """Get the model name of this device.""" + return self.profile_device.model_name + + @property + def udn(self) -> str: + """Get the UDN of the device.""" + return self.profile_device.udn + + @property + def device_type(self) -> str: + """Get the device type of this device.""" + return self.profile_device.device_type + + async def async_get_total_bytes_received(self) -> int | None: + """Get total bytes received.""" + self.traffic_times_polled += 1 + return self.traffic_data[BYTES_RECEIVED] + + async def async_get_total_bytes_sent(self) -> int | None: + """Get total bytes sent.""" + return self.traffic_data[BYTES_SENT] + + async def async_get_total_packets_received(self) -> int | None: + """Get total packets received.""" + return self.traffic_data[PACKETS_RECEIVED] + + async def async_get_total_packets_sent(self) -> int | None: + """Get total packets sent.""" + return self.traffic_data[PACKETS_SENT] + + async def async_get_external_ip_address( + self, services: Sequence[str] | None = None + ) -> str | None: + """ + Get the external IP address. + + :param services List of service names to try to get action from, defaults to [WANIPC,WANPPP] + """ + return self.status_data[ROUTER_IP] + + async def async_get_status_info( + self, services: Sequence[str] | None = None + ) -> StatusInfo | None: + """ + Get status info. + + :param services List of service names to try to get action from, defaults to [WANIPC,WANPPP] + """ + self.status_times_polled += 1 + return StatusInfo( + self.status_data[WAN_STATUS], "", self.status_data[ROUTER_UPTIME] + ) + @pytest.fixture(autouse=True) def mock_upnp_device(): """Mock homeassistant.components.upnp.Device.""" + + async def mock_async_create_upnp_device( + hass: HomeAssistant, location: str + ) -> UpnpDevice: + """Create UPnP device.""" + return MockUpnpDevice(location) + with patch( - "homeassistant.components.upnp.Device", new=MockDevice - ) as mock_async_create_device: - yield mock_async_create_device + "homeassistant.components.upnp.device.async_create_upnp_device", + side_effect=mock_async_create_upnp_device, + ) as mock_async_create_upnp_device, patch( + "homeassistant.components.upnp.device.IgdDevice", new=MockIgdDevice + ) as mock_igd_device: + yield mock_async_create_upnp_device, mock_igd_device @pytest.fixture diff --git a/tests/components/upnp/test_binary_sensor.py b/tests/components/upnp/test_binary_sensor.py index 46f0021a07b789..0a8095cb10f520 100644 --- a/tests/components/upnp/test_binary_sensor.py +++ b/tests/components/upnp/test_binary_sensor.py @@ -1,7 +1,6 @@ """Tests for UPnP/IGD binary_sensor.""" from datetime import timedelta -from unittest.mock import AsyncMock from homeassistant.components.upnp.const import ( DOMAIN, @@ -12,7 +11,7 @@ from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from .conftest import MockDevice +from .conftest import MockIgdDevice from tests.common import MockConfigEntry, async_fire_time_changed @@ -21,20 +20,19 @@ async def test_upnp_binary_sensors( hass: HomeAssistant, setup_integration: MockConfigEntry ): """Test normal sensors.""" - mock_device: MockDevice = hass.data[DOMAIN][setup_integration.entry_id].device - # First poll. wan_status_state = hass.states.get("binary_sensor.mock_name_wan_status") assert wan_status_state.state == "on" # Second poll. - mock_device.async_get_status = AsyncMock( - return_value={ - WAN_STATUS: "Disconnected", - ROUTER_UPTIME: 100, - ROUTER_IP: "", - } - ) + mock_device: MockIgdDevice = hass.data[DOMAIN][ + setup_integration.entry_id + ].device._igd_device + mock_device.status_data = { + WAN_STATUS: "Disconnected", + ROUTER_UPTIME: 100, + ROUTER_IP: "", + } async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) await hass.async_block_till_done() diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 0771e51f890cc1..097ef1eb1c6365 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -25,7 +25,7 @@ TEST_ST, TEST_UDN, TEST_USN, - MockDevice, + MockIgdDevice, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -199,9 +199,11 @@ async def test_options_flow(hass: HomeAssistant): config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) is True await hass.async_block_till_done() - mock_device: MockDevice = hass.data[DOMAIN][config_entry.entry_id].device # Reset. + mock_device: MockIgdDevice = hass.data[DOMAIN][ + config_entry.entry_id + ].device._igd_device mock_device.traffic_times_polled = 0 mock_device.status_times_polled = 0 diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index 7729068a2ed528..39a63893e33661 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -3,14 +3,17 @@ import pytest +from homeassistant.components import ssdp +from homeassistant.components.upnp import UpnpDataUpdateCoordinator from homeassistant.components.upnp.const import ( CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DOMAIN, ) +from homeassistant.components.upnp.device import Device from homeassistant.core import HomeAssistant -from .conftest import TEST_ST, TEST_UDN +from .conftest import TEST_DISCOVERY, TEST_ST, TEST_UDN from tests.common import MockConfigEntry @@ -18,7 +21,6 @@ @pytest.mark.usefixtures("ssdp_instant_discovery", "mock_get_source_ip") async def test_async_setup_entry_default(hass: HomeAssistant): """Test async_setup_entry.""" - entry = MockConfigEntry( domain=DOMAIN, data={ @@ -30,3 +32,21 @@ async def test_async_setup_entry_default(hass: HomeAssistant): # Load config_entry. entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) is True + + +async def test_reinitialize_device( + hass: HomeAssistant, setup_integration: MockConfigEntry +): + """Test device is reinitialized when device changes location.""" + config_entry = setup_integration + coordinator: UpnpDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + device: Device = coordinator.device + assert device._igd_device.device.device_url == TEST_DISCOVERY.ssdp_location + + # Reinit. + new_location = "http://192.168.1.1:12345/desc.xml" + headers = { + ssdp.ATTR_SSDP_LOCATION: new_location, + } + await device.async_ssdp_callback(headers, ...) + assert device._igd_device.device.device_url == new_location diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index 7d6b498ab24edf..a249264ffee374 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -1,32 +1,32 @@ """Tests for UPnP/IGD sensor.""" from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import patch +import pytest + +from homeassistant.components.upnp import UpnpDataUpdateCoordinator from homeassistant.components.upnp.const import ( BYTES_RECEIVED, BYTES_SENT, + DEFAULT_SCAN_INTERVAL, DOMAIN, PACKETS_RECEIVED, PACKETS_SENT, ROUTER_IP, ROUTER_UPTIME, - TIMESTAMP, - UPDATE_INTERVAL, WAN_STATUS, ) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from .conftest import MockDevice +from .conftest import MockIgdDevice from tests.common import MockConfigEntry, async_fire_time_changed async def test_upnp_sensors(hass: HomeAssistant, setup_integration: MockConfigEntry): """Test normal sensors.""" - mock_device: MockDevice = hass.data[DOMAIN][setup_integration.entry_id].device - # First poll. b_received_state = hass.states.get("sensor.mock_name_b_received") b_sent_state = hass.states.get("sensor.mock_name_b_sent") @@ -42,23 +42,22 @@ async def test_upnp_sensors(hass: HomeAssistant, setup_integration: MockConfigEn assert wan_status_state.state == "Connected" # Second poll. - mock_device.async_get_traffic_data = AsyncMock( - return_value={ - TIMESTAMP: mock_device._timestamp + UPDATE_INTERVAL, - BYTES_RECEIVED: 10240, - BYTES_SENT: 20480, - PACKETS_RECEIVED: 30, - PACKETS_SENT: 40, - } - ) - mock_device.async_get_status = AsyncMock( - return_value={ - WAN_STATUS: "Disconnected", - ROUTER_UPTIME: 100, - ROUTER_IP: "", - } - ) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) + mock_device: MockIgdDevice = hass.data[DOMAIN][ + setup_integration.entry_id + ].device._igd_device + mock_device.traffic_data = { + BYTES_RECEIVED: 10240, + BYTES_SENT: 20480, + PACKETS_RECEIVED: 30, + PACKETS_SENT: 40, + } + mock_device.status_data = { + WAN_STATUS: "Disconnected", + ROUTER_UPTIME: 100, + ROUTER_IP: "", + } + now = dt_util.utcnow() + async_fire_time_changed(hass, now + timedelta(seconds=DEFAULT_SCAN_INTERVAL)) await hass.async_block_till_done() b_received_state = hass.states.get("sensor.mock_name_b_received") @@ -79,7 +78,9 @@ async def test_derived_upnp_sensors( hass: HomeAssistant, setup_integration: MockConfigEntry ): """Test derived sensors.""" - mock_device: MockDevice = hass.data[DOMAIN][setup_integration.entry_id].device + coordinator: UpnpDataUpdateCoordinator = hass.data[DOMAIN][ + setup_integration.entry_id + ] # First poll. kib_s_received_state = hass.states.get("sensor.mock_name_kib_s_received") @@ -92,23 +93,28 @@ async def test_derived_upnp_sensors( assert packets_s_sent_state.state == "unknown" # Second poll. - mock_device.async_get_traffic_data = AsyncMock( - return_value={ - TIMESTAMP: mock_device._timestamp + UPDATE_INTERVAL, - BYTES_RECEIVED: int(10240 * UPDATE_INTERVAL.total_seconds()), - BYTES_SENT: int(20480 * UPDATE_INTERVAL.total_seconds()), - PACKETS_RECEIVED: int(30 * UPDATE_INTERVAL.total_seconds()), - PACKETS_SENT: int(40 * UPDATE_INTERVAL.total_seconds()), + now = dt_util.utcnow() + with patch( + "homeassistant.components.upnp.device.utcnow", + return_value=now + timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ): + mock_device: MockIgdDevice = coordinator.device._igd_device + mock_device.traffic_data = { + BYTES_RECEIVED: int(10240 * DEFAULT_SCAN_INTERVAL), + BYTES_SENT: int(20480 * DEFAULT_SCAN_INTERVAL), + PACKETS_RECEIVED: int(30 * DEFAULT_SCAN_INTERVAL), + PACKETS_SENT: int(40 * DEFAULT_SCAN_INTERVAL), } - ) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) - await hass.async_block_till_done() + async_fire_time_changed(hass, now + timedelta(seconds=DEFAULT_SCAN_INTERVAL)) + await hass.async_block_till_done() - kib_s_received_state = hass.states.get("sensor.mock_name_kib_s_received") - kib_s_sent_state = hass.states.get("sensor.mock_name_kib_s_sent") - packets_s_received_state = hass.states.get("sensor.mock_name_packets_s_received") - packets_s_sent_state = hass.states.get("sensor.mock_name_packets_s_sent") - assert kib_s_received_state.state == "10.0" - assert kib_s_sent_state.state == "20.0" - assert packets_s_received_state.state == "30.0" - assert packets_s_sent_state.state == "40.0" + kib_s_received_state = hass.states.get("sensor.mock_name_kib_s_received") + kib_s_sent_state = hass.states.get("sensor.mock_name_kib_s_sent") + packets_s_received_state = hass.states.get( + "sensor.mock_name_packets_s_received" + ) + packets_s_sent_state = hass.states.get("sensor.mock_name_packets_s_sent") + assert float(kib_s_received_state.state) == pytest.approx(10.0, rel=0.1) + assert float(kib_s_sent_state.state) == pytest.approx(20.0, rel=0.1) + assert float(packets_s_received_state.state) == pytest.approx(30.0, rel=0.1) + assert float(packets_s_sent_state.state) == pytest.approx(40.0, rel=0.1) diff --git a/tests/components/uptimerobot/common.py b/tests/components/uptimerobot/common.py index 224d2d32911312..6ec0e7aef7d144 100644 --- a/tests/components/uptimerobot/common.py +++ b/tests/components/uptimerobot/common.py @@ -20,10 +20,15 @@ from tests.common import MockConfigEntry -MOCK_UPTIMEROBOT_API_KEY = "1234" +MOCK_UPTIMEROBOT_API_KEY = "0242ac120003" +MOCK_UPTIMEROBOT_EMAIL = "test@test.test" MOCK_UPTIMEROBOT_UNIQUE_ID = "1234567890" -MOCK_UPTIMEROBOT_ACCOUNT = {"email": "test@test.test", "user_id": 1234567890} +MOCK_UPTIMEROBOT_ACCOUNT = { + "email": MOCK_UPTIMEROBOT_EMAIL, + "user_id": 1234567890, + "up_monitors": 1, +} MOCK_UPTIMEROBOT_ERROR = {"message": "test error from API."} MOCK_UPTIMEROBOT_MONITOR = { "id": 1234, @@ -35,13 +40,16 @@ MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA = { "domain": DOMAIN, - "title": "test@test.test", + "title": MOCK_UPTIMEROBOT_EMAIL, "data": {"platform": DOMAIN, "api_key": MOCK_UPTIMEROBOT_API_KEY}, "unique_id": MOCK_UPTIMEROBOT_UNIQUE_ID, "source": config_entries.SOURCE_USER, } -UPTIMEROBOT_TEST_ENTITY = "binary_sensor.test_monitor" +STATE_UP = "up" + +UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY = "binary_sensor.test_monitor" +UPTIMEROBOT_SENSOR_TEST_ENTITY = "sensor.test_monitor" class MockApiResponseKey(str, Enum): @@ -89,7 +97,8 @@ async def setup_uptimerobot_integration(hass: HomeAssistant) -> MockConfigEntry: assert await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY).state == STATE_UP assert mock_entry.state == config_entries.ConfigEntryState.LOADED return mock_entry diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index 25ca76a29140cb..0cf0c3a6fbe912 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -15,7 +15,7 @@ from .common import ( MOCK_UPTIMEROBOT_MONITOR, - UPTIMEROBOT_TEST_ENTITY, + UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY, setup_uptimerobot_integration, ) @@ -26,7 +26,7 @@ async def test_presentation(hass: HomeAssistant) -> None: """Test the presenstation of UptimeRobot binary_sensors.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) assert entity.state == STATE_ON assert entity.attributes["device_class"] == BinarySensorDeviceClass.CONNECTIVITY @@ -38,7 +38,7 @@ async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: """Test entity unaviable on update failure.""" await setup_uptimerobot_integration(hass) - entity = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) assert entity.state == STATE_ON with patch( @@ -48,5 +48,5 @@ async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() - entity = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) assert entity.state == STATE_UNAVAILABLE diff --git a/tests/components/uptimerobot/test_diagnostics.py b/tests/components/uptimerobot/test_diagnostics.py new file mode 100644 index 00000000000000..a0780f64e902a5 --- /dev/null +++ b/tests/components/uptimerobot/test_diagnostics.py @@ -0,0 +1,78 @@ +"""Test UptimeRobot diagnostics.""" +import json +from unittest.mock import patch + +from aiohttp import ClientSession +from pyuptimerobot import UptimeRobotException + +from homeassistant.core import HomeAssistant + +from .common import ( + MOCK_UPTIMEROBOT_ACCOUNT, + MOCK_UPTIMEROBOT_API_KEY, + MOCK_UPTIMEROBOT_EMAIL, + MockApiResponseKey, + mock_uptimerobot_api_response, + setup_uptimerobot_integration, +) + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, +) -> None: + """Test config entry diagnostics.""" + entry = await setup_uptimerobot_integration(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response( + key=MockApiResponseKey.ACCOUNT, + data=MOCK_UPTIMEROBOT_ACCOUNT, + ), + ): + + result = await get_diagnostics_for_config_entry( + hass, + hass_client, + entry, + ) + + assert result["account"] == { + "down_monitors": 0, + "paused_monitors": 0, + "up_monitors": 1, + } + + assert result["monitors"] == [ + {"id": 1234, "interval": 0, "status": 2, "type": "MonitorType.HTTP"} + ] + + assert list(result.keys()) == ["account", "monitors"] + + result_dump = json.dumps(result) + assert MOCK_UPTIMEROBOT_EMAIL not in result_dump + assert MOCK_UPTIMEROBOT_API_KEY not in result_dump + + +async def test_entry_diagnostics_exception( + hass: HomeAssistant, + hass_client: ClientSession, +) -> None: + """Test config entry diagnostics with exception.""" + entry = await setup_uptimerobot_integration(hass) + + with patch( + "pyuptimerobot.UptimeRobot.async_get_account_details", + side_effect=UptimeRobotException("Test exception"), + ): + + result = await get_diagnostics_for_config_entry( + hass, + hass_client, + entry, + ) + + assert result["account"] == "Test exception" diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 3a11319f230c69..94dd7e8af4cc0e 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -20,7 +20,7 @@ from .common import ( MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, MOCK_UPTIMEROBOT_MONITOR, - UPTIMEROBOT_TEST_ENTITY, + UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY, MockApiResponseKey, mock_uptimerobot_api_response, setup_uptimerobot_integration, @@ -68,7 +68,7 @@ async def test_reauthentication_trigger_after_setup( """Test reauthentication trigger.""" mock_config_entry = await setup_uptimerobot_integration(hass) - binary_sensor = hass.states.get(UPTIMEROBOT_TEST_ENTITY) + binary_sensor = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) assert mock_config_entry.state == config_entries.ConfigEntryState.LOADED assert binary_sensor.state == STATE_ON @@ -81,7 +81,10 @@ async def test_reauthentication_trigger_after_setup( await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() - assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_UNAVAILABLE + assert ( + hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state + == STATE_UNAVAILABLE + ) assert "Authentication failed while fetching uptimerobot data" in caplog.text @@ -107,7 +110,7 @@ async def test_integration_reload(hass: HomeAssistant): entry = hass.config_entries.async_get_entry(mock_entry.entry_id) assert entry.state == config_entries.ConfigEntryState.LOADED - assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON async def test_update_errors(hass: HomeAssistant, caplog: LogCaptureFixture): @@ -120,7 +123,10 @@ async def test_update_errors(hass: HomeAssistant, caplog: LogCaptureFixture): ): async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() - assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_UNAVAILABLE + assert ( + hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state + == STATE_UNAVAILABLE + ) with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", @@ -128,7 +134,7 @@ async def test_update_errors(hass: HomeAssistant, caplog: LogCaptureFixture): ): async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() - assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON + assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", @@ -136,7 +142,10 @@ async def test_update_errors(hass: HomeAssistant, caplog: LogCaptureFixture): ): async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() - assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_UNAVAILABLE + assert ( + hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state + == STATE_UNAVAILABLE + ) assert "Error fetching uptimerobot data: test error from API" in caplog.text @@ -152,8 +161,8 @@ async def test_device_management(hass: HomeAssistant): assert devices[0].identifiers == {(DOMAIN, "1234")} assert devices[0].name == "Test monitor" - assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON - assert hass.states.get(f"{UPTIMEROBOT_TEST_ENTITY}_2") is None + assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2") is None with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", @@ -169,8 +178,10 @@ async def test_device_management(hass: HomeAssistant): assert devices[0].identifiers == {(DOMAIN, "1234")} assert devices[1].identifiers == {(DOMAIN, "12345")} - assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON - assert hass.states.get(f"{UPTIMEROBOT_TEST_ENTITY}_2").state == STATE_ON + assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert ( + hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2").state == STATE_ON + ) with patch( "pyuptimerobot.UptimeRobot.async_get_monitors", @@ -183,5 +194,5 @@ async def test_device_management(hass: HomeAssistant): assert len(devices) == 1 assert devices[0].identifiers == {(DOMAIN, "1234")} - assert hass.states.get(UPTIMEROBOT_TEST_ENTITY).state == STATE_ON - assert hass.states.get(f"{UPTIMEROBOT_TEST_ENTITY}_2") is None + assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON + assert hass.states.get(f"{UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY}_2") is None diff --git a/tests/components/uptimerobot/test_sensor.py b/tests/components/uptimerobot/test_sensor.py new file mode 100644 index 00000000000000..3e833af9bd497e --- /dev/null +++ b/tests/components/uptimerobot/test_sensor.py @@ -0,0 +1,50 @@ +"""Test UptimeRobot sensor.""" + +from unittest.mock import patch + +from pyuptimerobot import UptimeRobotAuthenticationException + +from homeassistant.components.uptimerobot.const import COORDINATOR_UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from .common import ( + MOCK_UPTIMEROBOT_MONITOR, + STATE_UP, + UPTIMEROBOT_SENSOR_TEST_ENTITY, + setup_uptimerobot_integration, +) + +from tests.common import async_fire_time_changed + +SENSOR_ICON = "mdi:television-shimmer" + + +async def test_presentation(hass: HomeAssistant) -> None: + """Test the presenstation of UptimeRobot sensors.""" + await setup_uptimerobot_integration(hass) + + entity = hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY) + + assert entity.state == STATE_UP + assert entity.attributes["icon"] == SENSOR_ICON + assert entity.attributes["target"] == MOCK_UPTIMEROBOT_MONITOR["url"] + + +async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: + """Test entity unaviable on update failure.""" + await setup_uptimerobot_integration(hass) + + entity = hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY) + assert entity.state == STATE_UP + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + side_effect=UptimeRobotAuthenticationException, + ): + async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + entity = hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY) + assert entity.state == STATE_UNAVAILABLE diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index cc34add67262bc..ce86965f0933d0 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -779,6 +779,7 @@ def test_human_readable_device_name(): assert "8A2A" in name +@pytest.mark.usefixtures("mock_integration_frame") async def test_service_info_compatibility(hass, caplog): """Test compatibility with old-style dict. @@ -794,10 +795,6 @@ async def test_service_info_compatibility(hass, caplog): ) # Ensure first call get logged - assert discovery_info["vid"] == 12345 - assert "Detected code that accessed discovery_info['vid']" in caplog.text - - # Ensure second call doesn't get logged - caplog.clear() - assert discovery_info["vid"] == 12345 - assert "Detected code that accessed discovery_info['vid']" not in caplog.text + with patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()): + assert discovery_info["vid"] == 12345 + assert "Detected integration that accessed discovery_info['vid']" in caplog.text diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index aa6de34f611b19..61e6fc4dae8edf 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -2,7 +2,6 @@ from datetime import timedelta from unittest.mock import patch -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.utility_meter.const import ( ATTR_TARIFF, DOMAIN, @@ -17,6 +16,7 @@ CONF_PLATFORM, ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_START, + Platform, ) from homeassistant.core import State from homeassistant.setup import async_setup_component @@ -46,7 +46,7 @@ async def test_restore_state(hass): ) assert await async_setup_component(hass, DOMAIN, config) - assert await async_setup_component(hass, SENSOR_DOMAIN, config) + assert await async_setup_component(hass, Platform.SENSOR, config) await hass.async_block_till_done() # restore from cache @@ -67,7 +67,7 @@ async def test_services(hass): } assert await async_setup_component(hass, DOMAIN, config) - assert await async_setup_component(hass, SENSOR_DOMAIN, config) + assert await async_setup_component(hass, Platform.SENSOR, config) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_START) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 8d9b819f610326..51212580aaf87d 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -5,8 +5,8 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - STATE_CLASS_TOTAL, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.components.utility_meter.const import ( ATTR_TARIFF, @@ -269,15 +269,15 @@ async def test_device_class(hass): state = hass.states.get("sensor.energy_meter") assert state is not None assert state.state == "0" - assert state.attributes.get(ATTR_DEVICE_CLASS) == "energy" - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL + assert state.attributes.get(ATTR_DEVICE_CLASS) is SensorDeviceClass.ENERGY.value + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR state = hass.states.get("sensor.gas_meter") assert state is not None assert state.state == "0" assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_TOTAL_INCREASING + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "some_archaic_unit" diff --git a/tests/components/vacuum/test_device_action.py b/tests/components/vacuum/test_device_action.py index aa5dc1786f7029..ce357f3d6cab03 100644 --- a/tests/components/vacuum/test_device_action.py +++ b/tests/components/vacuum/test_device_action.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.vacuum import DOMAIN from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -52,7 +53,9 @@ async def test_get_actions(hass, device_reg, entity_reg): "entity_id": "vacuum.test_5678", }, ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index 84a25d183b8512..086d34f65cd3b7 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.vacuum import ( DOMAIN, STATE_CLEANING, @@ -65,7 +66,9 @@ async def test_get_conditions(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - conditions = await async_get_device_automations(hass, "condition", device_entry.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) assert_lists_same(conditions, expected_conditions) diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 495ba02967b32f..1315952fdcadaf 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -4,6 +4,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.vacuum import DOMAIN, STATE_CLEANING, STATE_DOCKED from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -65,7 +66,9 @@ async def test_get_triggers(hass, device_reg, entity_reg): "entity_id": f"{DOMAIN}.test_5678", }, ] - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert_lists_same(triggers, expected_triggers) @@ -79,11 +82,13 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): ) entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) - triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) assert len(triggers) == 2 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( - hass, "trigger", trigger + hass, DeviceAutomationType.TRIGGER, trigger ) assert capabilities == { "extra_fields": [ diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py deleted file mode 100644 index 9075b385f4016a..00000000000000 --- a/tests/components/vacuum/test_init.py +++ /dev/null @@ -1,18 +0,0 @@ -"""The tests for Vacuum.""" -from homeassistant.components import vacuum - - -def test_deprecated_base_class(caplog): - """Test deprecated base class.""" - - class CustomVacuum(vacuum.VacuumDevice): - pass - - class CustomStateVacuum(vacuum.StateVacuumDevice): - pass - - CustomVacuum() - assert "VacuumDevice is deprecated, modify CustomVacuum" in caplog.text - - CustomStateVacuum() - assert "StateVacuumDevice is deprecated, modify CustomStateVacuum" in caplog.text diff --git a/tests/components/vallox/__init__.py b/tests/components/vallox/__init__.py new file mode 100644 index 00000000000000..60fbbde6beb9f4 --- /dev/null +++ b/tests/components/vallox/__init__.py @@ -0,0 +1 @@ +"""Tests for the Vallox integration.""" diff --git a/tests/components/vallox/test_config_flow.py b/tests/components/vallox/test_config_flow.py new file mode 100644 index 00000000000000..ee6b05f1f8b51b --- /dev/null +++ b/tests/components/vallox/test_config_flow.py @@ -0,0 +1,298 @@ +"""Test the Vallox integration config flow.""" +from unittest.mock import patch + +from vallox_websocket_api.exceptions import ValloxApiException + +from homeassistant.components.vallox.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_form_no_input(hass: HomeAssistant) -> None: + """Test that the form is returned with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + +async def test_form_create_entry(hass: HomeAssistant) -> None: + """Test that an entry is created with valid input.""" + init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert init["type"] == RESULT_TYPE_FORM + assert init["errors"] is None + + with patch( + "homeassistant.components.vallox.config_flow.Vallox.get_info", + return_value=None, + ), patch( + "homeassistant.components.vallox.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + init["flow_id"], + {"host": "1.2.3.4"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Vallox" + assert result["data"] == {"host": "1.2.3.4", "name": "Vallox"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_ip(hass: HomeAssistant) -> None: + """Test that invalid IP error is handled.""" + init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + init["flow_id"], + {"host": "test.host.com"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"host": "invalid_host"} + + +async def test_form_vallox_api_exception_cannot_connect(hass: HomeAssistant) -> None: + """Test that cannot connect error is handled.""" + init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.vallox.config_flow.Vallox.get_info", + side_effect=ValloxApiException, + ): + result = await hass.config_entries.flow.async_configure( + init["flow_id"], + {"host": "4.3.2.1"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"host": "cannot_connect"} + + +async def test_form_os_error_cannot_connect(hass: HomeAssistant) -> None: + """Test that cannot connect error is handled.""" + init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.vallox.config_flow.Vallox.get_info", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_configure( + init["flow_id"], + {"host": "5.6.7.8"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"host": "cannot_connect"} + + +async def test_form_unknown_exception(hass: HomeAssistant) -> None: + """Test that unknown exceptions are handled.""" + init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.vallox.config_flow.Vallox.get_info", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + init["flow_id"], + {"host": "54.12.31.41"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"host": "unknown"} + + +async def test_form_already_configured(hass: HomeAssistant) -> None: + """Test that already configured error is handled.""" + init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "20.40.10.30", + CONF_NAME: "Vallox 110 MV", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + init["flow_id"], + {"host": "20.40.10.30"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_import_with_custom_name(hass: HomeAssistant) -> None: + """Test that import is handled.""" + name = "Vallox 90 MV" + + with patch( + "homeassistant.components.vallox.config_flow.Vallox.get_info", + return_value=None, + ), patch( + "homeassistant.components.vallox.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": "1.2.3.4", "name": name}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == name + assert result["data"] == {"host": "1.2.3.4", "name": "Vallox 90 MV"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_without_custom_name(hass: HomeAssistant) -> None: + """Test that import is handled.""" + with patch( + "homeassistant.components.vallox.config_flow.Vallox.get_info", + return_value=None, + ), patch( + "homeassistant.components.vallox.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": "1.2.3.4"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Vallox" + assert result["data"] == {"host": "1.2.3.4", "name": "Vallox"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_invalid_ip(hass: HomeAssistant) -> None: + """Test that invalid IP error is handled during import.""" + name = "Vallox 90 MV" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": "vallox90mv.host.name", "name": name}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_host" + + +async def test_import_already_configured(hass: HomeAssistant) -> None: + """Test that an already configured Vallox device is handled during import.""" + name = "Vallox 145 MV" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "40.10.20.30", + CONF_NAME: "Vallox 145 MV", + }, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": "40.10.20.30", "name": name}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_import_cannot_connect_os_error(hass: HomeAssistant) -> None: + """Test that cannot connect error is handled.""" + name = "Vallox 90 MV" + + with patch( + "homeassistant.components.vallox.config_flow.Vallox.get_info", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": "1.2.3.4", "name": name}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_cannot_connect_vallox_api_exception(hass: HomeAssistant) -> None: + """Test that cannot connect error is handled.""" + name = "Vallox 90 MV" + + with patch( + "homeassistant.components.vallox.config_flow.Vallox.get_info", + side_effect=ValloxApiException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": "5.6.3.1", "name": name}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_unknown_exception(hass: HomeAssistant) -> None: + """Test that unknown exceptions are handled.""" + name = "Vallox 245 MV" + + with patch( + "homeassistant.components.vallox.config_flow.Vallox.get_info", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"host": "1.2.3.4", "name": name}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 01a40af1751637..960eedcbd011b1 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -1,16 +1,41 @@ """Tests for the Velbus config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +import serial.tools.list_ports from velbusaio.exceptions import VelbusConnectionFailed from homeassistant import data_entry_flow +from homeassistant.components import usb from homeassistant.components.velbus import config_flow -from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.components.velbus.const import DOMAIN +from homeassistant.config_entries import SOURCE_USB +from homeassistant.const import CONF_NAME, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from .const import PORT_SERIAL, PORT_TCP +from tests.common import MockConfigEntry + +DISCOVERY_INFO = usb.UsbServiceInfo( + device=PORT_SERIAL, + pid="10CF", + vid="0B1B", + serial_number="1234", + description="Velbus VMB1USB", + manufacturer="Velleman", +) + + +def com_port(): + """Mock of a serial port.""" + port = serial.tools.list_ports_common.ListPortInfo(PORT_SERIAL) + port.serial_number = "1234" + port.manufacturer = "Virtual serial port" + port.device = PORT_SERIAL + port.description = "Some serial port" + return port + @pytest.fixture(autouse=True) def override_async_setup_entry() -> AsyncMock: @@ -85,3 +110,49 @@ async def test_abort_if_already_setup(hass: HomeAssistant): result = await flow.async_step_user({CONF_PORT: PORT_TCP, CONF_NAME: "velbus test"}) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"port": "already_configured"} + + +@pytest.mark.usefixtures("controller") +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_usb(hass: HomeAssistant): + """Test usb discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USB}, + data=DISCOVERY_INFO, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + # test an already configured discovery + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: PORT_SERIAL}, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USB}, + data=DISCOVERY_INFO, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("controller_connection_failed") +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_flow_usb_failed(hass: HomeAssistant): + """Test usb discovery flow with a failed velbus test.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USB}, + data=DISCOVERY_INFO, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/venstar/__init__.py b/tests/components/venstar/__init__.py index 326aeeeb0e2419..c7b4815c5bbff4 100644 --- a/tests/components/venstar/__init__.py +++ b/tests/components/venstar/__init__.py @@ -60,3 +60,7 @@ def update_runtimes(self): def update_alerts(self): """Mock update_alerts.""" return True + + def get_runtimes(self): + """Mock get runtimes.""" + return {} diff --git a/tests/components/venstar/fixtures/colortouch_runtimes.json b/tests/components/venstar/fixtures/colortouch_runtimes.json new file mode 100644 index 00000000000000..2ec323755c2b56 --- /dev/null +++ b/tests/components/venstar/fixtures/colortouch_runtimes.json @@ -0,0 +1 @@ +{"runtimes":[{"ts":1637452800,"heat1":0,"heat2":0,"cool1":156,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637539200,"heat1":0,"heat2":0,"cool1":216,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637625600,"heat1":0,"heat2":0,"cool1":234,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637712000,"heat1":0,"heat2":0,"cool1":225,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637798400,"heat1":0,"heat2":0,"cool1":153,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637884800,"heat1":0,"heat2":0,"cool1":94,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637921499,"heat1":0,"heat2":0,"cool1":12,"cool2":0,"aux1":0,"aux2":0,"fc":0}]} \ No newline at end of file diff --git a/tests/components/venstar/fixtures/t2k_runtimes.json b/tests/components/venstar/fixtures/t2k_runtimes.json new file mode 100644 index 00000000000000..bea2697a387c38 --- /dev/null +++ b/tests/components/venstar/fixtures/t2k_runtimes.json @@ -0,0 +1 @@ +{"runtimes":[{"ts":1637452800,"heat1":0,"heat2":0,"cool1":156,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637539200,"heat1":0,"heat2":0,"cool1":216,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637625600,"heat1":0,"heat2":0,"cool1":234,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637712000,"heat1":0,"heat2":0,"cool1":225,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637798400,"heat1":0,"heat2":0,"cool1":153,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637884800,"heat1":0,"heat2":0,"cool1":94,"cool2":0,"aux1":0,"aux2":0,"fc":0},{"ts":1637921489,"heat1":0,"heat2":0,"cool1":12,"cool2":0,"aux1":0,"aux2":0,"fc":0}]} \ No newline at end of file diff --git a/tests/components/venstar/test_climate.py b/tests/components/venstar/test_climate.py index babd946073bb92..fe1e6141c982e4 100644 --- a/tests/components/venstar/test_climate.py +++ b/tests/components/venstar/test_climate.py @@ -20,7 +20,7 @@ async def test_colortouch(hass): """Test interfacing with a venstar colortouch with attached humidifier.""" - with patch("homeassistant.components.onewire.sensor.asyncio.sleep"): + with patch("homeassistant.components.venstar.VENSTAR_SLEEP", new=0): await async_init_integration(hass) state = hass.states.get("climate.colortouch") @@ -56,7 +56,7 @@ async def test_colortouch(hass): async def test_t2000(hass): """Test interfacing with a venstar T2000 presently turned off.""" - with patch("homeassistant.components.onewire.sensor.asyncio.sleep"): + with patch("homeassistant.components.venstar.VENSTAR_SLEEP", new=0): await async_init_integration(hass) state = hass.states.get("climate.t2000") diff --git a/tests/components/venstar/test_init.py b/tests/components/venstar/test_init.py index b245f4eef6d01b..696f20ed1056c6 100644 --- a/tests/components/venstar/test_init.py +++ b/tests/components/venstar/test_init.py @@ -37,7 +37,11 @@ async def test_setup_entry(hass: HomeAssistant): "homeassistant.components.venstar.VenstarColorTouch.update_alerts", new=VenstarColorTouchMock.update_alerts, ), patch( - "homeassistant.components.onewire.sensor.asyncio.sleep" + "homeassistant.components.venstar.VenstarColorTouch.get_runtimes", + new=VenstarColorTouchMock.get_runtimes, + ), patch( + "homeassistant.components.venstar.VENSTAR_SLEEP", + new=0, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -72,6 +76,9 @@ async def test_setup_entry_exception(hass: HomeAssistant): ), patch( "homeassistant.components.venstar.VenstarColorTouch.update_alerts", new=VenstarColorTouchMock.update_alerts, + ), patch( + "homeassistant.components.venstar.VenstarColorTouch.get_runtimes", + new=VenstarColorTouchMock.get_runtimes, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/vera/common.py b/tests/components/vera/common.py index 1ce55ac9e8f505..6212b68fd422ae 100644 --- a/tests/components/vera/common.py +++ b/tests/components/vera/common.py @@ -1,8 +1,9 @@ """Common code for tests.""" from __future__ import annotations +from collections.abc import Callable from enum import Enum -from typing import Callable, NamedTuple +from typing import NamedTuple from unittest.mock import MagicMock import pyvera as pv diff --git a/tests/components/vera/test_sensor.py b/tests/components/vera/test_sensor.py index a2086f9f5e0000..6f6e62e00a24e8 100644 --- a/tests/components/vera/test_sensor.py +++ b/tests/components/vera/test_sensor.py @@ -1,7 +1,8 @@ """Vera tests.""" from __future__ import annotations -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from unittest.mock import MagicMock import pyvera as pv diff --git a/tests/components/version/common.py b/tests/components/version/common.py new file mode 100644 index 00000000000000..17d72d6de72d5b --- /dev/null +++ b/tests/components/version/common.py @@ -0,0 +1,71 @@ +"""Fixtures for version integration.""" +from __future__ import annotations + +from typing import Any, Final +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.version.const import ( + DEFAULT_CONFIGURATION, + DEFAULT_NAME_CURRENT, + DOMAIN, + UPDATE_COORDINATOR_UPDATE_INTERVAL, + VERSION_SOURCE_LOCAL, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from tests.common import MockConfigEntry, async_fire_time_changed + +MOCK_VERSION: Final = "1970.1.0" +MOCK_VERSION_DATA: Final = {"source": "local", "channel": "stable"} + + +MOCK_VERSION_CONFIG_ENTRY_DATA: Final[dict[str, Any]] = { + "domain": DOMAIN, + "title": VERSION_SOURCE_LOCAL, + "data": DEFAULT_CONFIGURATION, + "source": config_entries.SOURCE_USER, +} + +TEST_DEFAULT_IMPORT_CONFIG: Final = { + **DEFAULT_CONFIGURATION, + CONF_NAME: DEFAULT_NAME_CURRENT, +} + + +async def mock_get_version_update( + hass: HomeAssistant, + version: str = MOCK_VERSION, + data: dict[str, Any] = MOCK_VERSION_DATA, + side_effect: Exception = None, +) -> None: + """Mock getting version.""" + with patch( + "pyhaversion.HaVersion.get_version", + return_value=(version, data), + side_effect=side_effect, + ): + + async_fire_time_changed(hass, dt.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + +async def setup_version_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Version integration.""" + mock_entry = MockConfigEntry(**MOCK_VERSION_CONFIG_ENTRY_DATA) + mock_entry.add_to_hass(hass) + + with patch( + "pyhaversion.HaVersion.get_version", + return_value=(MOCK_VERSION, MOCK_VERSION_DATA), + ): + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.local_installation").state == MOCK_VERSION + assert mock_entry.state == config_entries.ConfigEntryState.LOADED + + return mock_entry diff --git a/tests/components/version/test_config_flow.py b/tests/components/version/test_config_flow.py new file mode 100644 index 00000000000000..757afeac93d7c7 --- /dev/null +++ b/tests/components/version/test_config_flow.py @@ -0,0 +1,232 @@ +"""Test the Version config flow.""" +from unittest.mock import patch + +from pyhaversion.consts import HaVersionChannel, HaVersionSource + +from homeassistant import config_entries +from homeassistant.components.version.const import ( + CONF_BETA, + CONF_BOARD, + CONF_CHANNEL, + CONF_IMAGE, + CONF_VERSION_SOURCE, + DEFAULT_CONFIGURATION, + DOMAIN, + UPDATE_COORDINATOR_UPDATE_INTERVAL, + VERSION_SOURCE_DOCKER_HUB, + VERSION_SOURCE_PYPI, + VERSION_SOURCE_VERSIONS, +) +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.util import dt + +from tests.common import async_fire_time_changed +from tests.components.version.common import ( + MOCK_VERSION, + MOCK_VERSION_DATA, + setup_version_integration, +) + + +async def test_reload_config_entry(hass: HomeAssistant): + """Test reloading the config entry.""" + config_entry = await setup_version_integration(hass) + assert config_entry.state == config_entries.ConfigEntryState.LOADED + + with patch( + "pyhaversion.HaVersion.get_version", + return_value=(MOCK_VERSION, MOCK_VERSION_DATA), + ): + assert await hass.config_entries.async_reload(config_entry.entry_id) + async_fire_time_changed(hass, dt.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(config_entry.entry_id) + assert entry.state == config_entries.ConfigEntryState.LOADED + + +async def test_basic_form(hass: HomeAssistant) -> None: + """Test that we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": False}, + ) + assert result["type"] == RESULT_TYPE_FORM + + with patch( + "homeassistant.components.version.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == VERSION_SOURCE_DOCKER_HUB + assert result2["data"] == { + **DEFAULT_CONFIGURATION, + CONF_SOURCE: HaVersionSource.CONTAINER, + CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_advanced_form_pypi(hass: HomeAssistant) -> None: + """Show advanced form when pypi is selected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + ) + assert result["type"] == RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_VERSION_SOURCE: VERSION_SOURCE_PYPI}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + with patch( + "homeassistant.components.version.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_BETA: True} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == VERSION_SOURCE_PYPI + assert result["data"] == { + **DEFAULT_CONFIGURATION, + CONF_BETA: True, + CONF_SOURCE: HaVersionSource.PYPI, + CONF_VERSION_SOURCE: VERSION_SOURCE_PYPI, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_advanced_form_container(hass: HomeAssistant) -> None: + """Show advanced form when container source is selected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + ) + assert result["type"] == RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + with patch( + "homeassistant.components.version.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IMAGE: "odroid-n2-homeassistant"} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == VERSION_SOURCE_DOCKER_HUB + assert result["data"] == { + **DEFAULT_CONFIGURATION, + CONF_IMAGE: "odroid-n2-homeassistant", + CONF_SOURCE: HaVersionSource.CONTAINER, + CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_advanced_form_supervisor(hass: HomeAssistant) -> None: + """Show advanced form when docker source is selected.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + ) + assert result["type"] == RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_VERSION_SOURCE: VERSION_SOURCE_VERSIONS}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "version_source" + + with patch( + "homeassistant.components.version.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_CHANNEL: "Dev", CONF_IMAGE: "odroid-n2", CONF_BOARD: "ODROID N2"}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"{VERSION_SOURCE_VERSIONS} Dev" + assert result["data"] == { + **DEFAULT_CONFIGURATION, + CONF_IMAGE: "odroid-n2", + CONF_BOARD: "ODROID N2", + CONF_CHANNEL: HaVersionChannel.DEV, + CONF_SOURCE: HaVersionSource.SUPERVISOR, + CONF_VERSION_SOURCE: VERSION_SOURCE_VERSIONS, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_existing(hass: HomeAssistant) -> None: + """Test importing existing configuration.""" + with patch( + "homeassistant.components.version.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index cd56223a1e6bec..72e6382034559e 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -1,130 +1,132 @@ """The test for the version sensor platform.""" -from datetime import timedelta +from __future__ import annotations + +from typing import Any from unittest.mock import patch -from pyhaversion import HaVersionSource, exceptions as pyhaversionexceptions +from pyhaversion import HaVersionChannel, HaVersionSource +from pyhaversion.exceptions import HaVersionException import pytest -from homeassistant.components.version.sensor import HA_VERSION_SOURCES +from homeassistant.components.version.const import ( + CONF_BETA, + CONF_CHANNEL, + CONF_IMAGE, + CONF_VERSION_SOURCE, + DEFAULT_NAME_LATEST, + DOMAIN, + VERSION_SOURCE_DOCKER_HUB, + VERSION_SOURCE_VERSIONS, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_SOURCE +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt - -from tests.common import async_fire_time_changed - -MOCK_VERSION = "10.0" - -@pytest.mark.parametrize( - "source,target_source,name", - ( - ( - ("local", HaVersionSource.LOCAL, "current_version"), - ("docker", HaVersionSource.CONTAINER, "latest_version"), - ("hassio", HaVersionSource.SUPERVISOR, "latest_version"), - ) - + tuple( - (source, HaVersionSource(source), "latest_version") - for source in HA_VERSION_SOURCES - if source != HaVersionSource.LOCAL - ) - ), +from .common import ( + MOCK_VERSION, + MOCK_VERSION_DATA, + TEST_DEFAULT_IMPORT_CONFIG, + mock_get_version_update, + setup_version_integration, ) -async def test_version_source(hass, source, target_source, name): - """Test the Version sensor with different sources.""" - config = { - "sensor": {"platform": "version", "source": source, "image": "qemux86-64"} - } - - with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( - "homeassistant.components.version.sensor.HaVersion.version", MOCK_VERSION - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - state = hass.states.get(f"sensor.{name}") - assert state - assert state.attributes["source"] == target_source - - assert state.state == MOCK_VERSION - - -async def test_version_fetch_exception(hass, caplog): - """Test fetch exception thrown during updates.""" - config = {"sensor": {"platform": "version"}} - with patch( - "homeassistant.components.version.sensor.HaVersion.get_version", - side_effect=pyhaversionexceptions.HaVersionFetchException( - "Fetch exception from pyhaversion" - ), - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - assert "Fetch exception from pyhaversion" in caplog.text -async def test_version_parse_exception(hass, caplog): - """Test parse exception thrown during updates.""" - config = {"sensor": {"platform": "version"}} +async def async_setup_sensor_wrapper( + hass: HomeAssistant, config: dict[str, Any] +) -> ConfigEntry: + """Set up the Version sensor platform.""" with patch( - "homeassistant.components.version.sensor.HaVersion.get_version", - side_effect=pyhaversionexceptions.HaVersionParseException, + "pyhaversion.HaVersion.get_version", + return_value=(MOCK_VERSION, MOCK_VERSION_DATA), ): - assert await async_setup_component(hass, "sensor", config) + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": DOMAIN, **config}} + ) await hass.async_block_till_done() - assert "Could not parse data received for HaVersionSource.LOCAL" in caplog.text + config_entries = hass.config_entries.async_entries(DOMAIN) + config_entry = config_entries[-1] + assert config_entry.source == "import" + return config_entry -async def test_update(hass): - """Test updates.""" - config = {"sensor": {"platform": "version"}} - with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( - "homeassistant.components.version.sensor.HaVersion.version", MOCK_VERSION - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() +async def test_version_sensor(hass: HomeAssistant): + """Test the Version sensor with different sources.""" + await setup_version_integration(hass) - state = hass.states.get("sensor.current_version") - assert state + state = hass.states.get("sensor.local_installation") assert state.state == MOCK_VERSION + assert "source" not in state.attributes + assert "channel" not in state.attributes - with patch("homeassistant.components.version.sensor.HaVersion.get_version"), patch( - "homeassistant.components.version.sensor.HaVersion.version", "1234" - ): - - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() - - state = hass.states.get("sensor.current_version") - assert state - assert state.state == "1234" - - -async def test_image_name_container(hass): - """Test the Version sensor with image name for container.""" - config = { - "sensor": {"platform": "version", "source": "docker", "image": "qemux86-64"} - } - with patch("homeassistant.components.version.sensor.HaVersion") as haversion: - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - constructor = haversion.call_args[1] - assert constructor["source"] == "container" - assert constructor["image"] == "qemux86-64-homeassistant" +async def test_update(hass: HomeAssistant, caplog: pytest.LogCaptureFixture): + """Test updates.""" + await setup_version_integration(hass) + assert hass.states.get("sensor.local_installation").state == MOCK_VERSION + await mock_get_version_update(hass, version="1970.1.1") + assert hass.states.get("sensor.local_installation").state == "1970.1.1" -async def test_image_name_supervisor(hass): - """Test the Version sensor with image name for supervisor.""" - config = { - "sensor": {"platform": "version", "source": "hassio", "image": "qemux86-64"} - } + assert "Error fetching version data" not in caplog.text + await mock_get_version_update(hass, side_effect=HaVersionException) + assert hass.states.get("sensor.local_installation").state == "unavailable" + assert "Error fetching version data" in caplog.text - with patch("homeassistant.components.version.sensor.HaVersion") as haversion: - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - constructor = haversion.call_args[1] - assert constructor["source"] == "supervisor" - assert constructor["image"] == "qemux86-64" +@pytest.mark.parametrize( + "yaml,converted", + ( + ( + {}, + TEST_DEFAULT_IMPORT_CONFIG, + ), + ( + {CONF_NAME: "test"}, + {**TEST_DEFAULT_IMPORT_CONFIG, CONF_NAME: "test"}, + ), + ( + {CONF_SOURCE: "hassio", CONF_IMAGE: "odroid-n2"}, + { + **TEST_DEFAULT_IMPORT_CONFIG, + CONF_NAME: DEFAULT_NAME_LATEST, + CONF_SOURCE: HaVersionSource.SUPERVISOR, + CONF_VERSION_SOURCE: VERSION_SOURCE_VERSIONS, + CONF_IMAGE: "odroid-n2", + }, + ), + ( + {CONF_SOURCE: "docker"}, + { + **TEST_DEFAULT_IMPORT_CONFIG, + CONF_NAME: DEFAULT_NAME_LATEST, + CONF_SOURCE: HaVersionSource.CONTAINER, + CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB, + }, + ), + ( + {CONF_BETA: True}, + { + **TEST_DEFAULT_IMPORT_CONFIG, + CONF_CHANNEL: HaVersionChannel.BETA, + }, + ), + ( + {CONF_SOURCE: "container", CONF_IMAGE: "odroid-n2"}, + { + **TEST_DEFAULT_IMPORT_CONFIG, + CONF_NAME: DEFAULT_NAME_LATEST, + CONF_SOURCE: HaVersionSource.CONTAINER, + CONF_VERSION_SOURCE: VERSION_SOURCE_DOCKER_HUB, + CONF_IMAGE: "odroid-n2-homeassistant", + }, + ), + ), +) +async def test_config_import( + hass: HomeAssistant, yaml: dict[str, Any], converted: dict[str, Any] +) -> None: + """Test importing YAML configuration.""" + config_entry = await async_setup_sensor_wrapper(hass, yaml) + assert config_entry.data == converted diff --git a/tests/components/vicare/__init__.py b/tests/components/vicare/__init__.py index f67e50be1d6670..ae9df782886ad3 100644 --- a/tests/components/vicare/__init__.py +++ b/tests/components/vicare/__init__.py @@ -1,20 +1,22 @@ """Test for ViCare.""" +from __future__ import annotations + +from typing import Final + from homeassistant.components.vicare.const import CONF_HEATING_TYPE -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_NAME, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) +from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME -ENTRY_CONFIG = { +ENTRY_CONFIG: Final[dict[str, str]] = { CONF_USERNAME: "foo@bar.com", CONF_PASSWORD: "1234", CONF_CLIENT_ID: "5678", CONF_HEATING_TYPE: "auto", - CONF_SCAN_INTERVAL: 60, - CONF_NAME: "ViCare", +} + +ENTRY_CONFIG_NO_HEATING_TYPE: Final[dict[str, str]] = { + CONF_USERNAME: "foo@bar.com", + CONF_PASSWORD: "1234", + CONF_CLIENT_ID: "5678", } MOCK_MAC = "B874241B7B9" diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index c816f535077651..0bedb0d73b8006 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -3,23 +3,18 @@ from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError -from homeassistant import config_entries, data_entry_flow, setup +from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp -from homeassistant.components.vicare.const import ( - CONF_CIRCUIT, - CONF_HEATING_TYPE, - DOMAIN, -) +from homeassistant.components.vicare.const import CONF_CIRCUIT, DOMAIN, VICARE_NAME from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME -from . import ENTRY_CONFIG, MOCK_MAC +from . import ENTRY_CONFIG, ENTRY_CONFIG_NO_HEATING_TYPE, MOCK_MAC from tests.common import MockConfigEntry 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} ) @@ -54,7 +49,6 @@ async def test_form(hass): async def test_import(hass): """Test that the import works.""" - await setup.async_setup_component(hass, "persistent_notification", {}) with patch( "homeassistant.components.vicare.config_flow.vicare_login", @@ -71,7 +65,7 @@ async def test_import(hass): data=ENTRY_CONFIG, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Configuration.yaml" + assert result["title"] == VICARE_NAME assert result["data"] == ENTRY_CONFIG await hass.async_block_till_done() @@ -81,7 +75,6 @@ async def test_import(hass): async def test_import_removes_circuit(hass): """Test that the import works.""" - await setup.async_setup_component(hass, "persistent_notification", {}) with patch( "homeassistant.components.vicare.config_flow.vicare_login", @@ -99,7 +92,7 @@ async def test_import_removes_circuit(hass): data=ENTRY_CONFIG, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Configuration.yaml" + assert result["title"] == VICARE_NAME assert result["data"] == ENTRY_CONFIG await hass.async_block_till_done() @@ -109,7 +102,6 @@ async def test_import_removes_circuit(hass): async def test_import_adds_heating_type(hass): """Test that the import works.""" - await setup.async_setup_component(hass, "persistent_notification", {}) with patch( "homeassistant.components.vicare.config_flow.vicare_login", @@ -120,14 +112,13 @@ async def test_import_adds_heating_type(hass): "homeassistant.components.vicare.async_setup_entry", return_value=True, ) as mock_setup_entry: - del ENTRY_CONFIG[CONF_HEATING_TYPE] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_NO_HEATING_TYPE, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Configuration.yaml" + assert result["title"] == VICARE_NAME assert result["data"] == ENTRY_CONFIG await hass.async_block_till_done() @@ -162,7 +153,6 @@ async def test_invalid_login(hass) -> None: async def test_form_dhcp(hass): """Test we can setup from dhcp.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -203,29 +193,10 @@ async def test_form_dhcp(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_already_configured(hass): - """Test that configuring same instance is rejectes.""" - mock_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="Configuration.yaml", - data=ENTRY_CONFIG, - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=ENTRY_CONFIG, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - async def test_import_single_instance_allowed(hass): """Test that configuring more than one instance is rejected.""" mock_entry = MockConfigEntry( domain=DOMAIN, - unique_id="Configuration.yaml", data=ENTRY_CONFIG, ) mock_entry.add_to_hass(hass) @@ -236,14 +207,13 @@ async def test_import_single_instance_allowed(hass): data=ENTRY_CONFIG, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" async def test_dhcp_single_instance_allowed(hass): """Test that configuring more than one instance is rejected.""" mock_entry = MockConfigEntry( domain=DOMAIN, - unique_id="Configuration.yaml", data=ENTRY_CONFIG, ) mock_entry.add_to_hass(hass) @@ -263,7 +233,6 @@ async def test_dhcp_single_instance_allowed(hass): async def test_user_input_single_instance_allowed(hass): """Test that configuring more than one instance is rejected.""" - await setup.async_setup_component(hass, "persistent_notification", {}) mock_entry = MockConfigEntry( domain=DOMAIN, unique_id="ViCare", diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index ffe79cb4ecf452..ad67d309cdd882 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -216,6 +216,22 @@ def vizio_update_with_apps_fixture(vizio_update: pytest.fixture): yield +@pytest.fixture(name="vizio_update_with_apps_on_input") +def vizio_update_with_apps_on_input_fixture(vizio_update: pytest.fixture): + """Mock valid updates to vizio device that supports apps but is on a TV input.""" + 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_current_input", + return_value=CURRENT_INPUT, + ), patch( + "homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config", + return_value=AppConfig("unknown", 1, "app"), + ): + yield + + @pytest.fixture(name="vizio_hostname_check") def vizio_hostname_check(): """Mock vizio hostname resolution.""" diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index ccda9253ec790b..288dd2c6ac0c0a 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -3,9 +3,8 @@ import pytest -from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN from homeassistant.components.vizio.const import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -25,7 +24,7 @@ async def test_setup_component( hass, DOMAIN, {DOMAIN: MOCK_USER_VALID_TV_CONFIG} ) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 async def test_tv_load_and_unload( @@ -40,12 +39,12 @@ async def test_tv_load_and_unload( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 assert DOMAIN in hass.data assert await config_entry.async_unload(hass) await hass.async_block_till_done() - entities = hass.states.async_entity_ids(MP_DOMAIN) + entities = hass.states.async_entity_ids(Platform.MEDIA_PLAYER) assert len(entities) == 1 for entity in entities: assert hass.states.get(entity).state == STATE_UNAVAILABLE @@ -64,12 +63,12 @@ async def test_speaker_load_and_unload( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 assert DOMAIN in hass.data assert await config_entry.async_unload(hass) await hass.async_block_till_done() - entities = hass.states.async_entity_ids(MP_DOMAIN) + entities = hass.states.async_entity_ids(Platform.MEDIA_PLAYER) assert len(entities) == 1 for entity in entities: assert hass.states.get(entity).state == STATE_UNAVAILABLE @@ -91,7 +90,7 @@ async def test_coordinator_update_failure( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 assert DOMAIN in hass.data # Failing 25 days in a row should result in a single log message diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 7a030ade53fc7f..d3ef4019c57258 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -751,3 +751,19 @@ async def test_apps_update( sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] apps = list(set(sources) - set(INPUT_LIST)) assert len(apps) == len(APP_LIST) + + +async def test_vizio_update_with_apps_on_input( + hass: HomeAssistant, vizio_connect, vizio_update_with_apps_on_input +) -> None: + """Test a vizio TV with apps that is on a TV input.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG), + unique_id=UNIQUE_ID, + ) + await _add_config_entry_to_hass(hass, config_entry) + attr = _get_attr_and_assert_base_attr(hass, DEVICE_CLASS_TV, STATE_ON) + # App name and app ID should not be in the attributes + assert "app_name" not in attr + assert "app_id" not in attr diff --git a/tests/components/vlc_telnet/test_config_flow.py b/tests/components/vlc_telnet/test_config_flow.py index 6a0659b6360d47..f86644b3447cc5 100644 --- a/tests/components/vlc_telnet/test_config_flow.py +++ b/tests/components/vlc_telnet/test_config_flow.py @@ -79,38 +79,7 @@ async def test_user_flow( assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_flow(hass: HomeAssistant) -> None: - """Test successful import flow.""" - test_data = { - "password": "test-password", - "host": "1.1.1.1", - "port": 8888, - "name": "custom name", - } - with patch("homeassistant.components.vlc_telnet.config_flow.Client.connect"), patch( - "homeassistant.components.vlc_telnet.config_flow.Client.login" - ), patch( - "homeassistant.components.vlc_telnet.config_flow.Client.disconnect" - ), patch( - "homeassistant.components.vlc_telnet.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=test_data, - ) - await hass.async_block_till_done() - - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == test_data["name"] - assert result["data"] == test_data - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - "source", [config_entries.SOURCE_USER, config_entries.SOURCE_IMPORT] -) +@pytest.mark.parametrize("source", [config_entries.SOURCE_USER]) async def test_abort_already_configured(hass: HomeAssistant, source: str) -> None: """Test we handle already configured host.""" entry_data = { @@ -133,9 +102,7 @@ async def test_abort_already_configured(hass: HomeAssistant, source: str) -> Non assert result["reason"] == "already_configured" -@pytest.mark.parametrize( - "source", [config_entries.SOURCE_USER, config_entries.SOURCE_IMPORT] -) +@pytest.mark.parametrize("source", [config_entries.SOURCE_USER]) @pytest.mark.parametrize( "error, connect_side_effect, login_side_effect", [ diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 222ed90f67a344..424bbe5e065b3f 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -4,231 +4,228 @@ import os import shutil +import pytest + from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) import homeassistant.components.tts as tts -from homeassistant.config import async_process_ha_core_config -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component, get_test_home_assistant, mock_service +from tests.common import assert_setup_component, async_mock_service from tests.components.tts.test_init import mutagen_mock # noqa: F401 +URL = "https://api.voicerss.org/" +FORM_DATA = { + "key": "1234567xx", + "hl": "en-us", + "c": "MP3", + "f": "8khz_8bit_mono", + "src": "I person is on front of your door.", +} -class TestTTSVoiceRSSPlatform: - """Test the voicerss speech component.""" - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() +@pytest.fixture(autouse=True) +def cleanup_cache(hass): + """Prevent TTS writing.""" + yield + default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) + if os.path.isdir(default_tts): + shutil.rmtree(default_tts) - asyncio.run_coroutine_threadsafe( - async_process_ha_core_config( - self.hass, {"internal_url": "http://example.local:8123"} - ), - self.hass.loop, - ) - self.url = "https://api.voicerss.org/" - self.form_data = { - "key": "1234567xx", - "hl": "en-us", - "c": "MP3", - "f": "8khz_8bit_mono", - "src": "I person is on front of your door.", - } +async def test_setup_component(hass): + """Test setup component.""" + config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + +async def test_setup_component_without_api_key(hass): + """Test setup component without api key.""" + config = {tts.DOMAIN: {"platform": "voicerss"}} + + with assert_setup_component(0, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + +async def test_service_say(hass, aioclient_mock): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + aioclient_mock.post(URL, data=FORM_DATA, status=HTTPStatus.OK, content=b"test") - def teardown_method(self): - """Stop everything that was started.""" - default_tts = self.hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) - - self.hass.stop() - - def test_setup_component(self): - """Test setup component.""" - config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - def test_setup_component_without_api_key(self): - """Test setup component without api key.""" - config = {tts.DOMAIN: {"platform": "voicerss"}} - - with assert_setup_component(0, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - def test_service_say(self, aioclient_mock): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - aioclient_mock.post( - self.url, data=self.form_data, status=HTTPStatus.OK, content=b"test" - ) - - config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "voicerss_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "I person is on front of your door.", - }, - ) - self.hass.block_till_done() - - assert len(calls) == 1 - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == self.form_data - assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".mp3") != -1 - - def test_service_say_german_config(self, aioclient_mock): - """Test service call say with german code in the config.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - self.form_data["hl"] = "de-de" - aioclient_mock.post( - self.url, data=self.form_data, status=HTTPStatus.OK, content=b"test" - ) - - config = { - tts.DOMAIN: { - "platform": "voicerss", - "api_key": "1234567xx", - "language": "de-de", - } + config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "voicerss_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "I person is on front of your door.", + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == FORM_DATA + assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".mp3") != -1 + + +async def test_service_say_german_config(hass, aioclient_mock): + """Test service call say with german code in the config.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + form_data = {**FORM_DATA, "hl": "de-de"} + aioclient_mock.post(URL, data=form_data, status=HTTPStatus.OK, content=b"test") + + config = { + tts.DOMAIN: { + "platform": "voicerss", + "api_key": "1234567xx", + "language": "de-de", } + } + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "voicerss_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "I person is on front of your door.", + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == form_data + - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "voicerss_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "I person is on front of your door.", - }, - ) - self.hass.block_till_done() - - assert len(calls) == 1 - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == self.form_data - - def test_service_say_german_service(self, aioclient_mock): - """Test service call say with german code in the service.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - self.form_data["hl"] = "de-de" - aioclient_mock.post( - self.url, data=self.form_data, status=HTTPStatus.OK, content=b"test" - ) - - config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "voicerss_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "I person is on front of your door.", - tts.ATTR_LANGUAGE: "de-de", - }, - ) - self.hass.block_till_done() - - assert len(calls) == 1 - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == self.form_data - - def test_service_say_error(self, aioclient_mock): - """Test service call say with http response 400.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - aioclient_mock.post(self.url, data=self.form_data, status=400, content=b"test") - - config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "voicerss_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "I person is on front of your door.", - }, - ) - self.hass.block_till_done() - - assert len(calls) == 0 - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == self.form_data - - def test_service_say_timeout(self, aioclient_mock): - """Test service call say with http timeout.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - aioclient_mock.post(self.url, data=self.form_data, exc=asyncio.TimeoutError()) - - config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "voicerss_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "I person is on front of your door.", - }, - ) - self.hass.block_till_done() - - assert len(calls) == 0 - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == self.form_data - - def test_service_say_error_msg(self, aioclient_mock): - """Test service call say with http error api message.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - aioclient_mock.post( - self.url, - data=self.form_data, - status=HTTPStatus.OK, - content=b"The subscription does not support SSML!", - ) - - config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "voicerss_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "I person is on front of your door.", - }, - ) - self.hass.block_till_done() - - assert len(calls) == 0 - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == self.form_data +async def test_service_say_german_service(hass, aioclient_mock): + """Test service call say with german code in the service.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + form_data = {**FORM_DATA, "hl": "de-de"} + aioclient_mock.post(URL, data=form_data, status=HTTPStatus.OK, content=b"test") + + config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "voicerss_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "I person is on front of your door.", + tts.ATTR_LANGUAGE: "de-de", + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == form_data + + +async def test_service_say_error(hass, aioclient_mock): + """Test service call say with http response 400.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + aioclient_mock.post(URL, data=FORM_DATA, status=400, content=b"test") + + config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "voicerss_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "I person is on front of your door.", + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == FORM_DATA + + +async def test_service_say_timeout(hass, aioclient_mock): + """Test service call say with http timeout.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + aioclient_mock.post(URL, data=FORM_DATA, exc=asyncio.TimeoutError()) + + config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "voicerss_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "I person is on front of your door.", + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == FORM_DATA + + +async def test_service_say_error_msg(hass, aioclient_mock): + """Test service call say with http error api message.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + aioclient_mock.post( + URL, + data=FORM_DATA, + status=HTTPStatus.OK, + content=b"The subscription does not support SSML!", + ) + + config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "voicerss_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "I person is on front of your door.", + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == FORM_DATA diff --git a/tests/components/vultr/conftest.py b/tests/components/vultr/conftest.py new file mode 100644 index 00000000000000..76c48c2574b497 --- /dev/null +++ b/tests/components/vultr/conftest.py @@ -0,0 +1,30 @@ +"""Test configuration for the Vultr tests.""" +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components import vultr +from homeassistant.core import HomeAssistant + +from .const import VALID_CONFIG + +from tests.common import load_fixture + + +@pytest.fixture(name="valid_config") +def valid_config(hass: HomeAssistant, requests_mock): + """Load a valid config.""" + requests_mock.get( + "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", + text=load_fixture("account_info.json", "vultr"), + ) + + with patch( + "vultr.Vultr.server_list", + return_value=json.loads(load_fixture("server_list.json", "vultr")), + ): + # Setup hub + vultr.setup(hass, VALID_CONFIG) + + yield diff --git a/tests/components/vultr/const.py b/tests/components/vultr/const.py new file mode 100644 index 00000000000000..06bbf2a74835e7 --- /dev/null +++ b/tests/components/vultr/const.py @@ -0,0 +1,3 @@ +"""Constants for the Vultr tests.""" + +VALID_CONFIG = {"vultr": {"api_key": "ABCDEFG1234567"}} diff --git a/tests/components/vultr/test_binary_sensor.py b/tests/components/vultr/test_binary_sensor.py index 1b84ddff29129c..80cd198e371acd 100644 --- a/tests/components/vultr/test_binary_sensor.py +++ b/tests/components/vultr/test_binary_sensor.py @@ -1,10 +1,5 @@ """Test the Vultr binary sensor platform.""" -import json -import unittest -from unittest.mock import patch - import pytest -import requests_mock import voluptuous as vol from homeassistant.components import vultr as base_vultr @@ -19,125 +14,91 @@ binary_sensor as vultr, ) from homeassistant.const import CONF_NAME, CONF_PLATFORM +from homeassistant.core import HomeAssistant -from tests.common import get_test_home_assistant, load_fixture -from tests.components.vultr.test_init import VALID_CONFIG - +CONFIGS = [ + {CONF_SUBSCRIPTION: "576965", CONF_NAME: "A Server"}, + {CONF_SUBSCRIPTION: "123456", CONF_NAME: "Failed Server"}, + {CONF_SUBSCRIPTION: "555555", CONF_NAME: vultr.DEFAULT_NAME}, +] -class TestVultrBinarySensorSetup(unittest.TestCase): - """Test the Vultr binary sensor platform.""" - DEVICES = [] +@pytest.mark.usefixtures("valid_config") +def test_binary_sensor(hass: HomeAssistant): + """Test successful instance.""" + hass_devices = [] - def add_entities(self, devices, action): + def add_entities(devices, action): + """Mock add devices.""" + for device in devices: + device.hass = hass + hass_devices.append(device) + + # Setup each of our test configs + for config in CONFIGS: + vultr.setup_platform(hass, config, add_entities, None) + + assert len(hass_devices) == 3 + + for device in hass_devices: + + # Test pre data retrieval + if device.subscription == "555555": + assert device.name == "Vultr {}" + + device.update() + device_attrs = device.extra_state_attributes + + if device.subscription == "555555": + assert device.name == "Vultr Another Server" + + if device.name == "A Server": + assert device.is_on is True + assert device.device_class == "power" + assert device.state == "on" + assert device.icon == "mdi:server" + assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" + assert device_attrs[ATTR_AUTO_BACKUPS] == "yes" + assert device_attrs[ATTR_IPV4_ADDRESS] == "123.123.123.123" + assert device_attrs[ATTR_COST_PER_MONTH] == "10.05" + assert device_attrs[ATTR_CREATED_AT] == "2013-12-19 14:45:41" + assert device_attrs[ATTR_SUBSCRIPTION_ID] == "576965" + elif device.name == "Failed Server": + assert device.is_on is False + assert device.state == "off" + assert device.icon == "mdi:server-off" + assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" + assert device_attrs[ATTR_AUTO_BACKUPS] == "no" + assert device_attrs[ATTR_IPV4_ADDRESS] == "192.168.100.50" + assert device_attrs[ATTR_COST_PER_MONTH] == "73.25" + assert device_attrs[ATTR_CREATED_AT] == "2014-10-13 14:45:41" + assert device_attrs[ATTR_SUBSCRIPTION_ID] == "123456" + + +def test_invalid_sensor_config(): + """Test config type failures.""" + with pytest.raises(vol.Invalid): # No subs + vultr.PLATFORM_SCHEMA({CONF_PLATFORM: base_vultr.DOMAIN}) + + +@pytest.mark.usefixtures("valid_config") +def test_invalid_sensors(hass: HomeAssistant): + """Test the VultrBinarySensor fails.""" + hass_devices = [] + + def add_entities(devices, action): """Mock add devices.""" for device in devices: - self.DEVICES.append(device) - - def setUp(self): - """Init values for this testcase class.""" - self.hass = get_test_home_assistant() - self.configs = [ - {CONF_SUBSCRIPTION: "576965", CONF_NAME: "A Server"}, - {CONF_SUBSCRIPTION: "123456", CONF_NAME: "Failed Server"}, - {CONF_SUBSCRIPTION: "555555", CONF_NAME: vultr.DEFAULT_NAME}, - ] - self.addCleanup(self.tear_down_cleanup) - - def tear_down_cleanup(self): - """Stop our started services.""" - self.hass.stop() - - @requests_mock.Mocker() - def test_binary_sensor(self, mock): - """Test successful instance.""" - mock.get( - "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("account_info.json", "vultr"), - ) - - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - # Setup hub - base_vultr.setup(self.hass, VALID_CONFIG) - - # Setup each of our test configs - for config in self.configs: - vultr.setup_platform(self.hass, config, self.add_entities, None) - - assert len(self.DEVICES) == 3 - - for device in self.DEVICES: - - # Test pre data retrieval - if device.subscription == "555555": - assert device.name == "Vultr {}" - - device.update() - device_attrs = device.extra_state_attributes - - if device.subscription == "555555": - assert device.name == "Vultr Another Server" - - if device.name == "A Server": - assert device.is_on is True - assert device.device_class == "power" - assert device.state == "on" - assert device.icon == "mdi:server" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "yes" - assert device_attrs[ATTR_IPV4_ADDRESS] == "123.123.123.123" - assert device_attrs[ATTR_COST_PER_MONTH] == "10.05" - assert device_attrs[ATTR_CREATED_AT] == "2013-12-19 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "576965" - elif device.name == "Failed Server": - assert device.is_on is False - assert device.state == "off" - assert device.icon == "mdi:server-off" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "no" - assert device_attrs[ATTR_IPV4_ADDRESS] == "192.168.100.50" - assert device_attrs[ATTR_COST_PER_MONTH] == "73.25" - assert device_attrs[ATTR_CREATED_AT] == "2014-10-13 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "123456" - - def test_invalid_sensor_config(self): - """Test config type failures.""" - with pytest.raises(vol.Invalid): # No subs - vultr.PLATFORM_SCHEMA({CONF_PLATFORM: base_vultr.DOMAIN}) - - @requests_mock.Mocker() - def test_invalid_sensors(self, mock): - """Test the VultrBinarySensor fails.""" - mock.get( - "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("account_info.json", "vultr"), - ) - - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - # Setup hub - base_vultr.setup(self.hass, VALID_CONFIG) - - bad_conf = {} # No subscription - - no_subs_setup = vultr.setup_platform( - self.hass, bad_conf, self.add_entities, None - ) - - assert not no_subs_setup - - bad_conf = { - CONF_NAME: "Missing Server", - CONF_SUBSCRIPTION: "555555", - } # Sub not associated with API key (not in server_list) - - wrong_subs_setup = vultr.setup_platform( - self.hass, bad_conf, self.add_entities, None - ) - - assert not wrong_subs_setup + device.hass = hass + hass_devices.append(device) + + bad_conf = {} # No subscription + + vultr.setup_platform(hass, bad_conf, add_entities, None) + + bad_conf = { + CONF_NAME: "Missing Server", + CONF_SUBSCRIPTION: "555555", + } # Sub not associated with API key (not in server_list) + + vultr.setup_platform(hass, bad_conf, add_entities, None) diff --git a/tests/components/vultr/test_init.py b/tests/components/vultr/test_init.py index 040eac1a674554..3805c68d95b5cf 100644 --- a/tests/components/vultr/test_init.py +++ b/tests/components/vultr/test_init.py @@ -1,44 +1,29 @@ """The tests for the Vultr component.""" from copy import deepcopy import json -import unittest from unittest.mock import patch -import requests_mock - from homeassistant import setup -import homeassistant.components.vultr as vultr - -from tests.common import get_test_home_assistant, load_fixture - -VALID_CONFIG = {"vultr": {"api_key": "ABCDEFG1234567"}} +from homeassistant.components import vultr +from homeassistant.core import HomeAssistant +from .const import VALID_CONFIG -class TestVultr(unittest.TestCase): - """Tests the Vultr component.""" +from tests.common import load_fixture - def setUp(self): - """Initialize values for this test case class.""" - self.hass = get_test_home_assistant() - self.config = VALID_CONFIG - self.addCleanup(self.tear_down_cleanup) - def tear_down_cleanup(self): - """Stop everything that we started.""" - self.hass.stop() +def test_setup(hass: HomeAssistant): + """Test successful setup.""" + with patch( + "vultr.Vultr.server_list", + return_value=json.loads(load_fixture("server_list.json", "vultr")), + ): + response = vultr.setup(hass, VALID_CONFIG) + assert response - @requests_mock.Mocker() - def test_setup(self, mock): - """Test successful setup.""" - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - response = vultr.setup(self.hass, self.config) - assert response - def test_setup_no_api_key(self): - """Test failed setup with missing API Key.""" - conf = deepcopy(self.config) - del conf["vultr"]["api_key"] - assert not setup.setup_component(self.hass, vultr.DOMAIN, conf) +async def test_setup_no_api_key(hass: HomeAssistant): + """Test failed setup with missing API Key.""" + conf = deepcopy(VALID_CONFIG) + del conf["vultr"]["api_key"] + assert not await setup.async_setup_component(hass, vultr.DOMAIN, conf) diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index ac7008d066be31..a0b93a59124e5b 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -1,10 +1,5 @@ """The tests for the Vultr sensor platform.""" -import json -import unittest -from unittest.mock import patch - import pytest -import requests_mock import voluptuous as vol from homeassistant.components import vultr as base_vultr @@ -16,152 +11,124 @@ CONF_PLATFORM, DATA_GIGABYTES, ) +from homeassistant.core import HomeAssistant + +CONFIGS = [ + { + CONF_NAME: vultr.DEFAULT_NAME, + CONF_SUBSCRIPTION: "576965", + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, + }, + { + CONF_NAME: "Server {}", + CONF_SUBSCRIPTION: "123456", + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, + }, + { + CONF_NAME: "VPS Charges", + CONF_SUBSCRIPTION: "555555", + CONF_MONITORED_CONDITIONS: ["pending_charges"], + }, +] + + +@pytest.mark.usefixtures("valid_config") +def test_sensor(hass: HomeAssistant): + """Test the Vultr sensor class and methods.""" + hass_devices = [] + + def add_entities(devices, action): + """Mock add devices.""" + for device in devices: + device.hass = hass + hass_devices.append(device) -from tests.common import get_test_home_assistant, load_fixture -from tests.components.vultr.test_init import VALID_CONFIG + for config in CONFIGS: + vultr.setup_platform(hass, config, add_entities, None) + assert len(hass_devices) == 5 -class TestVultrSensorSetup(unittest.TestCase): - """Test the Vultr platform.""" + tested = 0 - DEVICES = [] + for device in hass_devices: - def add_entities(self, devices, action): - """Mock add devices.""" - for device in devices: - device.hass = self.hass - self.DEVICES.append(device) + # Test pre update + if device.subscription == "576965": + assert vultr.DEFAULT_NAME == device.name - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - self.configs = [ + device.update() + + if device.unit_of_measurement == DATA_GIGABYTES: # Test Bandwidth Used + if device.subscription == "576965": + assert device.name == "Vultr my new server Current Bandwidth Used" + assert device.icon == "mdi:chart-histogram" + assert device.state == 131.51 + assert device.icon == "mdi:chart-histogram" + tested += 1 + + elif device.subscription == "123456": + assert device.name == "Server Current Bandwidth Used" + assert device.state == 957.46 + tested += 1 + + elif device.unit_of_measurement == "US$": # Test Pending Charges + + if device.subscription == "576965": # Default 'Vultr {} {}' + assert device.name == "Vultr my new server Pending Charges" + assert device.icon == "mdi:currency-usd" + assert device.state == 46.67 + assert device.icon == "mdi:currency-usd" + tested += 1 + + elif device.subscription == "123456": # Custom name with 1 {} + assert device.name == "Server Pending Charges" + assert device.state == "not a number" + tested += 1 + + elif device.subscription == "555555": # No {} in name + assert device.name == "VPS Charges" + assert device.state == 5.45 + tested += 1 + + assert tested == 5 + + +def test_invalid_sensor_config(): + """Test config type failures.""" + with pytest.raises(vol.Invalid): # No subscription + vultr.PLATFORM_SCHEMA( { - CONF_NAME: vultr.DEFAULT_NAME, - CONF_SUBSCRIPTION: "576965", + CONF_PLATFORM: base_vultr.DOMAIN, CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - }, + } + ) + with pytest.raises(vol.Invalid): # Bad monitored_conditions + vultr.PLATFORM_SCHEMA( { - CONF_NAME: "Server {}", + CONF_PLATFORM: base_vultr.DOMAIN, CONF_SUBSCRIPTION: "123456", - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - }, - { - CONF_NAME: "VPS Charges", - CONF_SUBSCRIPTION: "555555", - CONF_MONITORED_CONDITIONS: ["pending_charges"], - }, - ] - self.addCleanup(self.hass.stop) - - @requests_mock.Mocker() - def test_sensor(self, mock): - """Test the Vultr sensor class and methods.""" - mock.get( - "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("account_info.json", "vultr"), + CONF_MONITORED_CONDITIONS: ["non-existent-condition"], + } ) - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - # Setup hub - base_vultr.setup(self.hass, VALID_CONFIG) - - for config in self.configs: - setup = vultr.setup_platform(self.hass, config, self.add_entities, None) - - assert setup is None - assert len(self.DEVICES) == 5 +@pytest.mark.usefixtures("valid_config") +def test_invalid_sensors(hass: HomeAssistant): + """Test the VultrSensor fails.""" + hass_devices = [] - tested = 0 - - for device in self.DEVICES: + def add_entities(devices, action): + """Mock add devices.""" + for device in devices: + device.hass = hass + hass_devices.append(device) - # Test pre update - if device.subscription == "576965": - assert vultr.DEFAULT_NAME == device.name - - device.update() - - if device.unit_of_measurement == DATA_GIGABYTES: # Test Bandwidth Used - if device.subscription == "576965": - assert device.name == "Vultr my new server Current Bandwidth Used" - assert device.icon == "mdi:chart-histogram" - assert device.state == 131.51 - assert device.icon == "mdi:chart-histogram" - tested += 1 - - elif device.subscription == "123456": - assert device.name == "Server Current Bandwidth Used" - assert device.state == 957.46 - tested += 1 - - elif device.unit_of_measurement == "US$": # Test Pending Charges - - if device.subscription == "576965": # Default 'Vultr {} {}' - assert device.name == "Vultr my new server Pending Charges" - assert device.icon == "mdi:currency-usd" - assert device.state == 46.67 - assert device.icon == "mdi:currency-usd" - tested += 1 - - elif device.subscription == "123456": # Custom name with 1 {} - assert device.name == "Server Pending Charges" - assert device.state == "not a number" - tested += 1 - - elif device.subscription == "555555": # No {} in name - assert device.name == "VPS Charges" - assert device.state == 5.45 - tested += 1 - - assert tested == 5 - - def test_invalid_sensor_config(self): - """Test config type failures.""" - with pytest.raises(vol.Invalid): # No subscription - vultr.PLATFORM_SCHEMA( - { - CONF_PLATFORM: base_vultr.DOMAIN, - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - } - ) - with pytest.raises(vol.Invalid): # Bad monitored_conditions - vultr.PLATFORM_SCHEMA( - { - CONF_PLATFORM: base_vultr.DOMAIN, - CONF_SUBSCRIPTION: "123456", - CONF_MONITORED_CONDITIONS: ["non-existent-condition"], - } - ) - - @requests_mock.Mocker() - def test_invalid_sensors(self, mock): - """Test the VultrSensor fails.""" - mock.get( - "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("account_info.json", "vultr"), - ) + bad_conf = { + CONF_NAME: "Vultr {} {}", + CONF_SUBSCRIPTION: "", + CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, + } # No subs at all - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - # Setup hub - base_vultr.setup(self.hass, VALID_CONFIG) - - bad_conf = { - CONF_NAME: "Vultr {} {}", - CONF_SUBSCRIPTION: "", - CONF_MONITORED_CONDITIONS: vultr.SENSOR_KEYS, - } # No subs at all - - no_sub_setup = vultr.setup_platform( - self.hass, bad_conf, self.add_entities, None - ) + vultr.setup_platform(hass, bad_conf, add_entities, None) - assert no_sub_setup is None - assert len(self.DEVICES) == 0 + assert len(hass_devices) == 0 diff --git a/tests/components/vultr/test_switch.py b/tests/components/vultr/test_switch.py index ab3698b48f4d04..8997d1c0b9d49d 100644 --- a/tests/components/vultr/test_switch.py +++ b/tests/components/vultr/test_switch.py @@ -1,10 +1,10 @@ """Test the Vultr switch platform.""" +from __future__ import annotations + import json -import unittest from unittest.mock import patch import pytest -import requests_mock import voluptuous as vol from homeassistant.components import vultr as base_vultr @@ -19,159 +19,137 @@ switch as vultr, ) from homeassistant.const import CONF_NAME, CONF_PLATFORM +from homeassistant.core import HomeAssistant -from tests.common import get_test_home_assistant, load_fixture -from tests.components.vultr.test_init import VALID_CONFIG +from tests.common import load_fixture +CONFIGS = [ + {CONF_SUBSCRIPTION: "576965", CONF_NAME: "A Server"}, + {CONF_SUBSCRIPTION: "123456", CONF_NAME: "Failed Server"}, + {CONF_SUBSCRIPTION: "555555", CONF_NAME: vultr.DEFAULT_NAME}, +] -class TestVultrSwitchSetup(unittest.TestCase): - """Test the Vultr switch platform.""" - DEVICES = [] +@pytest.fixture(name="hass_devices") +def load_hass_devices(hass: HomeAssistant): + """Load a valid config.""" + hass_devices = [] - def add_entities(self, devices, action): + def add_entities(devices, action): """Mock add devices.""" for device in devices: - self.DEVICES.append(device) - - def setUp(self): - """Init values for this testcase class.""" - self.hass = get_test_home_assistant() - self.configs = [ - {CONF_SUBSCRIPTION: "576965", CONF_NAME: "A Server"}, - {CONF_SUBSCRIPTION: "123456", CONF_NAME: "Failed Server"}, - {CONF_SUBSCRIPTION: "555555", CONF_NAME: vultr.DEFAULT_NAME}, - ] - self.addCleanup(self.tear_down_cleanup) - - def tear_down_cleanup(self): - """Stop our started services.""" - self.hass.stop() - - @requests_mock.Mocker() - def test_switch(self, mock): - """Test successful instance.""" - mock.get( - "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("account_info.json", "vultr"), - ) - - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - # Setup hub - base_vultr.setup(self.hass, VALID_CONFIG) - - # Setup each of our test configs - for config in self.configs: - vultr.setup_platform(self.hass, config, self.add_entities, None) - - assert len(self.DEVICES) == 3 - - tested = 0 - - for device in self.DEVICES: - if device.subscription == "555555": - assert device.name == "Vultr {}" - tested += 1 - - device.update() - device_attrs = device.extra_state_attributes - - if device.subscription == "555555": - assert device.name == "Vultr Another Server" - tested += 1 - + device.hass = hass + hass_devices.append(device) + + # Setup each of our test configs + for config in CONFIGS: + vultr.setup_platform(hass, config, add_entities, None) + + yield hass_devices + + +@pytest.mark.usefixtures("valid_config") +def test_switch(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]): + """Test successful instance.""" + + assert len(hass_devices) == 3 + + tested = 0 + + for device in hass_devices: + if device.subscription == "555555": + assert device.name == "Vultr {}" + tested += 1 + + device.update() + device_attrs = device.extra_state_attributes + + if device.subscription == "555555": + assert device.name == "Vultr Another Server" + tested += 1 + + if device.name == "A Server": + assert device.is_on is True + assert device.state == "on" + assert device.icon == "mdi:server" + assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" + assert device_attrs[ATTR_AUTO_BACKUPS] == "yes" + assert device_attrs[ATTR_IPV4_ADDRESS] == "123.123.123.123" + assert device_attrs[ATTR_COST_PER_MONTH] == "10.05" + assert device_attrs[ATTR_CREATED_AT] == "2013-12-19 14:45:41" + assert device_attrs[ATTR_SUBSCRIPTION_ID] == "576965" + tested += 1 + + elif device.name == "Failed Server": + assert device.is_on is False + assert device.state == "off" + assert device.icon == "mdi:server-off" + assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" + assert device_attrs[ATTR_AUTO_BACKUPS] == "no" + assert device_attrs[ATTR_IPV4_ADDRESS] == "192.168.100.50" + assert device_attrs[ATTR_COST_PER_MONTH] == "73.25" + assert device_attrs[ATTR_CREATED_AT] == "2014-10-13 14:45:41" + assert device_attrs[ATTR_SUBSCRIPTION_ID] == "123456" + tested += 1 + + assert tested == 4 + + +@pytest.mark.usefixtures("valid_config") +def test_turn_on(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]): + """Test turning a subscription on.""" + with patch( + "vultr.Vultr.server_list", + return_value=json.loads(load_fixture("server_list.json", "vultr")), + ), patch("vultr.Vultr.server_start") as mock_start: + for device in hass_devices: + if device.name == "Failed Server": + device.update() + device.turn_on() + + # Turn on + assert mock_start.call_count == 1 + + +@pytest.mark.usefixtures("valid_config") +def test_turn_off(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]): + """Test turning a subscription off.""" + with patch( + "vultr.Vultr.server_list", + return_value=json.loads(load_fixture("server_list.json", "vultr")), + ), patch("vultr.Vultr.server_halt") as mock_halt: + for device in hass_devices: if device.name == "A Server": - assert device.is_on is True - assert device.state == "on" - assert device.icon == "mdi:server" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "yes" - assert device_attrs[ATTR_IPV4_ADDRESS] == "123.123.123.123" - assert device_attrs[ATTR_COST_PER_MONTH] == "10.05" - assert device_attrs[ATTR_CREATED_AT] == "2013-12-19 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "576965" - tested += 1 - - elif device.name == "Failed Server": - assert device.is_on is False - assert device.state == "off" - assert device.icon == "mdi:server-off" - assert device_attrs[ATTR_ALLOWED_BANDWIDTH] == "1000" - assert device_attrs[ATTR_AUTO_BACKUPS] == "no" - assert device_attrs[ATTR_IPV4_ADDRESS] == "192.168.100.50" - assert device_attrs[ATTR_COST_PER_MONTH] == "73.25" - assert device_attrs[ATTR_CREATED_AT] == "2014-10-13 14:45:41" - assert device_attrs[ATTR_SUBSCRIPTION_ID] == "123456" - tested += 1 - - assert tested == 4 - - @requests_mock.Mocker() - def test_turn_on(self, mock): - """Test turning a subscription on.""" - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ), patch("vultr.Vultr.server_start") as mock_start: - for device in self.DEVICES: - if device.name == "Failed Server": - device.turn_on() - - # Turn on - assert mock_start.call_count == 1 - - @requests_mock.Mocker() - def test_turn_off(self, mock): - """Test turning a subscription off.""" - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ), patch("vultr.Vultr.server_halt") as mock_halt: - for device in self.DEVICES: - if device.name == "A Server": - device.turn_off() - - # Turn off - assert mock_halt.call_count == 1 - - def test_invalid_switch_config(self): - """Test config type failures.""" - with pytest.raises(vol.Invalid): # No subscription - vultr.PLATFORM_SCHEMA({CONF_PLATFORM: base_vultr.DOMAIN}) - - @requests_mock.Mocker() - def test_invalid_switches(self, mock): - """Test the VultrSwitch fails.""" - mock.get( - "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", - text=load_fixture("account_info.json", "vultr"), - ) - - with patch( - "vultr.Vultr.server_list", - return_value=json.loads(load_fixture("server_list.json", "vultr")), - ): - # Setup hub - base_vultr.setup(self.hass, VALID_CONFIG) - - bad_conf = {} # No subscription - - no_subs_setup = vultr.setup_platform( - self.hass, bad_conf, self.add_entities, None - ) - - assert no_subs_setup is not None - - bad_conf = { - CONF_NAME: "Missing Server", - CONF_SUBSCRIPTION: "665544", - } # Sub not associated with API key (not in server_list) - - wrong_subs_setup = vultr.setup_platform( - self.hass, bad_conf, self.add_entities, None - ) - - assert wrong_subs_setup is not None + device.update() + device.turn_off() + + # Turn off + assert mock_halt.call_count == 1 + + +def test_invalid_switch_config(): + """Test config type failures.""" + with pytest.raises(vol.Invalid): # No subscription + vultr.PLATFORM_SCHEMA({CONF_PLATFORM: base_vultr.DOMAIN}) + + +@pytest.mark.usefixtures("valid_config") +def test_invalid_switches(hass: HomeAssistant): + """Test the VultrSwitch fails.""" + hass_devices = [] + + def add_entities(devices, action): + """Mock add devices.""" + for device in devices: + hass_devices.append(device) + + bad_conf = {} # No subscription + + vultr.setup_platform(hass, bad_conf, add_entities, None) + + bad_conf = { + CONF_NAME: "Missing Server", + CONF_SUBSCRIPTION: "665544", + } # Sub not associated with API key (not in server_list) + + vultr.setup_platform(hass, bad_conf, add_entities, None) diff --git a/tests/components/wake_on_lan/test_switch.py b/tests/components/wake_on_lan/test_switch.py index 1396ae80f1ef4d..cac287a87a1d04 100644 --- a/tests/components/wake_on_lan/test_switch.py +++ b/tests/components/wake_on_lan/test_switch.py @@ -1,5 +1,4 @@ """The tests for the wake on lan switch platform.""" -import platform import subprocess from unittest.mock import patch @@ -66,38 +65,6 @@ async def test_valid_hostname(hass): assert state.state == STATE_ON -async def test_valid_hostname_windows(hass): - """Test with valid hostname on windows.""" - assert await async_setup_component( - hass, - switch.DOMAIN, - { - "switch": { - "platform": "wake_on_lan", - "mac": "00-01-02-03-04-05", - "host": "validhostname", - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("switch.wake_on_lan") - assert state.state == STATE_OFF - - with patch.object(subprocess, "call", return_value=0), patch.object( - platform, "system", return_value="Windows" - ): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wake_on_lan"}, - blocking=True, - ) - - state = hass.states.get("switch.wake_on_lan") - assert state.state == STATE_ON - - async def test_broadcast_config_ip_and_port(hass, mock_send_magic_packet): """Test with broadcast address and broadcast port config.""" mac = "00-01-02-03-04-05" @@ -245,38 +212,6 @@ async def test_off_script(hass): assert len(calls) == 1 -async def test_invalid_hostname_windows(hass): - """Test with invalid hostname on windows.""" - - assert await async_setup_component( - hass, - switch.DOMAIN, - { - "switch": { - "platform": "wake_on_lan", - "mac": "00-01-02-03-04-05", - "host": "invalidhostname", - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("switch.wake_on_lan") - assert state.state == STATE_OFF - - with patch.object(subprocess, "call", return_value=2): - - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wake_on_lan"}, - blocking=True, - ) - - state = hass.states.get("switch.wake_on_lan") - assert state.state == STATE_OFF - - async def test_no_hostname_state(hass): """Test that the state updates if we do not pass in a hostname.""" diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index 21d1f0acbc5699..5effb103d7f717 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -10,9 +10,14 @@ CONF_ADDED_RANGE_KEY, CONF_CHARGING_POWER_KEY, CONF_CHARGING_SPEED_KEY, + CONF_CURRENT_VERSION_KEY, CONF_DATA_KEY, CONF_MAX_AVAILABLE_POWER_KEY, CONF_MAX_CHARGING_CURRENT_KEY, + CONF_NAME_KEY, + CONF_PART_NUMBER_KEY, + CONF_SERIAL_NUMBER_KEY, + CONF_SOFTWARE_KEY, CONF_STATION, DOMAIN, ) @@ -30,7 +35,13 @@ CONF_CHARGING_SPEED_KEY: 0, CONF_ADDED_RANGE_KEY: 150, CONF_ADDED_ENERGY_KEY: 44.697, - CONF_DATA_KEY: {CONF_MAX_CHARGING_CURRENT_KEY: 24}, + CONF_NAME_KEY: "WallboxName", + CONF_DATA_KEY: { + CONF_MAX_CHARGING_CURRENT_KEY: 24, + CONF_SERIAL_NUMBER_KEY: "20000", + CONF_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CONF_SOFTWARE_KEY: {CONF_CURRENT_VERSION_KEY: "5.5.10"}, + }, } ) ) diff --git a/tests/components/water_heater/test_device_action.py b/tests/components/water_heater/test_device_action.py index 060d9ead29fa4e..25b79910bdbe83 100644 --- a/tests/components/water_heater/test_device_action.py +++ b/tests/components/water_heater/test_device_action.py @@ -2,6 +2,7 @@ import pytest import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.water_heater import DOMAIN from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -52,7 +53,9 @@ async def test_get_actions(hass, device_reg, entity_reg): "entity_id": "water_heater.test_5678", }, ] - actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device_entry.id + ) assert_lists_same(actions, expected_actions) diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py deleted file mode 100644 index 967e8b03620d98..00000000000000 --- a/tests/components/water_heater/test_init.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Tests for Water heater.""" -from homeassistant.components import water_heater - - -def test_deprecated_base_class(caplog): - """Test deprecated base class.""" - - class CustomWaterHeater(water_heater.WaterHeaterDevice): - pass - - CustomWaterHeater() - assert "WaterHeaterDevice is deprecated, modify CustomWaterHeater" in caplog.text diff --git a/tests/components/watttime/conftest.py b/tests/components/watttime/conftest.py new file mode 100644 index 00000000000000..6483778c153aa5 --- /dev/null +++ b/tests/components/watttime/conftest.py @@ -0,0 +1,120 @@ +"""Define test fixtures for WattTime.""" +import json +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.watttime.config_flow import ( + CONF_LOCATION_TYPE, + LOCATION_TYPE_COORDINATES, +) +from homeassistant.components.watttime.const import ( + CONF_BALANCING_AUTHORITY, + CONF_BALANCING_AUTHORITY_ABBREV, + DOMAIN, +) +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="client") +def client_fixture(get_grid_region, data_realtime_emissions): + """Define an aiowatttime client.""" + client = Mock() + client.emissions.async_get_grid_region = get_grid_region + client.emissions.async_get_realtime_emissions = AsyncMock( + return_value=data_realtime_emissions + ) + return client + + +@pytest.fixture(name="config_auth") +def config_auth_fixture(hass): + """Define an auth config entry data fixture.""" + return { + CONF_USERNAME: "user", + CONF_PASSWORD: "password", + } + + +@pytest.fixture(name="config_coordinates") +def config_coordinates_fixture(hass): + """Define a coordinates config entry data fixture.""" + return { + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + } + + +@pytest.fixture(name="config_location_type") +def config_location_type_fixture(hass): + """Define a location type config entry data fixture.""" + return { + CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES, + } + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config_auth, config_coordinates, unique_id): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=unique_id, + data={ + **config_auth, + **config_coordinates, + CONF_BALANCING_AUTHORITY: "PJM New Jersey", + CONF_BALANCING_AUTHORITY_ABBREV: "PJM_NJ", + }, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="data_grid_region", scope="session") +def data_grid_region_fixture(): + """Define grid region data.""" + return json.loads(load_fixture("grid_region_data.json", "watttime")) + + +@pytest.fixture(name="data_realtime_emissions", scope="session") +def data_realtime_emissions_fixture(): + """Define realtime emissions data.""" + return json.loads(load_fixture("realtime_emissions_data.json", "watttime")) + + +@pytest.fixture(name="get_grid_region") +def get_grid_region_fixture(data_grid_region): + """Define an aiowatttime method to get grid region data.""" + return AsyncMock(return_value=data_grid_region) + + +@pytest.fixture(name="setup_watttime") +async def setup_watttime_fixture(hass, client, config_auth, config_coordinates): + """Define a fixture to set up WattTime.""" + with patch( + "homeassistant.components.watttime.Client.async_login", return_value=client + ), patch( + "homeassistant.components.watttime.config_flow.Client.async_login", + return_value=client, + ), patch( + "homeassistant.components.watttime.PLATFORMS", [] + ): + assert await async_setup_component( + hass, DOMAIN, {**config_auth, **config_coordinates} + ) + await hass.async_block_till_done() + yield + + +@pytest.fixture(name="unique_id") +def unique_id_fixture(hass): + """Define a config entry unique ID fixture.""" + return "32.87336, -117.22743" diff --git a/tests/components/watttime/fixtures/grid_region_data.json b/tests/components/watttime/fixtures/grid_region_data.json new file mode 100644 index 00000000000000..0104ebeb98c873 --- /dev/null +++ b/tests/components/watttime/fixtures/grid_region_data.json @@ -0,0 +1,5 @@ +{ + "id": 263, + "abbrev": "PJM_NJ", + "name": "PJM New Jersey" +} diff --git a/tests/components/watttime/fixtures/realtime_emissions_data.json b/tests/components/watttime/fixtures/realtime_emissions_data.json new file mode 100644 index 00000000000000..ee5d0ae28335db --- /dev/null +++ b/tests/components/watttime/fixtures/realtime_emissions_data.json @@ -0,0 +1,8 @@ +{ + "freq": "300", + "ba": "CAISO_NORTH", + "percent": "53", + "moer": "850.743982", + "point_time": "2019-01-29T14:55:00.00Z" +} + diff --git a/tests/components/watttime/test_config_flow.py b/tests/components/watttime/test_config_flow.py index de6e16a400a33d..514376d58a58fa 100644 --- a/tests/components/watttime/test_config_flow.py +++ b/tests/components/watttime/test_config_flow.py @@ -7,7 +7,6 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.watttime.config_flow import ( CONF_LOCATION_TYPE, - LOCATION_TYPE_COORDINATES, LOCATION_TYPE_HOME, ) from homeassistant.components.watttime.const import ( @@ -29,106 +28,108 @@ RESULT_TYPE_FORM, ) -from tests.common import MockConfigEntry - -@pytest.fixture(name="client") -def client_fixture(get_grid_region): - """Define a fixture for an aiowatttime client.""" - client = AsyncMock(return_value=None) - client.emissions.async_get_grid_region = get_grid_region - return client - - -@pytest.fixture(name="client_login") -def client_login_fixture(client): - """Define a fixture for patching the aiowatttime coroutine to get a client.""" +@pytest.mark.parametrize( + "exc,error", [(InvalidCredentialsError, "invalid_auth"), (Exception, "unknown")] +) +async def test_auth_errors( + hass: HomeAssistant, config_auth, config_location_type, exc, error +) -> None: + """Test that issues with auth show the correct error.""" with patch( - "homeassistant.components.watttime.config_flow.Client.async_login" - ) as mock_client: - mock_client.return_value = client - yield mock_client + "homeassistant.components.watttime.config_flow.Client.async_login", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config_auth + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": error} -@pytest.fixture(name="get_grid_region") -def get_grid_region_fixture(): - """Define a fixture for getting grid region data.""" - return AsyncMock(return_value={"abbrev": "AUTH_1", "id": 1, "name": "Authority 1"}) +@pytest.mark.parametrize( + "get_grid_region,errors", + [ + ( + AsyncMock(side_effect=CoordinatesNotFoundError), + {"latitude": "unknown_coordinates"}, + ), + ( + AsyncMock(side_effect=Exception), + {"base": "unknown"}, + ), + ], +) +async def test_coordinate_errors( + hass: HomeAssistant, + config_auth, + config_coordinates, + config_location_type, + errors, + setup_watttime, +) -> None: + """Test that issues with coordinates show the correct error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config_auth + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config_location_type + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config_coordinates + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == errors -async def test_duplicate_error(hass: HomeAssistant, client_login): +@pytest.mark.parametrize( + "config_location_type", [{CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}] +) +async def test_duplicate_error( + hass: HomeAssistant, config_auth, config_entry, config_location_type, setup_watttime +): """Test that errors are shown when duplicate entries are added.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="32.87336, -117.22743", - data={ - CONF_USERNAME: "user", - CONF_PASSWORD: "password", - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - }, - ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config_auth ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}, + result["flow_id"], user_input=config_location_type ) - assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" -async def test_options_flow(hass): +async def test_options_flow(hass: HomeAssistant, config_entry): """Test config flow options.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="32.87336, -117.22743", - data={ - CONF_USERNAME: "user", - CONF_PASSWORD: "password", - CONF_LATITUDE: 32.87336, - CONF_LONGITUDE: -117.22743, - }, - ) - entry.add_to_hass(hass) - with patch( "homeassistant.components.watttime.async_setup_entry", return_value=True ): - await hass.config_entries.async_setup(entry.entry_id) - result = await hass.config_entries.options.async_init(entry.entry_id) - + await hass.config_entries.async_setup(config_entry.entry_id) + 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 entry.options == {CONF_SHOW_ON_MAP: False} + assert config_entry.options == {CONF_SHOW_ON_MAP: False} -async def test_show_form_coordinates(hass: HomeAssistant, client_login) -> None: +async def test_show_form_coordinates( + hass: HomeAssistant, config_auth, config_location_type, setup_watttime +) -> None: """Test showing the form to input custom latitude/longitude.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, + result["flow_id"], user_input=config_auth ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES}, + result["flow_id"], user_input=config_location_type ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "coordinates" assert result["errors"] is None @@ -139,73 +140,15 @@ async def test_show_form_user(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"] is None -@pytest.mark.parametrize( - "get_grid_region", [AsyncMock(side_effect=CoordinatesNotFoundError)] -) -async def test_step_coordinates_unknown_coordinates( - hass: HomeAssistant, client_login -) -> None: - """Test that providing coordinates with no data is handled.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_LATITUDE: "0", CONF_LONGITUDE: "0"}, - ) - - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {"latitude": "unknown_coordinates"} - - -@pytest.mark.parametrize("get_grid_region", [AsyncMock(side_effect=Exception)]) -async def test_step_coordinates_unknown_error( - hass: HomeAssistant, client_login +async def test_step_reauth( + hass: HomeAssistant, config_auth, config_coordinates, config_entry, setup_watttime ) -> None: - """Test that providing coordinates with no data is handled.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}, - ) - - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {"base": "unknown"} - - -async def test_step_reauth(hass: HomeAssistant, client_login) -> None: """Test a full reauth flow.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="51.528308, -0.3817765", - data={ - CONF_USERNAME: "user", - CONF_PASSWORD: "password", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - CONF_BALANCING_AUTHORITY: "Authority 1", - CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1", - }, - ).add_to_hass(hass) - with patch( "homeassistant.components.watttime.async_setup_entry", return_value=True, @@ -214,117 +157,62 @@ async def test_step_reauth(hass: HomeAssistant, client_login) -> None: DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data={ - CONF_USERNAME: "user", - CONF_PASSWORD: "password", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - CONF_BALANCING_AUTHORITY: "Authority 1", - CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1", + **config_auth, + **config_coordinates, }, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password"}, ) - await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 -async def test_step_reauth_invalid_credentials(hass: HomeAssistant) -> None: - """Test that invalid credentials during reauth are handled.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="51.528308, -0.3817765", - data={ - CONF_USERNAME: "user", - CONF_PASSWORD: "password", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - CONF_BALANCING_AUTHORITY: "Authority 1", - CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1", - }, - ).add_to_hass(hass) - - with patch( - "homeassistant.components.watttime.config_flow.Client.async_login", - AsyncMock(side_effect=InvalidCredentialsError), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_REAUTH}, - data={ - CONF_USERNAME: "user", - CONF_PASSWORD: "password", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - CONF_BALANCING_AUTHORITY: "Authority 1", - CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1", - }, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: "password"}, - ) - - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_step_user_coordinates(hass: HomeAssistant, client_login) -> None: +async def test_step_user_coordinates( + hass: HomeAssistant, + config_auth, + config_location_type, + config_coordinates, + setup_watttime, +) -> None: """Test a full login flow (inputting custom coordinates).""" - - with patch( - "homeassistant.components.watttime.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_COORDINATES}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"}, - ) - await hass.async_block_till_done() - + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config_auth + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config_location_type + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config_coordinates + ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "51.528308, -0.3817765" + assert result["title"] == "32.87336, -117.22743" assert result["data"] == { CONF_USERNAME: "user", CONF_PASSWORD: "password", - CONF_LATITUDE: 51.528308, - CONF_LONGITUDE: -0.3817765, - CONF_BALANCING_AUTHORITY: "Authority 1", - CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1", + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + CONF_BALANCING_AUTHORITY: "PJM New Jersey", + CONF_BALANCING_AUTHORITY_ABBREV: "PJM_NJ", } -async def test_step_user_home(hass: HomeAssistant, client_login) -> None: +@pytest.mark.parametrize( + "config_location_type", [{CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}] +) +async def test_step_user_home( + hass: HomeAssistant, config_auth, config_location_type, setup_watttime +) -> None: """Test a full login flow (selecting the home location).""" - - with patch( - "homeassistant.components.watttime.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_LOCATION_TYPE: LOCATION_TYPE_HOME}, - ) - await hass.async_block_till_done() - + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config_auth + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=config_location_type + ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == "32.87336, -117.22743" assert result["data"] == { @@ -332,41 +220,6 @@ async def test_step_user_home(hass: HomeAssistant, client_login) -> None: CONF_PASSWORD: "password", CONF_LATITUDE: 32.87336, CONF_LONGITUDE: -117.22743, - CONF_BALANCING_AUTHORITY: "Authority 1", - CONF_BALANCING_AUTHORITY_ABBREV: "AUTH_1", + CONF_BALANCING_AUTHORITY: "PJM New Jersey", + CONF_BALANCING_AUTHORITY_ABBREV: "PJM_NJ", } - - -async def test_step_user_invalid_credentials(hass: HomeAssistant) -> None: - """Test that invalid credentials are handled.""" - - with patch( - "homeassistant.components.watttime.config_flow.Client.async_login", - AsyncMock(side_effect=InvalidCredentialsError), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, - ) - - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {"base": "invalid_auth"} - - -@pytest.mark.parametrize("get_grid_region", [AsyncMock(side_effect=Exception)]) -async def test_step_user_unknown_error(hass: HomeAssistant, client_login) -> None: - """Test that an unknown error during the login step is handled.""" - - with patch( - "homeassistant.components.watttime.config_flow.Client.async_login", - AsyncMock(side_effect=Exception), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={CONF_USERNAME: "user", CONF_PASSWORD: "password"}, - ) - - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {"base": "unknown"} diff --git a/tests/components/watttime/test_diagnostics.py b/tests/components/watttime/test_diagnostics.py new file mode 100644 index 00000000000000..0d8d87203bbbf9 --- /dev/null +++ b/tests/components/watttime/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test WattTime diagnostics.""" +from homeassistant.components.diagnostics import REDACTED + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_entry_diagnostics(hass, config_entry, hass_client, setup_watttime): + """Test config entry diagnostics.""" + assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + "entry": { + "data": { + "username": REDACTED, + "password": REDACTED, + "latitude": REDACTED, + "longitude": REDACTED, + "balancing_authority": "PJM New Jersey", + "balancing_authority_abbreviation": "PJM_NJ", + }, + "options": {}, + }, + "data": { + "freq": "300", + "ba": "CAISO_NORTH", + "percent": "53", + "moer": "850.743982", + "point_time": "2019-01-29T14:55:00.00Z", + }, + } diff --git a/tests/components/waze_travel_time/test_config_flow.py b/tests/components/waze_travel_time/test_config_flow.py index f0f8a0f3bde594..015850ba1b8171 100644 --- a/tests/components/waze_travel_time/test_config_flow.py +++ b/tests/components/waze_travel_time/test_config_flow.py @@ -168,100 +168,6 @@ async def _setup_dupe_import(hass, mock_update): await hass.async_block_till_done() -async def test_dupe_import(hass, mock_update): - """Test duplicate import.""" - await _setup_dupe_import(hass, mock_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_REGION: "US", - CONF_AVOID_FERRIES: True, - CONF_AVOID_SUBSCRIPTION_ROADS: True, - CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_INCL_FILTER: "include", - CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_VEHICLE_TYPE: "taxi", - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_dupe_import_false_check_different_options_value(hass, mock_update): - """Test false duplicate import check when options value differs.""" - await _setup_dupe_import(hass, mock_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_REGION: "US", - CONF_AVOID_FERRIES: True, - CONF_AVOID_SUBSCRIPTION_ROADS: True, - CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_INCL_FILTER: "include", - CONF_REALTIME: False, - CONF_UNITS: CONF_UNIT_SYSTEM_IMPERIAL, - CONF_VEHICLE_TYPE: "car", - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - -async def test_dupe_import_false_check_default_option(hass, mock_update): - """Test false duplicate import check when option with a default is missing.""" - await _setup_dupe_import(hass, mock_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_REGION: "US", - CONF_AVOID_FERRIES: True, - CONF_AVOID_SUBSCRIPTION_ROADS: True, - CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_INCL_FILTER: "include", - CONF_REALTIME: False, - CONF_VEHICLE_TYPE: "taxi", - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - -async def test_dupe_import_false_check_no_default_option(hass, mock_update): - """Test false duplicate import check option when option with no default is miissing.""" - await _setup_dupe_import(hass, mock_update) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - CONF_REGION: "US", - CONF_AVOID_FERRIES: True, - CONF_AVOID_SUBSCRIPTION_ROADS: True, - CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_REALTIME: False, - CONF_VEHICLE_TYPE: "taxi", - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - async def test_dupe(hass, validate_config_entry, bypass_setup): """Test setting up the same entry data twice is OK.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 4125e94749ada2..9849a6abe1886b 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -168,3 +168,26 @@ async def test_precipitation_conversion( native_value, native_unit, unit_system.accumulated_precipitation_unit ) assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx(expected, rel=1e-2) + + +async def test_none_forecast( + hass, + enable_custom_integrations, +): + """Test that conversion with None values succeeds.""" + entity0 = await create_entity( + hass, + pressure=None, + pressure_unit=PRESSURE_INHG, + wind_speed=None, + wind_speed_unit=SPEED_METERS_PER_SECOND, + precipitation=None, + precipitation_unit=LENGTH_MILLIMETERS, + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + assert forecast[ATTR_FORECAST_PRESSURE] is None + assert forecast[ATTR_FORECAST_WIND_SPEED] is None + assert forecast[ATTR_FORECAST_PRECIPITATION] is None diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index f00c20af74aadf..c7ed1a23985988 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -1,8 +1,12 @@ """Test the webhook component.""" from http import HTTPStatus +from ipaddress import ip_address +from unittest.mock import patch +from aiohttp import web import pytest +from homeassistant.components import webhook from homeassistant.config import async_process_ha_core_config from homeassistant.setup import async_setup_component @@ -17,19 +21,19 @@ def mock_client(hass, hass_client): async def test_unregistering_webhook(hass, mock_client): """Test unregistering a webhook.""" hooks = [] - webhook_id = hass.components.webhook.async_generate_id() + webhook_id = webhook.async_generate_id() async def handle(*args): """Handle webhook.""" hooks.append(args) - hass.components.webhook.async_register("test", "Test hook", webhook_id, handle) + webhook.async_register(hass, "test", "Test hook", webhook_id, handle) resp = await mock_client.post(f"/api/webhook/{webhook_id}") assert resp.status == HTTPStatus.OK assert len(hooks) == 1 - hass.components.webhook.async_unregister(webhook_id) + webhook.async_unregister(hass, webhook_id) resp = await mock_client.post(f"/api/webhook/{webhook_id}") assert resp.status == HTTPStatus.OK @@ -42,14 +46,14 @@ async def test_generate_webhook_url(hass): hass, {"external_url": "https://example.com"}, ) - url = hass.components.webhook.async_generate_url("some_id") + url = webhook.async_generate_url(hass, "some_id") assert url == "https://example.com/api/webhook/some_id" async def test_async_generate_path(hass): """Test generating just the path component of the url correctly.""" - path = hass.components.webhook.async_generate_path("some_id") + path = webhook.async_generate_path("some_id") assert path == "/api/webhook/some_id" @@ -61,7 +65,7 @@ async def test_posting_webhook_nonexisting(hass, mock_client): async def test_posting_webhook_invalid_json(hass, mock_client): """Test posting to a nonexisting webhook.""" - hass.components.webhook.async_register("test", "Test hook", "hello", None) + webhook.async_register(hass, "test", "Test hook", "hello", None) resp = await mock_client.post("/api/webhook/hello", data="not-json") assert resp.status == HTTPStatus.OK @@ -69,13 +73,13 @@ async def test_posting_webhook_invalid_json(hass, mock_client): async def test_posting_webhook_json(hass, mock_client): """Test posting a webhook with JSON data.""" hooks = [] - webhook_id = hass.components.webhook.async_generate_id() + webhook_id = webhook.async_generate_id() async def handle(*args): """Handle webhook.""" hooks.append((args[0], args[1], await args[2].text())) - hass.components.webhook.async_register("test", "Test hook", webhook_id, handle) + webhook.async_register(hass, "test", "Test hook", webhook_id, handle) resp = await mock_client.post(f"/api/webhook/{webhook_id}", json={"data": True}) assert resp.status == HTTPStatus.OK @@ -88,13 +92,13 @@ async def handle(*args): async def test_posting_webhook_no_data(hass, mock_client): """Test posting a webhook with no data.""" hooks = [] - webhook_id = hass.components.webhook.async_generate_id() + webhook_id = webhook.async_generate_id() async def handle(*args): """Handle webhook.""" hooks.append(args) - hass.components.webhook.async_register("test", "Test hook", webhook_id, handle) + webhook.async_register(hass, "test", "Test hook", webhook_id, handle) resp = await mock_client.post(f"/api/webhook/{webhook_id}") assert resp.status == HTTPStatus.OK @@ -108,13 +112,13 @@ async def handle(*args): async def test_webhook_put(hass, mock_client): """Test sending a put request to a webhook.""" hooks = [] - webhook_id = hass.components.webhook.async_generate_id() + webhook_id = webhook.async_generate_id() async def handle(*args): """Handle webhook.""" hooks.append(args) - hass.components.webhook.async_register("test", "Test hook", webhook_id, handle) + webhook.async_register(hass, "test", "Test hook", webhook_id, handle) resp = await mock_client.put(f"/api/webhook/{webhook_id}") assert resp.status == HTTPStatus.OK @@ -127,13 +131,13 @@ async def handle(*args): async def test_webhook_head(hass, mock_client): """Test sending a head request to a webhook.""" hooks = [] - webhook_id = hass.components.webhook.async_generate_id() + webhook_id = webhook.async_generate_id() async def handle(*args): """Handle webhook.""" hooks.append(args) - hass.components.webhook.async_register("test", "Test hook", webhook_id, handle) + webhook.async_register(hass, "test", "Test hook", webhook_id, handle) resp = await mock_client.head(f"/api/webhook/{webhook_id}") assert resp.status == HTTPStatus.OK @@ -143,6 +147,37 @@ async def handle(*args): assert hooks[0][2].method == "HEAD" +async def test_webhook_local_only(hass, mock_client): + """Test posting a webhook with local only.""" + hooks = [] + webhook_id = webhook.async_generate_id() + + async def handle(*args): + """Handle webhook.""" + hooks.append((args[0], args[1], await args[2].text())) + + webhook.async_register( + hass, "test", "Test hook", webhook_id, handle, local_only=True + ) + + resp = await mock_client.post(f"/api/webhook/{webhook_id}", json={"data": True}) + assert resp.status == HTTPStatus.OK + assert len(hooks) == 1 + assert hooks[0][0] is hass + assert hooks[0][1] == webhook_id + assert hooks[0][2] == '{"data": true}' + + # Request from remote IP + with patch( + "homeassistant.components.webhook.ip_address", + return_value=ip_address("123.123.123.123"), + ): + resp = await mock_client.post(f"/api/webhook/{webhook_id}", json={"data": True}) + assert resp.status == HTTPStatus.OK + # No hook received + assert len(hooks) == 1 + + async def test_listing_webhook( hass, hass_ws_client, hass_access_token, enable_custom_integrations ): @@ -150,7 +185,8 @@ async def test_listing_webhook( assert await async_setup_component(hass, "webhook", {}) client = await hass_ws_client(hass, hass_access_token) - hass.components.webhook.async_register("test", "Test hook", "my-id", None) + webhook.async_register(hass, "test", "Test hook", "my-id", None) + webhook.async_register(hass, "test", "Test hook", "my-2", None, local_only=True) await client.send_json({"id": 5, "type": "webhook/list"}) @@ -158,5 +194,84 @@ async def test_listing_webhook( assert msg["id"] == 5 assert msg["success"] assert msg["result"] == [ - {"webhook_id": "my-id", "domain": "test", "name": "Test hook"} + { + "webhook_id": "my-id", + "domain": "test", + "name": "Test hook", + "local_only": False, + }, + { + "webhook_id": "my-2", + "domain": "test", + "name": "Test hook", + "local_only": True, + }, ] + + +async def test_ws_webhook(hass, caplog, hass_ws_client): + """Test sending webhook msg via WS API.""" + assert await async_setup_component(hass, "webhook", {}) + + received = [] + + async def handler(hass, webhook_id, request): + """Handle a webhook.""" + received.append(request) + return web.json_response({"from": "handler"}) + + webhook.async_register(hass, "test", "Test", "mock-webhook-id", handler) + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "webhook/handle", + "webhook_id": "mock-webhook-id", + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "body": '{"hello": "world"}', + "query": "a=2", + } + ) + + result = await client.receive_json() + assert result["success"], result + assert result["result"] == { + "status": 200, + "body": '{"from": "handler"}', + "headers": {"Content-Type": "application/json"}, + } + + assert len(received) == 1 + assert received[0].headers["content-type"] == "application/json" + assert received[0].query == {"a": "2"} + assert await received[0].json() == {"hello": "world"} + + # Non existing webhook + caplog.clear() + + await client.send_json( + { + "id": 6, + "type": "webhook/handle", + "webhook_id": "mock-nonexisting-id", + "method": "POST", + "body": '{"nonexisting": "payload"}', + } + ) + + result = await client.receive_json() + assert result["success"], result + assert result["result"] == { + "status": 200, + "body": None, + "headers": {"Content-Type": "application/octet-stream"}, + } + + assert ( + "Received message for unregistered webhook mock-nonexisting-id from webhook/ws" + in caplog.text + ) + assert '{"nonexisting": "payload"}' in caplog.text diff --git a/tests/components/webostv/__init__.py b/tests/components/webostv/__init__.py index adef8e9b86abb2..1cbc72b43fc696 100644 --- a/tests/components/webostv/__init__.py +++ b/tests/components/webostv/__init__.py @@ -1 +1,81 @@ """Tests for the WebOS TV integration.""" +from pickle import dumps +from unittest.mock import patch + +import sqlalchemy as db +from sqlalchemy import create_engine + +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.components.webostv.const import DOMAIN +from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component + +from .const import CLIENT_KEY, FAKE_UUID, HOST, MOCK_CLIENT_KEYS, TV_NAME + +from tests.common import MockConfigEntry + + +async def setup_webostv(hass, unique_id=FAKE_UUID): + """Initialize webostv and media_player for tests.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_CLIENT_SECRET: CLIENT_KEY, + }, + title=TV_NAME, + unique_id=unique_id, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.webostv.read_client_keys", + return_value=MOCK_CLIENT_KEYS, + ): + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_HOST: HOST}}, + ) + await hass.async_block_till_done() + + return entry + + +async def setup_legacy_component(hass, create_entity=True): + """Initialize webostv component with legacy entity.""" + if create_entity: + ent_reg = entity_registry.async_get(hass) + assert ent_reg.async_get_or_create(MP_DOMAIN, DOMAIN, CLIENT_KEY) + + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_HOST: HOST}}, + ) + await hass.async_block_till_done() + + +def create_memory_sqlite_engine(url): + """Create fake db keys file in memory.""" + mem_eng = create_engine("sqlite:///:memory:") + table = db.Table( + "unnamed", + db.MetaData(), + db.Column("key", db.String), + db.Column("value", db.String), + ) + table.create(mem_eng) + query = db.insert(table).values(key=HOST, value=dumps(CLIENT_KEY)) + connection = mem_eng.connect() + connection.execute(query) + return mem_eng + + +def is_entity_unique_id_updated(hass): + """Check if entity has new unique_id from UUID.""" + ent_reg = entity_registry.async_get(hass) + return ent_reg.async_get_entity_id( + MP_DOMAIN, DOMAIN, FAKE_UUID + ) and not ent_reg.async_get_entity_id(MP_DOMAIN, DOMAIN, CLIENT_KEY) diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py new file mode 100644 index 00000000000000..05f1be66d00cb9 --- /dev/null +++ b/tests/components/webostv/conftest.py @@ -0,0 +1,73 @@ +"""Common fixtures and objects for the LG webOS integration tests.""" +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.webostv.const import LIVE_TV_APP_ID +from homeassistant.helpers import entity_registry + +from .const import CHANNEL_1, CHANNEL_2, CLIENT_KEY, FAKE_UUID, MOCK_APPS, MOCK_INPUTS + +from tests.common import async_mock_service + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +@pytest.fixture(name="client") +def client_fixture(): + """Patch of client library for tests.""" + with patch( + "homeassistant.components.webostv.WebOsClient", autospec=True + ) as mock_client_class: + client = mock_client_class.return_value + client.hello_info = {"deviceUUID": FAKE_UUID} + client.software_info = {"major_ver": "major", "minor_ver": "minor"} + client.system_info = {"modelName": "TVFAKE"} + client.client_key = CLIENT_KEY + client.apps = MOCK_APPS + client.inputs = MOCK_INPUTS + client.current_app_id = LIVE_TV_APP_ID + + client.channels = [CHANNEL_1, CHANNEL_2] + client.current_channel = CHANNEL_1 + + client.volume = 37 + client.sound_output = "speaker" + client.muted = False + client.is_on = True + + async def mock_state_update_callback(): + await client.register_state_update_callback.call_args[0][0](client) + + client.mock_state_update = AsyncMock(side_effect=mock_state_update_callback) + + yield client + + +@pytest.fixture(name="client_entity_removed") +def client_entity_removed_fixture(hass): + """Patch of client library, entity removed waiting for connect.""" + with patch( + "homeassistant.components.webostv.WebOsClient", autospec=True + ) as mock_client_class: + client = mock_client_class.return_value + client.hello_info = {"deviceUUID": FAKE_UUID} + client.connected = False + + def mock_is_connected(): + return client.connected + + client.is_connected = Mock(side_effect=mock_is_connected) + + async def mock_connected(): + ent_reg = entity_registry.async_get(hass) + ent_reg.async_remove("media_player.webostv_some_secret") + client.connected = True + + client.connect = AsyncMock(side_effect=mock_connected) + + yield client diff --git a/tests/components/webostv/const.py b/tests/components/webostv/const.py new file mode 100644 index 00000000000000..eca38837d8ef76 --- /dev/null +++ b/tests/components/webostv/const.py @@ -0,0 +1,36 @@ +"""Constants for LG webOS Smart TV tests.""" +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.components.webostv.const import LIVE_TV_APP_ID + +FAKE_UUID = "some-fake-uuid" +TV_NAME = "fake_webos" +ENTITY_ID = f"{MP_DOMAIN}.{TV_NAME}" +HOST = "1.2.3.4" +CLIENT_KEY = "some-secret" +MOCK_CLIENT_KEYS = {HOST: CLIENT_KEY} +MOCK_JSON = '{"1.2.3.4": "some-secret"}' + +CHANNEL_1 = { + "channelNumber": "1", + "channelName": "Channel 1", + "channelId": "ch1id", +} +CHANNEL_2 = { + "channelNumber": "20", + "channelName": "Channel Name 2", + "channelId": "ch2id", +} + +MOCK_APPS = { + LIVE_TV_APP_ID: { + "title": "Live TV", + "id": LIVE_TV_APP_ID, + "largeIcon": "large-icon", + "icon": "icon", + }, +} + +MOCK_INPUTS = { + "in1": {"label": "Input01", "id": "in1", "appId": "app0"}, + "in2": {"label": "Input02", "id": "in2", "appId": "app1"}, +} diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py new file mode 100644 index 00000000000000..b2b20677513a42 --- /dev/null +++ b/tests/components/webostv/test_config_flow.py @@ -0,0 +1,366 @@ +"""Test the WebOS Tv config flow.""" +import dataclasses +from unittest.mock import Mock, patch + +from aiowebostv import WebOsTvPairError +import pytest + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID +from homeassistant.config_entries import SOURCE_SSDP +from homeassistant.const import ( + CONF_CLIENT_SECRET, + CONF_CUSTOMIZE, + CONF_HOST, + CONF_ICON, + CONF_NAME, + CONF_SOURCE, + CONF_UNIQUE_ID, +) +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import setup_webostv +from .const import CLIENT_KEY, FAKE_UUID, HOST, MOCK_APPS, MOCK_INPUTS, TV_NAME + +MOCK_YAML_CONFIG = { + CONF_HOST: HOST, + CONF_NAME: TV_NAME, + CONF_ICON: "mdi:test", + CONF_CLIENT_SECRET: CLIENT_KEY, + CONF_UNIQUE_ID: FAKE_UUID, +} + +MOCK_DISCOVERY_INFO = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location=f"http://{HOST}", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "LG Webostv", + ssdp.ATTR_UPNP_UDN: f"uuid:{FAKE_UUID}", + }, +) + + +async def test_import(hass, client): + """Test we can import yaml config.""" + assert client + + with patch("homeassistant.components.webostv.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_IMPORT}, + data=MOCK_YAML_CONFIG, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TV_NAME + assert result["data"][CONF_HOST] == MOCK_YAML_CONFIG[CONF_HOST] + assert result["data"][CONF_CLIENT_SECRET] == MOCK_YAML_CONFIG[CONF_CLIENT_SECRET] + assert result["result"].unique_id == MOCK_YAML_CONFIG[CONF_UNIQUE_ID] + + with patch("homeassistant.components.webostv.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_IMPORT}, + data=MOCK_YAML_CONFIG, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "sources", + [ + ["Live TV", "Input01", "Input02"], + "Live TV, Input01 , Input02", + "Live TV,Input01 ,Input02", + ], +) +async def test_import_sources(hass, client, sources): + """Test import yaml config with sources list/csv.""" + assert client + + with patch("homeassistant.components.webostv.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_IMPORT}, + data={ + **MOCK_YAML_CONFIG, + CONF_CUSTOMIZE: { + CONF_SOURCES: sources, + }, + }, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TV_NAME + assert result["data"][CONF_HOST] == MOCK_YAML_CONFIG[CONF_HOST] + assert result["data"][CONF_CLIENT_SECRET] == MOCK_YAML_CONFIG[CONF_CLIENT_SECRET] + assert result["options"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] + assert result["result"].unique_id == MOCK_YAML_CONFIG[CONF_UNIQUE_ID] + + +async def test_form(hass, client): + """Test we get the form.""" + assert client + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + data=MOCK_YAML_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + data=MOCK_YAML_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "pairing" + + with patch("homeassistant.components.webostv.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TV_NAME + + +@pytest.mark.parametrize( + "apps, inputs", + [ + # Live TV in apps (default) + (MOCK_APPS, MOCK_INPUTS), + # Live TV in inputs + ( + {}, + { + **MOCK_INPUTS, + "livetv": {"label": "Live TV", "id": "livetv", "appId": LIVE_TV_APP_ID}, + }, + ), + # Live TV not found + ({}, MOCK_INPUTS), + ], +) +async def test_options_flow_live_tv_in_apps(hass, client, apps, inputs): + """Test options config flow Live TV found in apps.""" + client.apps = apps + client.inputs = inputs + entry = await setup_webostv(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SOURCES: ["Live TV", "Input01", "Input02"]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"][CONF_SOURCES] == ["Live TV", "Input01", "Input02"] + + +async def test_options_flow_cannot_retrieve(hass, client): + """Test options config flow cannot retrieve sources.""" + entry = await setup_webostv(hass) + + client.connect = Mock(side_effect=ConnectionRefusedError()) + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_retrieve"} + + +async def test_form_cannot_connect(hass, client): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + data=MOCK_YAML_CONFIG, + ) + + client.connect = Mock(side_effect=ConnectionRefusedError()) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_pairexception(hass, client): + """Test pairing exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + data=MOCK_YAML_CONFIG, + ) + + client.connect = Mock(side_effect=WebOsTvPairError("error")) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "error_pairing" + + +async def test_entry_already_configured(hass, client): + """Test entry already configured.""" + await setup_webostv(hass) + assert client + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + data=MOCK_YAML_CONFIG, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_form_ssdp(hass, client): + """Test that the ssdp confirmation form is served.""" + assert client + + with patch("homeassistant.components.webostv.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "pairing" + + +async def test_ssdp_in_progress(hass, client): + """Test abort if ssdp paring is already in progress.""" + assert client + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + data=MOCK_YAML_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "pairing" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_in_progress" + + +async def test_ssdp_update_uuid(hass, client): + """Test that ssdp updates existing host entry uuid.""" + entry = await setup_webostv(hass, None) + assert client + assert entry.unique_id is None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.unique_id == MOCK_DISCOVERY_INFO[ssdp.ATTR_UPNP_UDN][5:] + + +async def test_ssdp_not_update_uuid(hass, client): + """Test that ssdp not updates different host.""" + entry = await setup_webostv(hass, None) + assert client + assert entry.unique_id is None + + discovery_info = dataclasses.replace(MOCK_DISCOVERY_INFO) + discovery_info.ssdp_location = "http://1.2.3.5" + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "pairing" + assert entry.unique_id is None + + +async def test_form_abort_uuid_configured(hass, client): + """Test abort if uuid is already configured, verify host update.""" + entry = await setup_webostv(hass, MOCK_DISCOVERY_INFO[ssdp.ATTR_UPNP_UDN][5:]) + assert client + assert entry.unique_id == MOCK_DISCOVERY_INFO[ssdp.ATTR_UPNP_UDN][5:] + assert entry.data[CONF_HOST] == HOST + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + user_config = { + CONF_HOST: "new_host", + CONF_NAME: TV_NAME, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + data=user_config, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == "new_host" diff --git a/tests/components/webostv/test_device_trigger.py b/tests/components/webostv/test_device_trigger.py new file mode 100644 index 00000000000000..fb8512d56f13c6 --- /dev/null +++ b/tests/components/webostv/test_device_trigger.py @@ -0,0 +1,175 @@ +"""The tests for WebOS TV device triggers.""" +import pytest + +from homeassistant.components import automation +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.webostv import DOMAIN, device_trigger +from homeassistant.config_entries import ConfigEntryState +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import async_get as get_dev_reg +from homeassistant.setup import async_setup_component + +from . import setup_webostv +from .const import ENTITY_ID, FAKE_UUID + +from tests.common import MockConfigEntry, async_get_device_automations + + +async def test_get_triggers(hass, client): + """Test we get the expected triggers.""" + await setup_webostv(hass) + + device_reg = get_dev_reg(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN, FAKE_UUID)}) + + turn_on_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "webostv.turn_on", + "device_id": device.id, + } + + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert turn_on_trigger in triggers + + +async def test_if_fires_on_turn_on_request(hass, calls, client): + """Test for turn_on and turn_off triggers firing.""" + await setup_webostv(hass) + + device_reg = get_dev_reg(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN, FAKE_UUID)}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "webostv.turn_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.device_id }}", + "id": "{{ trigger.id }}", + }, + }, + }, + { + "trigger": { + "platform": "webostv.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == device.id + assert calls[0].data["id"] == 0 + assert calls[1].data["some"] == ENTITY_ID + assert calls[1].data["id"] == 0 + + +async def test_get_triggers_for_invalid_device_id(hass, caplog): + """Test error raised for invalid shelly device_id.""" + await async_setup_component(hass, "persistent_notification", {}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "invalid_device_id", + "type": "webostv.turn_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.invalid_device }}", + "id": "{{ trigger.id }}", + }, + }, + } + ] + }, + ) + await hass.async_block_till_done() + + assert ( + "Invalid config for [automation]: Device invalid_device_id is not a valid webostv device" + in caplog.text + ) + + +async def test_failure_scenarios(hass, client): + """Test failure scenarios.""" + await setup_webostv(hass) + + # Test wrong trigger platform type + with pytest.raises(HomeAssistantError): + await device_trigger.async_attach_trigger( + hass, {"type": "wrong.type", "device_id": "invalid_device_id"}, None, {} + ) + + # Test invalid device id + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config( + hass, + { + "platform": "device", + "domain": DOMAIN, + "type": "webostv.turn_on", + "device_id": "invalid_device_id", + }, + ) + + entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) + entry.add_to_hass(hass) + device_reg = get_dev_reg(hass) + + device = device_reg.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("fake", "fake")} + ) + + config = { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "webostv.turn_on", + } + + # Test that device id from non webostv domain raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config(hass, config) + + # Test no exception if device is not loaded + await hass.config_entries.async_unload(entry.entry_id) + assert await device_trigger.async_validate_trigger_config(hass, config) == config diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py new file mode 100644 index 00000000000000..8729576d86957b --- /dev/null +++ b/tests/components/webostv/test_init.py @@ -0,0 +1,141 @@ +"""The tests for the WebOS TV platform.""" + +from unittest.mock import Mock, mock_open, patch + +from aiowebostv import WebOsTvPairError + +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.components.webostv import DOMAIN + +from . import ( + create_memory_sqlite_engine, + is_entity_unique_id_updated, + setup_legacy_component, +) +from .const import MOCK_JSON + + +async def test_missing_keys_file_abort(hass, client, caplog): + """Test abort import when no pairing keys file.""" + with patch( + "homeassistant.components.webostv.os.path.isfile", Mock(return_value=False) + ): + await setup_legacy_component(hass) + + assert "No pairing keys, Not importing" in caplog.text + assert not is_entity_unique_id_updated(hass) + + +async def test_empty_json_abort(hass, client, caplog): + """Test abort import when keys file is empty.""" + m_open = mock_open(read_data="[]") + + with patch( + "homeassistant.components.webostv.os.path.isfile", Mock(return_value=True) + ), patch("homeassistant.components.webostv.open", m_open, create=True): + await setup_legacy_component(hass) + + assert "No pairing keys, Not importing" in caplog.text + assert not is_entity_unique_id_updated(hass) + + +async def test_valid_json_migrate_not_needed(hass, client, caplog): + """Test import from valid json entity already migrated or removed.""" + m_open = mock_open(read_data=MOCK_JSON) + + with patch( + "homeassistant.components.webostv.os.path.isfile", Mock(return_value=True) + ), patch("homeassistant.components.webostv.open", m_open, create=True): + await setup_legacy_component(hass, False) + + assert "Migrating webOS Smart TV entity" not in caplog.text + assert not is_entity_unique_id_updated(hass) + + +async def test_valid_json_missing_host_key(hass, client, caplog): + """Test import from valid json missing host key.""" + m_open = mock_open(read_data='{"1.2.3.5": "other-key"}') + + with patch( + "homeassistant.components.webostv.os.path.isfile", Mock(return_value=True) + ), patch("homeassistant.components.webostv.open", m_open, create=True): + await setup_legacy_component(hass) + + assert "Not importing webOS Smart TV host" in caplog.text + assert not is_entity_unique_id_updated(hass) + + +async def test_not_connected_import(hass, client, caplog, monkeypatch): + """Test import while device is not connected.""" + m_open = mock_open(read_data=MOCK_JSON) + monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) + monkeypatch.setattr(client, "connect", Mock(side_effect=OSError)) + + with patch( + "homeassistant.components.webostv.os.path.isfile", Mock(return_value=True) + ), patch("homeassistant.components.webostv.open", m_open, create=True): + await setup_legacy_component(hass) + + assert f"Please make sure webOS TV {MP_DOMAIN}.{DOMAIN}" in caplog.text + assert not is_entity_unique_id_updated(hass) + + +async def test_pair_error_import_abort(hass, client, caplog, monkeypatch): + """Test abort import if device is not paired.""" + m_open = mock_open(read_data=MOCK_JSON) + monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) + monkeypatch.setattr(client, "connect", Mock(side_effect=WebOsTvPairError)) + + with patch( + "homeassistant.components.webostv.os.path.isfile", Mock(return_value=True) + ), patch("homeassistant.components.webostv.open", m_open, create=True): + await setup_legacy_component(hass) + + assert f"Please make sure webOS TV {MP_DOMAIN}.{DOMAIN}" not in caplog.text + assert not is_entity_unique_id_updated(hass) + + +async def test_entity_removed_import_abort(hass, client_entity_removed, caplog): + """Test abort import if entity removed by user during import.""" + m_open = mock_open(read_data=MOCK_JSON) + + with patch( + "homeassistant.components.webostv.os.path.isfile", Mock(return_value=True) + ), patch("homeassistant.components.webostv.open", m_open, create=True): + await setup_legacy_component(hass) + + assert "Not updating webOSTV Smart TV entity" in caplog.text + assert not is_entity_unique_id_updated(hass) + + +async def test_json_import(hass, client, caplog, monkeypatch): + """Test import from json keys file.""" + m_open = mock_open(read_data=MOCK_JSON) + monkeypatch.setattr(client, "is_connected", Mock(return_value=True)) + monkeypatch.setattr(client, "connect", Mock(return_value=True)) + + with patch( + "homeassistant.components.webostv.os.path.isfile", Mock(return_value=True) + ), patch("homeassistant.components.webostv.open", m_open, create=True): + await setup_legacy_component(hass) + + assert "imported from YAML config" in caplog.text + assert is_entity_unique_id_updated(hass) + + +async def test_sqlite_import(hass, client, caplog, monkeypatch): + """Test import from sqlite keys file.""" + m_open = mock_open(read_data="will raise JSONDecodeError") + monkeypatch.setattr(client, "is_connected", Mock(return_value=True)) + monkeypatch.setattr(client, "connect", Mock(return_value=True)) + + with patch( + "homeassistant.components.webostv.os.path.isfile", Mock(return_value=True) + ), patch("homeassistant.components.webostv.open", m_open, create=True), patch( + "homeassistant.components.webostv.db.create_engine", + side_effect=create_memory_sqlite_engine, + ): + await setup_legacy_component(hass) + + assert "imported from YAML config" in caplog.text + assert is_entity_unique_id_updated(hass) diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 716c496d88a87f..c249b491d9a8f7 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -1,110 +1,223 @@ """The tests for the LG webOS media player platform.""" -import json -import os -from unittest.mock import patch +import asyncio +from datetime import timedelta +from unittest.mock import Mock import pytest -from sqlitedict import SqliteDict -from homeassistant.components import media_player +from homeassistant.components import automation +from homeassistant.components.media_player import ( + DOMAIN as MP_DOMAIN, + MediaPlayerDeviceClass, +) from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, + MEDIA_TYPE_CHANNEL, + SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, ) from homeassistant.components.webostv.const import ( ATTR_BUTTON, ATTR_PAYLOAD, + ATTR_SOUND_OUTPUT, DOMAIN, + LIVE_TV_APP_ID, SERVICE_BUTTON, SERVICE_COMMAND, - WEBOSTV_CONFIG_FILE, + SERVICE_SELECT_SOUND_OUTPUT, + WebOsTvCommandError, +) +from homeassistant.components.webostv.media_player import ( + SUPPORT_WEBOSTV, + SUPPORT_WEBOSTV_VOLUME, ) from homeassistant.const import ( ATTR_COMMAND, + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - CONF_HOST, - CONF_NAME, + ATTR_SUPPORTED_FEATURES, + ENTITY_MATCH_NONE, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PLAY_PAUSE, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_TURN_OFF, + SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_OFF, + STATE_ON, ) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component +from homeassistant.util import dt + +from . import setup_webostv +from .const import CHANNEL_2, ENTITY_ID, TV_NAME + +from tests.common import async_fire_time_changed -NAME = "fake" -ENTITY_ID = f"{media_player.DOMAIN}.{NAME}" +@pytest.mark.parametrize( + "service, attr_data, client_call", + [ + (SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}, ("set_mute", True)), + (SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: False}, ("set_mute", False)), + (SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 1.00}, ("set_volume", 100)), + (SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0.54}, ("set_volume", 54)), + (SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0.0}, ("set_volume", 0)), + ], +) +async def test_services_with_parameters(hass, client, service, attr_data, client_call): + """Test services that has parameters in calls.""" + await setup_webostv(hass) -@pytest.fixture(name="client") -def client_fixture(): - """Patch of client library for tests.""" - with patch( - "homeassistant.components.webostv.WebOsClient", autospec=True - ) as mock_client_class: - mock_client_class.create.return_value = mock_client_class.return_value - client = mock_client_class.return_value - client.software_info = {"device_id": "a1:b1:c1:d1:e1:f1"} - client.client_key = "0123456789" - yield client + data = {ATTR_ENTITY_ID: ENTITY_ID, **attr_data} + assert await hass.services.async_call(MP_DOMAIN, service, data, True) + getattr(client, client_call[0]).assert_called_once_with(client_call[1]) -async def setup_webostv(hass): - """Initialize webostv and media_player for tests.""" - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_HOST: "fake", CONF_NAME: NAME}}, + +@pytest.mark.parametrize( + "service, client_call", + [ + (SERVICE_TURN_OFF, "power_off"), + (SERVICE_VOLUME_UP, "volume_up"), + (SERVICE_VOLUME_DOWN, "volume_down"), + (SERVICE_MEDIA_PLAY, "play"), + (SERVICE_MEDIA_PAUSE, "pause"), + (SERVICE_MEDIA_STOP, "stop"), + ], +) +async def test_services(hass, client, service, client_call): + """Test simple services without parameters.""" + await setup_webostv(hass) + + data = {ATTR_ENTITY_ID: ENTITY_ID} + assert await hass.services.async_call(MP_DOMAIN, service, data, True) + + getattr(client, client_call).assert_called_once() + + +async def test_media_play_pause(hass, client): + """Test media play pause service.""" + await setup_webostv(hass) + + data = {ATTR_ENTITY_ID: ENTITY_ID} + + # After init state is playing - check pause call + assert await hass.services.async_call( + MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data, True ) - await hass.async_block_till_done() + client.pause.assert_called_once() + client.play.assert_not_called() -@pytest.fixture -def cleanup_config(hass): - """Test cleanup, remove the config file.""" - yield - os.remove(hass.config.path(WEBOSTV_CONFIG_FILE)) + # After pause state is paused - check play call + assert await hass.services.async_call( + MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data, True + ) + client.play.assert_called_once() + client.pause.assert_called_once() -async def test_mute(hass, client): - """Test simple service call.""" +@pytest.mark.parametrize( + "service, client_call", + [ + (SERVICE_MEDIA_NEXT_TRACK, ("fast_forward", "channel_up")), + (SERVICE_MEDIA_PREVIOUS_TRACK, ("rewind", "channel_down")), + ], +) +async def test_media_next_previous_track( + hass, client, service, client_call, monkeypatch +): + """Test media next/previous track services.""" await setup_webostv(hass) + # check channel up/down for live TV channels + data = {ATTR_ENTITY_ID: ENTITY_ID} + assert await hass.services.async_call(MP_DOMAIN, service, data, True) + + getattr(client, client_call[0]).assert_not_called() + getattr(client, client_call[1]).assert_called_once() + + # check next/previous for not Live TV channels + monkeypatch.setattr(client, "current_app_id", "in1") + data = {ATTR_ENTITY_ID: ENTITY_ID} + assert await hass.services.async_call(MP_DOMAIN, service, data, True) + + getattr(client, client_call[0]).assert_called_once() + getattr(client, client_call[1]).assert_called_once() + + +async def test_select_source_with_empty_source_list(hass, client, caplog): + """Ensure we don't call client methods when we don't have sources.""" + await setup_webostv(hass) + await client.mock_state_update() + data = { ATTR_ENTITY_ID: ENTITY_ID, - ATTR_MEDIA_VOLUME_MUTED: True, + ATTR_INPUT_SOURCE: "nonexistent", } - await hass.services.async_call(media_player.DOMAIN, SERVICE_VOLUME_MUTE, data) - await hass.async_block_till_done() + assert await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) - client.set_mute.assert_called_once() + client.launch_app.assert_not_called() + client.set_input.assert_not_called() + assert f"Source nonexistent not found for {TV_NAME}" in caplog.text -async def test_select_source_with_empty_source_list(hass, client): - """Ensure we don't call client methods when we don't have sources.""" +async def test_select_app_source(hass, client): + """Test select app source.""" + await setup_webostv(hass) + await client.mock_state_update() + + data = { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_INPUT_SOURCE: "Live TV", + } + assert await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) + client.launch_app.assert_called_once_with(LIVE_TV_APP_ID) + client.set_input.assert_not_called() + + +async def test_select_input_source(hass, client): + """Test select input source.""" await setup_webostv(hass) + await client.mock_state_update() data = { ATTR_ENTITY_ID: ENTITY_ID, - ATTR_INPUT_SOURCE: "nonexistent", + ATTR_INPUT_SOURCE: "Input01", } - await hass.services.async_call(media_player.DOMAIN, SERVICE_SELECT_SOURCE, data) - await hass.async_block_till_done() + assert await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data, True) client.launch_app.assert_not_called() - client.set_input.assert_not_called() + client.set_input.assert_called_once_with("in1") async def test_button(hass, client): """Test generic button functionality.""" - await setup_webostv(hass) data = { ATTR_ENTITY_ID: ENTITY_ID, ATTR_BUTTON: "test", } - await hass.services.async_call(DOMAIN, SERVICE_BUTTON, data) - await hass.async_block_till_done() + assert await hass.services.async_call(DOMAIN, SERVICE_BUTTON, data, True) client.button.assert_called_once() client.button.assert_called_with("test") @@ -118,8 +231,7 @@ async def test_command(hass, client): ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: "test", } - await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data) - await hass.async_block_till_done() + assert await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data, True) client.request.assert_called_with("test", payload=None) @@ -133,43 +245,314 @@ async def test_command_with_optional_arg(hass, client): ATTR_COMMAND: "test", ATTR_PAYLOAD: {"target": "https://www.google.com"}, } - await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data) - await hass.async_block_till_done() + assert await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data, True) client.request.assert_called_with( "test", payload={"target": "https://www.google.com"} ) -async def test_migrate_keyfile_to_sqlite(hass, client, cleanup_config): - """Test migration from JSON key-file to Sqlite based one.""" - key = "3d5b1aeeb98e" - # Create config file with JSON content - config_file = hass.config.path(WEBOSTV_CONFIG_FILE) - with open(config_file, "w+") as file: - json.dump({"host": key}, file) +async def test_select_sound_output(hass, client): + """Test select sound output service.""" + await setup_webostv(hass) + + data = { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_SOUND_OUTPUT: "external_speaker", + } + assert await hass.services.async_call( + DOMAIN, SERVICE_SELECT_SOUND_OUTPUT, data, True + ) + + client.change_sound_output.assert_called_once_with("external_speaker") + + +async def test_device_info_startup_off(hass, client, monkeypatch): + """Test device info when device is off at startup.""" + monkeypatch.setattr(client, "system_info", None) + monkeypatch.setattr(client, "is_on", False) + entry = await setup_webostv(hass) + await client.mock_state_update() + + assert hass.states.get(ENTITY_ID).state == STATE_OFF + + device_reg = device_registry.async_get(hass) + device = device_reg.async_get_device({(DOMAIN, entry.unique_id)}) + + assert device + assert device.identifiers == {(DOMAIN, entry.unique_id)} + assert device.manufacturer == "LG" + assert device.name == TV_NAME + assert device.sw_version is None + assert device.model is None + - # Run the component setup +async def test_entity_attributes(hass, client, monkeypatch): + """Test entity attributes.""" + entry = await setup_webostv(hass) + await client.mock_state_update() + + # Attributes when device is on + state = hass.states.get(ENTITY_ID) + attrs = state.attributes + + assert state.state == STATE_ON + assert state.name == TV_NAME + assert attrs[ATTR_DEVICE_CLASS] == MediaPlayerDeviceClass.TV + assert attrs[ATTR_MEDIA_VOLUME_MUTED] is False + assert attrs[ATTR_MEDIA_VOLUME_LEVEL] == 0.37 + assert attrs[ATTR_INPUT_SOURCE] == "Live TV" + assert attrs[ATTR_INPUT_SOURCE_LIST] == ["Input01", "Input02", "Live TV"] + assert attrs[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_CHANNEL + assert attrs[ATTR_MEDIA_TITLE] == "Channel 1" + assert attrs[ATTR_SOUND_OUTPUT] == "speaker" + + # Volume level not available + monkeypatch.setattr(client, "volume", None) + await client.mock_state_update() + attrs = hass.states.get(ENTITY_ID).attributes + + assert attrs.get(ATTR_MEDIA_VOLUME_LEVEL) is None + + # Channel change + monkeypatch.setattr(client, "current_channel", CHANNEL_2) + await client.mock_state_update() + attrs = hass.states.get(ENTITY_ID).attributes + + assert attrs[ATTR_MEDIA_TITLE] == "Channel Name 2" + + # Device Info + device_reg = device_registry.async_get(hass) + device = device_reg.async_get_device({(DOMAIN, entry.unique_id)}) + + assert device + assert device.identifiers == {(DOMAIN, entry.unique_id)} + assert device.manufacturer == "LG" + assert device.name == TV_NAME + assert device.sw_version == "major.minor" + assert device.model == "TVFAKE" + + # Sound output when off + monkeypatch.setattr(client, "sound_output", None) + monkeypatch.setattr(client, "is_on", False) + await client.mock_state_update() + state = hass.states.get(ENTITY_ID) + + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_SOUND_OUTPUT) is None + + +async def test_service_entity_id_none(hass, client): + """Test service call with none as entity id.""" await setup_webostv(hass) - # Assert that the config file is a Sqlite database which contains the key - with SqliteDict(config_file) as conf: - assert conf.get("host") == key + data = { + ATTR_ENTITY_ID: ENTITY_MATCH_NONE, + ATTR_SOUND_OUTPUT: "external_speaker", + } + assert await hass.services.async_call( + DOMAIN, SERVICE_SELECT_SOUND_OUTPUT, data, True + ) + client.change_sound_output.assert_not_called() -async def test_dont_migrate_sqlite_keyfile(hass, client, cleanup_config): - """Test that migration is not performed and setup still succeeds when config file is already an Sqlite DB.""" - key = "3d5b1aeeb98e" - # Create config file with Sqlite DB - config_file = hass.config.path(WEBOSTV_CONFIG_FILE) - with SqliteDict(config_file) as conf: - conf["host"] = key - conf.commit() +@pytest.mark.parametrize( + "media_id, ch_id", + [ + ("Channel 1", "ch1id"), # Perfect Match by channel name + ("Name 2", "ch2id"), # Partial Match by channel name + ("20", "ch2id"), # Perfect Match by channel number + ], +) +async def test_play_media(hass, client, media_id, ch_id): + """Test play media service.""" + await setup_webostv(hass) + await client.mock_state_update() + + data = { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_CHANNEL, + ATTR_MEDIA_CONTENT_ID: media_id, + } + assert await hass.services.async_call(MP_DOMAIN, SERVICE_PLAY_MEDIA, data, True) + + client.set_channel.assert_called_once_with(ch_id) - # Run the component setup + +async def test_update_sources_live_tv_find(hass, client, monkeypatch): + """Test finding live TV app id in update sources.""" await setup_webostv(hass) + await client.mock_state_update() + + # Live TV found in app list + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + + assert "Live TV" in sources + assert len(sources) == 3 + + # Live TV is current app + apps = { + LIVE_TV_APP_ID: { + "title": "Live TV", + "id": "some_id", + }, + } + monkeypatch.setattr(client, "apps", apps) + monkeypatch.setattr(client, "current_app_id", "some_id") + await client.mock_state_update() + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + + assert "Live TV" in sources + assert len(sources) == 3 + + # Live TV is is in inputs + inputs = { + LIVE_TV_APP_ID: { + "label": "Live TV", + "id": "some_id", + "appId": LIVE_TV_APP_ID, + }, + } + monkeypatch.setattr(client, "inputs", inputs) + await client.mock_state_update() + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + + assert "Live TV" in sources + assert len(sources) == 1 + + # Live TV is current input + inputs = { + LIVE_TV_APP_ID: { + "label": "Live TV", + "id": "some_id", + "appId": "some_id", + }, + } + monkeypatch.setattr(client, "inputs", inputs) + await client.mock_state_update() + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + + assert "Live TV" in sources + assert len(sources) == 1 + + # Live TV not found + monkeypatch.setattr(client, "current_app_id", "other_id") + await client.mock_state_update() + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + + assert "Live TV" in sources + assert len(sources) == 1 + + # Live TV not found in sources/apps but is current app + monkeypatch.setattr(client, "apps", {}) + monkeypatch.setattr(client, "current_app_id", LIVE_TV_APP_ID) + await client.mock_state_update() + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + + assert "Live TV" in sources + assert len(sources) == 1 + + # Bad update, keep old update + monkeypatch.setattr(client, "inputs", {}) + await client.mock_state_update() + sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] + + assert "Live TV" in sources + assert len(sources) == 1 + + +async def test_client_disconnected(hass, client, monkeypatch): + """Test error not raised when client is disconnected.""" + await setup_webostv(hass) + monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) + monkeypatch.setattr(client, "connect", Mock(side_effect=asyncio.TimeoutError)) + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=20)) + await hass.async_block_till_done() + + +async def test_control_error_handling(hass, client, caplog, monkeypatch): + """Test control errors handling.""" + await setup_webostv(hass) + monkeypatch.setattr(client, "play", Mock(side_effect=WebOsTvCommandError)) + data = {ATTR_ENTITY_ID: ENTITY_ID} + + # Device on, raise HomeAssistantError + with pytest.raises(HomeAssistantError) as exc: + assert await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) + + assert ( + str(exc.value) + == f"Error calling async_media_play on entity {ENTITY_ID}, state:on" + ) + assert client.play.call_count == 1 + + # Device off, log a warning + monkeypatch.setattr(client, "is_on", False) + monkeypatch.setattr(client, "play", Mock(side_effect=asyncio.TimeoutError)) + await client.mock_state_update() + assert await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) + + assert client.play.call_count == 1 + assert ( + f"Error calling async_media_play on entity {ENTITY_ID}, state:off, error: TimeoutError()" + in caplog.text + ) + + +async def test_supported_features(hass, client, monkeypatch): + """Test test supported features.""" + monkeypatch.setattr(client, "sound_output", "lineout") + await setup_webostv(hass) + await client.mock_state_update() + + # No sound control support + supported = SUPPORT_WEBOSTV + attrs = hass.states.get(ENTITY_ID).attributes + + assert attrs[ATTR_SUPPORTED_FEATURES] == supported + + # Support volume mute, step + monkeypatch.setattr(client, "sound_output", "external_speaker") + await client.mock_state_update() + supported = supported | SUPPORT_WEBOSTV_VOLUME + attrs = hass.states.get(ENTITY_ID).attributes + + assert attrs[ATTR_SUPPORTED_FEATURES] == supported + + # Support volume mute, step, set + monkeypatch.setattr(client, "sound_output", "speaker") + await client.mock_state_update() + supported = supported | SUPPORT_WEBOSTV_VOLUME | SUPPORT_VOLUME_SET + attrs = hass.states.get(ENTITY_ID).attributes + + assert attrs[ATTR_SUPPORTED_FEATURES] == supported + + # Support turn on + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "webostv.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + supported |= SUPPORT_TURN_ON + await client.mock_state_update() + attrs = hass.states.get(ENTITY_ID).attributes - # Assert that the config file is still an Sqlite database and setup didn't fail - with SqliteDict(config_file) as conf: - assert conf.get("host") == key + assert attrs[ATTR_SUPPORTED_FEATURES] == supported diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py new file mode 100644 index 00000000000000..a518854573762c --- /dev/null +++ b/tests/components/webostv/test_notify.py @@ -0,0 +1,127 @@ +"""The tests for the WebOS TV notify platform.""" +from unittest.mock import Mock, call + +from aiowebostv import WebOsTvPairError +import pytest + +from homeassistant.components.notify import ATTR_MESSAGE, DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.webostv import DOMAIN +from homeassistant.const import CONF_ICON, CONF_SERVICE_DATA +from homeassistant.setup import async_setup_component + +from . import setup_webostv +from .const import TV_NAME + +ICON_PATH = "/some/path" +MESSAGE = "one, two, testing, testing" + + +async def test_notify(hass, client): + """Test sending a message.""" + await setup_webostv(hass) + assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + + await hass.services.async_call( + NOTIFY_DOMAIN, + TV_NAME, + { + ATTR_MESSAGE: MESSAGE, + CONF_SERVICE_DATA: { + CONF_ICON: ICON_PATH, + }, + }, + blocking=True, + ) + assert client.mock_calls[0] == call.connect() + assert client.connect.call_count == 1 + client.send_message.assert_called_with(MESSAGE, icon_path=ICON_PATH) + + +async def test_notify_not_connected(hass, client, monkeypatch): + """Test sending a message when client is not connected.""" + await setup_webostv(hass) + assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + + monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) + await hass.services.async_call( + NOTIFY_DOMAIN, + TV_NAME, + { + ATTR_MESSAGE: MESSAGE, + CONF_SERVICE_DATA: { + CONF_ICON: ICON_PATH, + }, + }, + blocking=True, + ) + assert client.mock_calls[0] == call.connect() + assert client.connect.call_count == 2 + client.send_message.assert_called_with(MESSAGE, icon_path=ICON_PATH) + + +async def test_icon_not_found(hass, caplog, client, monkeypatch): + """Test notify icon not found error.""" + await setup_webostv(hass) + assert hass.services.has_service(NOTIFY_DOMAIN, TV_NAME) + + monkeypatch.setattr(client, "send_message", Mock(side_effect=FileNotFoundError)) + await hass.services.async_call( + NOTIFY_DOMAIN, + TV_NAME, + { + ATTR_MESSAGE: MESSAGE, + CONF_SERVICE_DATA: { + CONF_ICON: ICON_PATH, + }, + }, + blocking=True, + ) + assert client.mock_calls[0] == call.connect() + assert client.connect.call_count == 1 + client.send_message.assert_called_with(MESSAGE, icon_path=ICON_PATH) + assert f"Icon {ICON_PATH} not found" in caplog.text + + +@pytest.mark.parametrize( + "side_effect,error", + [ + (WebOsTvPairError, "Pairing with TV failed"), + (ConnectionRefusedError, "TV unreachable"), + ], +) +async def test_connection_errors(hass, caplog, client, monkeypatch, side_effect, error): + """Test connection errors scenarios.""" + await setup_webostv(hass) + assert hass.services.has_service("notify", TV_NAME) + + monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) + monkeypatch.setattr(client, "connect", Mock(side_effect=side_effect)) + await hass.services.async_call( + NOTIFY_DOMAIN, + TV_NAME, + { + ATTR_MESSAGE: MESSAGE, + CONF_SERVICE_DATA: { + CONF_ICON: ICON_PATH, + }, + }, + blocking=True, + ) + assert client.mock_calls[0] == call.connect() + assert client.connect.call_count == 1 + client.send_message.assert_not_called() + assert error in caplog.text + + +async def test_no_discovery_info(hass, caplog): + """Test setup without discovery info.""" + assert NOTIFY_DOMAIN not in hass.config.components + assert await async_setup_component( + hass, + NOTIFY_DOMAIN, + {"notify": {"platform": DOMAIN}}, + ) + await hass.async_block_till_done() + assert NOTIFY_DOMAIN in hass.config.components + assert f"Failed to initialize notification service {DOMAIN}" in caplog.text + assert not hass.services.has_service("notify", TV_NAME) diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py new file mode 100644 index 00000000000000..cbc72638ad98b3 --- /dev/null +++ b/tests/components/webostv/test_trigger.py @@ -0,0 +1,178 @@ +"""The tests for WebOS TV automation triggers.""" +from unittest.mock import patch + +from homeassistant.components import automation +from homeassistant.components.webostv import DOMAIN +from homeassistant.const import SERVICE_RELOAD +from homeassistant.helpers.device_registry import async_get as get_dev_reg +from homeassistant.setup import async_setup_component + +from . import setup_webostv +from .const import ENTITY_ID, FAKE_UUID + +from tests.common import MockEntity, MockEntityPlatform + + +async def test_webostv_turn_on_trigger_device_id(hass, calls, client): + """Test for turn_on triggers by device_id firing.""" + await setup_webostv(hass) + + device_reg = get_dev_reg(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN, FAKE_UUID)}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "webostv.turn_on", + "device_id": device.id, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": device.id, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == device.id + assert calls[0].data["id"] == 0 + + with patch("homeassistant.config.load_yaml", return_value={}): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) + + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == device.id + assert calls[0].data["id"] == 0 + + +async def test_webostv_turn_on_trigger_entity_id(hass, calls, client): + """Test for turn_on triggers by entity_id firing.""" + await setup_webostv(hass) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "webostv.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == ENTITY_ID + assert calls[0].data["id"] == 0 + + +async def test_wrong_trigger_platform_type(hass, caplog, client): + """Test wrong trigger platform type.""" + await setup_webostv(hass) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "webostv.wrong_type", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + assert ( + "ValueError: Unknown webOS Smart TV trigger platform webostv.wrong_type" + in caplog.text + ) + + +async def test_trigger_invalid_entity_id(hass, caplog, client): + """Test turn on trigger using invalid entity_id.""" + await setup_webostv(hass) + + platform = MockEntityPlatform(hass) + + invalid_entity = f"{DOMAIN}.invalid" + await platform.async_add_entities([MockEntity(name=invalid_entity)]) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "webostv.turn_on", + "entity_id": invalid_entity, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + assert ( + f"ValueError: Entity {invalid_entity} is not a valid webostv entity" + in caplog.text + ) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 1099519a2a0dc0..58c9b414d5a16b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -6,7 +6,6 @@ import pytest import voluptuous as vol -from homeassistant.bootstrap import SIGNAL_BOOTSTRAP_INTEGRATONS from homeassistant.components.websocket_api import const from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, @@ -14,6 +13,7 @@ TYPE_AUTH_REQUIRED, ) from homeassistant.components.websocket_api.const import URL +from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATONS from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity @@ -24,6 +24,63 @@ from tests.common import MockEntity, MockEntityPlatform, async_mock_service +async def test_fire_event(hass, websocket_client): + """Test fire event command.""" + runs = [] + + async def event_handler(event): + runs.append(event) + + hass.bus.async_listen_once("event_type_test", event_handler) + + await websocket_client.send_json( + { + "id": 5, + "type": "fire_event", + "event_type": "event_type_test", + "event_data": {"hello": "world"}, + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + assert len(runs) == 1 + + assert runs[0].event_type == "event_type_test" + assert runs[0].data == {"hello": "world"} + + +async def test_fire_event_without_data(hass, websocket_client): + """Test fire event command.""" + runs = [] + + async def event_handler(event): + runs.append(event) + + hass.bus.async_listen_once("event_type_test", event_handler) + + await websocket_client.send_json( + { + "id": 5, + "type": "fire_event", + "event_type": "event_type_test", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + assert len(runs) == 1 + + assert runs[0].event_type == "event_type_test" + assert runs[0].data == {} + + async def test_call_service(hass, websocket_client): """Test call service command.""" calls = async_mock_service(hass, "domain_test", "test_service") diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index 1d6bf5f2f6be89..0a9ae710bc5915 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -20,7 +20,7 @@ async def test_send_big_result(hass, websocket_client): async def send_big_result(hass, connection, msg): await connection.send_big_result(msg["id"], {"big": "result"}) - hass.components.websocket_api.async_register_command(send_big_result) + websocket_api.async_register_command(hass, send_big_result) await websocket_client.send_json({"id": 5, "type": "big_result"}) diff --git a/tests/components/websocket_api/test_init.py b/tests/components/websocket_api/test_init.py index 041c0e76533f9d..c991eeed0d165e 100644 --- a/tests/components/websocket_api/test_init.py +++ b/tests/components/websocket_api/test_init.py @@ -4,7 +4,11 @@ from aiohttp import WSMsgType import voluptuous as vol -from homeassistant.components.websocket_api import const, messages +from homeassistant.components.websocket_api import ( + async_register_command, + const, + messages, +) async def test_invalid_message_format(websocket_client): @@ -49,7 +53,8 @@ async def test_unknown_command(websocket_client): async def test_handler_failing(hass, websocket_client): """Test a command that raises.""" - hass.components.websocket_api.async_register_command( + async_register_command( + hass, "bla", Mock(side_effect=TypeError), messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({"type": "bla"}), @@ -65,7 +70,8 @@ async def test_handler_failing(hass, websocket_client): async def test_invalid_vol(hass, websocket_client): """Test a command that raises invalid vol error.""" - hass.components.websocket_api.async_register_command( + async_register_command( + hass, "bla", Mock(side_effect=TypeError), messages.BASE_COMMAND_MESSAGE_SCHEMA.extend( diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 13ec0cb2337dca..cf974f523a82f8 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -14,6 +14,7 @@ MOCK_PORT = 50000 MOCK_NAME = "WemoDeviceName" MOCK_SERIAL_NUMBER = "WemoSerialNumber" +MOCK_FIRMWARE_VERSION = "WeMo_WW_2.00.XXXXX.PVT-OWRT" @pytest.fixture(name="pywemo_model") @@ -58,6 +59,8 @@ def pywemo_device_fixture(pywemo_registry, pywemo_model): device.name = MOCK_NAME device.serialnumber = MOCK_SERIAL_NUMBER device.model_name = pywemo_model.replace("LongPress", "") + device.udn = f"uuid:{device.model_name}-1_0-{device.serialnumber}" + device.firmware_version = MOCK_FIRMWARE_VERSION device.get_state.return_value = 0 # Default to Off device.supports_long_press.return_value = cls.supports_long_press() diff --git a/tests/components/wemo/entity_test_helpers.py b/tests/components/wemo/entity_test_helpers.py index 6836f87a4a06f6..61c7cd5bf2e35c 100644 --- a/tests/components/wemo/entity_test_helpers.py +++ b/tests/components/wemo/entity_test_helpers.py @@ -9,7 +9,9 @@ from homeassistant.components.wemo import wemo_device from homeassistant.const import ( ATTR_ENTITY_ID, + SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) @@ -53,7 +55,7 @@ def get_state(force_update=None): return nonlocal call_count call_count += 1 - hass.add_job(waiting.set) + hass.loop.call_soon_threadsafe(waiting.set) event.wait() # Danger! Do not use a Mock side_effect here. The test will deadlock. When @@ -121,8 +123,6 @@ async def test_avaliable_after_update( ActionException when the SERVICE_TURN_ON method is called and that the state will be On after the update. """ - await async_setup_component(hass, domain, {}) - await hass.services.async_call( domain, SERVICE_TURN_ON, @@ -134,3 +134,46 @@ async def test_avaliable_after_update( pywemo_registry.callbacks[pywemo_device.name](pywemo_device, "", "") await hass.async_block_till_done() assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + + +async def test_turn_off_state(hass, wemo_entity, domain): + """Test that the device state is updated after turning off.""" + await hass.services.async_call( + domain, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, + blocking=True, + ) + assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + + +class EntityTestHelpers: + """Common state update helpers.""" + + async def test_async_update_locked_multiple_updates( + self, hass, pywemo_device, wemo_entity + ): + """Test that two hass async_update state updates do not proceed at the same time.""" + await test_async_update_locked_multiple_updates( + hass, pywemo_device, wemo_entity + ) + + async def test_async_update_locked_multiple_callbacks( + self, hass, pywemo_device, wemo_entity + ): + """Test that two device callback state updates do not proceed at the same time.""" + await test_async_update_locked_multiple_callbacks( + hass, pywemo_device, wemo_entity + ) + + async def test_async_update_locked_callback_and_update( + self, hass, pywemo_device, wemo_entity + ): + """Test that a callback and a state update request can't both happen at the same time. + + When a state update is received via a callback from the device at the same time + as hass is calling `async_update`, verify that only one of the updates proceeds. + """ + await test_async_update_locked_callback_and_update( + hass, pywemo_device, wemo_entity + ) diff --git a/tests/components/wemo/test_binary_sensor.py b/tests/components/wemo/test_binary_sensor.py index 26e4981203dd46..481a6348688665 100644 --- a/tests/components/wemo/test_binary_sensor.py +++ b/tests/components/wemo/test_binary_sensor.py @@ -13,39 +13,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component -from . import entity_test_helpers - - -class EntityTestHelpers: - """Common state update helpers.""" - - async def test_async_update_locked_multiple_updates( - self, hass, pywemo_device, wemo_entity - ): - """Test that two hass async_update state updates do not proceed at the same time.""" - await entity_test_helpers.test_async_update_locked_multiple_updates( - hass, pywemo_device, wemo_entity - ) - - async def test_async_update_locked_multiple_callbacks( - self, hass, pywemo_device, wemo_entity - ): - """Test that two device callback state updates do not proceed at the same time.""" - await entity_test_helpers.test_async_update_locked_multiple_callbacks( - hass, pywemo_device, wemo_entity - ) - - async def test_async_update_locked_callback_and_update( - self, hass, pywemo_device, wemo_entity - ): - """Test that a callback and a state update request can't both happen at the same time. - - When a state update is received via a callback from the device at the same time - as hass is calling `async_update`, verify that only one of the updates proceeds. - """ - await entity_test_helpers.test_async_update_locked_callback_and_update( - hass, pywemo_device, wemo_entity - ) +from .entity_test_helpers import EntityTestHelpers class TestMotion(EntityTestHelpers): diff --git a/tests/components/wemo/test_config_flow.py b/tests/components/wemo/test_config_flow.py new file mode 100644 index 00000000000000..81040e44c9cf56 --- /dev/null +++ b/tests/components/wemo/test_config_flow.py @@ -0,0 +1,22 @@ +"""Tests for Wemo config flow.""" + +from homeassistant import data_entry_flow +from homeassistant.components.wemo.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from tests.common import patch + + +async def test_not_discovered(hass: HomeAssistant) -> None: + """Test setting up with no devices discovered.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch("homeassistant.components.wemo.config_flow.pywemo") as mock_pywemo: + mock_pywemo.discover_devices.return_value = [] + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "no_devices_found" diff --git a/tests/components/wemo/test_device_trigger.py b/tests/components/wemo/test_device_trigger.py index 0ad7d95dd7aaa0..e29864c7a640de 100644 --- a/tests/components/wemo/test_device_trigger.py +++ b/tests/components/wemo/test_device_trigger.py @@ -3,6 +3,7 @@ from pywemo.subscribe import EVENT_TYPE_LONG_PRESS from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.wemo.const import DOMAIN, WEMO_SUBSCRIPTION_EVENT from homeassistant.const import ( CONF_DEVICE_ID, @@ -65,6 +66,13 @@ async def test_get_triggers(hass, wemo_entity): CONF_PLATFORM: "device", CONF_TYPE: EVENT_TYPE_LONG_PRESS, }, + { + CONF_DEVICE_ID: wemo_entity.device_id, + CONF_DOMAIN: Platform.SWITCH, + CONF_ENTITY_ID: wemo_entity.entity_id, + CONF_PLATFORM: "device", + CONF_TYPE: "changed_states", + }, { CONF_DEVICE_ID: wemo_entity.device_id, CONF_DOMAIN: Platform.SWITCH, @@ -81,7 +89,7 @@ async def test_get_triggers(hass, wemo_entity): }, ] triggers = await async_get_device_automations( - hass, "trigger", wemo_entity.device_id + hass, DeviceAutomationType.TRIGGER, wemo_entity.device_id ) assert_lists_same(triggers, expected_triggers) diff --git a/tests/components/wemo/test_fan.py b/tests/components/wemo/test_fan.py index dc450311e6ab49..56bf89391810ad 100644 --- a/tests/components/wemo/test_fan.py +++ b/tests/components/wemo/test_fan.py @@ -2,15 +2,20 @@ import pytest from pywemo.exceptions import ActionException +from pywemo.ouimeaux_device.humidifier import DesiredHumidity, FanMode -from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, +) from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) from homeassistant.components.wemo import fan from homeassistant.components.wemo.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component from . import entity_test_helpers @@ -89,6 +94,11 @@ async def test_available_after_update( ) +async def test_turn_off_state(hass, wemo_entity): + """Test that the device state is updated after turning off.""" + await entity_test_helpers.test_turn_off_state(hass, wemo_entity, FAN_DOMAIN) + + async def test_fan_reset_filter_service(hass, pywemo_device, wemo_entity): """Verify that SERVICE_RESET_FILTER_LIFE is registered and works.""" assert await hass.services.async_call( @@ -103,12 +113,12 @@ async def test_fan_reset_filter_service(hass, pywemo_device, wemo_entity): @pytest.mark.parametrize( "test_input,expected", [ - (0, fan.WEMO_HUMIDITY_45), - (45, fan.WEMO_HUMIDITY_45), - (50, fan.WEMO_HUMIDITY_50), - (55, fan.WEMO_HUMIDITY_55), - (60, fan.WEMO_HUMIDITY_60), - (100, fan.WEMO_HUMIDITY_100), + (0, DesiredHumidity.FortyFivePercent), + (45, DesiredHumidity.FortyFivePercent), + (50, DesiredHumidity.FiftyPercent), + (55, DesiredHumidity.FiftyFivePercent), + (60, DesiredHumidity.SixtyPercent), + (100, DesiredHumidity.OneHundredPercent), ], ) async def test_fan_set_humidity_service( @@ -125,3 +135,47 @@ async def test_fan_set_humidity_service( blocking=True, ) pywemo_device.set_humidity.assert_called_with(expected) + + +@pytest.mark.parametrize( + "percentage,expected_fan_mode", + [ + (0, FanMode.Off), + (10, FanMode.Minimum), + (30, FanMode.Low), + (50, FanMode.Medium), + (70, FanMode.High), + (100, FanMode.Maximum), + ], +) +async def test_fan_set_percentage( + hass, pywemo_device, wemo_entity, percentage, expected_fan_mode +): + """Verify set_percentage works properly through the entire range of FanModes.""" + assert await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: [wemo_entity.entity_id], ATTR_PERCENTAGE: percentage}, + blocking=True, + ) + pywemo_device.set_state.assert_called_with(expected_fan_mode) + + +class TestInitialFanMode: + """Test that the FanMode is set to High when turned on the first time.""" + + @pytest.fixture + def pywemo_device(self, pywemo_device): + """Set the FanMode to off initially.""" + pywemo_device.fan_mode = FanMode.Off + yield pywemo_device + + async def test_fan_mode_high_initially(self, hass, pywemo_device, wemo_entity): + """Verify the FanMode is set to High when turned on.""" + assert await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, + blocking=True, + ) + pywemo_device.set_state.assert_called_with(FanMode.High) diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index f34e9bd04716f0..6cd415792b5d94 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -10,7 +10,13 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt -from .conftest import MOCK_HOST, MOCK_NAME, MOCK_PORT, MOCK_SERIAL_NUMBER +from .conftest import ( + MOCK_FIRMWARE_VERSION, + MOCK_HOST, + MOCK_NAME, + MOCK_PORT, + MOCK_SERIAL_NUMBER, +) from tests.common import async_fire_time_changed @@ -109,6 +115,8 @@ def create_device(counter): device.name = f"{MOCK_NAME}_{counter}" device.serialnumber = f"{MOCK_SERIAL_NUMBER}_{counter}" device.model_name = "Motion" + device.udn = f"uuid:{device.model_name}-1_0-{device.serialnumber}" + device.firmware_version = MOCK_FIRMWARE_VERSION device.get_state.return_value = 0 # Default to Off device.supports_long_press.return_value = False return device diff --git a/tests/components/wemo/test_light_bridge.py b/tests/components/wemo/test_light_bridge.py index b00cfe30ef7aef..3184335b173ba6 100644 --- a/tests/components/wemo/test_light_bridge.py +++ b/tests/components/wemo/test_light_bridge.py @@ -8,7 +8,7 @@ DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ATTR_COLOR_TEMP, DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component @@ -80,6 +80,11 @@ async def test_available_after_update( ) +async def test_turn_off_state(hass, pywemo_bridge_light, wemo_entity): + """Test that the device state is updated after turning off.""" + await entity_test_helpers.test_turn_off_state(hass, wemo_entity, LIGHT_DOMAIN) + + async def test_light_update_entity( hass, pywemo_registry, pywemo_bridge_light, wemo_entity ): @@ -88,13 +93,16 @@ async def test_light_update_entity( # On state. pywemo_bridge_light.state["onoff"] = 1 + pywemo_bridge_light.state["temperature_mireds"] = 432 await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, blocking=True, ) - assert hass.states.get(wemo_entity.entity_id).state == STATE_ON + state = hass.states.get(wemo_entity.entity_id) + assert state.attributes.get(ATTR_COLOR_TEMP) == 432 + assert state.state == STATE_ON # Off state. pywemo_bridge_light.state["onoff"] = 0 diff --git a/tests/components/wemo/test_light_dimmer.py b/tests/components/wemo/test_light_dimmer.py index 830eb6dbdf4795..56054fa77e9e71 100644 --- a/tests/components/wemo/test_light_dimmer.py +++ b/tests/components/wemo/test_light_dimmer.py @@ -7,8 +7,8 @@ DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component from . import entity_test_helpers @@ -45,6 +45,38 @@ async def test_available_after_update( ) +async def test_turn_off_state(hass, wemo_entity): + """Test that the device state is updated after turning off.""" + await entity_test_helpers.test_turn_off_state(hass, wemo_entity, LIGHT_DOMAIN) + + +async def test_turn_on_brightness(hass, pywemo_device, wemo_entity): + """Test setting the brightness value of the light.""" + brightness = 0 + state = 0 + + def set_brightness(b): + nonlocal brightness + nonlocal state + brightness, state = (b, int(bool(b))) + + pywemo_device.get_state.side_effect = lambda: state + pywemo_device.get_brightness.side_effect = lambda: brightness + pywemo_device.set_brightness.side_effect = set_brightness + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [wemo_entity.entity_id], ATTR_BRIGHTNESS: 204}, + blocking=True, + ) + + pywemo_device.set_brightness.assert_called_once_with(80) + states = hass.states.get(wemo_entity.entity_id) + assert states.state == STATE_ON + assert states.attributes[ATTR_BRIGHTNESS] == 204 + + async def test_light_registry_state_callback( hass, pywemo_registry, pywemo_device, wemo_entity ): diff --git a/tests/components/wemo/test_sensor.py b/tests/components/wemo/test_sensor.py index eb322d469cdd66..305aad6102cdc3 100644 --- a/tests/components/wemo/test_sensor.py +++ b/tests/components/wemo/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.setup import async_setup_component -from . import entity_test_helpers +from .entity_test_helpers import EntityTestHelpers @pytest.fixture @@ -33,7 +33,7 @@ def pywemo_device_fixture(pywemo_device): yield pywemo_device -class InsightTestTemplate: +class InsightTestTemplate(EntityTestHelpers): """Base class for testing WeMo Insight Sensors.""" ENTITY_ID_SUFFIX: str @@ -46,39 +46,6 @@ def wemo_entity_suffix_fixture(cls): """Select the appropriate entity for the test.""" return cls.ENTITY_ID_SUFFIX - # Tests that are in common among wemo platforms. These test methods will be run - # in the scope of this test module. They will run using the pywemo_model from - # this test module (Insight). - async def test_async_update_locked_multiple_updates( - self, hass, pywemo_device, wemo_entity - ): - """Test that two hass async_update state updates do not proceed at the same time.""" - await entity_test_helpers.test_async_update_locked_multiple_updates( - hass, - pywemo_device, - wemo_entity, - ) - - async def test_async_update_locked_multiple_callbacks( - self, hass, pywemo_device, wemo_entity - ): - """Test that two device callback state updates do not proceed at the same time.""" - await entity_test_helpers.test_async_update_locked_multiple_callbacks( - hass, - pywemo_device, - wemo_entity, - ) - - async def test_async_update_locked_callback_and_update( - self, hass, pywemo_device, wemo_entity - ): - """Test that a callback and a state update request can't both happen at the same time.""" - await entity_test_helpers.test_async_update_locked_callback_and_update( - hass, - pywemo_device, - wemo_entity, - ) - async def test_state_unavailable(self, hass, wemo_entity, pywemo_device): """Test that there is no failure if the insight_params is not populated.""" del pywemo_device.insight_params[self.INSIGHT_PARAM_NAME] diff --git a/tests/components/wemo/test_switch.py b/tests/components/wemo/test_switch.py index 1023498c792951..9c1dc804645ae3 100644 --- a/tests/components/wemo/test_switch.py +++ b/tests/components/wemo/test_switch.py @@ -85,3 +85,8 @@ async def test_available_after_update( await entity_test_helpers.test_avaliable_after_update( hass, pywemo_registry, pywemo_device, wemo_entity, SWITCH_DOMAIN ) + + +async def test_turn_off_state(hass, wemo_entity): + """Test that the device state is updated after turning off.""" + await entity_test_helpers.test_turn_off_state(hass, wemo_entity, SWITCH_DOMAIN) diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index 9ef9e6b56857fa..9bd3367aeeeb8a 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -17,7 +17,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import MOCK_HOST +from .conftest import MOCK_FIRMWARE_VERSION, MOCK_HOST, MOCK_SERIAL_NUMBER from tests.common import async_fire_time_changed @@ -154,6 +154,20 @@ async def test_async_update_data_subscribed( pywemo_device.get_state.assert_not_called() +async def test_device_info(hass, wemo_entity): + """Verify the DeviceInfo data is set properly.""" + dr = device_registry.async_get(hass) + device_entries = list(dr.devices.values()) + + assert len(device_entries) == 1 + assert device_entries[0].connections == { + ("upnp", f"uuid:LightSwitch-1_0-{MOCK_SERIAL_NUMBER}") + } + assert device_entries[0].manufacturer == "Belkin" + assert device_entries[0].model == "LightSwitch" + assert device_entries[0].sw_version == MOCK_FIRMWARE_VERSION + + class TestInsight: """Tests specific to the WeMo Insight device.""" diff --git a/tests/components/whois/__init__.py b/tests/components/whois/__init__.py new file mode 100644 index 00000000000000..753779d0f40eac --- /dev/null +++ b/tests/components/whois/__init__.py @@ -0,0 +1 @@ +"""Tests for the Whois integration.""" diff --git a/tests/components/whois/conftest.py b/tests/components/whois/conftest.py new file mode 100644 index 00000000000000..ef14750356e784 --- /dev/null +++ b/tests/components/whois/conftest.py @@ -0,0 +1,136 @@ +"""Fixtures for Whois integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from homeassistant.components.whois.const import DOMAIN +from homeassistant.const import CONF_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Home Assistant", + domain=DOMAIN, + data={ + CONF_DOMAIN: "home-assistant.io", + }, + unique_id="home-assistant.io", + ) + + +@pytest.fixture +def mock_whois_config_flow() -> Generator[MagicMock, None, None]: + """Return a mocked whois.""" + with patch("homeassistant.components.whois.config_flow.whois.query") as whois_mock: + yield whois_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.whois.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_whois() -> Generator[MagicMock, None, None]: + """Return a mocked query.""" + + with patch( + "homeassistant.components.whois.whois_query", + ) as whois_mock: + domain = whois_mock.return_value + domain.abuse_contact = "abuse@example.com" + domain.admin = "admin@example.com" + domain.creation_date = datetime(2019, 1, 1, 0, 0, 0) + domain.dnssec = True + domain.expiration_date = datetime(2023, 1, 1, 0, 0, 0) + domain.last_updated = datetime( + 2022, 1, 1, 0, 0, 0, tzinfo=dt_util.get_time_zone("Europe/Amsterdam") + ) + domain.name = "home-assistant.io" + domain.name_servers = ["ns1.example.com", "ns2.example.com"] + domain.owner = "owner@example.com" + domain.registrant = "registrant@example.com" + domain.registrar = "My Registrar" + domain.reseller = "Top Domains, Low Prices" + domain.status = "OK" + domain.statuses = ["OK"] + yield whois_mock + + +@pytest.fixture +def mock_whois_missing_some_attrs() -> Generator[Mock, None, None]: + """Return a mocked query that only sets admin.""" + + class LimitedWhoisMock: + """A limited mock of whois_query.""" + + def __init__(self, *args, **kwargs): + """Mock only attributes the library always sets being available.""" + self.creation_date = datetime(2019, 1, 1, 0, 0, 0) + self.dnssec = True + self.expiration_date = datetime(2023, 1, 1, 0, 0, 0) + self.last_updated = datetime( + 2022, 1, 1, 0, 0, 0, tzinfo=dt_util.get_time_zone("Europe/Amsterdam") + ) + self.name = "home-assistant.io" + self.name_servers = ["ns1.example.com", "ns2.example.com"] + self.registrar = "My Registrar" + self.status = "OK" + self.statuses = ["OK"] + + with patch( + "homeassistant.components.whois.whois_query", LimitedWhoisMock + ) as whois_mock: + yield whois_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_whois: MagicMock +) -> MockConfigEntry: + """Set up thewhois integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +@pytest.fixture +async def init_integration_missing_some_attrs( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_whois_missing_some_attrs: MagicMock, +) -> MockConfigEntry: + """Set up thewhois integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +@pytest.fixture +def enable_all_entities() -> Generator[AsyncMock, None, None]: + """Test fixture that ensures all entities are enabled in the registry.""" + with patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + return_value=True, + ) as mock_entity_registry_enabled_by_default: + yield mock_entity_registry_enabled_by_default diff --git a/tests/components/whois/test_config_flow.py b/tests/components/whois/test_config_flow.py new file mode 100644 index 00000000000000..be73cfe7b7e65b --- /dev/null +++ b/tests/components/whois/test_config_flow.py @@ -0,0 +1,147 @@ +"""Tests for the Whois config flow.""" +from unittest.mock import AsyncMock, MagicMock + +import pytest +from whois.exceptions import ( + FailedParsingWhoisOutput, + UnknownDateFormat, + UnknownTld, + WhoisCommandFailed, +) + +from homeassistant.components.whois.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_DOMAIN, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_whois_config_flow: MagicMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_DOMAIN: "Example.com"}, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "Example.com" + assert result2.get("data") == {CONF_DOMAIN: "example.com"} + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "throw,reason", + [ + (UnknownTld, "unknown_tld"), + (FailedParsingWhoisOutput, "unexpected_response"), + (UnknownDateFormat, "unknown_date_format"), + (WhoisCommandFailed, "whois_command_failed"), + ], +) +async def test_full_flow_with_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_whois_config_flow: MagicMock, + throw: Exception, + reason: str, +) -> None: + """Test the full user configuration flow with an error. + + This tests tests a full config flow, with an error happening; allowing + the user to fix the error and try again. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_whois_config_flow.side_effect = throw + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_DOMAIN: "Example.com"}, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {"base": reason} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_whois_config_flow.mock_calls) == 1 + + mock_whois_config_flow.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_DOMAIN: "Example.com"}, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "Example.com" + assert result3.get("data") == {CONF_DOMAIN: "example.com"} + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_whois_config_flow.mock_calls) == 2 + + +async def test_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_whois_config_flow: MagicMock, +) -> None: + """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_DOMAIN: "HOME-Assistant.io"}, + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_import_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_whois_config_flow: MagicMock, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_DOMAIN: "Example.com", CONF_NAME: "My Example Domain"}, + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "My Example Domain" + assert result.get("data") == { + CONF_DOMAIN: "example.com", + } + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/whois/test_diagnostics.py b/tests/components/whois/test_diagnostics.py new file mode 100644 index 00000000000000..3bf6d82f6ef732 --- /dev/null +++ b/tests/components/whois/test_diagnostics.py @@ -0,0 +1,25 @@ +"""Tests for the diagnostics data provided by the Whois integration.""" +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "creation_date": "2019-01-01T00:00:00", + "expiration_date": "2023-01-01T00:00:00", + "last_updated": "2022-01-01T00:00:00+01:00", + "status": "OK", + "statuses": ["OK"], + "dnssec": True, + } diff --git a/tests/components/whois/test_init.py b/tests/components/whois/test_init.py new file mode 100644 index 00000000000000..3cd9efc801dd65 --- /dev/null +++ b/tests/components/whois/test_init.py @@ -0,0 +1,80 @@ +"""Tests for the Whois integration.""" +from unittest.mock import MagicMock + +import pytest +from whois.exceptions import ( + FailedParsingWhoisOutput, + UnknownDateFormat, + UnknownTld, + WhoisCommandFailed, +) + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.whois.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_whois: MagicMock, +) -> None: + """Test the Whois configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert len(mock_whois.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "side_effect", + [FailedParsingWhoisOutput, UnknownDateFormat, UnknownTld, WhoisCommandFailed], +) +async def test_error_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_whois: MagicMock, + caplog: pytest.LogCaptureFixture, + side_effect: Exception, +) -> None: + """Test the Whois threw an error.""" + mock_config_entry.add_to_hass(hass) + mock_whois.side_effect = side_effect + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert len(mock_whois.mock_calls) == 1 + + +async def test_import_config( + hass: HomeAssistant, + mock_whois: MagicMock, + mock_whois_config_flow: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the Whois being set up from config via import.""" + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + {SENSOR_DOMAIN: {"platform": DOMAIN, CONF_DOMAIN: "home-assistant.io"}}, + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_whois.mock_calls) == 1 + assert "the Whois platform in YAML is deprecated" in caplog.text diff --git a/tests/components/whois/test_sensor.py b/tests/components/whois/test_sensor.py new file mode 100644 index 00000000000000..e824522ed09318 --- /dev/null +++ b/tests/components/whois/test_sensor.py @@ -0,0 +1,228 @@ +"""Tests for the sensors provided by the Whois integration.""" +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.whois.const import DOMAIN, SCAN_INTERVAL +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.freeze_time("2022-01-01 12:00:00", tz_offset=0) +async def test_whois_sensors( + hass: HomeAssistant, + enable_all_entities: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Test the Whois sensors.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.home_assistant_io_admin") + entry = entity_registry.async_get("sensor.home_assistant_io_admin") + assert entry + assert state + assert entry.unique_id == "home-assistant.io_admin" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "admin@example.com" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Admin" + assert state.attributes.get(ATTR_ICON) == "mdi:account-star" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.home_assistant_io_created") + entry = entity_registry.async_get("sensor.home_assistant_io_created") + assert entry + assert state + assert entry.unique_id == "home-assistant.io_creation_date" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "2019-01-01T00:00:00+00:00" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Created" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.home_assistant_io_days_until_expiration") + entry = entity_registry.async_get("sensor.home_assistant_io_days_until_expiration") + assert entry + assert state + assert entry.unique_id == "home-assistant.io_days_until_expiration" + assert entry.entity_category is None + assert state.state == "364" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "home-assistant.io Days Until Expiration" + ) + assert state.attributes.get(ATTR_ICON) == "mdi:calendar-clock" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.home_assistant_io_expires") + entry = entity_registry.async_get("sensor.home_assistant_io_expires") + assert entry + assert state + assert entry.unique_id == "home-assistant.io_expiration_date" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "2023-01-01T00:00:00+00:00" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Expires" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.home_assistant_io_last_updated") + entry = entity_registry.async_get("sensor.home_assistant_io_last_updated") + assert entry + assert state + assert entry.unique_id == "home-assistant.io_last_updated" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "2021-12-31T23:00:00+00:00" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Last Updated" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + assert ATTR_ICON not in state.attributes + + state = hass.states.get("sensor.home_assistant_io_owner") + entry = entity_registry.async_get("sensor.home_assistant_io_owner") + assert entry + assert state + assert entry.unique_id == "home-assistant.io_owner" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "owner@example.com" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Owner" + assert state.attributes.get(ATTR_ICON) == "mdi:account" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.home_assistant_io_registrant") + entry = entity_registry.async_get("sensor.home_assistant_io_registrant") + assert entry + assert state + assert entry.unique_id == "home-assistant.io_registrant" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "registrant@example.com" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Registrant" + assert state.attributes.get(ATTR_ICON) == "mdi:account-edit" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.home_assistant_io_registrar") + entry = entity_registry.async_get("sensor.home_assistant_io_registrar") + assert entry + assert state + assert entry.unique_id == "home-assistant.io_registrar" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "My Registrar" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Registrar" + assert state.attributes.get(ATTR_ICON) == "mdi:store" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.home_assistant_io_reseller") + entry = entity_registry.async_get("sensor.home_assistant_io_reseller") + assert entry + assert state + assert entry.unique_id == "home-assistant.io_reseller" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "Top Domains, Low Prices" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Reseller" + assert state.attributes.get(ATTR_ICON) == "mdi:store" + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.configuration_url is None + assert device_entry.entry_type == dr.DeviceEntryType.SERVICE + assert device_entry.identifiers == {(DOMAIN, "home-assistant.io")} + assert device_entry.manufacturer is None + assert device_entry.model is None + assert device_entry.name is None + assert device_entry.sw_version is None + + +@pytest.mark.freeze_time("2022-01-01 12:00:00", tz_offset=0) +async def test_whois_sensors_missing_some_attrs( + hass: HomeAssistant, + enable_all_entities: AsyncMock, + init_integration_missing_some_attrs: MockConfigEntry, +) -> None: + """Test the Whois sensors with owner and reseller missing.""" + entity_registry = er.async_get(hass) + + state = hass.states.get("sensor.home_assistant_io_last_updated") + entry = entity_registry.async_get("sensor.home_assistant_io_last_updated") + assert entry + assert state + assert entry.unique_id == "home-assistant.io_last_updated" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "2021-12-31T23:00:00+00:00" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home-assistant.io Last Updated" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + assert ATTR_ICON not in state.attributes + + assert hass.states.get("sensor.home_assistant_io_owner").state == STATE_UNKNOWN + assert hass.states.get("sensor.home_assistant_io_reseller").state == STATE_UNKNOWN + assert hass.states.get("sensor.home_assistant_io_registrant").state == STATE_UNKNOWN + assert hass.states.get("sensor.home_assistant_io_admin").state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "entity_id", + ( + "sensor.home_assistant_io_admin", + "sensor.home_assistant_io_owner", + "sensor.home_assistant_io_registrant", + "sensor.home_assistant_io_registrar", + "sensor.home_assistant_io_reseller", + ), +) +async def test_disabled_by_default_sensors( + hass: HomeAssistant, + init_integration: MockConfigEntry, + entity_id: str, +) -> None: + """Test the disabled by default Whois sensors.""" + registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state is None + + entry = registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.parametrize( + "entity_id", + ( + "sensor.home_assistant_io_admin", + "sensor.home_assistant_io_created", + "sensor.home_assistant_io_days_until_expiration", + "sensor.home_assistant_io_expires", + "sensor.home_assistant_io_last_updated", + "sensor.home_assistant_io_owner", + "sensor.home_assistant_io_registrant", + "sensor.home_assistant_io_registrar", + "sensor.home_assistant_io_reseller", + ), +) +async def test_no_data( + hass: HomeAssistant, + mock_whois: MagicMock, + enable_all_entities: AsyncMock, + init_integration: MockConfigEntry, + entity_id: str, +) -> None: + """Test whois sensors become unknown when there is no data provided.""" + mock_whois.return_value = None + + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index 71eb350410b931..b90a004ed0bf79 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -216,13 +216,15 @@ async def setup_profile(self, user_id: int) -> ConfigEntryWithingsApi: self._aioclient_mock.clear_requests() self._aioclient_mock.post( - "https://account.withings.com/oauth2/token", + "https://wbsapi.withings.net/v2/oauth2", json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - "userid": profile_config.user_id, + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": profile_config.user_id, + }, }, ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 210d1f669e915b..2643ac18c24ece 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -90,13 +90,15 @@ async def test_config_reauth_profile( aioclient_mock.clear_requests() aioclient_mock.post( - "https://account.withings.com/oauth2/token", + "https://wbsapi.withings.net/v2/oauth2", json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - "userid": "0", + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": "0", + }, }, ) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 7e337da8afbb55..fbe9c6304bb982 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -17,7 +17,6 @@ SleepModel, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.withings.common import ( WITHINGS_MEASUREMENTS_MAP, WithingsAttribute, @@ -25,6 +24,7 @@ get_platform_attributes, ) from homeassistant.components.withings.const import Measurement +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry @@ -310,14 +310,14 @@ async def test_sensor_default_enabled_entities( await component_factory.configure_component(profile_configs=(PERSON0,)) # Assert entities should not exist yet. - for attribute in get_platform_attributes(SENSOR_DOMAIN): + for attribute in get_platform_attributes(Platform.SENSOR): assert not await async_get_entity_id(hass, attribute, PERSON0.user_id) # person 0 await component_factory.setup_profile(PERSON0.user_id) # Assert entities should exist. - for attribute in get_platform_attributes(SENSOR_DOMAIN): + for attribute in get_platform_attributes(Platform.SENSOR): entity_id = await async_get_entity_id(hass, attribute, PERSON0.user_id) assert entity_id assert entity_registry.async_is_registered(entity_id) @@ -356,14 +356,14 @@ async def test_all_entities( await component_factory.configure_component(profile_configs=(PERSON0,)) # Assert entities should not exist yet. - for attribute in get_platform_attributes(SENSOR_DOMAIN): + for attribute in get_platform_attributes(Platform.SENSOR): assert not await async_get_entity_id(hass, attribute, PERSON0.user_id) # person 0 await component_factory.setup_profile(PERSON0.user_id) # Assert entities should exist. - for attribute in get_platform_attributes(SENSOR_DOMAIN): + for attribute in get_platform_attributes(Platform.SENSOR): entity_id = await async_get_entity_id(hass, attribute, PERSON0.user_id) assert entity_id assert entity_registry.async_is_registered(entity_id) diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index 708bbf46834cd9..f89d92aaa16fc2 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -1,13 +1,13 @@ """Fixtures for WLED integration tests.""" +from collections.abc import Generator import json -from typing import Generator from unittest.mock import MagicMock, patch import pytest from wled import Device as WLEDDevice from homeassistant.components.wled.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -19,7 +19,8 @@ def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: "192.168.1.123", CONF_MAC: "aabbccddeeff"}, + data={CONF_HOST: "192.168.1.123"}, + unique_id="aabbccddeeff", ) diff --git a/tests/components/wled/test_binary_sensor.py b/tests/components/wled/test_binary_sensor.py index 5c40f8833e5378..311044d213d816 100644 --- a/tests/components/wled/test_binary_sensor.py +++ b/tests/components/wled/test_binary_sensor.py @@ -4,15 +4,10 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_OFF, - STATE_ON, -) +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory from tests.common import MockConfigEntry @@ -32,7 +27,7 @@ async def test_update_available( entry = entity_registry.async_get("binary_sensor.wled_rgb_light_firmware") assert entry assert entry.unique_id == "aabbccddeeff_update" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC @pytest.mark.parametrize("mock_wled", ["wled/rgb_websocket.json"], indirect=True) @@ -51,4 +46,4 @@ async def test_no_update_available( entry = entity_registry.async_get("binary_sensor.wled_websocket_firmware") assert entry assert entry.unique_id == "aabbccddeeff_update" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC diff --git a/tests/components/wled/test_button.py b/tests/components/wled/test_button.py index d6eea403d974ac..c9c8412a5b9067 100644 --- a/tests/components/wled/test_button.py +++ b/tests/components/wled/test_button.py @@ -13,12 +13,12 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ENTITY_CATEGORY_CONFIG, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory from tests.common import MockConfigEntry @@ -37,7 +37,7 @@ async def test_button_restart( entry = entity_registry.async_get("button.wled_rgb_light_restart") assert entry assert entry.unique_id == "aabbccddeeff_restart" - assert entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entry.entity_category is EntityCategory.CONFIG await hass.services.async_call( BUTTON_DOMAIN, @@ -111,7 +111,7 @@ async def test_button_update_stay_stable( entry = entity_registry.async_get("button.wled_rgb_light_update") assert entry assert entry.unique_id == "aabbccddeeff_update" - assert entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entry.entity_category is EntityCategory.CONFIG state = hass.states.get("button.wled_rgb_light_update") assert state diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 770d6abd2f8224..27023708400947 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -34,11 +34,12 @@ async def test_full_user_flow_implementation( result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} ) - assert result.get("title") == "192.168.1.123" + assert result.get("title") == "WLED RGB Light" assert result.get("type") == RESULT_TYPE_CREATE_ENTRY assert "data" in result assert result["data"][CONF_HOST] == "192.168.1.123" - assert result["data"][CONF_MAC] == "aabbccddeeff" + assert "result" in result + assert result["result"].unique_id == "aabbccddeeff" async def test_full_zeroconf_flow_implementation( @@ -53,7 +54,7 @@ async def test_full_zeroconf_flow_implementation( hostname="example.local.", name="mock_name", port=None, - properties={}, + properties={CONF_MAC: "aabbccddeeff"}, type="mock_type", ), ) @@ -61,26 +62,25 @@ async def test_full_zeroconf_flow_implementation( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 - assert result.get("description_placeholders") == {CONF_NAME: "example"} + assert ( + flows[0].get("context", {}).get("configuration_url") == "http://192.168.1.123" + ) + assert result.get("description_placeholders") == {CONF_NAME: "WLED RGB Light"} assert result.get("step_id") == "zeroconf_confirm" assert result.get("type") == RESULT_TYPE_FORM assert "flow_id" in result - flow = flows[0] - assert "context" in flow - assert flow["context"][CONF_HOST] == "192.168.1.123" - assert flow["context"][CONF_NAME] == "example" - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result2.get("title") == "example" + assert result2.get("title") == "WLED RGB Light" assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY assert "data" in result2 assert result2["data"][CONF_HOST] == "192.168.1.123" - assert result2["data"][CONF_MAC] == "aabbccddeeff" + assert "result" in result2 + assert result2["result"].unique_id == "aabbccddeeff" async def test_connection_error( @@ -113,7 +113,7 @@ async def test_zeroconf_connection_error( hostname="example.local.", name="mock_name", port=None, - properties={}, + properties={CONF_MAC: "aabbccddeeff"}, type="mock_type", ), ) @@ -122,39 +122,30 @@ async def test_zeroconf_connection_error( assert result.get("reason") == "cannot_connect" -async def test_zeroconf_confirm_connection_error( - hass: HomeAssistant, mock_wled_config_flow: MagicMock +async def test_user_device_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wled_config_flow: MagicMock, ) -> None: - """Test we abort zeroconf flow on WLED connection error.""" - mock_wled_config_flow.update.side_effect = WLEDConnectionError - + """Test we abort zeroconf flow if WLED device already configured.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={ - "source": SOURCE_ZEROCONF, - CONF_HOST: "example.com", - CONF_NAME: "test", - }, - data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - hostname="example.com.", - name="mock_name", - port=None, - properties={}, - type="mock_type", - ), + context={"source": SOURCE_USER}, + data={CONF_HOST: "192.168.1.123"}, ) assert result.get("type") == RESULT_TYPE_ABORT - assert result.get("reason") == "cannot_connect" + assert result.get("reason") == "already_configured" -async def test_user_device_exists_abort( +async def test_user_with_cct_channel_abort( hass: HomeAssistant, - init_integration: MagicMock, mock_wled_config_flow: MagicMock, ) -> None: - """Test we abort zeroconf flow if WLED device already configured.""" + """Test we abort user flow if WLED device uses a CCT channel.""" + mock_wled_config_flow.update.return_value.info.leds.cct = True + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -162,15 +153,16 @@ async def test_user_device_exists_abort( ) assert result.get("type") == RESULT_TYPE_ABORT - assert result.get("reason") == "already_configured" + assert result.get("reason") == "cct_unsupported" -async def test_zeroconf_device_exists_abort( +async def test_zeroconf_without_mac_device_exists_abort( hass: HomeAssistant, - init_integration: MagicMock, + mock_config_entry: MockConfigEntry, mock_wled_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow if WLED device already configured.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -190,10 +182,11 @@ async def test_zeroconf_device_exists_abort( async def test_zeroconf_with_mac_device_exists_abort( hass: HomeAssistant, - init_integration: MockConfigEntry, + mock_config_entry: MockConfigEntry, mock_wled_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow if WLED device already configured.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, @@ -211,6 +204,30 @@ async def test_zeroconf_with_mac_device_exists_abort( assert result.get("reason") == "already_configured" +async def test_zeroconf_with_cct_channel_abort( + hass: HomeAssistant, + mock_wled_config_flow: MagicMock, +) -> None: + """Test we abort zeroconf flow if WLED device uses a CCT channel.""" + mock_wled_config_flow.update.return_value.info.leds.cct = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "aabbccddeeff"}, + type="mock_type", + ), + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "cct_unsupported" + + async def test_options_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 47190604238f7e..a7d7929c84e3df 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -1,7 +1,7 @@ """Tests for the coordinator of the WLED integration.""" import asyncio +from collections.abc import Callable from copy import deepcopy -from typing import Callable from unittest.mock import MagicMock import pytest diff --git a/tests/components/wled/test_diagnostics.py b/tests/components/wled/test_diagnostics.py new file mode 100644 index 00000000000000..d8782848c92d09 --- /dev/null +++ b/tests/components/wled/test_diagnostics.py @@ -0,0 +1,210 @@ +"""Tests for the diagnostics data provided by the WLED integration.""" +from aiohttp import ClientSession + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "info": { + "architecture": "esp8266", + "arduino_core_version": "2.4.2", + "brand": "WLED", + "build_type": "bin", + "effect_count": 81, + "filesystem": None, + "free_heap": 14600, + "leds": { + "__type": "", + "repr": "Leds(cct=False, count=30, fps=None, max_power=850, max_segments=10, power=470, rgbw=False, wv=True)", + }, + "live_ip": "Unknown", + "live_mode": "Unknown", + "live": False, + "mac_address": "aabbccddeeff", + "name": "WLED RGB Light", + "pallet_count": 50, + "product": "DIY light", + "udp_port": 21324, + "uptime": 32, + "version_id": 1909122, + "version": "0.8.5", + "version_latest_beta": "0.13.0b1", + "version_latest_stable": "0.12.0", + "websocket": None, + "wifi": "**REDACTED**", + }, + "state": { + "brightness": 127, + "nightlight": { + "__type": "", + "repr": "Nightlight(duration=60, fade=True, on=False, mode=, target_brightness=0)", + }, + "on": True, + "playlist": -1, + "preset": -1, + "segments": [ + { + "__type": "", + "repr": "Segment(brightness=127, clones=-1, color_primary=(255, 159, 0), color_secondary=(0, 0, 0), color_tertiary=(0, 0, 0), effect=Effect(effect_id=0, name='Solid'), intensity=128, length=20, on=True, palette=Palette(name='Default', palette_id=0), reverse=False, segment_id=0, selected=True, speed=32, start=0, stop=19)", + }, + { + "__type": "", + "repr": "Segment(brightness=127, clones=-1, color_primary=(0, 255, 123), color_secondary=(0, 0, 0), color_tertiary=(0, 0, 0), effect=Effect(effect_id=1, name='Blink'), intensity=64, length=10, on=True, palette=Palette(name='Random Cycle', palette_id=1), reverse=True, segment_id=1, selected=True, speed=16, start=20, stop=30)", + }, + ], + "sync": { + "__type": "", + "repr": "Sync(receive=True, send=False)", + }, + "transition": 7, + "lor": 0, + }, + "effects": { + "27": "Android", + "68": "BPM", + "1": "Blink", + "26": "Blink Rainbow", + "2": "Breathe", + "13": "Chase", + "28": "Chase", + "31": "Chase Flash", + "32": "Chase Flash Rnd", + "14": "Chase Rainbow", + "30": "Chase Rainbow", + "29": "Chase Random", + "52": "Circus", + "34": "Colorful", + "8": "Colorloop", + "74": "Colortwinkle", + "67": "Colorwaves", + "21": "Dark Sparkle", + "18": "Dissolve", + "19": "Dissolve Rnd", + "11": "Dual Scan", + "60": "Dual Scanner", + "7": "Dynamic", + "12": "Fade", + "69": "Fill Noise", + "66": "Fire 2012", + "45": "Fire Flicker", + "42": "Fireworks", + "46": "Gradient", + "53": "Halloween", + "58": "ICU", + "49": "In In", + "48": "In Out", + "64": "Juggle", + "75": "Lake", + "41": "Lighthouse", + "57": "Lightning", + "47": "Loading", + "25": "Mega Strobe", + "44": "Merry Christmas", + "76": "Meteor", + "59": "Multi Comet", + "70": "Noise 1", + "71": "Noise 2", + "72": "Noise 3", + "73": "Noise 4", + "62": "Oscillate", + "51": "Out In", + "50": "Out Out", + "65": "Palette", + "63": "Pride 2015", + "78": "Railway", + "43": "Rain", + "9": "Rainbow", + "33": "Rainbow Runner", + "5": "Random Colors", + "38": "Red & Blue", + "79": "Ripple", + "15": "Running", + "37": "Running 2", + "16": "Saw", + "10": "Scan", + "40": "Scanner", + "77": "Smooth Meteor", + "0": "Solid", + "20": "Sparkle", + "22": "Sparkle+", + "39": "Stream", + "61": "Stream 2", + "23": "Strobe", + "24": "Strobe Rainbow", + "6": "Sweep", + "36": "Sweep Random", + "35": "Traffic Light", + "54": "Tri Chase", + "56": "Tri Fade", + "55": "Tri Wipe", + "17": "Twinkle", + "80": "Twinklefox", + "3": "Wipe", + "4": "Wipe Random", + }, + "palettes": { + "18": "Analogous", + "46": "April Night", + "39": "Autumn", + "3": "Based on Primary", + "5": "Based on Set", + "26": "Beach", + "22": "Beech", + "15": "Breeze", + "48": "C9", + "7": "Cloud", + "37": "Cyane", + "0": "Default", + "24": "Departure", + "30": "Drywet", + "35": "Fire", + "10": "Forest", + "32": "Grintage", + "28": "Hult", + "29": "Hult 64", + "36": "Icefire", + "31": "Jul", + "25": "Landscape", + "8": "Lava", + "38": "Light Pink", + "40": "Magenta", + "41": "Magred", + "9": "Ocean", + "44": "Orange & Teal", + "47": "Orangery", + "6": "Party", + "20": "Pastel", + "2": "Primary Color", + "11": "Rainbow", + "12": "Rainbow Bands", + "1": "Random Cycle", + "16": "Red & Blue", + "33": "Rewhi", + "14": "Rivendell", + "49": "Sakura", + "4": "Set Colors", + "27": "Sherbet", + "19": "Splash", + "13": "Sunset", + "21": "Sunset 2", + "34": "Tertiary", + "45": "Tiamat", + "23": "Vintage", + "43": "Yelblu", + "17": "Yellowout", + "42": "Yelmag", + }, + "playlists": {}, + "presets": {}, + } diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index 01821262389639..72a065a3b8eff5 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -1,6 +1,6 @@ """Tests for the WLED integration.""" import asyncio -from typing import Callable +from collections.abc import Callable from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -68,3 +68,21 @@ async def test_setting_unique_id( """Test we set unique ID if not set yet.""" assert hass.data[DOMAIN] assert init_integration.unique_id == "aabbccddeeff" + + +async def test_error_config_entry_with_cct_channel( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wled: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the WLED fails entry setup with a CCT channel.""" + mock_wled.update.return_value.info.leds.cct = True + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Ensure config entry is errored and are connected and disconnected + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert "has a CCT channel, which is not supported" in caplog.text diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index 345c0c632fed0a..d798a723246b48 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -11,13 +11,13 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, - ENTITY_CATEGORY_CONFIG, SERVICE_SELECT_OPTION, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture @@ -90,7 +90,7 @@ async def test_color_palette_state( entry = entity_registry.async_get("select.wled_rgb_light_segment_1_color_palette") assert entry assert entry.unique_id == "aabbccddeeff_palette_1" - assert entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entry.entity_category is EntityCategory.CONFIG async def test_color_palette_segment_change_state( diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index bc401f574a6317..bf3ef3b060b75e 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -12,13 +12,13 @@ ATTR_UNIT_OF_MEASUREMENT, DATA_BYTES, ELECTRIC_CURRENT_MILLIAMPERE, - ENTITY_CATEGORY_DIAGNOSTIC, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -99,7 +99,7 @@ async def test_sensors( entry = registry.async_get("sensor.wled_rgb_light_estimated_current") assert entry assert entry.unique_id == "aabbccddeeff_estimated_current" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC state = hass.states.get("sensor.wled_rgb_light_uptime") assert state @@ -110,31 +110,31 @@ async def test_sensors( entry = registry.async_get("sensor.wled_rgb_light_uptime") assert entry assert entry.unique_id == "aabbccddeeff_uptime" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC state = hass.states.get("sensor.wled_rgb_light_free_memory") assert state assert state.attributes.get(ATTR_ICON) == "mdi:memory" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == DATA_BYTES assert state.state == "14600" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC entry = registry.async_get("sensor.wled_rgb_light_free_memory") assert entry assert entry.unique_id == "aabbccddeeff_free_heap" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC state = hass.states.get("sensor.wled_rgb_light_wifi_signal") assert state assert state.attributes.get(ATTR_ICON) == "mdi:wifi" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "76" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC entry = registry.async_get("sensor.wled_rgb_light_wifi_signal") assert entry assert entry.unique_id == "aabbccddeeff_wifi_signal" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC state = hass.states.get("sensor.wled_rgb_light_wifi_rssi") assert state @@ -148,7 +148,7 @@ async def test_sensors( entry = registry.async_get("sensor.wled_rgb_light_wifi_rssi") assert entry assert entry.unique_id == "aabbccddeeff_wifi_rssi" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC state = hass.states.get("sensor.wled_rgb_light_wifi_channel") assert state @@ -159,7 +159,7 @@ async def test_sensors( entry = registry.async_get("sensor.wled_rgb_light_wifi_channel") assert entry assert entry.unique_id == "aabbccddeeff_wifi_channel" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC state = hass.states.get("sensor.wled_rgb_light_wifi_bssid") assert state @@ -170,7 +170,7 @@ async def test_sensors( entry = registry.async_get("sensor.wled_rgb_light_wifi_bssid") assert entry assert entry.unique_id == "aabbccddeeff_wifi_bssid" - assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entry.entity_category is EntityCategory.DIAGNOSTIC @pytest.mark.parametrize( @@ -196,7 +196,7 @@ async def test_disabled_by_default_sensors( entry = registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @pytest.mark.parametrize( diff --git a/tests/components/wled/test_switch.py b/tests/components/wled/test_switch.py index c47d7012f6e31a..2bf494284f5f25 100644 --- a/tests/components/wled/test_switch.py +++ b/tests/components/wled/test_switch.py @@ -16,7 +16,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, - ENTITY_CATEGORY_CONFIG, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -25,6 +24,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture @@ -47,7 +47,7 @@ async def test_switch_state( entry = entity_registry.async_get("switch.wled_rgb_light_nightlight") assert entry assert entry.unique_id == "aabbccddeeff_nightlight" - assert entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entry.entity_category is EntityCategory.CONFIG state = hass.states.get("switch.wled_rgb_light_sync_send") assert state @@ -58,7 +58,7 @@ async def test_switch_state( entry = entity_registry.async_get("switch.wled_rgb_light_sync_send") assert entry assert entry.unique_id == "aabbccddeeff_sync_send" - assert entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entry.entity_category is EntityCategory.CONFIG state = hass.states.get("switch.wled_rgb_light_sync_receive") assert state @@ -69,7 +69,7 @@ async def test_switch_state( entry = entity_registry.async_get("switch.wled_rgb_light_sync_receive") assert entry assert entry.unique_id == "aabbccddeeff_sync_receive" - assert entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entry.entity_category is EntityCategory.CONFIG state = hass.states.get("switch.wled_rgb_light_reverse") assert state @@ -79,7 +79,7 @@ async def test_switch_state( entry = entity_registry.async_get("switch.wled_rgb_light_reverse") assert entry assert entry.unique_id == "aabbccddeeff_reverse_0" - assert entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entry.entity_category is EntityCategory.CONFIG async def test_switch_change_state( diff --git a/tests/components/yale_smart_alarm/test_config_flow.py b/tests/components/yale_smart_alarm/test_config_flow.py index a2a0e41ba40a9f..a7c454851bf6c6 100644 --- a/tests/components/yale_smart_alarm/test_config_flow.py +++ b/tests/components/yale_smart_alarm/test_config_flow.py @@ -4,12 +4,16 @@ from unittest.mock import patch import pytest -from yalesmartalarmclient.client import AuthenticationError +from yalesmartalarmclient.exceptions import AuthenticationError, UnknownError from homeassistant import config_entries from homeassistant.components.yale_smart_alarm.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) from tests.common import MockConfigEntry @@ -20,7 +24,7 @@ async def test_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} with patch( @@ -40,7 +44,7 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == "create_entry" + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY assert result2["title"] == "test-username" assert result2["data"] == { "username": "test-username", @@ -51,7 +55,18 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "sideeffect,p_error", + [ + (AuthenticationError, "invalid_auth"), + (ConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (UnknownError, "cannot_connect"), + ], +) +async def test_form_invalid_auth( + hass: HomeAssistant, sideeffect: Exception, p_error: str +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -59,7 +74,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: with patch( "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", - side_effect=AuthenticationError, + side_effect=sideeffect, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -72,12 +87,38 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": p_error} + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + ), patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + } @pytest.mark.parametrize( - "input,output", + "p_input,p_output", [ ( { @@ -107,7 +148,9 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ), ], ) -async def test_import_flow_success(hass, input: dict[str, str], output: dict[str, str]): +async def test_import_flow_success( + hass, p_input: dict[str, str], p_output: dict[str, str] +): """Test a successful import of yaml.""" with patch( @@ -119,13 +162,13 @@ async def test_import_flow_success(hass, input: dict[str, str], output: dict[str result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=input, + data=p_input, ) await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "test-username" - assert result2["data"] == output + assert result2["data"] == p_output assert len(mock_setup_entry.mock_calls) == 1 @@ -184,7 +227,18 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_reauth_flow_invalid_login(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "sideeffect,p_error", + [ + (AuthenticationError, "invalid_auth"), + (ConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (UnknownError, "cannot_connect"), + ], +) +async def test_reauth_flow_error( + hass: HomeAssistant, sideeffect: Exception, p_error: str +) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( domain=DOMAIN, @@ -192,6 +246,8 @@ async def test_reauth_flow_invalid_login(hass: HomeAssistant) -> None: data={ "username": "test-username", "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", }, ) entry.add_to_hass(hass) @@ -208,7 +264,7 @@ async def test_reauth_flow_invalid_login(hass: HomeAssistant) -> None: with patch( "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", - side_effect=AuthenticationError, + side_effect=sideeffect, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -220,5 +276,100 @@ async def test_reauth_flow_invalid_login(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["step_id"] == "reauth_confirm" - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": p_error} + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value="", + ), patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert entry.data == { + "username": "test-username", + "password": "new-test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + } + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options config flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"code": "123456", "lock_code_digits": 6}, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {"code": "123456", "lock_code_digits": 6} + + +async def test_options_flow_format_mismatch(hass: HomeAssistant) -> None: + """Test options config flow with a code format mismatch error.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"code": "123", "lock_code_digits": 6}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "code_format_mismatch"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"code": "123456", "lock_code_digits": 6}, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {"code": "123456", "lock_code_digits": 6} diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index beab0b396fddc1..db4dbce4b8b8dc 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -4,407 +4,401 @@ import os import shutil +import pytest + from homeassistant.components.media_player.const import ( DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) import homeassistant.components.tts as tts -from homeassistant.config import async_process_ha_core_config -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component, get_test_home_assistant, mock_service +from tests.common import assert_setup_component, async_mock_service from tests.components.tts.test_init import ( # noqa: F401, pylint: disable=unused-import mutagen_mock, ) +URL = "https://tts.voicetech.yandex.net/generate?" -class TestTTSYandexPlatform: - """Test the speech component.""" - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self._base_url = "https://tts.voicetech.yandex.net/generate?" +@pytest.fixture(autouse=True) +def cleanup_cache(hass): + """Prevent TTS writing.""" + yield + default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) + if os.path.isdir(default_tts): + shutil.rmtree(default_tts) - asyncio.run_coroutine_threadsafe( - async_process_ha_core_config( - self.hass, {"internal_url": "http://example.local:8123"} - ), - self.hass.loop, - ) - def teardown_method(self): - """Stop everything that was started.""" - default_tts = self.hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) +async def test_setup_component(hass): + """Test setup component.""" + config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} - self.hass.stop() + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() - def test_setup_component(self): - """Test setup component.""" - config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) +async def test_setup_component_without_api_key(hass): + """Test setup component without api key.""" + config = {tts.DOMAIN: {"platform": "yandextts"}} - def test_setup_component_without_api_key(self): - """Test setup component without api key.""" - config = {tts.DOMAIN: {"platform": "yandextts"}} + with assert_setup_component(0, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() - with assert_setup_component(0, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - def test_service_say(self, aioclient_mock): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) +async def test_service_say(hass, aioclient_mock): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - url_param = { - "text": "HomeAssistant", - "lang": "en-US", - "key": "1234567xx", - "speaker": "zahar", - "format": "mp3", - "emotion": "neutral", - "speed": 1, - } - aioclient_mock.get( - self._base_url, status=HTTPStatus.OK, content=b"test", params=url_param - ) - - config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "yandextts_say", - {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, - ) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert len(calls) == 1 - - def test_service_say_russian_config(self, aioclient_mock): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - url_param = { - "text": "HomeAssistant", - "lang": "ru-RU", - "key": "1234567xx", - "speaker": "zahar", - "format": "mp3", - "emotion": "neutral", - "speed": 1, - } - aioclient_mock.get( - self._base_url, status=HTTPStatus.OK, content=b"test", params=url_param - ) - - config = { - tts.DOMAIN: { - "platform": "yandextts", - "api_key": "1234567xx", - "language": "ru-RU", - } - } + url_param = { + "text": "HomeAssistant", + "lang": "en-US", + "key": "1234567xx", + "speaker": "zahar", + "format": "mp3", + "emotion": "neutral", + "speed": 1, + } + aioclient_mock.get(URL, status=HTTPStatus.OK, content=b"test", params=url_param) - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "yandextts_say", - {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, - ) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert len(calls) == 1 - - def test_service_say_russian_service(self, aioclient_mock): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - url_param = { - "text": "HomeAssistant", - "lang": "ru-RU", - "key": "1234567xx", - "speaker": "zahar", - "format": "mp3", - "emotion": "neutral", - "speed": 1, - } - aioclient_mock.get( - self._base_url, status=HTTPStatus.OK, content=b"test", params=url_param - ) - - config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "yandextts_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "HomeAssistant", - tts.ATTR_LANGUAGE: "ru-RU", - }, - ) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert len(calls) == 1 - - def test_service_say_timeout(self, aioclient_mock): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - url_param = { - "text": "HomeAssistant", - "lang": "en-US", - "key": "1234567xx", - "speaker": "zahar", - "format": "mp3", - "emotion": "neutral", - "speed": 1, - } - aioclient_mock.get( - self._base_url, - status=HTTPStatus.OK, - exc=asyncio.TimeoutError(), - params=url_param, - ) - - config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "yandextts_say", - {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, - ) - self.hass.block_till_done() - - assert len(calls) == 0 - assert len(aioclient_mock.mock_calls) == 1 - - def test_service_say_http_error(self, aioclient_mock): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - url_param = { - "text": "HomeAssistant", - "lang": "en-US", - "key": "1234567xx", - "speaker": "zahar", - "format": "mp3", - "emotion": "neutral", - "speed": 1, - } - aioclient_mock.get( - self._base_url, - status=HTTPStatus.FORBIDDEN, - content=b"test", - params=url_param, - ) - - config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "yandextts_say", - {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, - ) - self.hass.block_till_done() - - assert len(calls) == 0 - - def test_service_say_specified_speaker(self, aioclient_mock): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - url_param = { - "text": "HomeAssistant", - "lang": "en-US", - "key": "1234567xx", - "speaker": "alyss", - "format": "mp3", - "emotion": "neutral", - "speed": 1, - } - aioclient_mock.get( - self._base_url, status=HTTPStatus.OK, content=b"test", params=url_param - ) - - config = { - tts.DOMAIN: { - "platform": "yandextts", - "api_key": "1234567xx", - "voice": "alyss", - } - } + config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "yandextts_say", - {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, - ) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert len(calls) == 1 - - def test_service_say_specified_emotion(self, aioclient_mock): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - url_param = { - "text": "HomeAssistant", - "lang": "en-US", - "key": "1234567xx", - "speaker": "zahar", - "format": "mp3", - "emotion": "evil", - "speed": 1, - } - aioclient_mock.get( - self._base_url, status=HTTPStatus.OK, content=b"test", params=url_param - ) - - config = { - tts.DOMAIN: { - "platform": "yandextts", - "api_key": "1234567xx", - "emotion": "evil", - } - } + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "yandextts_say", - {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, - ) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert len(calls) == 1 - - def test_service_say_specified_low_speed(self, aioclient_mock): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - url_param = { - "text": "HomeAssistant", - "lang": "en-US", - "key": "1234567xx", - "speaker": "zahar", - "format": "mp3", - "emotion": "neutral", - "speed": "0.1", - } - aioclient_mock.get( - self._base_url, status=HTTPStatus.OK, content=b"test", params=url_param - ) + await hass.services.async_call( + tts.DOMAIN, + "yandextts_say", + {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, + ) + await hass.async_block_till_done() - config = { - tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx", "speed": 0.1} - } + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "yandextts_say", - {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, - ) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert len(calls) == 1 - - def test_service_say_specified_speed(self, aioclient_mock): - """Test service call say.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - url_param = { - "text": "HomeAssistant", - "lang": "en-US", - "key": "1234567xx", - "speaker": "zahar", - "format": "mp3", - "emotion": "neutral", - "speed": 2, - } - aioclient_mock.get( - self._base_url, status=HTTPStatus.OK, content=b"test", params=url_param - ) - config = { - tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx", "speed": 2} - } +async def test_service_say_russian_config(hass, aioclient_mock): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + "text": "HomeAssistant", + "lang": "ru-RU", + "key": "1234567xx", + "speaker": "zahar", + "format": "mp3", + "emotion": "neutral", + "speed": 1, + } + aioclient_mock.get(URL, status=HTTPStatus.OK, content=b"test", params=url_param) - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "yandextts_say", - {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, - ) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert len(calls) == 1 - - def test_service_say_specified_options(self, aioclient_mock): - """Test service call say with options.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - url_param = { - "text": "HomeAssistant", - "lang": "en-US", - "key": "1234567xx", - "speaker": "zahar", - "format": "mp3", + config = { + tts.DOMAIN: { + "platform": "yandextts", + "api_key": "1234567xx", + "language": "ru-RU", + } + } + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "yandextts_say", + {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, + ) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 + + +async def test_service_say_russian_service(hass, aioclient_mock): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + "text": "HomeAssistant", + "lang": "ru-RU", + "key": "1234567xx", + "speaker": "zahar", + "format": "mp3", + "emotion": "neutral", + "speed": 1, + } + aioclient_mock.get(URL, status=HTTPStatus.OK, content=b"test", params=url_param) + + config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "yandextts_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "HomeAssistant", + tts.ATTR_LANGUAGE: "ru-RU", + }, + ) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 + + +async def test_service_say_timeout(hass, aioclient_mock): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + "text": "HomeAssistant", + "lang": "en-US", + "key": "1234567xx", + "speaker": "zahar", + "format": "mp3", + "emotion": "neutral", + "speed": 1, + } + aioclient_mock.get( + URL, + status=HTTPStatus.OK, + exc=asyncio.TimeoutError(), + params=url_param, + ) + + config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "yandextts_say", + {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + assert len(aioclient_mock.mock_calls) == 1 + + +async def test_service_say_http_error(hass, aioclient_mock): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + "text": "HomeAssistant", + "lang": "en-US", + "key": "1234567xx", + "speaker": "zahar", + "format": "mp3", + "emotion": "neutral", + "speed": 1, + } + aioclient_mock.get( + URL, + status=HTTPStatus.FORBIDDEN, + content=b"test", + params=url_param, + ) + + config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "yandextts_say", + {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + + +async def test_service_say_specified_speaker(hass, aioclient_mock): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + "text": "HomeAssistant", + "lang": "en-US", + "key": "1234567xx", + "speaker": "alyss", + "format": "mp3", + "emotion": "neutral", + "speed": 1, + } + aioclient_mock.get(URL, status=HTTPStatus.OK, content=b"test", params=url_param) + + config = { + tts.DOMAIN: { + "platform": "yandextts", + "api_key": "1234567xx", + "voice": "alyss", + } + } + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "yandextts_say", + {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, + ) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 + + +async def test_service_say_specified_emotion(hass, aioclient_mock): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + "text": "HomeAssistant", + "lang": "en-US", + "key": "1234567xx", + "speaker": "zahar", + "format": "mp3", + "emotion": "evil", + "speed": 1, + } + aioclient_mock.get(URL, status=HTTPStatus.OK, content=b"test", params=url_param) + + config = { + tts.DOMAIN: { + "platform": "yandextts", + "api_key": "1234567xx", "emotion": "evil", - "speed": 2, } - aioclient_mock.get( - self._base_url, status=HTTPStatus.OK, content=b"test", params=url_param - ) - config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "yandextts_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "HomeAssistant", - "options": {"emotion": "evil", "speed": 2}, - }, - ) - self.hass.block_till_done() - - assert len(aioclient_mock.mock_calls) == 1 - assert len(calls) == 1 + } + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "yandextts_say", + {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, + ) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 + + +async def test_service_say_specified_low_speed(hass, aioclient_mock): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + "text": "HomeAssistant", + "lang": "en-US", + "key": "1234567xx", + "speaker": "zahar", + "format": "mp3", + "emotion": "neutral", + "speed": "0.1", + } + aioclient_mock.get(URL, status=HTTPStatus.OK, content=b"test", params=url_param) + + config = { + tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx", "speed": 0.1} + } + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "yandextts_say", + {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, + ) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 + + +async def test_service_say_specified_speed(hass, aioclient_mock): + """Test service call say.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + "text": "HomeAssistant", + "lang": "en-US", + "key": "1234567xx", + "speaker": "zahar", + "format": "mp3", + "emotion": "neutral", + "speed": 2, + } + aioclient_mock.get(URL, status=HTTPStatus.OK, content=b"test", params=url_param) + + config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx", "speed": 2}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "yandextts_say", + {"entity_id": "media_player.something", tts.ATTR_MESSAGE: "HomeAssistant"}, + ) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 + + +async def test_service_say_specified_options(hass, aioclient_mock): + """Test service call say with options.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + url_param = { + "text": "HomeAssistant", + "lang": "en-US", + "key": "1234567xx", + "speaker": "zahar", + "format": "mp3", + "emotion": "evil", + "speed": 2, + } + aioclient_mock.get(URL, status=HTTPStatus.OK, content=b"test", params=url_param) + config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} + + with assert_setup_component(1, tts.DOMAIN): + await async_setup_component(hass, tts.DOMAIN, config) + await hass.async_block_till_done() + + await hass.services.async_call( + tts.DOMAIN, + "yandextts_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "HomeAssistant", + "options": {"emotion": "evil", "speed": 2}, + }, + ) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 1 + assert len(calls) == 1 diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index b6bf0b10d67510..b48cfc5402a89c 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -157,7 +157,7 @@ def _mocked_bulb(cannot_connect=False): return bulb -def _patched_ssdp_listener(info, *args, **kwargs): +def _patched_ssdp_listener(info: ssdp.SsdpHeaders, *args, **kwargs): listener = SsdpSearchListener(*args, **kwargs) async def _async_callback(*_): @@ -181,12 +181,7 @@ def _patch_discovery(no_device=False, capabilities=None): def _generate_fake_ssdp_listener(*args, **kwargs): info = None if not no_device: - info = ssdp.SsdpServiceInfo( - ssdp_usn="", - ssdp_st=scanner.SSDP_ST, - upnp={}, - ssdp_headers=capabilities or CAPABILITIES, - ) + info = capabilities or CAPABILITIES return _patched_ssdp_listener(info, *args, **kwargs) return patch( diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 1bd96a306ebb1a..24b6ec97ec64cb 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import call, patch +import pytest from zeroconf import InterfaceChoice, IPVersion, ServiceStateChange from zeroconf.asyncio import AsyncServiceInfo @@ -294,7 +295,11 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): zc_gen.ZEROCONF, { "_http._tcp.local.": [ - {"domain": "shelly", "name": "shelly*", "macaddress": "FFAADD*"} + { + "domain": "shelly", + "name": "shelly*", + "properties": {"macaddress": "ffaadd*"}, + } ] }, clear=True, @@ -329,7 +334,11 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): with patch.dict( zc_gen.ZEROCONF, - {"_airplay._tcp.local.": [{"domain": "samsungtv", "manufacturer": "samsung*"}]}, + { + "_airplay._tcp.local.": [ + {"domain": "samsungtv", "properties": {"manufacturer": "samsung*"}} + ] + }, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -362,7 +371,11 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): with patch.dict( zc_gen.ZEROCONF, - {"_airplay._tcp.local.": [{"domain": "appletv", "model": "appletv*"}]}, + { + "_airplay._tcp.local.": [ + {"domain": "appletv", "properties": {"model": "appletv*"}} + ] + }, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -395,7 +408,11 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): with patch.dict( zc_gen.ZEROCONF, - {"_airplay._tcp.local.": [{"domain": "samsungtv", "manufacturer": "samsung*"}]}, + { + "_airplay._tcp.local.": [ + {"domain": "samsungtv", "properties": {"manufacturer": "samsung*"}} + ] + }, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -459,7 +476,11 @@ def http_only_service_update_mock(ipv6, zeroconf, services, handlers): with patch.dict( zc_gen.ZEROCONF, - {"_airplay._tcp.local.": [{"domain": "samsungtv", "manufacturer": "samsung*"}]}, + { + "_airplay._tcp.local.": [ + {"domain": "samsungtv", "properties": {"manufacturer": "samsung*"}} + ] + }, clear=True, ), patch.object( hass.config_entries.flow, "async_init" @@ -520,7 +541,7 @@ async def test_homekit_match_partial_dash(hass, mock_async_zeroconf): ), ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", - side_effect=get_homekit_info_mock("Rachio-fa46ba", HOMEKIT_STATUS_UNPAIRED), + side_effect=get_homekit_info_mock("Smart Bridge-001", HOMEKIT_STATUS_UNPAIRED), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -528,7 +549,7 @@ async def test_homekit_match_partial_dash(hass, mock_async_zeroconf): assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 - assert mock_config_flow.mock_calls[0][1][0] == "rachio" + assert mock_config_flow.mock_calls[0][1][0] == "lutron_caseta" async def test_homekit_match_partial_fnmatch(hass, mock_async_zeroconf): @@ -629,7 +650,7 @@ async def test_homekit_invalid_paring_status(hass, mock_async_zeroconf): ), ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", - side_effect=get_homekit_info_mock("tado", b"invalid"), + side_effect=get_homekit_info_mock("Smart Bridge", b"invalid"), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -637,7 +658,7 @@ async def test_homekit_invalid_paring_status(hass, mock_async_zeroconf): assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 - assert mock_config_flow.mock_calls[0][1][0] == "tado" + assert mock_config_flow.mock_calls[0][1][0] == "lutron_caseta" async def test_homekit_not_paired(hass, mock_async_zeroconf): @@ -665,18 +686,52 @@ async def test_homekit_not_paired(hass, mock_async_zeroconf): assert mock_config_flow.mock_calls[0][1][0] == "homekit_controller" +async def test_homekit_controller_still_discovered_unpaired_for_cloud( + hass, mock_async_zeroconf +): + """Test discovery is still passed to homekit controller when unpaired and discovered by cloud integration. + + Since we prefer local control, if the integration that is being discovered + is cloud AND the homekit device is unpaired we still want to discovery it + """ + with patch.dict( + zc_gen.ZEROCONF, + {"_hap._udp.local.": [{"domain": "homekit_controller"}]}, + clear=True, + ), patch.object( + hass.config_entries.flow, "async_init" + ) as mock_config_flow, patch.object( + zeroconf, + "HaAsyncServiceBrowser", + side_effect=lambda *args, **kwargs: service_update_mock( + *args, **kwargs, limit_service="_hap._udp.local." + ), + ) as mock_service_browser, patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_homekit_info_mock("Rachio-xyz", HOMEKIT_STATUS_UNPAIRED), + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[0][1][0] == "rachio" + assert mock_config_flow.mock_calls[1][1][0] == "homekit_controller" + + async def test_info_from_service_non_utf8(hass): """Test info_from_service handles non UTF-8 property keys and values correctly.""" service_type = "_test._tcp.local." info = zeroconf.info_from_service( get_service_info_mock(service_type, f"test.{service_type}") ) - raw_info = info["properties"].pop("_raw", False) + raw_info = info.properties.pop("_raw", False) assert raw_info assert len(raw_info) == len(PROPERTIES) - 1 assert NON_ASCII_KEY not in raw_info - assert len(info["properties"]) <= len(raw_info) - assert "non-utf8-value" not in info["properties"] + assert len(info.properties) <= len(raw_info) + assert "non-utf8-value" not in info.properties assert raw_info["non-utf8-value"] is NON_UTF8_VALUE @@ -695,7 +750,7 @@ async def test_info_from_service_with_link_local_address_first(hass): service_info = get_service_info_mock(service_type, f"test.{service_type}") service_info.addresses = ["169.254.12.3", "192.168.66.12"] info = zeroconf.info_from_service(service_info) - assert info["host"] == "192.168.66.12" + assert info.host == "192.168.66.12" async def test_info_from_service_with_link_local_address_second(hass): @@ -704,7 +759,7 @@ async def test_info_from_service_with_link_local_address_second(hass): service_info = get_service_info_mock(service_type, f"test.{service_type}") service_info.addresses = ["192.168.66.12", "169.254.12.3"] info = zeroconf.info_from_service(service_info) - assert info["host"] == "192.168.66.12" + assert info.host == "192.168.66.12" async def test_info_from_service_with_link_local_address_only(hass): @@ -722,7 +777,7 @@ async def test_info_from_service_prefers_ipv4(hass): service_info = get_service_info_mock(service_type, f"test.{service_type}") service_info.addresses = ["2001:db8:3333:4444:5555:6666:7777:8888", "192.168.66.12"] info = zeroconf.info_from_service(service_info) - assert info["host"] == "192.168.66.12" + assert info.host == "192.168.66.12" async def test_get_instance(hass, mock_async_zeroconf): @@ -1032,6 +1087,7 @@ async def test_no_name(hass, mock_async_zeroconf): assert info.name == "Home._home-assistant._tcp.local." +@pytest.mark.usefixtures("mock_integration_frame") async def test_service_info_compatibility(hass, caplog): """Test compatibility with old-style dict. @@ -1046,21 +1102,15 @@ async def test_service_info_compatibility(hass, caplog): properties={}, ) - # Ensure first call get logged - assert discovery_info["host"] == "mock_host" - assert discovery_info.get("host") == "mock_host" + with patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()): + assert discovery_info["host"] == "mock_host" + assert "Detected integration that accessed discovery_info['host']" in caplog.text + + with patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()): + assert discovery_info.get("host") == "mock_host" + assert ( + "Detected integration that accessed discovery_info.get('host')" in caplog.text + ) + assert discovery_info.get("host", "fallback_host") == "mock_host" assert discovery_info.get("invalid_key", "fallback_host") == "fallback_host" - assert "Detected code that accessed discovery_info['host']" in caplog.text - assert "Detected code that accessed discovery_info.get('host')" not in caplog.text - - # Ensure second call doesn't get logged - caplog.clear() - assert discovery_info["host"] == "mock_host" - assert discovery_info.get("host") == "mock_host" - assert "Detected code that accessed discovery_info['host']" not in caplog.text - assert "Detected code that accessed discovery_info.get('host')" not in caplog.text - - discovery_info._warning_logged = False - assert discovery_info.get("host") == "mock_host" - assert "Detected code that accessed discovery_info.get('host')" in caplog.text diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index b302869d9e4b95..48772d31fb63a8 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -106,7 +106,7 @@ async def send_attributes_report(hass, cluster: zigpy.zcl.Cluster, attributes: d await hass.async_block_till_done() -async def find_entity_id(domain, zha_device, hass): +async def find_entity_id(domain, zha_device, hass, qualifier=None): """Find the entity id under the testing. This is used to get the entity id in order to get the state from the state @@ -115,7 +115,12 @@ async def find_entity_id(domain, zha_device, hass): entities = await find_entity_ids(domain, zha_device, hass) if not entities: return None - return entities[0] + if qualifier: + for entity_id in entities: + if qualifier in entity_id: + return entity_id + else: + return entities[0] async def find_entity_ids(domain, zha_device, hass): diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index 39063225e50131..84e66f0833a0d8 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -6,7 +6,6 @@ import zigpy.zcl.clusters.security as security import zigpy.zcl.foundation as zcl_f -from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ALARM_ARMED_AWAY, @@ -15,6 +14,7 @@ STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_UNAVAILABLE, + Platform, ) from .common import async_enable_traffic, find_entity_id @@ -46,7 +46,7 @@ async def test_alarm_control_panel(hass, zha_device_joined_restored, zigpy_devic zha_device = await zha_device_joined_restored(zigpy_device) cluster = zigpy_device.endpoints.get(1).ias_ace - entity_id = await find_entity_id(ALARM_DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.ALARM_CONTROL_PANEL, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED await async_enable_traffic(hass, [zha_device], enabled=False) @@ -62,7 +62,10 @@ async def test_alarm_control_panel(hass, zha_device_joined_restored, zigpy_devic # arm_away from HA cluster.client_command.reset_mock() await hass.services.async_call( - ALARM_DOMAIN, "alarm_arm_away", {ATTR_ENTITY_ID: entity_id}, blocking=True + Platform.ALARM_CONTROL_PANEL, + "alarm_arm_away", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY @@ -82,19 +85,22 @@ async def test_alarm_control_panel(hass, zha_device_joined_restored, zigpy_devic # trip alarm from faulty code entry cluster.client_command.reset_mock() await hass.services.async_call( - ALARM_DOMAIN, "alarm_arm_away", {ATTR_ENTITY_ID: entity_id}, blocking=True + Platform.ALARM_CONTROL_PANEL, + "alarm_arm_away", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY cluster.client_command.reset_mock() await hass.services.async_call( - ALARM_DOMAIN, + Platform.ALARM_CONTROL_PANEL, "alarm_disarm", {ATTR_ENTITY_ID: entity_id, "code": "1111"}, blocking=True, ) await hass.services.async_call( - ALARM_DOMAIN, + Platform.ALARM_CONTROL_PANEL, "alarm_disarm", {ATTR_ENTITY_ID: entity_id, "code": "1111"}, blocking=True, @@ -117,7 +123,10 @@ async def test_alarm_control_panel(hass, zha_device_joined_restored, zigpy_devic # arm_home from HA cluster.client_command.reset_mock() await hass.services.async_call( - ALARM_DOMAIN, "alarm_arm_home", {ATTR_ENTITY_ID: entity_id}, blocking=True + Platform.ALARM_CONTROL_PANEL, + "alarm_arm_home", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME @@ -134,7 +143,10 @@ async def test_alarm_control_panel(hass, zha_device_joined_restored, zigpy_devic # arm_night from HA cluster.client_command.reset_mock() await hass.services.async_call( - ALARM_DOMAIN, "alarm_arm_night", {ATTR_ENTITY_ID: entity_id}, blocking=True + Platform.ALARM_CONTROL_PANEL, + "alarm_arm_night", + {ATTR_ENTITY_ID: entity_id}, + blocking=True, ) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT @@ -228,7 +240,7 @@ async def reset_alarm_panel(hass, cluster, entity_id): """Reset the state of the alarm panel.""" cluster.client_command.reset_mock() await hass.services.async_call( - ALARM_DOMAIN, + Platform.ALARM_CONTROL_PANEL, "alarm_disarm", {ATTR_ENTITY_ID: entity_id, "code": "4321"}, blocking=True, diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 1ab638d0b26ddf..bfe2a3ce4f50bb 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -4,8 +4,7 @@ import zigpy.zcl.clusters.measurement as measurement import zigpy.zcl.clusters.security as security -from homeassistant.components.binary_sensor import DOMAIN -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from .common import ( async_enable_traffic, @@ -78,7 +77,7 @@ async def test_binary_sensor( """Test ZHA binary_sensor platform.""" zigpy_device = zigpy_device_mock(device) zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/zha/test_button.py b/tests/components/zha/test_button.py new file mode 100644 index 00000000000000..762d2d46e54271 --- /dev/null +++ b/tests/components/zha/test_button.py @@ -0,0 +1,89 @@ +"""Test ZHA button.""" +from unittest.mock import patch + +from freezegun import freeze_time +import pytest +from zigpy.const import SIG_EP_PROFILE +import zigpy.profiles.zha as zha +import zigpy.zcl.clusters.general as general +import zigpy.zcl.clusters.security as security +import zigpy.zcl.foundation as zcl_f + +from homeassistant.components.button import DOMAIN, ButtonDeviceClass +from homeassistant.components.button.const import SERVICE_PRESS +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_UNKNOWN, +) +from homeassistant.helpers import entity_registry as er + +from .common import find_entity_id +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE + +from tests.common import mock_coro + + +@pytest.fixture +async def contact_sensor(hass, zigpy_device_mock, zha_device_joined_restored): + """Contact sensor fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + security.IasZone.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_ZONE, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = await zha_device_joined_restored(zigpy_device) + return zha_device, zigpy_device.endpoints[1].identify + + +@freeze_time("2021-11-04 17:37:00", tz_offset=-1) +async def test_button(hass, contact_sensor): + """Test zha button platform.""" + + entity_registry = er.async_get(hass) + zha_device, cluster = contact_sensor + assert cluster is not None + entity_id = await find_entity_id(DOMAIN, zha_device, hass) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.UPDATE + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + + with patch( + "zigpy.zcl.Cluster.request", + return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 5 # duration in seconds + + state = hass.states.get(entity_id) + assert state + assert state.state == "2021-11-04T16:37:00+00:00" + assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.UPDATE diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index f4ec40fcb15031..8eafdc451cc9dd 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -101,6 +101,7 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock): [ (0x0000, 0, {}), (0x0001, 1, {"battery_voltage", "battery_percentage_remaining"}), + (0x0002, 1, {"current_temperature"}), (0x0003, 0, {}), (0x0004, 0, {}), (0x0005, 1, {}), @@ -203,6 +204,7 @@ async def test_in_channel_config( [ (0x0000, 0), (0x0001, 1), + (0x0002, 1), (0x0003, 0), (0x0004, 0), (0x0005, 1), @@ -619,3 +621,23 @@ async def test_zll_device_groups( zigpy_coordinator_device.add_to_group.await_args_list[1][0][0] == group_2.group_id ) + + +@mock.patch( + "homeassistant.components.zha.core.channels.ChannelPool.add_client_channels" +) +@mock.patch( + "homeassistant.components.zha.core.discovery.PROBE.discover_entities", + mock.MagicMock(), +) +async def test_cluster_no_ep_attribute(m1, zha_device_mock): + """Test channels for clusters without ep_attribute.""" + + zha_device = zha_device_mock( + {1: {SIG_EP_INPUT: [0x042E], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, + ) + + channels = zha_channels.Channels.new(zha_device) + pools = {pool.id: pool for pool in channels.pools} + assert "1:0x042e" in pools[1].all_channels + assert pools[1].all_channels["1:0x042e"].name diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index 09da644bd291d5..4dc72b092e49eb 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -45,14 +45,14 @@ SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.zha.climate import ( - DOMAIN, - HVAC_MODE_2_SYSTEM, - SEQ_OF_OPERATION, -) +from homeassistant.components.zha.climate import HVAC_MODE_2_SYSTEM, SEQ_OF_OPERATION from homeassistant.components.zha.core.const import PRESET_COMPLEX, PRESET_SCHEDULE -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_UNKNOWN, + Platform, +) from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -244,7 +244,7 @@ async def test_climate_local_temp(hass, device_climate): """Test local temperature.""" thrm_cluster = device_climate.device.endpoints[1].thermostat - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) state = hass.states.get(entity_id) assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None @@ -254,12 +254,14 @@ async def test_climate_local_temp(hass, device_climate): assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.0 -async def test_climate_hvac_action_running_state(hass, device_climate): +async def test_climate_hvac_action_running_state(hass, device_climate_sinope): """Test hvac action via running state.""" - thrm_cluster = device_climate.device.endpoints[1].thermostat - entity_id = await find_entity_id(DOMAIN, device_climate, hass) - sensor_entity_id = await find_entity_id(SENSOR_DOMAIN, device_climate, hass) + thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat + entity_id = await find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) + sensor_entity_id = await find_entity_id( + Platform.SENSOR, device_climate_sinope, hass, "hvac" + ) state = hass.states.get(entity_id) assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF @@ -319,8 +321,8 @@ async def test_climate_hvac_action_running_state_zen(hass, device_climate_zen): """Test Zen hvac action via running state.""" thrm_cluster = device_climate_zen.device.endpoints[1].thermostat - entity_id = await find_entity_id(DOMAIN, device_climate_zen, hass) - sensor_entity_id = await find_entity_id(SENSOR_DOMAIN, device_climate_zen, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate_zen, hass) + sensor_entity_id = await find_entity_id(Platform.SENSOR, device_climate_zen, hass) state = hass.states.get(entity_id) assert ATTR_HVAC_ACTION not in state.attributes @@ -404,10 +406,10 @@ async def test_climate_hvac_action_pi_demand(hass, device_climate): """Test hvac action based on pi_heating/cooling_demand attrs.""" thrm_cluster = device_climate.device.endpoints[1].thermostat - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) state = hass.states.get(entity_id) - assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert ATTR_HVAC_ACTION not in state.attributes await send_attributes_report(hass, thrm_cluster, {0x0007: 10}) state = hass.states.get(entity_id) @@ -451,7 +453,7 @@ async def test_hvac_mode(hass, device_climate, sys_mode, hvac_mode): """Test HVAC modee.""" thrm_cluster = device_climate.device.endpoints[1].thermostat - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) state = hass.states.get(entity_id) assert state.state == HVAC_MODE_OFF @@ -489,7 +491,7 @@ async def test_hvac_modes(hass, device_climate_mock, seq_of_op, modes): device_climate = await device_climate_mock( CLIMATE, {"ctrl_seqe_of_oper": seq_of_op} ) - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) state = hass.states.get(entity_id) assert set(state.attributes[ATTR_HVAC_MODES]) == modes @@ -520,10 +522,10 @@ async def test_target_temperature( manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) if preset: await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: preset}, blocking=True, @@ -556,10 +558,10 @@ async def test_target_temperature_high( manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) if preset: await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: preset}, blocking=True, @@ -592,10 +594,10 @@ async def test_target_temperature_low( manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) if preset: await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: preset}, blocking=True, @@ -620,13 +622,13 @@ async def test_set_hvac_mode(hass, device_climate, hvac_mode, sys_mode): """Test setting hvac mode.""" thrm_cluster = device_climate.device.endpoints[1].thermostat - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) state = hass.states.get(entity_id) assert state.state == HVAC_MODE_OFF await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode}, blocking=True, @@ -645,7 +647,7 @@ async def test_set_hvac_mode(hass, device_climate, hvac_mode, sys_mode): # turn off thrm_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVAC_MODE_OFF}, blocking=True, @@ -661,7 +663,7 @@ async def test_set_hvac_mode(hass, device_climate, hvac_mode, sys_mode): async def test_preset_setting(hass, device_climate_sinope): """Test preset setting.""" - entity_id = await find_entity_id(DOMAIN, device_climate_sinope, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -673,7 +675,7 @@ async def test_preset_setting(hass, device_climate_sinope): ] await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, @@ -690,7 +692,7 @@ async def test_preset_setting(hass, device_climate_sinope): zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0] ] await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, @@ -707,7 +709,7 @@ async def test_preset_setting(hass, device_climate_sinope): zcl_f.WriteAttributesResponse.deserialize(b"\x01\x01\x01")[0] ] await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, blocking=True, @@ -724,7 +726,7 @@ async def test_preset_setting(hass, device_climate_sinope): zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0] ] await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, blocking=True, @@ -739,14 +741,14 @@ async def test_preset_setting(hass, device_climate_sinope): async def test_preset_setting_invalid(hass, device_climate_sinope): """Test invalid preset setting.""" - entity_id = await find_entity_id(DOMAIN, device_climate_sinope, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "invalid_preset"}, blocking=True, @@ -760,14 +762,14 @@ async def test_preset_setting_invalid(hass, device_climate_sinope): async def test_set_temperature_hvac_mode(hass, device_climate): """Test setting HVAC mode in temperature service call.""" - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat state = hass.states.get(entity_id) assert state.state == HVAC_MODE_OFF await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -800,14 +802,14 @@ async def test_set_temperature_heat_cool(hass, device_climate_mock): manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat state = hass.states.get(entity_id) assert state.state == HVAC_MODE_HEAT_COOL await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 21}, blocking=True, @@ -819,7 +821,7 @@ async def test_set_temperature_heat_cool(hass, device_climate_mock): assert thrm_cluster.write_attributes.await_count == 0 await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -841,7 +843,7 @@ async def test_set_temperature_heat_cool(hass, device_climate_mock): } await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, @@ -849,7 +851,7 @@ async def test_set_temperature_heat_cool(hass, device_climate_mock): thrm_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -886,14 +888,14 @@ async def test_set_temperature_heat(hass, device_climate_mock): manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat state = hass.states.get(entity_id) assert state.state == HVAC_MODE_HEAT await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -910,7 +912,7 @@ async def test_set_temperature_heat(hass, device_climate_mock): assert thrm_cluster.write_attributes.await_count == 0 await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 21}, blocking=True, @@ -926,7 +928,7 @@ async def test_set_temperature_heat(hass, device_climate_mock): } await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, @@ -934,7 +936,7 @@ async def test_set_temperature_heat(hass, device_climate_mock): thrm_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 22}, blocking=True, @@ -965,14 +967,14 @@ async def test_set_temperature_cool(hass, device_climate_mock): manuf=MANUF_SINOPE, quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, ) - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat state = hass.states.get(entity_id) assert state.state == HVAC_MODE_COOL await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: entity_id, @@ -989,7 +991,7 @@ async def test_set_temperature_cool(hass, device_climate_mock): assert thrm_cluster.write_attributes.await_count == 0 await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 21}, blocking=True, @@ -1005,7 +1007,7 @@ async def test_set_temperature_cool(hass, device_climate_mock): } await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, @@ -1013,7 +1015,7 @@ async def test_set_temperature_cool(hass, device_climate_mock): thrm_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 22}, blocking=True, @@ -1048,14 +1050,14 @@ async def test_set_temperature_wrong_mode(hass, device_climate_mock): }, manuf=MANUF_SINOPE, ) - entity_id = await find_entity_id(DOMAIN, device_climate, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate, hass) thrm_cluster = device_climate.device.endpoints[1].thermostat state = hass.states.get(entity_id) assert state.state == HVAC_MODE_DRY await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 24}, blocking=True, @@ -1071,14 +1073,14 @@ async def test_set_temperature_wrong_mode(hass, device_climate_mock): async def test_occupancy_reset(hass, device_climate_sinope): """Test away preset reset.""" - entity_id = await find_entity_id(DOMAIN, device_climate_sinope, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate_sinope, hass) thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, @@ -1098,7 +1100,7 @@ async def test_occupancy_reset(hass, device_climate_sinope): async def test_fan_mode(hass, device_climate_fan): """Test fan mode.""" - entity_id = await find_entity_id(DOMAIN, device_climate_fan, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate_fan, hass) thrm_cluster = device_climate_fan.device.endpoints[1].thermostat state = hass.states.get(entity_id) @@ -1127,11 +1129,11 @@ async def test_fan_mode(hass, device_climate_fan): async def test_set_fan_mode_not_supported(hass, device_climate_fan): """Test fan setting unsupported mode.""" - entity_id = await find_entity_id(DOMAIN, device_climate_fan, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate_fan, hass) fan_cluster = device_climate_fan.device.endpoints[1].fan await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, blocking=True, @@ -1142,14 +1144,14 @@ async def test_set_fan_mode_not_supported(hass, device_climate_fan): async def test_set_fan_mode(hass, device_climate_fan): """Test fan mode setting.""" - entity_id = await find_entity_id(DOMAIN, device_climate_fan, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate_fan, hass) fan_cluster = device_climate_fan.device.endpoints[1].fan state = hass.states.get(entity_id) assert state.attributes[ATTR_FAN_MODE] == FAN_AUTO await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_ON}, blocking=True, @@ -1159,7 +1161,7 @@ async def test_set_fan_mode(hass, device_climate_fan): fan_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_AUTO}, blocking=True, @@ -1171,14 +1173,14 @@ async def test_set_fan_mode(hass, device_climate_fan): async def test_set_moes_preset(hass, device_climate_moes): """Test setting preset for moes trv.""" - entity_id = await find_entity_id(DOMAIN, device_climate_moes, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate_moes, hass) thrm_cluster = device_climate_moes.device.endpoints[1].thermostat state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, @@ -1191,7 +1193,7 @@ async def test_set_moes_preset(hass, device_climate_moes): thrm_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_SCHEDULE}, blocking=True, @@ -1207,7 +1209,7 @@ async def test_set_moes_preset(hass, device_climate_moes): thrm_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_COMFORT}, blocking=True, @@ -1223,7 +1225,7 @@ async def test_set_moes_preset(hass, device_climate_moes): thrm_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_ECO}, blocking=True, @@ -1239,7 +1241,7 @@ async def test_set_moes_preset(hass, device_climate_moes): thrm_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_BOOST}, blocking=True, @@ -1255,7 +1257,7 @@ async def test_set_moes_preset(hass, device_climate_moes): thrm_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_COMPLEX}, blocking=True, @@ -1271,7 +1273,7 @@ async def test_set_moes_preset(hass, device_climate_moes): thrm_cluster.write_attributes.reset_mock() await hass.services.async_call( - DOMAIN, + Platform.CLIMATE, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, blocking=True, @@ -1286,7 +1288,7 @@ async def test_set_moes_preset(hass, device_climate_moes): async def test_set_moes_operation_mode(hass, device_climate_moes): """Test setting preset for moes trv.""" - entity_id = await find_entity_id(DOMAIN, device_climate_moes, hass) + entity_id = await find_entity_id(Platform.CLIMATE, device_climate_moes, hass) thrm_cluster = device_climate_moes.device.endpoints[1].thermostat await send_attributes_report(hass, thrm_cluster, {"operation_preset": 0}) diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index e002e2c26f0b33..45c5928797dcf2 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -11,7 +11,6 @@ from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, - DOMAIN, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, @@ -22,6 +21,7 @@ STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import CoreState, State @@ -116,7 +116,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): assert cluster.read_attributes.call_count == 2 assert "current_position_lift_percentage" in cluster.read_attributes.call_args[0][0] - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None await async_enable_traffic(hass, [zha_device], enabled=False) @@ -140,7 +140,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): "zigpy.zcl.Cluster.request", return_value=mock_coro([0x1, zcl_f.Status.SUCCESS]) ): await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True + Platform.COVER, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True ) assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False @@ -153,7 +153,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): "zigpy.zcl.Cluster.request", return_value=mock_coro([0x0, zcl_f.Status.SUCCESS]) ): await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True + Platform.COVER, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True ) assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False @@ -166,7 +166,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): "zigpy.zcl.Cluster.request", return_value=mock_coro([0x5, zcl_f.Status.SUCCESS]) ): await hass.services.async_call( - DOMAIN, + Platform.COVER, SERVICE_SET_COVER_POSITION, {"entity_id": entity_id, "position": 47}, blocking=True, @@ -183,7 +183,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): "zigpy.zcl.Cluster.request", return_value=mock_coro([0x2, zcl_f.Status.SUCCESS]) ): await hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True + Platform.COVER, SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True ) assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False @@ -204,7 +204,7 @@ async def test_shade(hass, zha_device_joined_restored, zigpy_shade_device): cluster_on_off = zigpy_shade_device.endpoints.get(1).on_off cluster_level = zigpy_shade_device.endpoints.get(1).level - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None await async_enable_traffic(hass, [zha_device], enabled=False) @@ -226,7 +226,7 @@ async def test_shade(hass, zha_device_joined_restored, zigpy_shade_device): # close from UI command fails with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True + Platform.COVER, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True ) assert cluster_on_off.request.call_count == 1 assert cluster_on_off.request.call_args[0][0] is False @@ -237,7 +237,7 @@ async def test_shade(hass, zha_device_joined_restored, zigpy_shade_device): "zigpy.zcl.Cluster.request", AsyncMock(return_value=[0x1, zcl_f.Status.SUCCESS]) ): await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True + Platform.COVER, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True ) assert cluster_on_off.request.call_count == 1 assert cluster_on_off.request.call_args[0][0] is False @@ -249,7 +249,7 @@ async def test_shade(hass, zha_device_joined_restored, zigpy_shade_device): await send_attributes_report(hass, cluster_level, {0: 0}) with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True + Platform.COVER, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True ) assert cluster_on_off.request.call_count == 1 assert cluster_on_off.request.call_args[0][0] is False @@ -261,7 +261,7 @@ async def test_shade(hass, zha_device_joined_restored, zigpy_shade_device): "zigpy.zcl.Cluster.request", AsyncMock(return_value=[0x0, zcl_f.Status.SUCCESS]) ): await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True + Platform.COVER, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True ) assert cluster_on_off.request.call_count == 1 assert cluster_on_off.request.call_args[0][0] is False @@ -271,7 +271,7 @@ async def test_shade(hass, zha_device_joined_restored, zigpy_shade_device): # set position UI command fails with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): await hass.services.async_call( - DOMAIN, + Platform.COVER, SERVICE_SET_COVER_POSITION, {"entity_id": entity_id, "position": 47}, blocking=True, @@ -287,7 +287,7 @@ async def test_shade(hass, zha_device_joined_restored, zigpy_shade_device): "zigpy.zcl.Cluster.request", AsyncMock(return_value=[0x5, zcl_f.Status.SUCCESS]) ): await hass.services.async_call( - DOMAIN, + Platform.COVER, SERVICE_SET_COVER_POSITION, {"entity_id": entity_id, "position": 47}, blocking=True, @@ -313,7 +313,7 @@ async def test_shade(hass, zha_device_joined_restored, zigpy_shade_device): # test cover stop with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): await hass.services.async_call( - DOMAIN, + Platform.COVER, SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True, @@ -340,7 +340,7 @@ async def test_restore_state(hass, zha_device_restored, zigpy_shade_device): hass.state = CoreState.starting zha_device = await zha_device_restored(zigpy_shade_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None # test that the cover was created and that it is unavailable @@ -356,7 +356,7 @@ async def test_keen_vent(hass, zha_device_joined_restored, zigpy_keen_vent): cluster_on_off = zigpy_keen_vent.endpoints.get(1).on_off cluster_level = zigpy_keen_vent.endpoints.get(1).level - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None await async_enable_traffic(hass, [zha_device], enabled=False) @@ -377,7 +377,7 @@ async def test_keen_vent(hass, zha_device_joined_restored, zigpy_keen_vent): with p1, p2: await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True + Platform.COVER, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True ) assert cluster_on_off.request.call_count == 1 assert cluster_on_off.request.call_args[0][0] is False @@ -391,7 +391,7 @@ async def test_keen_vent(hass, zha_device_joined_restored, zigpy_keen_vent): with p1, p2: await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True + Platform.COVER, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True ) await asyncio.sleep(0) assert cluster_on_off.request.call_count == 1 diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index b67f54a0a160b6..9bc52a784f6288 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -8,7 +8,9 @@ import zigpy.zcl.foundation as zcl_f import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.zha import DOMAIN +from homeassistant.const import Platform from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -51,11 +53,37 @@ async def test_get_actions(hass, device_ias): ha_device_registry = dr.async_get(hass) reg_device = ha_device_registry.async_get_device({(DOMAIN, ieee_address)}) - actions = await async_get_device_automations(hass, "action", reg_device.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, reg_device.id + ) expected_actions = [ {"domain": DOMAIN, "type": "squawk", "device_id": reg_device.id}, {"domain": DOMAIN, "type": "warn", "device_id": reg_device.id}, + { + "domain": Platform.SELECT, + "type": "select_option", + "device_id": reg_device.id, + "entity_id": "select.fakemanufacturer_fakemodel_e769900a_ias_wd_warningmode", + }, + { + "domain": Platform.SELECT, + "type": "select_option", + "device_id": reg_device.id, + "entity_id": "select.fakemanufacturer_fakemodel_e769900a_ias_wd_sirenlevel", + }, + { + "domain": Platform.SELECT, + "type": "select_option", + "device_id": reg_device.id, + "entity_id": "select.fakemanufacturer_fakemodel_e769900a_ias_wd_strobelevel", + }, + { + "domain": Platform.SELECT, + "type": "select_option", + "device_id": reg_device.id, + "entity_id": "select.fakemanufacturer_fakemodel_e769900a_ias_wd_strobe", + }, ] assert actions == expected_actions diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index 60dd136b9fdad4..e345918179ee61 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -6,11 +6,11 @@ import zigpy.profiles.zha import zigpy.zcl.clusters.general as general -from homeassistant.components.device_tracker import DOMAIN, SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER from homeassistant.components.zha.core.registries import ( SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE, ) -from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE +from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, Platform import homeassistant.util.dt as dt_util from .common import ( @@ -49,7 +49,7 @@ async def test_device_tracker(hass, zha_device_joined_restored, zigpy_device_dt) zha_device = await zha_device_joined_restored(zigpy_device_dt) cluster = zigpy_device_dt.endpoints.get(1).power - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.DEVICE_TRACKER, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_HOME @@ -80,7 +80,7 @@ async def test_device_tracker(hass, zha_device_joined_restored, zigpy_device_dt) assert hass.states.get(entity_id).state == STATE_HOME - entity = hass.data[DOMAIN].get_entity(entity_id) + entity = hass.data[Platform.DEVICE_TRACKER].get_entity(entity_id) assert entity.is_connected is True assert entity.source_type == SOURCE_TYPE_ROUTER diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index fbfc2144a6ac3e..87cf6ba344da7b 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -7,6 +7,7 @@ import zigpy.zcl.clusters.general as general import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -90,7 +91,9 @@ async def test_triggers(hass, mock_devices): ha_device_registry = dr.async_get(hass) reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) - triggers = await async_get_device_automations(hass, "trigger", reg_device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, reg_device.id + ) expected_triggers = [ { @@ -148,7 +151,9 @@ async def test_no_triggers(hass, mock_devices): ha_device_registry = dr.async_get(hass) reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) - triggers = await async_get_device_automations(hass, "trigger", reg_device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, reg_device.id + ) assert triggers == [ { "device_id": reg_device.id, diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index d765ede0e5fb72..9953b6e9d15707 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -2,7 +2,7 @@ import re from unittest import mock -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from zigpy.const import SIG_ENDPOINTS, SIG_MANUFACTURER, SIG_MODEL, SIG_NODE_DESC @@ -27,6 +27,7 @@ import homeassistant.components.zha.lock import homeassistant.components.zha.sensor import homeassistant.components.zha.switch +from homeassistant.const import Platform import homeassistant.helpers.entity_registry from .common import get_zha_gateway @@ -69,6 +70,14 @@ def _mock( "zigpy.zcl.clusters.general.Identify.request", new=AsyncMock(return_value=[mock.sentinel.data, zcl_f.Status.SUCCESS]), ) +# We do this here because we are testing ZHA discovery logic. Point being we want to ensure that +# all discovered entities are dispatched for creation. In order to test this we need the entities +# added to HA. So we ensure that they are all enabled even though they won't necessarily be in reality +# at runtime +@patch( + "homeassistant.components.zha.entity.ZhaEntity.entity_registry_enabled_default", + new=Mock(return_value=True), +) @pytest.mark.parametrize("device", DEVICES) async def test_devices( device, @@ -124,17 +133,25 @@ async def test_devices( ch.id for pool in zha_dev.channels.pools for ch in pool.client_channels.values() } assert event_channels == set(device[DEV_SIG_EVT_CHANNELS]) - + # we need to probe the class create entity factory so we need to reset this to get accurate results + zha_regs.ZHA_ENTITIES.clean_up() # build a dict of entity_class -> (component, unique_id, channels) tuple ha_ent_info = {} + created_entity_count = 0 for call in _dispatch.call_args_list: _, component, entity_cls, unique_id, channels = call[0] - unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) # ieee + endpoint_id - ha_ent_info[(unique_id_head, entity_cls.__name__)] = ( - component, - unique_id, - channels, - ) + # the factory can return None. We filter these out to get an accurate created entity count + response = entity_cls.create_entity(unique_id, zha_dev, channels) + if response: + created_entity_count += 1 + unique_id_head = UNIQUE_ID_HD.match(unique_id).group( + 0 + ) # ieee + endpoint_id + ha_ent_info[(unique_id_head, entity_cls.__name__)] = ( + component, + unique_id, + channels, + ) for comp_id, ent_info in device[DEV_SIG_ENT_MAP].items(): component, unique_id = comp_id @@ -150,12 +167,12 @@ async def test_devices( ha_comp, ha_unique_id, ha_channels = ha_ent_info[ (test_unique_id_head, test_ent_class) ] - assert component is ha_comp + assert component is ha_comp.value # unique_id used for discover is the same for "multi entities" assert unique_id.startswith(ha_unique_id) assert {ch.name for ch in ha_channels} == set(ent_info[DEV_SIG_CHANNELS]) - assert _dispatch.call_count == len(device[DEV_SIG_ENT_MAP]) + assert created_entity_count == len(device[DEV_SIG_ENT_MAP]) entity_ids = hass_disable_services.states.async_entity_ids() await hass_disable_services.async_block_till_done() @@ -193,9 +210,9 @@ def test_discover_entities(m1, m2): @pytest.mark.parametrize( "device_type, component, hit", [ - (zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT, zha_const.LIGHT, True), - (zigpy.profiles.zha.DeviceType.ON_OFF_BALLAST, zha_const.SWITCH, True), - (zigpy.profiles.zha.DeviceType.SMART_PLUG, zha_const.SWITCH, True), + (zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT, Platform.LIGHT, True), + (zigpy.profiles.zha.DeviceType.ON_OFF_BALLAST, Platform.SWITCH, True), + (zigpy.profiles.zha.DeviceType.SMART_PLUG, Platform.SWITCH, True), (0xFFFF, None, False), ], ) @@ -234,7 +251,7 @@ def test_discover_by_device_type_override(): ep_mock.return_value.device_type = 0x0100 type(ep_channels).endpoint = ep_mock - overrides = {ep_channels.unique_id: {"type": zha_const.SWITCH}} + overrides = {ep_channels.unique_id: {"type": Platform.SWITCH}} get_entity_mock = mock.MagicMock( return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed) ) @@ -247,7 +264,7 @@ def test_discover_by_device_type_override(): 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][0] == Platform.SWITCH assert ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls @@ -268,13 +285,13 @@ def test_discover_probe_single_cluster(): "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity", get_entity_mock, ): - disc.PROBE.probe_single_cluster(zha_const.SWITCH, channel_mock, ep_channels) + disc.PROBE.probe_single_cluster(Platform.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][0] == Platform.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 @@ -297,7 +314,6 @@ async def test_discover_endpoint(device_info, channels_mock, hass): assert device_info[DEV_SIG_EVT_CHANNELS] == sorted( ch.id for pool in channels.pools for ch in pool.client_channels.values() ) - assert new_ent.call_count == len(list(device_info[DEV_SIG_ENT_MAP].values())) # build a dict of entity_class -> (component, unique_id, channels) tuple ha_ent_info = {} @@ -320,13 +336,11 @@ async def test_discover_endpoint(device_info, channels_mock, hass): ha_comp, ha_unique_id, ha_channels = ha_ent_info[ (test_unique_id_head, test_ent_class) ] - assert component is ha_comp + assert component is ha_comp.value # unique_id used for discover is the same for "multi entities" assert unique_id.startswith(ha_unique_id) assert {ch.name for ch in ha_channels} == set(ent_info[DEV_SIG_CHANNELS]) - assert new_ent.call_count == len(device_info[DEV_SIG_ENT_MAP]) - def _ch_mock(cluster): """Return mock of a channel with a cluster.""" @@ -366,17 +380,16 @@ class _Analog(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.general.AnalogInput 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), + (Platform.LOCK, door_ch), + (Platform.COVER, cover_ch), + (Platform.SENSOR, multistate_ch), + (Platform.BINARY_SENSOR, ias_ch), + (Platform.SENSOR, analog_ch), ) for call, details in zip(probe_mock.call_args_list, probes): component, ch = details @@ -384,19 +397,14 @@ class _Analog(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.general.AnalogInput 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, + zigpy.zcl.clusters.closures.DoorLock.cluster_id: Platform.LOCK, + zigpy.zcl.clusters.closures.WindowCovering.cluster_id: Platform.COVER, + zigpy.zcl.clusters.general.AnalogInput: Platform.SENSOR, + zigpy.zcl.clusters.general.MultistateInput: Platform.SENSOR, + zigpy.zcl.clusters.security.IasZone: Platform.BINARY_SENSOR, } with mock.patch.dict( @@ -453,3 +461,35 @@ async def test_group_probe_cleanup_called( await config_entry.async_unload(hass_disable_services) await hass_disable_services.async_block_till_done() disc.GROUP_PROBE.cleanup.assert_called() + + +@patch( + "zigpy.zcl.clusters.general.Identify.request", + new=AsyncMock(return_value=[mock.sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "homeassistant.components.zha.entity.ZhaEntity.entity_registry_enabled_default", + new=Mock(return_value=True), +) +async def test_channel_with_empty_ep_attribute_cluster( + hass_disable_services, + zigpy_device_mock, + zha_device_joined_restored, +): + """Test device discovery for cluster which does not have em_attribute.""" + entity_registry = homeassistant.helpers.entity_registry.async_get( + hass_disable_services + ) + + zigpy_device = zigpy_device_mock( + {1: {SIG_EP_INPUT: [0x042E], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + patch_cluster=False, + ) + zha_dev = await zha_device_joined_restored(zigpy_device) + ha_entity_id = entity_registry.async_get_entity_id( + "sensor", "zha", f"{zha_dev.ieee}-1-1070" + ) + assert ha_entity_id is not None diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 212152e231d43b..e94c028acd899a 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -14,7 +14,6 @@ ATTR_PERCENTAGE_STEP, ATTR_PRESET_MODE, ATTR_SPEED, - DOMAIN, SERVICE_SET_PRESET_MODE, SERVICE_SET_SPEED, SPEED_HIGH, @@ -23,7 +22,6 @@ SPEED_OFF, NotValidPresetModeError, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.zha.core.discovery import GROUP_PROBE from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.fan import ( @@ -38,6 +36,7 @@ STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.setup import async_setup_component @@ -151,7 +150,7 @@ async def test_fan(hass, zha_device_joined_restored, zigpy_device): zha_device = await zha_device_joined_restored(zigpy_device) cluster = zigpy_device.endpoints.get(1).fan - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.FAN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF @@ -217,14 +216,14 @@ async def async_turn_on(hass, entity_id, speed=None): if value is not None } - await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data, blocking=True) + await hass.services.async_call(Platform.FAN, SERVICE_TURN_ON, data, blocking=True) async def async_turn_off(hass, entity_id): """Turn fan off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data, blocking=True) + await hass.services.async_call(Platform.FAN, SERVICE_TURN_OFF, data, blocking=True) async def async_set_speed(hass, entity_id, speed=None): @@ -235,7 +234,7 @@ async def async_set_speed(hass, entity_id, speed=None): if value is not None } - await hass.services.async_call(DOMAIN, SERVICE_SET_SPEED, data, blocking=True) + await hass.services.async_call(Platform.FAN, SERVICE_SET_SPEED, data, blocking=True) async def async_set_preset_mode(hass, entity_id, preset_mode=None): @@ -246,7 +245,9 @@ async def async_set_preset_mode(hass, entity_id, preset_mode=None): if value is not None } - await hass.services.async_call(DOMAIN, SERVICE_SET_PRESET_MODE, data, blocking=True) + await hass.services.async_call( + Platform.FAN, SERVICE_SET_PRESET_MODE, data, blocking=True + ) @patch( @@ -282,10 +283,10 @@ async def test_zha_group_fan_entity(hass, device_fan_1, device_fan_2, coordinato entity_domains = GROUP_PROBE.determine_entity_domains(hass, zha_group) assert len(entity_domains) == 2 - assert LIGHT_DOMAIN in entity_domains - assert DOMAIN in entity_domains + assert Platform.LIGHT in entity_domains + assert Platform.FAN in entity_domains - entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group) + entity_id = async_find_group_entity_id(hass, Platform.FAN, zha_group) assert hass.states.get(entity_id) is not None group_fan_cluster = zha_group.endpoint[hvac.Fan.cluster_id] @@ -396,10 +397,10 @@ async def test_zha_group_fan_entity_failure_state( entity_domains = GROUP_PROBE.determine_entity_domains(hass, zha_group) assert len(entity_domains) == 2 - assert LIGHT_DOMAIN in entity_domains - assert DOMAIN in entity_domains + assert Platform.LIGHT in entity_domains + assert Platform.FAN in entity_domains - entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group) + entity_id = async_find_group_entity_id(hass, Platform.FAN, zha_group) assert hass.states.get(entity_id) is not None group_fan_cluster = zha_group.endpoint[hvac.Fan.cluster_id] @@ -450,7 +451,7 @@ async def test_fan_init( cluster.PLUGGED_ATTR_READS = plug_read zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.FAN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == expected_state assert hass.states.get(entity_id).attributes[ATTR_SPEED] == expected_speed @@ -469,7 +470,7 @@ async def test_fan_update_entity( cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.FAN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF assert hass.states.get(entity_id).attributes[ATTR_SPEED] == SPEED_OFF diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 6662f0c2c9f4aa..a263a6d7ed359d 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -8,9 +8,9 @@ import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.store import TOMBSTONE_LIFETIME +from homeassistant.const import Platform from .common import async_enable_traffic, async_find_group_entity_id, get_zha_gateway from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -144,7 +144,7 @@ async def test_gateway_group_methods(hass, device_light_1, device_light_2, coord for member in zha_group.members: assert member.device.ieee in member_ieee_addresses - entity_id = async_find_group_entity_id(hass, LIGHT_DOMAIN, zha_group) + entity_id = async_find_group_entity_id(hass, Platform.LIGHT, zha_group) assert hass.states.get(entity_id) is not None # test get group by name @@ -158,7 +158,7 @@ async def test_gateway_group_methods(hass, device_light_1, device_light_2, coord assert zha_gateway.async_get_group_by_name(zha_group.name) is None # the group entity should be cleaned up - assert entity_id not in hass.states.async_entity_ids(LIGHT_DOMAIN) + assert entity_id not in hass.states.async_entity_ids(Platform.LIGHT) # test creating a group with 1 member zha_group = await zha_gateway.async_create_zigpy_group( @@ -172,7 +172,7 @@ async def test_gateway_group_methods(hass, device_light_1, device_light_2, coord assert member.device.ieee in [device_light_1.ieee] # the group entity should not have been cleaned up - assert entity_id not in hass.states.async_entity_ids(LIGHT_DOMAIN) + assert entity_id not in hass.states.async_entity_ids(Platform.LIGHT) with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): await zha_group.members[0].async_remove_from_group() diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index d1a3b5dabb0c9f..ee437fc63c938a 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -9,10 +9,10 @@ import zigpy.zcl.clusters.lighting as lighting import zigpy.zcl.foundation as zcl_f -from homeassistant.components.light import DOMAIN, FLASH_LONG, FLASH_SHORT +from homeassistant.components.light import FLASH_LONG, FLASH_SHORT from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.light import FLASH_EFFECTS -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform import homeassistant.util.dt as dt_util from .common import ( @@ -187,7 +187,7 @@ async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored on_off_cluster = zigpy_device.endpoints[1].on_off on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 0} zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.LIGHT, zha_device, hass) # allow traffic to flow through the gateway and device await async_enable_traffic(hass, [zha_device]) @@ -245,7 +245,7 @@ async def test_light( # create zigpy devices zigpy_device = zigpy_device_mock(device) zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.LIGHT, zha_device, hass) assert entity_id is not None @@ -327,7 +327,7 @@ async def async_test_on_off_from_hass(hass, cluster, entity_id): # turn on via UI cluster.request.reset_mock() await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + Platform.LIGHT, "turn_on", {"entity_id": entity_id}, blocking=True ) assert cluster.request.call_count == 1 assert cluster.request.await_count == 1 @@ -344,7 +344,7 @@ async def async_test_off_from_hass(hass, cluster, entity_id): # turn off via UI cluster.request.reset_mock() await hass.services.async_call( - DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + Platform.LIGHT, "turn_off", {"entity_id": entity_id}, blocking=True ) assert cluster.request.call_count == 1 assert cluster.request.await_count == 1 @@ -362,7 +362,7 @@ async def async_test_level_on_off_from_hass( level_cluster.request.reset_mock() # turn on via UI await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + Platform.LIGHT, "turn_on", {"entity_id": entity_id}, blocking=True ) assert on_off_cluster.request.call_count == 1 assert on_off_cluster.request.await_count == 1 @@ -375,7 +375,10 @@ async def async_test_level_on_off_from_hass( level_cluster.request.reset_mock() await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": entity_id, "transition": 10}, blocking=True + Platform.LIGHT, + "turn_on", + {"entity_id": entity_id, "transition": 10}, + blocking=True, ) assert on_off_cluster.request.call_count == 1 assert on_off_cluster.request.await_count == 1 @@ -399,7 +402,10 @@ async def async_test_level_on_off_from_hass( level_cluster.request.reset_mock() await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": entity_id, "brightness": 10}, blocking=True + Platform.LIGHT, + "turn_on", + {"entity_id": entity_id, "brightness": 10}, + blocking=True, ) # the onoff cluster is now not used when brightness is present by default assert on_off_cluster.request.call_count == 0 @@ -442,7 +448,10 @@ async def async_test_flash_from_hass(hass, cluster, entity_id, flash): # turn on via UI cluster.request.reset_mock() await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": entity_id, "flash": flash}, blocking=True + Platform.LIGHT, + "turn_on", + {"entity_id": entity_id, "flash": flash}, + blocking=True, ) assert cluster.request.call_count == 1 assert cluster.request.await_count == 1 @@ -505,9 +514,9 @@ async def test_zha_group_light_entity( assert member.group == zha_group assert member.endpoint is not None - device_1_entity_id = await find_entity_id(DOMAIN, device_light_1, hass) - device_2_entity_id = await find_entity_id(DOMAIN, device_light_2, hass) - device_3_entity_id = await find_entity_id(DOMAIN, device_light_3, hass) + device_1_entity_id = await find_entity_id(Platform.LIGHT, device_light_1, hass) + device_2_entity_id = await find_entity_id(Platform.LIGHT, device_light_2, hass) + device_3_entity_id = await find_entity_id(Platform.LIGHT, device_light_3, hass) assert ( device_1_entity_id != device_2_entity_id @@ -515,7 +524,7 @@ async def test_zha_group_light_entity( ) assert device_2_entity_id != device_3_entity_id - group_entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group) + group_entity_id = async_find_group_entity_id(hass, Platform.LIGHT, zha_group) assert hass.states.get(group_entity_id) is not None assert device_1_entity_id in zha_group.member_entity_ids diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py index ca9b7961d384b8..c8685996c25cca 100644 --- a/tests/components/zha/test_lock.py +++ b/tests/components/zha/test_lock.py @@ -7,8 +7,12 @@ import zigpy.zcl.clusters.general as general import zigpy.zcl.foundation as zcl_f -from homeassistant.components.lock import DOMAIN -from homeassistant.const import STATE_LOCKED, STATE_UNAVAILABLE, STATE_UNLOCKED +from homeassistant.const import ( + STATE_LOCKED, + STATE_UNAVAILABLE, + STATE_UNLOCKED, + Platform, +) from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE @@ -44,7 +48,7 @@ async def test_lock(hass, lock): """Test zha lock platform.""" zha_device, cluster = lock - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.LOCK, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_UNLOCKED @@ -92,7 +96,7 @@ async def async_lock(hass, cluster, entity_id): ): # lock via UI await hass.services.async_call( - DOMAIN, "lock", {"entity_id": entity_id}, blocking=True + Platform.LOCK, "lock", {"entity_id": entity_id}, blocking=True ) assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False @@ -106,7 +110,7 @@ async def async_unlock(hass, cluster, entity_id): ): # lock via UI await hass.services.async_call( - DOMAIN, "unlock", {"entity_id": entity_id}, blocking=True + Platform.LOCK, "unlock", {"entity_id": entity_id}, blocking=True ) assert cluster.request.call_count == 1 assert cluster.request.call_args[0][0] is False diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index c27cd9fd654bba..ac72a00d80235b 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -7,8 +7,7 @@ import zigpy.zcl.clusters.general as general import zigpy.zcl.foundation as zcl_f -from homeassistant.components.number import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.setup import async_setup_component from .common import ( @@ -67,7 +66,7 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi assert "engineering_units" in attr_reads assert "application_type" in attr_reads - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.NUMBER, zha_device, hass) assert entity_id is not None await async_enable_traffic(hass, [zha_device], enabled=False) @@ -110,7 +109,10 @@ async def test_number(hass, zha_device_joined_restored, zigpy_analog_output_devi ): # set value via UI await hass.services.async_call( - DOMAIN, "set_value", {"entity_id": entity_id, "value": 30.0}, blocking=True + Platform.NUMBER, + "set_value", + {"entity_id": entity_id, "value": 30.0}, + blocking=True, ) assert len(cluster.write_attributes.mock_calls) == 1 assert cluster.write_attributes.call_args == call({"present_value": 30.0}) diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py new file mode 100644 index 00000000000000..fb21c900838e65 --- /dev/null +++ b/tests/components/zha/test_select.py @@ -0,0 +1,151 @@ +"""Test ZHA select entities.""" + +import pytest +from zigpy.const import SIG_EP_PROFILE +import zigpy.profiles.zha as zha +import zigpy.zcl.clusters.general as general +import zigpy.zcl.clusters.security as security + +from homeassistant.const import ENTITY_CATEGORY_CONFIG, STATE_UNKNOWN, Platform +from homeassistant.helpers import entity_registry as er, restore_state +from homeassistant.util import dt as dt_util + +from .common import find_entity_id +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE + + +@pytest.fixture +async def siren(hass, zigpy_device_mock, zha_device_joined_restored): + """Siren fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id, security.IasWd.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = await zha_device_joined_restored(zigpy_device) + return zha_device, zigpy_device.endpoints[1].ias_wd + + +@pytest.fixture +def core_rs(hass_storage): + """Core.restore_state fixture.""" + + def _storage(entity_id, state): + now = dt_util.utcnow().isoformat() + + hass_storage[restore_state.STORAGE_KEY] = { + "version": restore_state.STORAGE_VERSION, + "key": restore_state.STORAGE_KEY, + "data": [ + { + "state": { + "entity_id": entity_id, + "state": str(state), + "last_changed": now, + "last_updated": now, + "context": { + "id": "3c2243ff5f30447eb12e7348cfd5b8ff", + "user_id": None, + }, + }, + "last_seen": now, + } + ], + } + return + + return _storage + + +async def test_select(hass, siren): + """Test zha select platform.""" + + entity_registry = er.async_get(hass) + zha_device, cluster = siren + assert cluster is not None + select_name = security.IasWd.Warning.WarningMode.__name__ + entity_id = await find_entity_id( + Platform.SELECT, + zha_device, + hass, + qualifier=select_name.lower(), + ) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes["options"] == [ + "Stop", + "Burglar", + "Fire", + "Emergency", + "Police Panic", + "Fire Panic", + "Emergency Panic", + ] + + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == ENTITY_CATEGORY_CONFIG + + # Test select option with string value + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": entity_id, + "option": security.IasWd.Warning.WarningMode.Burglar.name, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == security.IasWd.Warning.WarningMode.Burglar.name + + +async def test_select_restore_state( + hass, + zigpy_device_mock, + core_rs, + zha_device_restored, +): + """Test zha select entity restore state.""" + + entity_id = "select.fakemanufacturer_fakemodel_e769900a_ias_wd_warningmode" + core_rs(entity_id, state="Burglar") + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id, security.IasWd.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = await zha_device_restored(zigpy_device) + cluster = zigpy_device.endpoints[1].ias_wd + assert cluster is not None + select_name = security.IasWd.Warning.WarningMode.__name__ + entity_id = await find_entity_id( + Platform.SELECT, + zha_device, + hass, + qualifier=select_name.lower(), + ) + + assert entity_id is not None + state = hass.states.get(entity_id) + assert state + assert state.state == security.IasWd.Warning.WarningMode.Burglar.name diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index af4bb082b03d12..c4e66e9809839d 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -8,7 +8,7 @@ import zigpy.zcl.clusters.measurement as measurement import zigpy.zcl.clusters.smartenergy as smartenergy -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.zha.core.const import ZHA_CHANNEL_READS_PER_REQ import homeassistant.config as config_util from homeassistant.const import ( @@ -17,7 +17,6 @@ CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, - DEVICE_CLASS_ENERGY, ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, @@ -32,6 +31,7 @@ TEMP_FAHRENHEIT, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, + Platform, ) from homeassistant.helpers import restore_state from homeassistant.helpers.entity_component import async_update_entity @@ -149,7 +149,8 @@ async def async_test_smart_energy_summation(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" assert ( - hass.states.get(entity_id).attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + hass.states.get(entity_id).attributes[ATTR_DEVICE_CLASS] + == SensorDeviceClass.ENERGY ) @@ -240,6 +241,12 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.0 +async def async_test_device_temperature(hass, cluster, entity_id): + """Test temperature sensor.""" + await send_attributes_report(hass, cluster, {0: 2900}) + assert_state(hass, entity_id, "29.0", TEMP_CELSIUS) + + @pytest.mark.parametrize( "cluster_id, entity_suffix, test_func, report_count, read_plug, unsupported_attrs", ( @@ -349,6 +356,14 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): }, None, ), + ( + general.DeviceTemperature.cluster_id, + "device_temperature", + async_test_device_temperature, + 1, + None, + None, + ), ), ) async def test_sensor( @@ -505,7 +520,7 @@ async def test_temp_uom( ) cluster = zigpy_device.endpoints[1].temperature zha_device = await zha_device_restored(zigpy_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.SENSOR, zha_device, hass) if not restore: await async_enable_traffic(hass, [zha_device], enabled=False) @@ -545,7 +560,7 @@ async def test_electrical_measurement_init( ) cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] zha_device = await zha_device_joined(zigpy_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.SENSOR, zha_device, hass) # allow traffic to flow through the gateway and devices await async_enable_traffic(hass, [zha_device]) @@ -681,7 +696,7 @@ async def test_unsupported_attributes_sensor( await async_enable_traffic(hass, [zha_device], enabled=False) await hass.async_block_till_done() - present_entity_ids = set(await find_entity_ids(DOMAIN, zha_device, hass)) + present_entity_ids = set(await find_entity_ids(Platform.SENSOR, zha_device, hass)) assert present_entity_ids == entity_ids assert missing_entity_ids not in present_entity_ids diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index 9a10f55f25a46d..17e12491f84023 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -9,7 +9,6 @@ import zigpy.zcl.clusters.security as security import zigpy.zcl.foundation as zcl_f -from homeassistant.components.siren import DOMAIN from homeassistant.components.siren.const import ( ATTR_DURATION, ATTR_TONE, @@ -19,7 +18,7 @@ WARNING_DEVICE_MODE_EMERGENCY_PANIC, WARNING_DEVICE_SOUND_MEDIUM, ) -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform import homeassistant.util.dt as dt_util from .common import async_enable_traffic, find_entity_id @@ -52,7 +51,7 @@ async def test_siren(hass, siren): zha_device, cluster = siren assert cluster is not None - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.SIREN, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF @@ -73,12 +72,12 @@ async def test_siren(hass, siren): ): # turn on via UI await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + Platform.SIREN, "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(cluster.request.mock_calls) == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0 - assert cluster.request.call_args[0][3] == 54 # bitmask for default args + assert cluster.request.call_args[0][3] == 50 # bitmask for default args assert cluster.request.call_args[0][4] == 5 # duration in seconds assert cluster.request.call_args[0][5] == 0 assert cluster.request.call_args[0][6] == 2 @@ -93,7 +92,7 @@ async def test_siren(hass, siren): ): # turn off via UI await hass.services.async_call( - DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + Platform.SIREN, "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(cluster.request.mock_calls) == 1 assert cluster.request.call_args[0][0] is False @@ -113,7 +112,7 @@ async def test_siren(hass, siren): ): # turn on via UI await hass.services.async_call( - DOMAIN, + Platform.SIREN, "turn_on", { "entity_id": entity_id, @@ -126,7 +125,7 @@ async def test_siren(hass, siren): assert len(cluster.request.mock_calls) == 1 assert cluster.request.call_args[0][0] is False assert cluster.request.call_args[0][1] == 0 - assert cluster.request.call_args[0][3] == 101 # bitmask for passed args + assert cluster.request.call_args[0][3] == 97 # bitmask for passed args assert cluster.request.call_args[0][4] == 10 # duration in seconds assert cluster.request.call_args[0][5] == 0 assert cluster.request.call_args[0][6] == 2 @@ -137,3 +136,5 @@ async def test_siren(hass, siren): now = dt_util.utcnow() + timedelta(seconds=15) async_fire_time_changed(hass, now) await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 04f43344b980ae..879bc26db9f220 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -6,9 +6,8 @@ import zigpy.zcl.clusters.general as general import zigpy.zcl.foundation as zcl_f -from homeassistant.components.switch import DOMAIN from homeassistant.components.zha.core.group import GroupMember -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from .common import ( async_enable_traffic, @@ -108,7 +107,7 @@ async def test_switch(hass, zha_device_joined_restored, zigpy_device): zha_device = await zha_device_joined_restored(zigpy_device) cluster = zigpy_device.endpoints.get(1).on_off - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = await find_entity_id(Platform.SWITCH, zha_device, hass) assert entity_id is not None assert hass.states.get(entity_id).state == STATE_OFF @@ -137,7 +136,7 @@ async def test_switch(hass, zha_device_joined_restored, zigpy_device): ): # turn on via UI await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + Platform.SWITCH, "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(cluster.request.mock_calls) == 1 assert cluster.request.call_args == call( @@ -151,7 +150,7 @@ async def test_switch(hass, zha_device_joined_restored, zigpy_device): ): # turn off via UI await hass.services.async_call( - DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + Platform.SWITCH, "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(cluster.request.mock_calls) == 1 assert cluster.request.call_args == call( @@ -193,7 +192,7 @@ async def test_zha_group_switch_entity( assert member.group == zha_group assert member.endpoint is not None - entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group) + entity_id = async_find_group_entity_id(hass, Platform.SWITCH, zha_group) assert hass.states.get(entity_id) is not None group_cluster_on_off = zha_group.endpoint[general.OnOff.cluster_id] @@ -220,7 +219,7 @@ async def test_zha_group_switch_entity( ): # turn on via UI await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + Platform.SWITCH, "turn_on", {"entity_id": entity_id}, blocking=True ) assert len(group_cluster_on_off.request.mock_calls) == 1 assert group_cluster_on_off.request.call_args == call( @@ -235,7 +234,7 @@ async def test_zha_group_switch_entity( ): # turn off via UI await hass.services.async_call( - DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + Platform.SWITCH, "turn_off", {"entity_id": entity_id}, blocking=True ) assert len(group_cluster_on_off.request.mock_calls) == 1 assert group_cluster_on_off.request.call_args == call( diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 06d3f10556c288..873c213527d255 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -24,6 +24,9 @@ DEVICES = [ { DEV_SIG_DEV_NO: 0, + SIG_MANUFACTURER: "ADUROLIGHT", + SIG_MODEL: "Adurolight_NCC", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2080, @@ -31,18 +34,37 @@ SIG_EP_INPUT: [0, 3, 4096, 64716], SIG_EP_OUTPUT: [3, 4, 6, 8, 4096, 64716], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: [], - DEV_SIG_ENT_MAP: {}, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008"], - SIG_MANUFACTURER: "ADUROLIGHT", - SIG_MODEL: "Adurolight_NCC", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", - DEV_SIG_ZHA_QUIRK: "AdurolightNCC", + DEV_SIG_ENTITIES: [ + "button.adurolight_adurolight_ncc_77665544_identify", + "sensor.adurolight_adurolight_ncc_77665544_basic_rssi", + "sensor.adurolight_adurolight_ncc_77665544_basic_lqi", + ], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.adurolight_adurolight_ncc_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.adurolight_adurolight_ncc_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.adurolight_adurolight_ncc_77665544_basic_lqi", + }, + }, }, { DEV_SIG_DEV_NO: 1, + SIG_MANUFACTURER: "Bosch", + SIG_MODEL: "ISW-ZPR1-WP13", + SIG_NODE_DESC: b"\x02@\x08\x00\x00l\x00\x00\x00\x00\x00\x00\x00", SIG_ENDPOINTS: { 5: { SIG_EP_TYPE: 1026, @@ -50,14 +72,28 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["5:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone", + "button.bosch_isw_zpr1_wp13_77665544_identify", "sensor.bosch_isw_zpr1_wp13_77665544_power", "sensor.bosch_isw_zpr1_wp13_77665544_temperature", + "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone", + "sensor.bosch_isw_zpr1_wp13_77665544_basic_rssi", + "sensor.bosch_isw_zpr1_wp13_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-5-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-5-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.bosch_isw_zpr1_wp13_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-5-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -68,19 +104,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-5-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.bosch_isw_zpr1_wp13_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-5-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-5-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["5:0x0019"], - SIG_MANUFACTURER: "Bosch", - SIG_MODEL: "ISW-ZPR1-WP13", - SIG_NODE_DESC: b"\x02@\x08\x00\x00l\x00\x00\x00\x00\x00\x00\x00", }, { DEV_SIG_DEV_NO: 2, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3130", + SIG_NODE_DESC: b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1, @@ -88,24 +128,43 @@ SIG_EP_INPUT: [0, 1, 3, 32, 2821], SIG_EP_OUTPUT: [3, 6, 8, 25], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["sensor.centralite_3130_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], + DEV_SIG_ENTITIES: [ + "button.centralite_3130_77665544_identify", + "sensor.centralite_3130_77665544_power", + "sensor.centralite_3130_77665544_basic_rssi", + "sensor.centralite_3130_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3130_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_77665544_power", - } + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], - SIG_MANUFACTURER: "CentraLite", - SIG_MODEL: "3130", - SIG_NODE_DESC: b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "CentraLite3130", }, { DEV_SIG_DEV_NO: 3, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3210-L", + SIG_NODE_DESC: b"\x01@\x8eN\x10RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 81, @@ -113,17 +172,20 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 1794, 2820, 2821, 64515], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ + "button.centralite_3210_l_77665544_identify", "sensor.centralite_3210_l_77665544_electrical_measurement", "sensor.centralite_3210_l_77665544_electrical_measurement_apparent_power", - "sensor.centralite_3210_l_77665544_electrical_measurement_apparent_power", "sensor.centralite_3210_l_77665544_electrical_measurement_rms_current", "sensor.centralite_3210_l_77665544_electrical_measurement_rms_voltage", "sensor.centralite_3210_l_77665544_smartenergy_metering", "sensor.centralite_3210_l_77665544_smartenergy_metering_summation_delivered", "switch.centralite_3210_l_77665544_on_off", + "sensor.centralite_3210_l_77665544_basic_rssi", + "sensor.centralite_3210_l_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { @@ -131,15 +193,10 @@ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.centralite_3210_l_77665544_on_off", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering", - }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering_summation_delivered", + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3210_l_77665544_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], @@ -161,14 +218,33 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_electrical_measurement_rms_voltage", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "CentraLite", - SIG_MODEL: "3210-L", - SIG_NODE_DESC: b"\x01@\x8eN\x10RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 4, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3310-S", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 770, @@ -176,14 +252,23 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1026, 2821, 64581], SIG_EP_OUTPUT: [3, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "sensor.centralite_3310_s_77665544_manufacturer_specific", + "button.centralite_3310_s_77665544_identify", "sensor.centralite_3310_s_77665544_power", "sensor.centralite_3310_s_77665544_temperature", + "sensor.centralite_3310_s_77665544_manufacturer_specific", + "sensor.centralite_3310_s_77665544_basic_rssi", + "sensor.centralite_3310_s_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3310_s_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -194,20 +279,28 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_77665544_temperature", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_77665544_basic_lqi", + }, ("sensor", "00:11:22:33:44:55:66:77-1-64581"): { DEV_SIG_CHANNELS: ["manufacturer_specific"], DEV_SIG_ENT_MAP_CLASS: "Humidity", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_77665544_manufacturer_specific", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "CentraLite", - SIG_MODEL: "3310-S", - SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "CentraLite3310S", }, { DEV_SIG_DEV_NO: 5, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3315-S", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -224,12 +317,26 @@ SIG_EP_PROFILE: 49887, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.centralite_3315_s_77665544_ias_zone", + "button.centralite_3315_s_77665544_identify", "sensor.centralite_3315_s_77665544_power", "sensor.centralite_3315_s_77665544_temperature", + "binary_sensor.centralite_3315_s_77665544_ias_zone", + "sensor.centralite_3315_s_77665544_basic_rssi", + "sensor.centralite_3315_s_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3315_s_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3315_s_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -240,20 +347,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3315_s_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "CentraLite", - SIG_MODEL: "3315-S", - SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "CentraLiteIASSensor", }, { DEV_SIG_DEV_NO: 6, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3320-L", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -270,12 +380,26 @@ SIG_EP_PROFILE: 49887, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.centralite_3320_l_77665544_ias_zone", + "button.centralite_3320_l_77665544_identify", "sensor.centralite_3320_l_77665544_power", "sensor.centralite_3320_l_77665544_temperature", + "binary_sensor.centralite_3320_l_77665544_ias_zone", + "sensor.centralite_3320_l_77665544_basic_rssi", + "sensor.centralite_3320_l_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3320_l_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3320_l_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -286,20 +410,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3320_l_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "CentraLite", - SIG_MODEL: "3320-L", - SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "CentraLiteIASSensor", }, { DEV_SIG_DEV_NO: 7, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3326-L", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -316,12 +443,26 @@ SIG_EP_PROFILE: 49887, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.centralite_3326_l_77665544_ias_zone", + "button.centralite_3326_l_77665544_identify", "sensor.centralite_3326_l_77665544_power", "sensor.centralite_3326_l_77665544_temperature", + "binary_sensor.centralite_3326_l_77665544_ias_zone", + "sensor.centralite_3326_l_77665544_basic_rssi", + "sensor.centralite_3326_l_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3326_l_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3326_l_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -332,20 +473,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3326_l_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "CentraLite", - SIG_MODEL: "3326-L", - SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "CentraLiteMotionSensor", }, { DEV_SIG_DEV_NO: 8, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "Motion Sensor-A", + SIG_NODE_DESC: b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -362,13 +506,27 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone", - "binary_sensor.centralite_motion_sensor_a_77665544_occupancy", + "button.centralite_motion_sensor_a_77665544_identify", "sensor.centralite_motion_sensor_a_77665544_power", "sensor.centralite_motion_sensor_a_77665544_temperature", + "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone", + "binary_sensor.centralite_motion_sensor_a_77665544_occupancy", + "sensor.centralite_motion_sensor_a_77665544_basic_rssi", + "sensor.centralite_motion_sensor_a_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_motion_sensor_a_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -379,10 +537,15 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_77665544_basic_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-2-1030"): { DEV_SIG_CHANNELS: ["occupancy"], @@ -390,14 +553,12 @@ DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_77665544_occupancy", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "CentraLite", - SIG_MODEL: "Motion Sensor-A", - SIG_NODE_DESC: b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "CentraLite3305S", }, { DEV_SIG_DEV_NO: 9, + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "PSMP5_00.00.02.02TC", + SIG_NODE_DESC: b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 81, @@ -414,10 +575,14 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["4:0x0019"], DEV_SIG_ENTITIES: [ + "button.climaxtechnology_psmp5_00_00_02_02tc_77665544_identify", "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering_summation_delivered", "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off", + "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_basic_rssi", + "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { @@ -425,6 +590,11 @@ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off", }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_psmp5_00_00_02_02tc_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", @@ -435,14 +605,23 @@ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["4:0x0019"], - SIG_MANUFACTURER: "ClimaxTechnology", - SIG_MODEL: "PSMP5_00.00.02.02TC", - SIG_NODE_DESC: b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { DEV_SIG_DEV_NO: 10, + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "SD8SC_00.00.03.12TC", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -450,25 +629,73 @@ SIG_EP_INPUT: [0, 3, 1280, 1282], SIG_EP_OUTPUT: [0], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone" + "button.climaxtechnology_sd8sc_00_00_03_12tc_77665544_identify", + "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone", + "sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_basic_rssi", + "sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_basic_lqi", + "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_warningmode", + "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_sirenlevel", + "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_strobelevel", + "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_strobe", + "siren.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd", ], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CHANNELS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_sd8sc_00_00_03_12tc_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_basic_lqi", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_warningmode", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_sirenlevel", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_strobelevel", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_strobe", + }, + ("siren", "00:11:22:33:44:55:66:77-1-1282"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHASiren", + DEV_SIG_ENT_MAP_ID: "siren.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd", + }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "ClimaxTechnology", - SIG_MODEL: "SD8SC_00.00.03.12TC", - SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { DEV_SIG_DEV_NO: 11, + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "WS15_00.00.03.03TC", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -476,25 +703,43 @@ SIG_EP_INPUT: [0, 3, 1280], SIG_EP_OUTPUT: [0], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone" + "button.climaxtechnology_ws15_00_00_03_03tc_77665544_identify", + "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone", + "sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_basic_rssi", + "sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CHANNELS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_ias_zone", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.climaxtechnology_ws15_00_00_03_03tc_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_ws15_00_00_03_03tc_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "ClimaxTechnology", - SIG_MODEL: "WS15_00.00.03.03TC", - SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { DEV_SIG_DEV_NO: 12, + SIG_MANUFACTURER: "Feibit Inc co.", + SIG_MODEL: "FB56-ZCW08KU1.1", + SIG_NODE_DESC: b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", SIG_ENDPOINTS: { 11: { SIG_EP_TYPE: 528, @@ -511,23 +756,41 @@ SIG_EP_PROFILE: 49246, }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off" + "button.feibit_inc_co_fb56_zcw08ku1_1_77665544_identify", + "light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off", + "sensor.feibit_inc_co_fb56_zcw08ku1_1_77665544_basic_rssi", + "sensor.feibit_inc_co_fb56_zcw08ku1_1_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-11"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "light_color", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.feibit_inc_co_fb56_zcw08ku1_1_77665544_level_light_color_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-11-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.feibit_inc_co_fb56_zcw08ku1_1_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-11-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.feibit_inc_co_fb56_zcw08ku1_1_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-11-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.feibit_inc_co_fb56_zcw08ku1_1_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "Feibit Inc co.", - SIG_MODEL: "FB56-ZCW08KU1.1", - SIG_NODE_DESC: b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, { DEV_SIG_DEV_NO: 13, + SIG_MANUFACTURER: "HEIMAN", + SIG_MODEL: "SmokeSensor-EM", + SIG_NODE_DESC: b"\x02@\x80\x0b\x12RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -535,31 +798,79 @@ SIG_EP_INPUT: [0, 1, 3, 1280, 1282], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.heiman_smokesensor_em_77665544_ias_zone", + "button.heiman_smokesensor_em_77665544_identify", "sensor.heiman_smokesensor_em_77665544_power", + "binary_sensor.heiman_smokesensor_em_77665544_ias_zone", + "sensor.heiman_smokesensor_em_77665544_basic_rssi", + "sensor.heiman_smokesensor_em_77665544_basic_lqi", + "select.heiman_smokesensor_em_77665544_ias_wd_warningmode", + "select.heiman_smokesensor_em_77665544_ias_wd_sirenlevel", + "select.heiman_smokesensor_em_77665544_ias_wd_strobelevel", + "select.heiman_smokesensor_em_77665544_ias_wd_strobe", + "siren.heiman_smokesensor_em_77665544_ias_wd", ], DEV_SIG_ENT_MAP: { - ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], - DEV_SIG_ENT_MAP_CLASS: "Battery", - DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_77665544_power", - }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CHANNELS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_smokesensor_em_77665544_ias_zone", }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.heiman_smokesensor_em_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_77665544_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_77665544_basic_lqi", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_77665544_ias_wd_warningmode", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_77665544_ias_wd_sirenlevel", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_77665544_ias_wd_strobelevel", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_77665544_ias_wd_strobe", + }, + ("siren", "00:11:22:33:44:55:66:77-1-1282"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHASiren", + DEV_SIG_ENT_MAP_ID: "siren.heiman_smokesensor_em_77665544_ias_wd", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "HEIMAN", - SIG_MODEL: "SmokeSensor-EM", - SIG_NODE_DESC: b"\x02@\x80\x0b\x12RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 14, + SIG_MANUFACTURER: "Heiman", + SIG_MODEL: "CO_V16", + SIG_NODE_DESC: b"\x02@\x84\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -567,23 +878,43 @@ SIG_EP_INPUT: [0, 1, 3, 9, 1280], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["binary_sensor.heiman_co_v16_77665544_ias_zone"], + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + DEV_SIG_ENTITIES: [ + "button.heiman_co_v16_77665544_identify", + "binary_sensor.heiman_co_v16_77665544_ias_zone", + "sensor.heiman_co_v16_77665544_basic_rssi", + "sensor.heiman_co_v16_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CHANNELS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_co_v16_77665544_ias_zone", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.heiman_co_v16_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_co_v16_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_co_v16_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Heiman", - SIG_MODEL: "CO_V16", - SIG_NODE_DESC: b"\x02@\x84\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", }, { DEV_SIG_DEV_NO: 15, + SIG_MANUFACTURER: "Heiman", + SIG_MODEL: "WarningDevice", + SIG_NODE_DESC: b"\x01@\x8e\x0b\x12RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1027, @@ -591,31 +922,73 @@ SIG_EP_INPUT: [0, 1, 3, 4, 9, 1280, 1282], SIG_EP_OUTPUT: [3, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ + "button.heiman_warningdevice_77665544_identify", "binary_sensor.heiman_warningdevice_77665544_ias_zone", + "sensor.heiman_warningdevice_77665544_basic_rssi", + "sensor.heiman_warningdevice_77665544_basic_lqi", + "select.heiman_warningdevice_77665544_ias_wd_warningmode", + "select.heiman_warningdevice_77665544_ias_wd_sirenlevel", + "select.heiman_warningdevice_77665544_ias_wd_strobelevel", + "select.heiman_warningdevice_77665544_ias_wd_strobe", "siren.heiman_warningdevice_77665544_ias_wd", ], DEV_SIG_ENT_MAP: { - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_77665544_ias_zone", + ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_77665544_ias_wd_warningmode", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_77665544_ias_wd_sirenlevel", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_77665544_ias_wd_strobelevel", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { + DEV_SIG_CHANNELS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_77665544_ias_wd_strobe", }, ("siren", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["ias_wd"], DEV_SIG_ENT_MAP_CLASS: "ZHASiren", DEV_SIG_ENT_MAP_ID: "siren.heiman_warningdevice_77665544_ias_wd", }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.heiman_warningdevice_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_warningdevice_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_warningdevice_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Heiman", - SIG_MODEL: "WarningDevice", - SIG_NODE_DESC: b"\x01@\x8e\x0b\x12RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 16, + SIG_MANUFACTURER: "HiveHome.com", + SIG_MODEL: "MOT003", + SIG_NODE_DESC: b"\x02@\x809\x10PP\x00\x00\x00P\x00\x00", SIG_ENDPOINTS: { 6: { SIG_EP_TYPE: 1026, @@ -623,15 +996,29 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1024, 1026, 1280], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["6:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.hivehome_com_mot003_77665544_ias_zone", - "sensor.hivehome_com_mot003_77665544_illuminance", + "button.hivehome_com_mot003_77665544_identify", "sensor.hivehome_com_mot003_77665544_power", + "sensor.hivehome_com_mot003_77665544_illuminance", "sensor.hivehome_com_mot003_77665544_temperature", + "binary_sensor.hivehome_com_mot003_77665544_ias_zone", + "sensor.hivehome_com_mot003_77665544_basic_rssi", + "sensor.hivehome_com_mot003_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-6-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.hivehome_com_mot003_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-6-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.hivehome_com_mot003_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-6-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -647,20 +1034,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-6-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.hivehome_com_mot003_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-6-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-6-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["6:0x0019"], - SIG_MANUFACTURER: "HiveHome.com", - SIG_MODEL: "MOT003", - SIG_NODE_DESC: b"\x02@\x809\x10PP\x00\x00\x00P\x00\x00", - DEV_SIG_ZHA_QUIRK: "MOT003", }, { DEV_SIG_DEV_NO: 17, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E12 WS opal 600lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 268, @@ -677,23 +1067,41 @@ SIG_EP_PROFILE: 41440, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off" + "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_identify", + "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off", + "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI bulb E12 WS opal 600lm", - SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", }, { DEV_SIG_DEV_NO: 18, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 CWS opal 600lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 512, @@ -701,25 +1109,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 4096], SIG_EP_OUTPUT: [5, 25, 32, 4096], SIG_EP_PROFILE: 49246, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off" + "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_identify", + "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off", + "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI bulb E26 CWS opal 600lm", - SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 19, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 W opal 1000lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 256, @@ -727,25 +1153,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 2821, 4096], SIG_EP_OUTPUT: [5, 25, 32, 4096], SIG_EP_PROFILE: 49246, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off" + "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_identify", + "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off", + "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI bulb E26 W opal 1000lm", - SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 20, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 WS opal 980lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 544, @@ -753,25 +1197,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 4096], SIG_EP_OUTPUT: [5, 25, 32, 4096], SIG_EP_PROFILE: 49246, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off" + "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_identify", + "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off", + "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI bulb E26 WS opal 980lm", - SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 21, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 opal 1000lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 256, @@ -779,25 +1241,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 2821, 4096], SIG_EP_OUTPUT: [5, 25, 32, 4096], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off" + "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_identify", + "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off", + "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI bulb E26 opal 1000lm", - SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 22, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI control outlet", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 266, @@ -805,26 +1285,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 64636], SIG_EP_OUTPUT: [5, 25, 32], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], DEV_SIG_ENTITIES: [ - "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off" + "button.ikea_of_sweden_tradfri_control_outlet_77665544_identify", + "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off", + "sensor.ikea_of_sweden_tradfri_control_outlet_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_control_outlet_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_control_outlet_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_control_outlet_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_control_outlet_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI control outlet", - SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", - DEV_SIG_ZHA_QUIRK: "TradfriPlug", }, { DEV_SIG_DEV_NO: 23, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI motion sensor", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2128, @@ -832,32 +1329,49 @@ SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], SIG_EP_OUTPUT: [3, 4, 6, 25, 4096], SIG_EP_PROFILE: 49246, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off", + "button.ikea_of_sweden_tradfri_motion_sensor_77665544_identify", "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_power", + "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off", + "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_motion_sensor_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_basic_lqi", + }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Motion", DEV_SIG_ENT_MAP_ID: "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_77665544_on_off", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0019"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI motion sensor", - SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "IkeaTradfriMotion", }, { DEV_SIG_DEV_NO: 24, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI on/off switch", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00,R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2080, @@ -865,26 +1379,43 @@ SIG_EP_INPUT: [0, 1, 3, 9, 32, 4096, 64636], SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 258, 4096], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], DEV_SIG_ENTITIES: [ - "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power" + "button.ikea_of_sweden_tradfri_on_off_switch_77665544_identify", + "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power", + "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_on_off_switch_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_power", - } + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI on/off switch", - SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00,R\x00\x00", - DEV_SIG_ZHA_QUIRK: "IkeaTradfriRemote2Btn", }, { DEV_SIG_DEV_NO: 25, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI remote control", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2096, @@ -892,26 +1423,43 @@ SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 25, 4096], SIG_EP_PROFILE: 49246, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power" + "button.ikea_of_sweden_tradfri_remote_control_77665544_identify", + "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power", + "sensor.ikea_of_sweden_tradfri_remote_control_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_remote_control_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_remote_control_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power", - } + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI remote control", - SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "IkeaTradfriRemote", }, { DEV_SIG_DEV_NO: 26, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI signal repeater", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 8, @@ -928,15 +1476,35 @@ SIG_EP_PROFILE: 41440, }, }, - DEV_SIG_ENTITIES: [], - DEV_SIG_ENT_MAP: {}, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI signal repeater", - SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", + DEV_SIG_ENTITIES: [ + "button.ikea_of_sweden_tradfri_signal_repeater_77665544_identify", + "sensor.ikea_of_sweden_tradfri_signal_repeater_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_signal_repeater_77665544_basic_lqi", + ], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_signal_repeater_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_signal_repeater_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_signal_repeater_77665544_basic_lqi", + }, + }, }, { DEV_SIG_DEV_NO: 27, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI wireless dimmer", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2064, @@ -944,25 +1512,43 @@ SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 4096], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power" + "button.ikea_of_sweden_tradfri_wireless_dimmer_77665544_identify", + "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power", + "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_basic_rssi", + "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ikea_of_sweden_tradfri_wireless_dimmer_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_power", - } + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_wireless_dimmer_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], - SIG_MANUFACTURER: "IKEA of Sweden", - SIG_MODEL: "TRADFRI wireless dimmer", - SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 28, + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45852", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 257, @@ -979,17 +1565,26 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], DEV_SIG_ENTITIES: [ - "light.jasco_products_45852_77665544_level_on_off", + "button.jasco_products_45852_77665544_identify", "sensor.jasco_products_45852_77665544_smartenergy_metering", "sensor.jasco_products_45852_77665544_smartenergy_metering_summation_delivered", + "light.jasco_products_45852_77665544_level_on_off", + "sensor.jasco_products_45852_77665544_basic_rssi", + "sensor.jasco_products_45852_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45852_77665544_level_on_off", }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45852_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", @@ -1000,14 +1595,23 @@ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_77665544_smartenergy_metering_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], - SIG_MANUFACTURER: "Jasco Products", - SIG_MODEL: "45852", - SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", }, { DEV_SIG_DEV_NO: 29, + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45856", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 256, @@ -1024,10 +1628,14 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], DEV_SIG_ENTITIES: [ + "button.jasco_products_45856_77665544_identify", "light.jasco_products_45856_77665544_on_off", "sensor.jasco_products_45856_77665544_smartenergy_metering", "sensor.jasco_products_45856_77665544_smartenergy_metering_summation_delivered", + "sensor.jasco_products_45856_77665544_basic_rssi", + "sensor.jasco_products_45856_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -1035,6 +1643,11 @@ DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45856_77665544_on_off", }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45856_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", @@ -1045,14 +1658,23 @@ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_77665544_smartenergy_metering_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], - SIG_MANUFACTURER: "Jasco Products", - SIG_MODEL: "45856", - SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", }, { DEV_SIG_DEV_NO: 30, + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45857", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 257, @@ -1069,17 +1691,26 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], DEV_SIG_ENTITIES: [ + "button.jasco_products_45857_77665544_identify", "light.jasco_products_45857_77665544_level_on_off", "sensor.jasco_products_45857_77665544_smartenergy_metering", "sensor.jasco_products_45857_77665544_smartenergy_metering_summation_delivered", + "sensor.jasco_products_45857_77665544_basic_rssi", + "sensor.jasco_products_45857_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.jasco_products_45857_77665544_level_on_off", }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45857_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", @@ -1090,44 +1721,48 @@ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_77665544_smartenergy_metering_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], - SIG_MANUFACTURER: "Jasco Products", - SIG_MODEL: "45857", - SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", }, { DEV_SIG_DEV_NO: 31, + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-610-MP-1.3", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 3, DEV_SIG_EP_ID: 1, - SIG_EP_INPUT: [ - 0, - 1, - 3, - 4, - 5, - 6, - 8, - 32, - 1026, - 1027, - 2821, - 64513, - 64514, - ], + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 8, 32, 1026, 1027, 2821, 64513, 64514], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "cover.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off", + "button.keen_home_inc_sv02_610_mp_1_3_77665544_identify", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_temperature", + "cover.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off", + "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_basic_rssi", + "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_610_mp_1_3_77665544_identify", + }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off"], DEV_SIG_ENT_MAP_CLASS: "KeenVent", @@ -1138,54 +1773,58 @@ DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_temperature", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - DEV_SIG_CHANNELS: ["pressure"], - DEV_SIG_ENT_MAP_CLASS: "Pressure", - DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Keen Home Inc", - SIG_MODEL: "SV02-610-MP-1.3", - SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", }, { DEV_SIG_DEV_NO: 32, + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-612-MP-1.2", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 3, DEV_SIG_EP_ID: 1, - SIG_EP_INPUT: [ - 0, - 1, - 3, - 4, - 5, - 6, - 8, - 32, - 1026, - 1027, - 2821, - 64513, - 64514, - ], + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 8, 32, 1026, 1027, 2821, 64513, 64514], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "cover.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off", + "button.keen_home_inc_sv02_612_mp_1_2_77665544_identify", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature", + "cover.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off", + "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_basic_rssi", + "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_2_77665544_identify", + }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off"], DEV_SIG_ENT_MAP_CLASS: "KeenVent", @@ -1196,54 +1835,58 @@ DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_temperature", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - DEV_SIG_CHANNELS: ["pressure"], - DEV_SIG_ENT_MAP_CLASS: "Pressure", - DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Keen Home Inc", - SIG_MODEL: "SV02-612-MP-1.2", - SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", }, { DEV_SIG_DEV_NO: 33, + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-612-MP-1.3", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 3, DEV_SIG_EP_ID: 1, - SIG_EP_INPUT: [ - 0, - 1, - 3, - 4, - 5, - 6, - 8, - 32, - 1026, - 1027, - 2821, - 64513, - 64514, - ], + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 8, 32, 1026, 1027, 2821, 64513, 64514], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "cover.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off", + "button.keen_home_inc_sv02_612_mp_1_3_77665544_identify", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_temperature", + "cover.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off", + "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_basic_rssi", + "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_3_77665544_identify", + }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off"], DEV_SIG_ENT_MAP_CLASS: "KeenVent", @@ -1254,25 +1897,33 @@ DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_temperature", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - DEV_SIG_CHANNELS: ["pressure"], - DEV_SIG_ENT_MAP_CLASS: "Pressure", - DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Keen Home Inc", - SIG_MODEL: "SV02-612-MP-1.3", - SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", - DEV_SIG_ZHA_QUIRK: "KeenHomeSmartVent", }, { DEV_SIG_DEV_NO: 34, + SIG_MANUFACTURER: "King Of Fans, Inc.", + SIG_MODEL: "HBUniversalCFRemote", + SIG_NODE_DESC: b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 257, @@ -1280,32 +1931,49 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 514], SIG_EP_OUTPUT: [3, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan", + "button.king_of_fans_inc_hbuniversalcfremote_77665544_identify", "light.king_of_fans_inc_hbuniversalcfremote_77665544_level_on_off", + "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan", + "sensor.king_of_fans_inc_hbuniversalcfremote_77665544_basic_rssi", + "sensor.king_of_fans_inc_hbuniversalcfremote_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.king_of_fans_inc_hbuniversalcfremote_77665544_level_on_off", }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.king_of_fans_inc_hbuniversalcfremote_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.king_of_fans_inc_hbuniversalcfremote_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.king_of_fans_inc_hbuniversalcfremote_77665544_basic_lqi", + }, ("fan", "00:11:22:33:44:55:66:77-1-514"): { DEV_SIG_CHANNELS: ["fan"], DEV_SIG_ENT_MAP_CLASS: "ZhaFan", DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_77665544_fan", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "King Of Fans, Inc.", - SIG_MODEL: "HBUniversalCFRemote", - SIG_NODE_DESC: b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "CeilingFan", }, { DEV_SIG_DEV_NO: 35, + SIG_MANUFACTURER: "LDS", + SIG_MODEL: "ZBT-CCTSwitch-D0001", + SIG_NODE_DESC: b"\x02@\x80h\x11RR\x00\x00,R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2048, @@ -1313,24 +1981,43 @@ SIG_EP_INPUT: [0, 1, 3, 4096, 64769], SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 768, 4096], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["sensor.lds_zbt_cctswitch_d0001_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], + DEV_SIG_ENTITIES: [ + "button.lds_zbt_cctswitch_d0001_77665544_identify", + "sensor.lds_zbt_cctswitch_d0001_77665544_power", + "sensor.lds_zbt_cctswitch_d0001_77665544_basic_rssi", + "sensor.lds_zbt_cctswitch_d0001_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lds_zbt_cctswitch_d0001_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_77665544_power", - } + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], - SIG_MANUFACTURER: "LDS", - SIG_MODEL: "ZBT-CCTSwitch-D0001", - SIG_NODE_DESC: b"\x02@\x80h\x11RR\x00\x00,R\x00\x00", - DEV_SIG_ZHA_QUIRK: "CCTSwitch", }, { DEV_SIG_DEV_NO: 36, + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "A19 RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 258, @@ -1338,23 +2025,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["light.ledvance_a19_rgbw_77665544_level_light_color_on_off"], + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + DEV_SIG_ENTITIES: [ + "button.ledvance_a19_rgbw_77665544_identify", + "light.ledvance_a19_rgbw_77665544_level_light_color_on_off", + "sensor.ledvance_a19_rgbw_77665544_basic_rssi", + "sensor.ledvance_a19_rgbw_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ledvance_a19_rgbw_77665544_level_light_color_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_a19_rgbw_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_a19_rgbw_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_a19_rgbw_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "LEDVANCE", - SIG_MODEL: "A19 RGBW", - SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 37, + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "FLEX RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 258, @@ -1362,25 +2069,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "light.ledvance_flex_rgbw_77665544_level_light_color_on_off" + "button.ledvance_flex_rgbw_77665544_identify", + "light.ledvance_flex_rgbw_77665544_level_light_color_on_off", + "sensor.ledvance_flex_rgbw_77665544_basic_rssi", + "sensor.ledvance_flex_rgbw_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ledvance_flex_rgbw_77665544_level_light_color_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_flex_rgbw_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_flex_rgbw_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_flex_rgbw_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "LEDVANCE", - SIG_MODEL: "FLEX RGBW", - SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 38, + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "PLUG", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 81, @@ -1388,23 +2113,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 2821, 64513, 64520], SIG_EP_OUTPUT: [3, 25], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["switch.ledvance_plug_77665544_on_off"], + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + DEV_SIG_ENTITIES: [ + "button.ledvance_plug_77665544_identify", + "switch.ledvance_plug_77665544_on_off", + "sensor.ledvance_plug_77665544_basic_rssi", + "sensor.ledvance_plug_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.ledvance_plug_77665544_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_plug_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_plug_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_plug_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "LEDVANCE", - SIG_MODEL: "PLUG", - SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 39, + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "RT RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 258, @@ -1412,23 +2157,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["light.ledvance_rt_rgbw_77665544_level_light_color_on_off"], + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + DEV_SIG_ENTITIES: [ + "button.ledvance_rt_rgbw_77665544_identify", + "light.ledvance_rt_rgbw_77665544_level_light_color_on_off", + "sensor.ledvance_rt_rgbw_77665544_basic_rssi", + "sensor.ledvance_rt_rgbw_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.ledvance_rt_rgbw_77665544_level_light_color_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_rt_rgbw_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_rt_rgbw_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_rt_rgbw_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "LEDVANCE", - SIG_MODEL: "RT RGBW", - SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 40, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.plug.maus01", + SIG_NODE_DESC: b"\x01@\x8e_\x11\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 81, @@ -1459,31 +2224,37 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "sensor.lumi_lumi_plug_maus01_77665544_analog_input", - "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", + "button.lumi_lumi_plug_maus01_77665544_identify", "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement", "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement_apparent_power", "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement_rms_current", "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement_rms_voltage", + "sensor.lumi_lumi_plug_maus01_77665544_analog_input", + "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", + "binary_sensor.lumi_lumi_plug_maus01_77665544_binary_input", "switch.lumi_lumi_plug_maus01_77665544_on_off", + "sensor.lumi_lumi_plug_maus01_77665544_basic_rssi", + "sensor.lumi_lumi_plug_maus01_77665544_basic_lqi", + "sensor.lumi_lumi_plug_maus01_77665544_device_temperature", ], DEV_SIG_ENT_MAP: { - ("sensor", "00:11:22:33:44:55:66:77-2-12"): { - DEV_SIG_CHANNELS: ["analog_input"], - DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_analog_input", - }, - ("sensor", "00:11:22:33:44:55:66:77-3-12"): { - DEV_SIG_CHANNELS: ["analog_input"], - DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", - }, ("switch", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.lumi_lumi_plug_maus01_77665544_on_off", }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CHANNELS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_device_temperature", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_plug_maus01_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", @@ -1504,20 +2275,38 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_electrical_measurement_rms_voltage", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_basic_lqi", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-12"): { + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_analog_input", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-12"): { + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_77665544_analog_input_2", + }, ("binary_sensor", "00:11:22:33:44:55:66:77-100-15"): { DEV_SIG_CHANNELS: ["binary_input"], DEV_SIG_ENT_MAP_CLASS: "BinaryInput", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_plug_maus01_77665544_binary_input", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.plug.maus01", - SIG_NODE_DESC: b"\x01@\x8e_\x11\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "Plug", }, { DEV_SIG_DEV_NO: 41, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.relay.c2acn01", + SIG_NODE_DESC: b"\x01@\x8e7\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 257, @@ -1534,13 +2323,18 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ + "button.lumi_lumi_relay_c2acn01_77665544_identify", "light.lumi_lumi_relay_c2acn01_77665544_on_off", "light.lumi_lumi_relay_c2acn01_77665544_on_off_2", "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement", "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement_apparent_power", "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement_rms_current", "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement_rms_voltage", + "sensor.lumi_lumi_relay_c2acn01_77665544_basic_rssi", + "sensor.lumi_lumi_relay_c2acn01_77665544_basic_lqi", + "sensor.lumi_lumi_relay_c2acn01_77665544_device_temperature", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -1548,6 +2342,16 @@ DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_77665544_on_off", }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CHANNELS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_77665544_device_temperature", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_relay_c2acn01_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", @@ -1568,20 +2372,28 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_77665544_electrical_measurement_rms_voltage", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_77665544_basic_lqi", + }, ("light", "00:11:22:33:44:55:66:77-2"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_77665544_on_off_2", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.relay.c2acn01", - SIG_NODE_DESC: b"\x01@\x8e7\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "Relay", }, { DEV_SIG_DEV_NO: 42, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b186acn01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 24321, @@ -1605,22 +2417,41 @@ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_ENTITIES: ["sensor.lumi_lumi_remote_b186acn01_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + DEV_SIG_ENTITIES: [ + "button.lumi_lumi_remote_b186acn01_77665544_identify", + "sensor.lumi_lumi_remote_b186acn01_77665544_power", + "sensor.lumi_lumi_remote_b186acn01_77665544_basic_rssi", + "sensor.lumi_lumi_remote_b186acn01_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b186acn01_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.remote.b186acn01", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "RemoteB186ACN01", }, { DEV_SIG_DEV_NO: 43, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b286acn01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 24321, @@ -1644,22 +2475,41 @@ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_ENTITIES: ["sensor.lumi_lumi_remote_b286acn01_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + DEV_SIG_ENTITIES: [ + "button.lumi_lumi_remote_b286acn01_77665544_identify", + "sensor.lumi_lumi_remote_b286acn01_77665544_power", + "sensor.lumi_lumi_remote_b286acn01_77665544_basic_rssi", + "sensor.lumi_lumi_remote_b286acn01_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286acn01_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.remote.b286acn01", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "RemoteB286ACN01", }, { DEV_SIG_DEV_NO: 44, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b286opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 261, @@ -1704,15 +2554,35 @@ SIG_EP_PROFILE: -1, }, }, - DEV_SIG_ENTITIES: [], - DEV_SIG_ENT_MAP: {}, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.remote.b286opcn01", - SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [ + "button.lumi_lumi_remote_b286opcn01_77665544_identify", + "sensor.lumi_lumi_remote_b286opcn01_77665544_basic_rssi", + "sensor.lumi_lumi_remote_b286opcn01_77665544_basic_lqi", + ], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286opcn01_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286opcn01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286opcn01_77665544_basic_lqi", + }, + }, }, { DEV_SIG_DEV_NO: 45, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b486opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 261, @@ -1757,15 +2627,35 @@ SIG_EP_PROFILE: -1, }, }, - DEV_SIG_ENTITIES: [], - DEV_SIG_ENT_MAP: {}, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.remote.b486opcn01", - SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [ + "button.lumi_lumi_remote_b486opcn01_77665544_identify", + "sensor.lumi_lumi_remote_b486opcn01_77665544_basic_rssi", + "sensor.lumi_lumi_remote_b486opcn01_77665544_basic_lqi", + ], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b486opcn01_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b486opcn01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b486opcn01_77665544_basic_lqi", + }, + }, }, { DEV_SIG_DEV_NO: 46, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b686opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 261, @@ -1773,17 +2663,37 @@ SIG_EP_INPUT: [0, 1, 3], SIG_EP_OUTPUT: [3, 6, 8, 768], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: [], - DEV_SIG_ENT_MAP: {}, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.remote.b686opcn01", - SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [ + "button.lumi_lumi_remote_b686opcn01_77665544_identify", + "sensor.lumi_lumi_remote_b686opcn01_77665544_basic_rssi", + "sensor.lumi_lumi_remote_b686opcn01_77665544_basic_lqi", + ], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_77665544_basic_lqi", + }, + }, }, { DEV_SIG_DEV_NO: 47, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b686opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 261, @@ -1828,15 +2738,35 @@ SIG_EP_PROFILE: None, }, }, - DEV_SIG_ENTITIES: [], - DEV_SIG_ENT_MAP: {}, DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.remote.b686opcn01", - SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + DEV_SIG_ENTITIES: [ + "button.lumi_lumi_remote_b686opcn01_77665544_identify", + "sensor.lumi_lumi_remote_b686opcn01_77665544_basic_rssi", + "sensor.lumi_lumi_remote_b686opcn01_77665544_basic_lqi", + ], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_77665544_basic_lqi", + }, + }, }, { DEV_SIG_DEV_NO: 48, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", SIG_ENDPOINTS: { 8: { SIG_EP_TYPE: 256, @@ -1844,31 +2774,43 @@ SIG_EP_INPUT: [0, 6], SIG_EP_OUTPUT: [0, 6], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["8:0x0006"], DEV_SIG_ENTITIES: [ - "binary_sensor.lumi_lumi_router_77665544_on_off", "light.lumi_lumi_router_77665544_on_off", + "binary_sensor.lumi_lumi_router_77665544_on_off", + "sensor.lumi_lumi_router_77665544_basic_rssi", + "sensor.lumi_lumi_router_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { - ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - DEV_SIG_CHANNELS: ["on_off", "on_off"], - DEV_SIG_ENT_MAP_CLASS: "Opening", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", - }, ("light", "00:11:22:33:44:55:66:77-8"): { - DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_77665544_on_off", }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_77665544_basic_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", + }, }, - DEV_SIG_EVT_CHANNELS: ["8:0x0006"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.router", - SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", }, { DEV_SIG_DEV_NO: 49, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", SIG_ENDPOINTS: { 8: { SIG_EP_TYPE: 256, @@ -1876,31 +2818,43 @@ SIG_EP_INPUT: [0, 6, 11, 17], SIG_EP_OUTPUT: [0, 6], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["8:0x0006"], DEV_SIG_ENTITIES: [ - "binary_sensor.lumi_lumi_router_77665544_on_off", "light.lumi_lumi_router_77665544_on_off", + "binary_sensor.lumi_lumi_router_77665544_on_off", + "sensor.lumi_lumi_router_77665544_basic_rssi", + "sensor.lumi_lumi_router_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { - ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - DEV_SIG_CHANNELS: ["on_off", "on_off"], - DEV_SIG_ENT_MAP_CLASS: "Opening", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", - }, ("light", "00:11:22:33:44:55:66:77-8"): { - DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_77665544_on_off", }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_77665544_basic_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", + }, }, - DEV_SIG_EVT_CHANNELS: ["8:0x0006"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.router", - SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", }, { DEV_SIG_DEV_NO: 50, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", SIG_ENDPOINTS: { 8: { SIG_EP_TYPE: 256, @@ -1908,31 +2862,43 @@ SIG_EP_INPUT: [0, 6, 17], SIG_EP_OUTPUT: [0, 6], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["8:0x0006"], DEV_SIG_ENTITIES: [ - "binary_sensor.lumi_lumi_router_77665544_on_off", "light.lumi_lumi_router_77665544_on_off", + "binary_sensor.lumi_lumi_router_77665544_on_off", + "sensor.lumi_lumi_router_77665544_basic_rssi", + "sensor.lumi_lumi_router_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { - ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { - DEV_SIG_CHANNELS: ["on_off", "on_off"], - DEV_SIG_ENT_MAP_CLASS: "Opening", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", - }, ("light", "00:11:22:33:44:55:66:77-8"): { - DEV_SIG_CHANNELS: ["on_off", "on_off"], + DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_77665544_on_off", }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_77665544_basic_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_77665544_on_off", + }, }, - DEV_SIG_EVT_CHANNELS: ["8:0x0006"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.router", - SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", }, { DEV_SIG_DEV_NO: 51, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sen_ill.mgl01", + SIG_NODE_DESC: b"\x02@\x84n\x12\x7fd\x00\x00,d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 262, @@ -1940,23 +2906,43 @@ SIG_EP_INPUT: [0, 1, 3, 1024], SIG_EP_OUTPUT: [3], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance"], + DEV_SIG_EVT_CHANNELS: [], + DEV_SIG_ENTITIES: [ + "button.lumi_lumi_sen_ill_mgl01_77665544_identify", + "sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance", + "sensor.lumi_lumi_sen_ill_mgl01_77665544_basic_rssi", + "sensor.lumi_lumi_sen_ill_mgl01_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sen_ill_mgl01_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { DEV_SIG_CHANNELS: ["illuminance"], DEV_SIG_ENT_MAP_CLASS: "Illuminance", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance", - } + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sen_ill.mgl01", - SIG_NODE_DESC: b"\x02@\x84n\x12\x7fd\x00\x00,d\x00\x00", }, { DEV_SIG_DEV_NO: 52, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_86sw1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 24321, @@ -1980,22 +2966,41 @@ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_86sw1_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + DEV_SIG_ENTITIES: [ + "button.lumi_lumi_sensor_86sw1_77665544_identify", + "sensor.lumi_lumi_sensor_86sw1_77665544_power", + "sensor.lumi_lumi_sensor_86sw1_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_86sw1_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_86sw1_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_86sw1", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "RemoteB186ACN01", }, { DEV_SIG_DEV_NO: 53, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_cube.aqgl01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 28417, @@ -2019,22 +3024,41 @@ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + DEV_SIG_ENTITIES: [ + "button.lumi_lumi_sensor_cube_aqgl01_77665544_identify", + "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power", + "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_cube_aqgl01_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_cube.aqgl01", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "CubeAQGL01", }, { DEV_SIG_DEV_NO: 54, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_ht", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 24322, @@ -2058,12 +3082,21 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], DEV_SIG_ENTITIES: [ - "sensor.lumi_lumi_sensor_ht_77665544_humidity", + "button.lumi_lumi_sensor_ht_77665544_identify", "sensor.lumi_lumi_sensor_ht_77665544_power", "sensor.lumi_lumi_sensor_ht_77665544_temperature", + "sensor.lumi_lumi_sensor_ht_77665544_humidity", + "sensor.lumi_lumi_sensor_ht_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_ht_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_ht_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -2074,20 +3107,28 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_77665544_temperature", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_77665544_basic_lqi", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1029"): { DEV_SIG_CHANNELS: ["humidity"], DEV_SIG_ENT_MAP_CLASS: "Humidity", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_77665544_humidity", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_ht", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "Weather", }, { DEV_SIG_DEV_NO: 55, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_magnet", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2128, @@ -2095,32 +3136,49 @@ SIG_EP_INPUT: [0, 1, 3, 25, 65535], SIG_EP_OUTPUT: [0, 3, 4, 5, 6, 8, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off", + "button.lumi_lumi_sensor_magnet_77665544_identify", "sensor.lumi_lumi_sensor_magnet_77665544_power", + "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off", + "sensor.lumi_lumi_sensor_magnet_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_magnet_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_77665544_basic_lqi", + }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Opening", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_magnet", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "Magnet", }, { DEV_SIG_DEV_NO: 56, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_magnet.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 24321, @@ -2128,32 +3186,49 @@ SIG_EP_INPUT: [0, 1, 3, 65535], SIG_EP_OUTPUT: [0, 4, 6, 65535], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0006"], DEV_SIG_ENTITIES: [ - "binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off", + "button.lumi_lumi_sensor_magnet_aq2_77665544_identify", "sensor.lumi_lumi_sensor_magnet_aq2_77665544_power", + "binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off", + "sensor.lumi_lumi_sensor_magnet_aq2_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_magnet_aq2_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_aq2_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_77665544_basic_lqi", + }, ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Opening", DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_aq2_77665544_on_off", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_magnet.aq2", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "MagnetAQ2", }, { DEV_SIG_DEV_NO: 57, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_motion.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 263, @@ -2161,15 +3236,34 @@ SIG_EP_INPUT: [0, 1, 3, 1024, 1030, 1280, 65535], SIG_EP_OUTPUT: [0, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone", - "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy", - "sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance", + "button.lumi_lumi_sensor_motion_aq2_77665544_identify", "sensor.lumi_lumi_sensor_motion_aq2_77665544_power", + "sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance", + "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy", + "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone", + "sensor.lumi_lumi_sensor_motion_aq2_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_motion_aq2_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1030"): { + DEV_SIG_CHANNELS: ["occupancy"], + DEV_SIG_ENT_MAP_CLASS: "Occupancy", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_motion_aq2_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -2180,25 +3274,23 @@ DEV_SIG_ENT_MAP_CLASS: "Illuminance", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_77665544_illuminance", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1030"): { - DEV_SIG_CHANNELS: ["occupancy"], - DEV_SIG_ENT_MAP_CLASS: "Occupancy", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_occupancy", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_77665544_basic_rssi", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_motion.aq2", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "MotionAQ2", }, { DEV_SIG_DEV_NO: 58, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_smoke", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -2206,32 +3298,49 @@ SIG_EP_INPUT: [0, 1, 3, 12, 18, 1280], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone", + "button.lumi_lumi_sensor_smoke_77665544_identify", "sensor.lumi_lumi_sensor_smoke_77665544_power", + "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone", + "sensor.lumi_lumi_sensor_smoke_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_smoke_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_smoke_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_77665544_power", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_smoke_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_smoke", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "MijiaHoneywellSmokeDetectorSensor", }, { DEV_SIG_DEV_NO: 59, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 6, @@ -2239,24 +3348,43 @@ SIG_EP_INPUT: [0, 1, 3], SIG_EP_OUTPUT: [0, 4, 5, 6, 8, 25], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_switch_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], + DEV_SIG_ENTITIES: [ + "button.lumi_lumi_sensor_switch_77665544_identify", + "sensor.lumi_lumi_sensor_switch_77665544_power", + "sensor.lumi_lumi_sensor_switch_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_switch_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_switch_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_77665544_power", - } + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_switch", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "MijaButton", }, { DEV_SIG_DEV_NO: 60, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 6, @@ -2264,24 +3392,37 @@ SIG_EP_INPUT: [0, 1, 65535], SIG_EP_OUTPUT: [0, 4, 6, 65535], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_switch_aq2_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0006"], + DEV_SIG_ENTITIES: [ + "sensor.lumi_lumi_sensor_switch_aq2_77665544_power", + "sensor.lumi_lumi_sensor_switch_aq2_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_switch_aq2_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_77665544_power", - } + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_switch.aq2", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "SwitchAQ2", }, { DEV_SIG_DEV_NO: 61, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch.aq3", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 6, @@ -2289,57 +3430,93 @@ SIG_EP_INPUT: [0, 1, 18], SIG_EP_OUTPUT: [0, 6], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["sensor.lumi_lumi_sensor_switch_aq3_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0006"], + DEV_SIG_ENTITIES: [ + "sensor.lumi_lumi_sensor_switch_aq3_77665544_power", + "sensor.lumi_lumi_sensor_switch_aq3_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_switch_aq3_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_switch.aq3", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "SwitchAQ3", }, { DEV_SIG_DEV_NO: 62, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_wleak.aq1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, DEV_SIG_EP_ID: 1, - SIG_EP_INPUT: [0, 1, 3, 1280], + SIG_EP_INPUT: [0, 1, 2, 3, 1280], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone", + "button.lumi_lumi_sensor_wleak_aq1_77665544_identify", "sensor.lumi_lumi_sensor_wleak_aq1_77665544_power", + "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone", + "sensor.lumi_lumi_sensor_wleak_aq1_77665544_basic_rssi", + "sensor.lumi_lumi_sensor_wleak_aq1_77665544_basic_lqi", + "sensor.lumi_lumi_sensor_wleak_aq1_77665544_device_temperature", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CHANNELS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_77665544_device_temperature", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_wleak_aq1_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_77665544_power", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_wleak_aq1_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.sensor_wleak.aq1", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "LeakAQ1", }, { DEV_SIG_DEV_NO: 63, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.vibration.aq1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 10, @@ -2356,36 +3533,53 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005"], DEV_SIG_ENTITIES: [ + "button.lumi_lumi_vibration_aq1_77665544_identify", + "sensor.lumi_lumi_vibration_aq1_77665544_power", "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone", "lock.lumi_lumi_vibration_aq1_77665544_door_lock", - "sensor.lumi_lumi_vibration_aq1_77665544_power", + "sensor.lumi_lumi_vibration_aq1_77665544_basic_rssi", + "sensor.lumi_lumi_vibration_aq1_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_vibration_aq1_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_77665544_basic_lqi", + }, ("lock", "00:11:22:33:44:55:66:77-1-257"): { DEV_SIG_CHANNELS: ["door_lock"], DEV_SIG_ENT_MAP_CLASS: "ZhaDoorLock", DEV_SIG_ENT_MAP_ID: "lock.lumi_lumi_vibration_aq1_77665544_door_lock", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone", - }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0019", "2:0x0005"], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.vibration.aq1", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "VibrationAQ1", }, { DEV_SIG_DEV_NO: 64, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.weather", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 24321, @@ -2393,29 +3587,48 @@ SIG_EP_INPUT: [0, 1, 3, 1026, 1027, 1029, 65535], SIG_EP_OUTPUT: [0, 4, 65535], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "sensor.lumi_lumi_weather_77665544_humidity", + "button.lumi_lumi_weather_77665544_identify", "sensor.lumi_lumi_weather_77665544_power", "sensor.lumi_lumi_weather_77665544_pressure", "sensor.lumi_lumi_weather_77665544_temperature", + "sensor.lumi_lumi_weather_77665544_humidity", + "sensor.lumi_lumi_weather_77665544_basic_rssi", + "sensor.lumi_lumi_weather_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_weather_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { + DEV_SIG_CHANNELS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_pressure", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_temperature", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { - DEV_SIG_CHANNELS: ["pressure"], - DEV_SIG_ENT_MAP_CLASS: "Pressure", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_pressure", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_basic_lqi", }, ("sensor", "00:11:22:33:44:55:66:77-1-1029"): { DEV_SIG_CHANNELS: ["humidity"], @@ -2423,14 +3636,12 @@ DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_77665544_humidity", }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "LUMI", - SIG_MODEL: "lumi.weather", - SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", - DEV_SIG_ZHA_QUIRK: "Weather", }, { DEV_SIG_DEV_NO: 65, + SIG_MANUFACTURER: "NYCE", + SIG_MODEL: "3010", + SIG_NODE_DESC: b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -2438,31 +3649,49 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1280], SIG_EP_OUTPUT: [], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "binary_sensor.nyce_3010_77665544_ias_zone", + "button.nyce_3010_77665544_identify", "sensor.nyce_3010_77665544_power", + "binary_sensor.nyce_3010_77665544_ias_zone", + "sensor.nyce_3010_77665544_basic_rssi", + "sensor.nyce_3010_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3010_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.nyce_3010_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_77665544_power", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3010_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "NYCE", - SIG_MODEL: "3010", - SIG_NODE_DESC: b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 66, + SIG_MANUFACTURER: "NYCE", + SIG_MODEL: "3014", + SIG_NODE_DESC: b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -2470,31 +3699,49 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1280], SIG_EP_OUTPUT: [], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "binary_sensor.nyce_3014_77665544_ias_zone", + "button.nyce_3014_77665544_identify", "sensor.nyce_3014_77665544_power", + "binary_sensor.nyce_3014_77665544_ias_zone", + "sensor.nyce_3014_77665544_basic_rssi", + "sensor.nyce_3014_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3014_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.nyce_3014_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_77665544_power", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3014_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "NYCE", - SIG_MODEL: "3014", - SIG_NODE_DESC: b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 67, + SIG_MANUFACTURER: None, + SIG_MODEL: None, + SIG_NODE_DESC: b"\x10@\x0f5\x11Y=\x00@\x00=\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 5, @@ -2511,15 +3758,15 @@ SIG_EP_PROFILE: 41440, }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: ["1:0x0019"], DEV_SIG_ENT_MAP: {}, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: None, - SIG_MODEL: None, - SIG_NODE_DESC: b"\x10@\x0f5\x11Y=\x00@\x00=\x00\x00", }, { DEV_SIG_DEV_NO: 68, + SIG_MANUFACTURER: None, + SIG_MODEL: None, + SIG_NODE_DESC: b"\x00@\x8f\xcd\xabR\x80\x00\x00\x00\x80\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 48879, @@ -2527,17 +3774,17 @@ SIG_EP_INPUT: [], SIG_EP_OUTPUT: [1280], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [], DEV_SIG_ENT_MAP: {}, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: None, - SIG_MODEL: None, - SIG_NODE_DESC: b"\x00@\x8f\xcd\xabR\x80\x00\x00\x00\x80\x00\x00", }, { DEV_SIG_DEV_NO: 69, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY A19 RGBW", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", SIG_ENDPOINTS: { 3: { SIG_EP_TYPE: 258, @@ -2545,26 +3792,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 64527], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ - "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off" + "button.osram_lightify_a19_rgbw_77665544_identify", + "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off", + "sensor.osram_lightify_a19_rgbw_77665544_basic_rssi", + "sensor.osram_lightify_a19_rgbw_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "light_color", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.osram_lightify_a19_rgbw_77665544_level_light_color_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-3-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_a19_rgbw_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_a19_rgbw_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_a19_rgbw_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["3:0x0019"], - SIG_MANUFACTURER: "OSRAM", - SIG_MODEL: "LIGHTIFY A19 RGBW", - SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", - DEV_SIG_ZHA_QUIRK: "LIGHTIFYA19RGBW", }, { DEV_SIG_DEV_NO: 70, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY Dimming Switch", + SIG_NODE_DESC: b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1, @@ -2572,24 +3836,43 @@ SIG_EP_INPUT: [0, 1, 3, 32, 2821], SIG_EP_OUTPUT: [3, 6, 8, 25], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["sensor.osram_lightify_dimming_switch_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], + DEV_SIG_ENTITIES: [ + "button.osram_lightify_dimming_switch_77665544_identify", + "sensor.osram_lightify_dimming_switch_77665544_power", + "sensor.osram_lightify_dimming_switch_77665544_basic_rssi", + "sensor.osram_lightify_dimming_switch_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_dimming_switch_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_77665544_power", - } + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0006", "1:0x0008", "1:0x0019"], - SIG_MANUFACTURER: "OSRAM", - SIG_MODEL: "LIGHTIFY Dimming Switch", - SIG_NODE_DESC: b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "CentraLite3130", }, { DEV_SIG_DEV_NO: 71, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY Flex RGBW", + SIG_NODE_DESC: b"\x19@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", SIG_ENDPOINTS: { 3: { SIG_EP_TYPE: 258, @@ -2597,26 +3880,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 64527], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ - "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off" + "button.osram_lightify_flex_rgbw_77665544_identify", + "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off", + "sensor.osram_lightify_flex_rgbw_77665544_basic_rssi", + "sensor.osram_lightify_flex_rgbw_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "light_color", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.osram_lightify_flex_rgbw_77665544_level_light_color_on_off", - } + }, + ("button", "00:11:22:33:44:55:66:77-3-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_flex_rgbw_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_flex_rgbw_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_flex_rgbw_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["3:0x0019"], - SIG_MANUFACTURER: "OSRAM", - SIG_MODEL: "LIGHTIFY Flex RGBW", - SIG_NODE_DESC: b"\x19@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", - DEV_SIG_ZHA_QUIRK: "FlexRGBW", }, { DEV_SIG_DEV_NO: 72, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY RT Tunable White", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", SIG_ENDPOINTS: { 3: { SIG_EP_TYPE: 258, @@ -2624,21 +3924,30 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2820, 64527], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ + "button.osram_lightify_rt_tunable_white_77665544_identify", "light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off", "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement", "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement_apparent_power", "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement_rms_current", "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement_rms_voltage", + "sensor.osram_lightify_rt_tunable_white_77665544_basic_rssi", + "sensor.osram_lightify_rt_tunable_white_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-3"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "light_color", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.osram_lightify_rt_tunable_white_77665544_level_light_color_on_off", }, + ("button", "00:11:22:33:44:55:66:77-3-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_rt_tunable_white_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", @@ -2659,15 +3968,23 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_77665544_electrical_measurement_rms_voltage", }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["3:0x0019"], - SIG_MANUFACTURER: "OSRAM", - SIG_MODEL: "LIGHTIFY RT Tunable White", - SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", - DEV_SIG_ZHA_QUIRK: "A19TunableWhite", }, { DEV_SIG_DEV_NO: 73, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "Plug 01", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", SIG_ENDPOINTS: { 3: { SIG_EP_TYPE: 16, @@ -2675,14 +3992,18 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 2820, 4096, 64527], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 49246, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["3:0x0019"], DEV_SIG_ENTITIES: [ + "button.osram_plug_01_77665544_identify", "sensor.osram_plug_01_77665544_electrical_measurement", "sensor.osram_plug_01_77665544_electrical_measurement_apparent_power", "sensor.osram_plug_01_77665544_electrical_measurement_rms_current", "sensor.osram_plug_01_77665544_electrical_measurement_rms_voltage", "switch.osram_plug_01_77665544_on_off", + "sensor.osram_plug_01_77665544_basic_rssi", + "sensor.osram_plug_01_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("switch", "00:11:22:33:44:55:66:77-3"): { @@ -2690,6 +4011,11 @@ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.osram_plug_01_77665544_on_off", }, + ("button", "00:11:22:33:44:55:66:77-3-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.osram_plug_01_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", @@ -2710,14 +4036,23 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_77665544_electrical_measurement_rms_voltage", }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["3:0x0019"], - SIG_MANUFACTURER: "OSRAM", - SIG_MODEL: "Plug 01", - SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", }, { DEV_SIG_DEV_NO: 74, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "Switch 4x-LIGHTIFY", + SIG_NODE_DESC: b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2064, @@ -2762,14 +4097,6 @@ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_ENTITIES: ["sensor.osram_switch_4x_lightify_77665544_power"], - DEV_SIG_ENT_MAP: { - ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], - DEV_SIG_ENT_MAP_CLASS: "Battery", - DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_77665544_power", - } - }, DEV_SIG_EVT_CHANNELS: [ "1:0x0005", "1:0x0006", @@ -2797,13 +4124,34 @@ "6:0x0008", "6:0x0300", ], - SIG_MANUFACTURER: "OSRAM", - SIG_MODEL: "Switch 4x-LIGHTIFY", - SIG_NODE_DESC: b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "LightifyX4", + DEV_SIG_ENTITIES: [ + "sensor.osram_switch_4x_lightify_77665544_power", + "sensor.osram_switch_4x_lightify_77665544_basic_rssi", + "sensor.osram_switch_4x_lightify_77665544_basic_lqi", + ], + DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_77665544_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_77665544_basic_lqi", + }, + }, }, { DEV_SIG_DEV_NO: 75, + SIG_MANUFACTURER: "Philips", + SIG_MODEL: "RWL020", + SIG_NODE_DESC: b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2096, @@ -2820,27 +4168,47 @@ SIG_EP_PROFILE: 260, }, }, - DEV_SIG_ENTITIES: ["sensor.philips_rwl020_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], + DEV_SIG_ENTITIES: [ + "button.philips_rwl020_77665544_identify", + "sensor.philips_rwl020_77665544_power", + "binary_sensor.philips_rwl020_77665544_binary_input", + "sensor.philips_rwl020_77665544_basic_rssi", + "sensor.philips_rwl020_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { - ("sensor", "00:11:22:33:44:55:66:77-2-1"): { - DEV_SIG_CHANNELS: ["power"], - DEV_SIG_ENT_MAP_CLASS: "Battery", - DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_77665544_power", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_77665544_basic_lqi", }, ("binary_sensor", "00:11:22:33:44:55:66:77-2-15"): { DEV_SIG_CHANNELS: ["binary_input"], DEV_SIG_ENT_MAP_CLASS: "BinaryInput", DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_rwl020_77665544_binary_input", }, + ("button", "00:11:22:33:44:55:66:77-2-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.philips_rwl020_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1"): { + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_77665544_power", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], - SIG_MANUFACTURER: "Philips", - SIG_MODEL: "RWL020", - SIG_NODE_DESC: b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00", - DEV_SIG_ZHA_QUIRK: "PhilipsRWL021", }, { DEV_SIG_DEV_NO: 76, + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "button", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -2848,14 +4216,28 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], SIG_EP_OUTPUT: [3, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.samjin_button_77665544_ias_zone", + "button.samjin_button_77665544_identify", "sensor.samjin_button_77665544_power", "sensor.samjin_button_77665544_temperature", + "binary_sensor.samjin_button_77665544_ias_zone", + "sensor.samjin_button_77665544_basic_rssi", + "sensor.samjin_button_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_button_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.samjin_button_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -2866,20 +4248,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_button_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Samjin", - SIG_MODEL: "button", - SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", - DEV_SIG_ZHA_QUIRK: "SamjinButton", }, { DEV_SIG_DEV_NO: 77, + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "multi", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -2887,15 +4272,28 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 64514], SIG_EP_OUTPUT: [3, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.samjin_multi_77665544_ias_zone", - "binary_sensor.samjin_multi_77665544_manufacturer_specific", + "button.samjin_multi_77665544_identify", "sensor.samjin_multi_77665544_power", "sensor.samjin_multi_77665544_temperature", + "binary_sensor.samjin_multi_77665544_ias_zone", + "sensor.samjin_multi_77665544_basic_rssi", + "sensor.samjin_multi_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.samjin_multi_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -2906,20 +4304,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Samjin", - SIG_MODEL: "multi", - SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", - DEV_SIG_ZHA_QUIRK: "SmartthingsMultiPurposeSensor", }, { DEV_SIG_DEV_NO: 78, + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "water", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -2927,14 +4328,28 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280], SIG_EP_OUTPUT: [3, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.samjin_water_77665544_ias_zone", + "button.samjin_water_77665544_identify", "sensor.samjin_water_77665544_power", "sensor.samjin_water_77665544_temperature", + "binary_sensor.samjin_water_77665544_ias_zone", + "sensor.samjin_water_77665544_basic_rssi", + "sensor.samjin_water_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_water_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.samjin_water_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -2945,19 +4360,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_water_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Samjin", - SIG_MODEL: "water", - SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", }, { DEV_SIG_DEV_NO: 79, + SIG_MANUFACTURER: "Securifi Ltd.", + SIG_MODEL: None, + SIG_NODE_DESC: b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 0, @@ -2965,20 +4384,24 @@ SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 2820, 2821], SIG_EP_OUTPUT: [0, 1, 3, 4, 5, 6, 25, 2820, 2821], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0019"], DEV_SIG_ENTITIES: [ + "button.securifi_ltd_unk_model_77665544_identify", "sensor.securifi_ltd_unk_model_77665544_electrical_measurement", "sensor.securifi_ltd_unk_model_77665544_electrical_measurement_apparent_power", "sensor.securifi_ltd_unk_model_77665544_electrical_measurement_rms_current", "sensor.securifi_ltd_unk_model_77665544_electrical_measurement_rms_voltage", "switch.securifi_ltd_unk_model_77665544_on_off", + "sensor.securifi_ltd_unk_model_77665544_basic_rssi", + "sensor.securifi_ltd_unk_model_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { - ("switch", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], - DEV_SIG_ENT_MAP_CLASS: "Switch", - DEV_SIG_ENT_MAP_ID: "switch.securifi_ltd_unk_model_77665544_on_off", + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.securifi_ltd_unk_model_77665544_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], @@ -3000,14 +4423,28 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_77665544_electrical_measurement_rms_voltage", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_77665544_basic_lqi", + }, + ("switch", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.securifi_ltd_unk_model_77665544_on_off", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0005", "1:0x0006", "1:0x0019"], - SIG_MANUFACTURER: "Securifi Ltd.", - SIG_MODEL: None, - SIG_NODE_DESC: b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 80, + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-DWS04N_SF", + SIG_NODE_DESC: b"\x02@\x801\x11R\xff\x00\x00\x00\xff\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -3015,14 +4452,28 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], SIG_EP_OUTPUT: [3, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone", + "button.sercomm_corp_sz_dws04n_sf_77665544_identify", "sensor.sercomm_corp_sz_dws04n_sf_77665544_power", "sensor.sercomm_corp_sz_dws04n_sf_77665544_temperature", + "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone", + "sensor.sercomm_corp_sz_dws04n_sf_77665544_basic_rssi", + "sensor.sercomm_corp_sz_dws04n_sf_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_dws04n_sf_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -3033,19 +4484,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_dws04n_sf_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Sercomm Corp.", - SIG_MODEL: "SZ-DWS04N_SF", - SIG_NODE_DESC: b"\x02@\x801\x11R\xff\x00\x00\x00\xff\x00\x00", }, { DEV_SIG_DEV_NO: 81, + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-ESW01", + SIG_NODE_DESC: b"\x01@\x8e1\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 256, @@ -3062,14 +4517,18 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], DEV_SIG_ENTITIES: [ - "light.sercomm_corp_sz_esw01_77665544_on_off", + "button.sercomm_corp_sz_esw01_77665544_identify", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement_apparent_power", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement_rms_current", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement_rms_voltage", "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering_summation_delivered", + "light.sercomm_corp_sz_esw01_77665544_on_off", + "sensor.sercomm_corp_sz_esw01_77665544_basic_rssi", + "sensor.sercomm_corp_sz_esw01_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -3077,15 +4536,10 @@ DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.sercomm_corp_sz_esw01_77665544_on_off", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", - }, - ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { - DEV_SIG_CHANNELS: ["smartenergy_metering"], - DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering_summation_delivered", + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_esw01_77665544_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], @@ -3107,14 +4561,33 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement_rms_voltage", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], - SIG_MANUFACTURER: "Sercomm Corp.", - SIG_MODEL: "SZ-ESW01", - SIG_NODE_DESC: b"\x01@\x8e1\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 82, + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-PIR04", + SIG_NODE_DESC: b"\x02@\x801\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -3122,15 +4595,29 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1024, 1026, 1280, 2821], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone", - "sensor.sercomm_corp_sz_pir04_77665544_illuminance", + "button.sercomm_corp_sz_pir04_77665544_identify", "sensor.sercomm_corp_sz_pir04_77665544_power", + "sensor.sercomm_corp_sz_pir04_77665544_illuminance", "sensor.sercomm_corp_sz_pir04_77665544_temperature", + "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone", + "sensor.sercomm_corp_sz_pir04_77665544_basic_rssi", + "sensor.sercomm_corp_sz_pir04_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_pir04_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -3146,19 +4633,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_pir04_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Sercomm Corp.", - SIG_MODEL: "SZ-PIR04", - SIG_NODE_DESC: b"\x02@\x801\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 83, + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "RM3250ZB", + SIG_NODE_DESC: b"\x11@\x8e\x9c\x11G+\x00\x00*+\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2, @@ -3166,20 +4657,24 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 2820, 2821, 65281], SIG_EP_OUTPUT: [3, 4, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ + "button.sinope_technologies_rm3250zb_77665544_identify", "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement", "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement_apparent_power", "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement_rms_current", "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement_rms_voltage", "switch.sinope_technologies_rm3250zb_77665544_on_off", + "sensor.sinope_technologies_rm3250zb_77665544_basic_rssi", + "sensor.sinope_technologies_rm3250zb_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { - ("switch", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], - DEV_SIG_ENT_MAP_CLASS: "Switch", - DEV_SIG_ENT_MAP_ID: "switch.sinope_technologies_rm3250zb_77665544_on_off", + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_rm3250zb_77665544_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], @@ -3201,14 +4696,28 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_77665544_electrical_measurement_rms_voltage", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_77665544_basic_lqi", + }, + ("switch", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.sinope_technologies_rm3250zb_77665544_on_off", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Sinope Technologies", - SIG_MODEL: "RM3250ZB", - SIG_NODE_DESC: b"\x11@\x8e\x9c\x11G+\x00\x00*+\x00\x00", }, { DEV_SIG_DEV_NO: 84, + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "TH1123ZB", + SIG_NODE_DESC: b"\x12@\x8c\x9c\x11G+\x00\x00\x00+\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 769, @@ -3225,26 +4734,30 @@ SIG_EP_PROFILE: 49757, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "climate.sinope_technologies_th1123zb_77665544_thermostat", + "button.sinope_technologies_th1123zb_77665544_identify", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_apparent_power", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_current", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_voltage", "sensor.sinope_technologies_th1123zb_77665544_temperature", "sensor.sinope_technologies_th1123zb_77665544_thermostat_hvac_action", + "climate.sinope_technologies_th1123zb_77665544_thermostat", + "sensor.sinope_technologies_th1123zb_77665544_basic_rssi", + "sensor.sinope_technologies_th1123zb_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1123zb_77665544_identify", + }, ("climate", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "Thermostat", DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1123zb_77665544_thermostat", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], - DEV_SIG_ENT_MAP_CLASS: "Temperature", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_temperature", - }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", @@ -3265,20 +4778,33 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_voltage", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_basic_lqi", + }, ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { DEV_SIG_CHANNELS: ["thermostat"], - DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction", + DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_thermostat_hvac_action", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Sinope Technologies", - SIG_MODEL: "TH1123ZB", - SIG_NODE_DESC: b"\x12@\x8c\x9c\x11G+\x00\x00\x00+\x00\x00", - DEV_SIG_ZHA_QUIRK: "SinopeTechnologiesThermostat", }, { DEV_SIG_DEV_NO: 85, + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "TH1124ZB", + SIG_NODE_DESC: b"\x11@\x8e\x9c\x11G+\x00\x00\x00+\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 769, @@ -3295,7 +4821,9 @@ SIG_EP_PROFILE: 49757, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ + "button.sinope_technologies_th1124zb_77665544_identify", "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement", "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement_apparent_power", "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement_rms_current", @@ -3303,23 +4831,20 @@ "sensor.sinope_technologies_th1124zb_77665544_temperature", "sensor.sinope_technologies_th1124zb_77665544_thermostat_hvac_action", "climate.sinope_technologies_th1124zb_77665544_thermostat", + "sensor.sinope_technologies_th1124zb_77665544_basic_rssi", + "sensor.sinope_technologies_th1124zb_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1124zb_77665544_identify", + }, ("climate", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["thermostat"], DEV_SIG_ENT_MAP_CLASS: "Thermostat", DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1124zb_77665544_thermostat", }, - ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { - DEV_SIG_CHANNELS: ["thermostat"], - DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_thermostat_hvac_action", - }, - ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { - DEV_SIG_CHANNELS: ["temperature"], - DEV_SIG_ENT_MAP_CLASS: "Temperature", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_temperature", - }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", @@ -3340,15 +4865,33 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement_rms_voltage", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CHANNELS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_basic_lqi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { + DEV_SIG_CHANNELS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_thermostat_hvac_action", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Sinope Technologies", - SIG_MODEL: "TH1124ZB", - SIG_NODE_DESC: b"\x11@\x8e\x9c\x11G+\x00\x00\x00+\x00\x00", - DEV_SIG_ZHA_QUIRK: "SinopeTechnologiesThermostat", }, { DEV_SIG_DEV_NO: 86, + SIG_MANUFACTURER: "SmartThings", + SIG_MODEL: "outletv4", + SIG_NODE_DESC: b"\x01@\x8e\n\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2, @@ -3356,20 +4899,30 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 9, 15, 2820], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ + "button.smartthings_outletv4_77665544_identify", "sensor.smartthings_outletv4_77665544_electrical_measurement", "sensor.smartthings_outletv4_77665544_electrical_measurement_apparent_power", "sensor.smartthings_outletv4_77665544_electrical_measurement_rms_current", "sensor.smartthings_outletv4_77665544_electrical_measurement_rms_voltage", + "binary_sensor.smartthings_outletv4_77665544_binary_input", "switch.smartthings_outletv4_77665544_on_off", + "sensor.smartthings_outletv4_77665544_basic_rssi", + "sensor.smartthings_outletv4_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { - ("switch", "00:11:22:33:44:55:66:77-1-6"): { - DEV_SIG_CHANNELS: ["on_off"], - DEV_SIG_ENT_MAP_CLASS: "Switch", - DEV_SIG_ENT_MAP_ID: "switch.smartthings_outletv4_77665544_on_off", + ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { + DEV_SIG_CHANNELS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_outletv4_77665544_binary_input", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.smartthings_outletv4_77665544_identify", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], @@ -3391,19 +4944,28 @@ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_77665544_electrical_measurement_rms_voltage", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { - DEV_SIG_CHANNELS: ["binary_input"], - DEV_SIG_ENT_MAP_CLASS: "BinaryInput", - DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_outletv4_77665544_binary_input", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_77665544_basic_lqi", + }, + ("switch", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CHANNELS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.smartthings_outletv4_77665544_on_off", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "SmartThings", - SIG_MODEL: "outletv4", - SIG_NODE_DESC: b"\x01@\x8e\n\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 87, + SIG_MANUFACTURER: "SmartThings", + SIG_MODEL: "tagv4", + SIG_NODE_DESC: b"\x02@\x80\n\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 32768, @@ -3411,9 +4973,16 @@ SIG_EP_INPUT: [0, 1, 3, 15, 32], SIG_EP_OUTPUT: [3, 25], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["device_tracker.smartthings_tagv4_77665544_power"], + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], + DEV_SIG_ENTITIES: [ + "button.smartthings_tagv4_77665544_identify", + "device_tracker.smartthings_tagv4_77665544_power", + "binary_sensor.smartthings_tagv4_77665544_binary_input", + "sensor.smartthings_tagv4_77665544_basic_rssi", + "sensor.smartthings_tagv4_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { ("device_tracker", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["power"], @@ -3425,15 +4994,28 @@ DEV_SIG_ENT_MAP_CLASS: "BinaryInput", DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_tagv4_77665544_binary_input", }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.smartthings_tagv4_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_tagv4_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_tagv4_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "SmartThings", - SIG_MODEL: "tagv4", - SIG_NODE_DESC: b"\x02@\x80\n\x11RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "SmartThingsTagV4", }, { DEV_SIG_DEV_NO: 88, + SIG_MANUFACTURER: "Third Reality, Inc", + SIG_MODEL: "3RSS007Z", + SIG_NODE_DESC: b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2, @@ -3441,23 +5023,43 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 25], SIG_EP_OUTPUT: [], SIG_EP_PROFILE: 260, - } + }, }, - DEV_SIG_ENTITIES: ["switch.third_reality_inc_3rss007z_77665544_on_off"], + DEV_SIG_EVT_CHANNELS: [], + DEV_SIG_ENTITIES: [ + "button.third_reality_inc_3rss007z_77665544_identify", + "switch.third_reality_inc_3rss007z_77665544_on_off", + "sensor.third_reality_inc_3rss007z_77665544_basic_rssi", + "sensor.third_reality_inc_3rss007z_77665544_basic_lqi", + ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss007z_77665544_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss007z_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss007z_77665544_basic_lqi", + }, ("switch", "00:11:22:33:44:55:66:77-1-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss007z_77665544_on_off", - } + }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "Third Reality, Inc", - SIG_MODEL: "3RSS007Z", - SIG_NODE_DESC: b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", }, { DEV_SIG_DEV_NO: 89, + SIG_MANUFACTURER: "Third Reality, Inc", + SIG_MODEL: "3RSS008Z", + SIG_NODE_DESC: b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 2, @@ -3465,32 +5067,49 @@ SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 25], SIG_EP_OUTPUT: [1], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ + "button.third_reality_inc_3rss008z_77665544_identify", "sensor.third_reality_inc_3rss008z_77665544_power", "switch.third_reality_inc_3rss008z_77665544_on_off", + "sensor.third_reality_inc_3rss008z_77665544_basic_rssi", + "sensor.third_reality_inc_3rss008z_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss008z_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_77665544_basic_lqi", + }, ("switch", "00:11:22:33:44:55:66:77-1-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss008z_77665544_on_off", }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "Third Reality, Inc", - SIG_MODEL: "3RSS008Z", - SIG_NODE_DESC: b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", - DEV_SIG_ZHA_QUIRK: "Switch", }, { DEV_SIG_DEV_NO: 90, + SIG_MANUFACTURER: "Visonic", + SIG_MODEL: "MCT-340 E", + SIG_NODE_DESC: b"\x02@\x80\x11\x10RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -3498,14 +5117,28 @@ SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "binary_sensor.visonic_mct_340_e_77665544_ias_zone", + "button.visonic_mct_340_e_77665544_identify", "sensor.visonic_mct_340_e_77665544_power", "sensor.visonic_mct_340_e_77665544_temperature", + "binary_sensor.visonic_mct_340_e_77665544_ias_zone", + "sensor.visonic_mct_340_e_77665544_basic_rssi", + "sensor.visonic_mct_340_e_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.visonic_mct_340_e_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.visonic_mct_340_e_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", @@ -3516,20 +5149,23 @@ DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_77665544_temperature", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.visonic_mct_340_e_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Visonic", - SIG_MODEL: "MCT-340 E", - SIG_NODE_DESC: b"\x02@\x80\x11\x10RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "MCT340E", }, { DEV_SIG_DEV_NO: 91, + SIG_MANUFACTURER: "Zen Within", + SIG_MODEL: "Zen-01", + SIG_NODE_DESC: b"\x02@\x80X\x11R\x80\x00\x00\x00\x80\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 769, @@ -3537,37 +5173,55 @@ SIG_EP_INPUT: [0, 1, 3, 4, 5, 32, 513, 514, 516, 2821], SIG_EP_OUTPUT: [10, 25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ - "climate.zen_within_zen_01_77665544_fan_thermostat", - "sensor.zen_within_zen_01_77665544_thermostat_hvac_action", + "button.zen_within_zen_01_77665544_identify", "sensor.zen_within_zen_01_77665544_power", + "sensor.zen_within_zen_01_77665544_thermostat_hvac_action", + "climate.zen_within_zen_01_77665544_fan_thermostat", + "sensor.zen_within_zen_01_77665544_basic_rssi", + "sensor.zen_within_zen_01_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { - ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CHANNELS: ["power"], - DEV_SIG_ENT_MAP_CLASS: "Battery", - DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_77665544_power", + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.zen_within_zen_01_77665544_identify", }, ("climate", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["thermostat", "fan"], DEV_SIG_ENT_MAP_CLASS: "ZenWithinThermostat", DEV_SIG_ENT_MAP_ID: "climate.zen_within_zen_01_77665544_fan_thermostat", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CHANNELS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_77665544_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_77665544_basic_lqi", + }, ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { DEV_SIG_CHANNELS: ["thermostat"], - DEV_SIG_ENT_MAP_CLASS: "ZenHVACAction", + DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction", DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_77665544_thermostat_hvac_action", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "Zen Within", - SIG_MODEL: "Zen-01", - SIG_NODE_DESC: b"\x02@\x80X\x11R\x80\x00\x00\x00\x80\x00\x00", }, { DEV_SIG_DEV_NO: 92, + SIG_MANUFACTURER: "_TYZB01_ns1ndbww", + SIG_MODEL: "TS0004", + SIG_NODE_DESC: b"\x01@\x8e\x02\x10R\x00\x02\x00,\x00\x02\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 256, @@ -3598,41 +5252,53 @@ SIG_EP_PROFILE: 260, }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ "light.tyzb01_ns1ndbww_ts0004_77665544_on_off", "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2", "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3", "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4", + "sensor.tyzb01_ns1ndbww_ts0004_77665544_basic_rssi", + "sensor.tyzb01_ns1ndbww_ts0004_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", - DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.tyzb01_ns1ndbww_ts0004_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.tyzb01_ns1ndbww_ts0004_77665544_basic_lqi", }, ("light", "00:11:22:33:44:55:66:77-2"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", - DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2", }, ("light", "00:11:22:33:44:55:66:77-3"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", - DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_3", }, ("light", "00:11:22:33:44:55:66:77-4"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Light", - DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_2", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_77665544_on_off_4", }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "_TYZB01_ns1ndbww", - SIG_MODEL: "TS0004", - SIG_NODE_DESC: b"\x01@\x8e\x02\x10R\x00\x02\x00,\x00\x02\x00", }, { DEV_SIG_DEV_NO: 93, + SIG_MANUFACTURER: "netvox", + SIG_MODEL: "Z308E3ED", + SIG_NODE_DESC: b"\x02@\x80\x9f\x10RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 1026, @@ -3640,32 +5306,49 @@ SIG_EP_INPUT: [0, 1, 3, 21, 32, 1280, 2821], SIG_EP_OUTPUT: [], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "binary_sensor.netvox_z308e3ed_77665544_ias_zone", + "button.netvox_z308e3ed_77665544_identify", "sensor.netvox_z308e3ed_77665544_power", + "binary_sensor.netvox_z308e3ed_77665544_ias_zone", + "sensor.netvox_z308e3ed_77665544_basic_rssi", + "sensor.netvox_z308e3ed_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CHANNELS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.netvox_z308e3ed_77665544_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.netvox_z308e3ed_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1"): { DEV_SIG_CHANNELS: ["power"], DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_77665544_power", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { - DEV_SIG_CHANNELS: ["ias_zone"], - DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.netvox_z308e3ed_77665544_ias_zone", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "netvox", - SIG_MODEL: "Z308E3ED", - SIG_NODE_DESC: b"\x02@\x80\x9f\x10RR\x00\x00\x00R\x00\x00", - DEV_SIG_ZHA_QUIRK: "Z308E3ED", }, { DEV_SIG_DEV_NO: 94, + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "E11-G13", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 257, @@ -3673,19 +5356,28 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ + "button.sengled_e11_g13_77665544_identify", "light.sengled_e11_g13_77665544_level_on_off", "sensor.sengled_e11_g13_77665544_smartenergy_metering", "sensor.sengled_e11_g13_77665544_smartenergy_metering_summation_delivered", + "sensor.sengled_e11_g13_77665544_basic_rssi", + "sensor.sengled_e11_g13_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.sengled_e11_g13_77665544_level_on_off", }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sengled_e11_g13_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", @@ -3696,14 +5388,23 @@ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_77665544_smartenergy_metering_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "sengled", - SIG_MODEL: "E11-G13", - SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 95, + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "E12-N14", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 257, @@ -3711,19 +5412,28 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ + "button.sengled_e12_n14_77665544_identify", "light.sengled_e12_n14_77665544_level_on_off", "sensor.sengled_e12_n14_77665544_smartenergy_metering", - "sensor.sengled_e12_n14_77665544_smartenergy_metering_sumaiton_delivered", + "sensor.sengled_e12_n14_77665544_smartenergy_metering_summation_delivered", + "sensor.sengled_e12_n14_77665544_basic_rssi", + "sensor.sengled_e12_n14_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.sengled_e12_n14_77665544_level_on_off", }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sengled_e12_n14_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", @@ -3734,14 +5444,23 @@ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_77665544_smartenergy_metering_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "sengled", - SIG_MODEL: "E12-N14", - SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 96, + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "Z01-A19NAE26", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 257, @@ -3749,19 +5468,28 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 1794, 2821], SIG_EP_OUTPUT: [25], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ + "button.sengled_z01_a19nae26_77665544_identify", "light.sengled_z01_a19nae26_77665544_level_light_color_on_off", "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering_summation_delivered", + "sensor.sengled_z01_a19nae26_77665544_basic_rssi", + "sensor.sengled_z01_a19nae26_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { - DEV_SIG_CHANNELS: ["level", "light_color", "on_off"], + DEV_SIG_CHANNELS: ["on_off", "level", "light_color"], DEV_SIG_ENT_MAP_CLASS: "Light", DEV_SIG_ENT_MAP_ID: "light.sengled_z01_a19nae26_77665544_level_light_color_on_off", }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sengled_z01_a19nae26_77665544_identify", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { DEV_SIG_CHANNELS: ["smartenergy_metering"], DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", @@ -3772,14 +5500,23 @@ DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering_summation_delivered", }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: ["1:0x0019"], - SIG_MANUFACTURER: "sengled", - SIG_MODEL: "Z01-A19NAE26", - SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 97, + SIG_MANUFACTURER: "unk_manufacturer", + SIG_MODEL: "unk_model", + SIG_NODE_DESC: b"\x01@\x8e\x10\x11RR\x00\x00\x00R\x00\x00", SIG_ENDPOINTS: { 1: { SIG_EP_TYPE: 512, @@ -3787,140 +5524,166 @@ SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 10, 21, 256, 64544, 64545], SIG_EP_OUTPUT: [3, 64544], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ - "cover.unk_manufacturer_unk_model_77665544_level_on_off_shade" + "button.unk_manufacturer_unk_model_77665544_identify", + "cover.unk_manufacturer_unk_model_77665544_level_on_off_shade", + "sensor.unk_manufacturer_unk_model_77665544_basic_rssi", + "sensor.unk_manufacturer_unk_model_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CHANNELS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "ZHAIdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.unk_manufacturer_unk_model_77665544_identify", + }, ("cover", "00:11:22:33:44:55:66:77-1"): { DEV_SIG_CHANNELS: ["level", "on_off", "shade"], DEV_SIG_ENT_MAP_CLASS: "Shade", DEV_SIG_ENT_MAP_ID: "cover.unk_manufacturer_unk_model_77665544_level_on_off_shade", - } + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.unk_manufacturer_unk_model_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.unk_manufacturer_unk_model_77665544_basic_lqi", + }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "unk_manufacturer", - SIG_MODEL: "unk_model", - SIG_NODE_DESC: b"\x01@\x8e\x10\x11RR\x00\x00\x00R\x00\x00", }, { DEV_SIG_DEV_NO: 98, + SIG_MANUFACTURER: "Digi", + SIG_MODEL: "XBee3", + SIG_NODE_DESC: b"\x01@\x8e\x1e\x10R\xff\x00\x00,\xff\x00\x00", SIG_ENDPOINTS: { 208: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 208, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_INPUT: [6, 12], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 209: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 209, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_INPUT: [6, 12], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 210: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 210, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_INPUT: [6, 12], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 211: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 211, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_INPUT: [6, 12], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 212: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 212, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006], + SIG_EP_INPUT: [6], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 213: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 213, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006], + SIG_EP_INPUT: [6], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 214: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 214, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006], + SIG_EP_INPUT: [6], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 215: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 215, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006, 0x000C], + SIG_EP_INPUT: [6, 12], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 216: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 216, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006], + SIG_EP_INPUT: [6], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 217: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 217, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006], + SIG_EP_INPUT: [6], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 218: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 218, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006, 0x000D], + SIG_EP_INPUT: [6, 13], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 219: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 219, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006, 0x000D], + SIG_EP_INPUT: [6, 13], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 220: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 220, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006], + SIG_EP_INPUT: [6], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 221: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 221, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006], + SIG_EP_INPUT: [6], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 222: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 222, - SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0006], + SIG_EP_INPUT: [6], SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, }, 232: { + SIG_EP_TYPE: 1, DEV_SIG_EP_ID: 232, + SIG_EP_INPUT: [17, 146], + SIG_EP_OUTPUT: [8, 17], SIG_EP_PROFILE: 49413, - SIG_EP_TYPE: 0x0001, - SIG_EP_INPUT: [0x0011, 0x0092], - SIG_EP_OUTPUT: [0x0008, 0x0011], }, }, + DEV_SIG_EVT_CHANNELS: ["232:0x0008"], DEV_SIG_ENTITIES: [ + "number.digi_xbee3_77665544_analog_output", + "number.digi_xbee3_77665544_analog_output_2", + "sensor.digi_xbee3_77665544_analog_input", + "sensor.digi_xbee3_77665544_analog_input_2", + "sensor.digi_xbee3_77665544_analog_input_3", + "sensor.digi_xbee3_77665544_analog_input_4", + "sensor.digi_xbee3_77665544_analog_input_5", "switch.digi_xbee3_77665544_on_off", "switch.digi_xbee3_77665544_on_off_2", "switch.digi_xbee3_77665544_on_off_3", @@ -3936,29 +5699,43 @@ "switch.digi_xbee3_77665544_on_off_13", "switch.digi_xbee3_77665544_on_off_14", "switch.digi_xbee3_77665544_on_off_15", - "sensor.digi_xbee3_77665544_analog_input", - "sensor.digi_xbee3_77665544_analog_input_2", - "sensor.digi_xbee3_77665544_analog_input_3", - "sensor.digi_xbee3_77665544_analog_input_4", - "number.digi_xbee3_77665544_analog_output", - "number.digi_xbee3_77665544_analog_output_2", ], DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-208-12"): { + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input", + }, ("switch", "00:11:22:33:44:55:66:77-208-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off", }, + ("sensor", "00:11:22:33:44:55:66:77-209-12"): { + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_2", + }, ("switch", "00:11:22:33:44:55:66:77-209-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_2", }, + ("sensor", "00:11:22:33:44:55:66:77-210-12"): { + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_3", + }, ("switch", "00:11:22:33:44:55:66:77-210-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_3", }, + ("sensor", "00:11:22:33:44:55:66:77-211-12"): { + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_4", + }, ("switch", "00:11:22:33:44:55:66:77-211-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", @@ -3979,6 +5756,11 @@ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_7", }, + ("sensor", "00:11:22:33:44:55:66:77-215-12"): { + DEV_SIG_CHANNELS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_5", + }, ("switch", "00:11:22:33:44:55:66:77-215-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", @@ -3994,6 +5776,11 @@ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_10", }, + ("number", "00:11:22:33:44:55:66:77-218-13"): { + DEV_SIG_CHANNELS: ["analog_output"], + DEV_SIG_ENT_MAP_CLASS: "ZhaNumber", + DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_77665544_analog_output", + }, ("switch", "00:11:22:33:44:55:66:77-218-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", @@ -4004,6 +5791,11 @@ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_12", }, + ("number", "00:11:22:33:44:55:66:77-219-13"): { + DEV_SIG_CHANNELS: ["analog_output"], + DEV_SIG_ENT_MAP_CLASS: "ZhaNumber", + DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_77665544_analog_output_2", + }, ("switch", "00:11:22:33:44:55:66:77-220-6"): { DEV_SIG_CHANNELS: ["on_off"], DEV_SIG_ENT_MAP_CLASS: "Switch", @@ -4019,62 +5811,29 @@ DEV_SIG_ENT_MAP_CLASS: "Switch", DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_77665544_on_off_15", }, - ("sensor", "00:11:22:33:44:55:66:77-208-12"): { - DEV_SIG_CHANNELS: ["analog_input"], - DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input", - }, - ("sensor", "00:11:22:33:44:55:66:77-209-12"): { - DEV_SIG_CHANNELS: ["analog_input"], - DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_2", - }, - ("sensor", "00:11:22:33:44:55:66:77-210-12"): { - DEV_SIG_CHANNELS: ["analog_input"], - DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_3", - }, - ("sensor", "00:11:22:33:44:55:66:77-211-12"): { - DEV_SIG_CHANNELS: ["analog_input"], - DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_4", - }, - ("sensor", "00:11:22:33:44:55:66:77-215-12"): { - DEV_SIG_CHANNELS: ["analog_input"], - DEV_SIG_ENT_MAP_CLASS: "AnalogInput", - DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_77665544_analog_input_5", - }, - ("number", "00:11:22:33:44:55:66:77-218-13"): { - DEV_SIG_CHANNELS: ["analog_output"], - DEV_SIG_ENT_MAP_CLASS: "ZhaNumber", - DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_77665544_analog_output", - }, - ("number", "00:11:22:33:44:55:66:77-219-13"): { - DEV_SIG_CHANNELS: ["analog_output"], - DEV_SIG_ENT_MAP_CLASS: "ZhaNumber", - DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_77665544_analog_output_2", - }, }, - DEV_SIG_EVT_CHANNELS: ["232:0x0008"], - SIG_MANUFACTURER: "Digi", - SIG_MODEL: "XBee3", - SIG_NODE_DESC: b"\x01@\x8e\x1e\x10R\xff\x00\x00,\xff\x00\x00", }, { DEV_SIG_DEV_NO: 99, + SIG_MANUFACTURER: "efektalab.ru", + SIG_MODEL: "EFEKTA_PWS", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", SIG_ENDPOINTS: { 1: { - SIG_EP_TYPE: 0x000C, + SIG_EP_TYPE: 12, DEV_SIG_EP_ID: 1, - SIG_EP_INPUT: [0x0000, 0x0001, 0x0402, 0x0408], + SIG_EP_INPUT: [0, 1, 1026, 1032], SIG_EP_OUTPUT: [], SIG_EP_PROFILE: 260, - } + }, }, + DEV_SIG_EVT_CHANNELS: [], DEV_SIG_ENTITIES: [ "sensor.efektalab_ru_efekta_pws_77665544_power", - "sensor.efektalab_ru_efekta_pws_77665544_temperature", "sensor.efektalab_ru_efekta_pws_77665544_soil_moisture", + "sensor.efektalab_ru_efekta_pws_77665544_temperature", + "sensor.efektalab_ru_efekta_pws_77665544_basic_rssi", + "sensor.efektalab_ru_efekta_pws_77665544_basic_lqi", ], DEV_SIG_ENT_MAP: { ("sensor", "00:11:22:33:44:55:66:77-1-1"): { @@ -4082,20 +5841,26 @@ DEV_SIG_ENT_MAP_CLASS: "Battery", DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_77665544_power", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1032"): { + DEV_SIG_CHANNELS: ["soil_moisture"], + DEV_SIG_ENT_MAP_CLASS: "SoilMoisture", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_77665544_soil_moisture", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { DEV_SIG_CHANNELS: ["temperature"], DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_77665544_temperature", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1032"): { - DEV_SIG_CHANNELS: ["soil_moisture"], - DEV_SIG_ENT_MAP_CLASS: "SoilMoisture", - DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_77665544_soil_moisture", + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_77665544_basic_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CHANNELS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_77665544_basic_lqi", }, }, - DEV_SIG_EVT_CHANNELS: [], - SIG_MANUFACTURER: "efektalab.ru", - SIG_MODEL: "EFEKTA_PWS", - SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", }, ] diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index ee62dd5df3a34f..b742032f863a5c 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -307,3 +307,49 @@ async def test_zone_condition(hass, calls): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 + + +async def test_unknown_zone(hass, calls, caplog): + """Test for firing on zone enter.""" + context = Context() + hass.states.async_set( + "test.entity", "hello", {"latitude": 32.881011, "longitude": -117.234758} + ) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "My Automation", + "trigger": { + "platform": "zone", + "entity_id": "test.entity", + "zone": "zone.no_such_zone", + "event": "enter", + }, + "action": { + "service": "test.automation", + }, + } + }, + ) + + assert ( + "Automation 'My Automation' is referencing non-existing zone 'zone.no_such_zone' in a zone trigger" + not in caplog.text + ) + + hass.states.async_set( + "test.entity", + "hello", + {"latitude": 32.880586, "longitude": -117.237564}, + context=context, + ) + await hass.async_block_till_done() + + assert ( + "Automation 'My Automation' is referencing non-existing zone 'zone.no_such_zone' in a zone trigger" + in caplog.text + ) diff --git a/tests/components/zwave/test_light.py b/tests/components/zwave/test_light.py index 9e943c54bb4e36..35128ccc69aae5 100644 --- a/tests/components/zwave/test_light.py +++ b/tests/components/zwave/test_light.py @@ -5,14 +5,14 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, - ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, ATTR_TRANSITION, - ATTR_WHITE_VALUE, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, SUPPORT_TRANSITION, - SUPPORT_WHITE_VALUE, ) from homeassistant.components.zwave import const, light @@ -38,7 +38,9 @@ def test_get_device_detects_dimmer(mock_openzwave): device = light.get_device(node=node, values=values, node_config={}) assert isinstance(device, light.ZwaveDimmer) - assert device.supported_features == SUPPORT_BRIGHTNESS + assert device.color_mode == COLOR_MODE_BRIGHTNESS + assert device.supported_features is None + assert device.supported_color_modes == {COLOR_MODE_BRIGHTNESS} def test_get_device_detects_colorlight(mock_openzwave): @@ -49,7 +51,9 @@ def test_get_device_detects_colorlight(mock_openzwave): device = light.get_device(node=node, values=values, node_config={}) assert isinstance(device, light.ZwaveColorLight) - assert device.supported_features == SUPPORT_BRIGHTNESS | SUPPORT_COLOR + assert device.color_mode == COLOR_MODE_RGB + assert device.supported_features is None + assert device.supported_color_modes == {COLOR_MODE_RGB} def test_get_device_detects_zw098(mock_openzwave): @@ -63,9 +67,9 @@ def test_get_device_detects_zw098(mock_openzwave): values = MockLightValues(primary=value) device = light.get_device(node=node, values=values, node_config={}) assert isinstance(device, light.ZwaveColorLight) - assert device.supported_features == ( - SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP - ) + assert device.color_mode == COLOR_MODE_RGB + assert device.supported_features is None + assert device.supported_color_modes == {COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGB} def test_get_device_detects_rgbw_light(mock_openzwave): @@ -79,9 +83,9 @@ def test_get_device_detects_rgbw_light(mock_openzwave): device = light.get_device(node=node, values=values, node_config={}) device.value_added() assert isinstance(device, light.ZwaveColorLight) - assert device.supported_features == ( - SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_WHITE_VALUE - ) + assert device.color_mode == COLOR_MODE_RGBW + assert device.supported_features is None + assert device.supported_color_modes == {COLOR_MODE_RGBW} def test_dimmer_turn_on(mock_openzwave): @@ -153,7 +157,9 @@ def test_dimmer_transitions(mock_openzwave): duration = MockValue(data=0, node=node) values = MockLightValues(primary=value, dimming_duration=duration) device = light.get_device(node=node, values=values, node_config={}) - assert device.supported_features == SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION + assert device.color_mode == COLOR_MODE_BRIGHTNESS + assert device.supported_features == SUPPORT_TRANSITION + assert device.supported_color_modes == {COLOR_MODE_BRIGHTNESS} # Test turn_on # Factory Default @@ -261,7 +267,7 @@ def test_dimmer_refresh_value(mock_openzwave): assert device.brightness == 118 -def test_set_hs_color(mock_openzwave): +def test_set_rgb_color(mock_openzwave): """Test setting zwave light color.""" node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR]) value = MockValue(data=0, node=node) @@ -273,7 +279,7 @@ def test_set_hs_color(mock_openzwave): assert color.data == "#0000000000" - device.turn_on(**{ATTR_HS_COLOR: (30, 50)}) + device.turn_on(**{ATTR_RGB_COLOR: (0xFF, 0xBF, 0x7F)}) assert color.data == "#ffbf7f0000" @@ -290,14 +296,14 @@ def test_set_white_value(mock_openzwave): assert color.data == "#0000000000" - device.turn_on(**{ATTR_WHITE_VALUE: 200}) + device.turn_on(**{ATTR_RGBW_COLOR: (0xFF, 0xFF, 0xFF, 0xC8)}) assert color.data == "#ffffffc800" def test_disable_white_if_set_color(mock_openzwave): """ - Test that _white is set to 0 if turn_on with ATTR_HS_COLOR. + Test that _white is set to 0 if turn_on with ATTR_RGB_COLOR. See Issue #13930 - many RGBW ZWave bulbs will only activate the RGB LED to produce color if _white is set to zero. @@ -312,12 +318,12 @@ def test_disable_white_if_set_color(mock_openzwave): device._white = 234 assert color.data == "#0000000000" - assert device.white_value == 234 + assert device.rgbw_color == (0, 0, 0, 234) - device.turn_on(**{ATTR_HS_COLOR: (30, 50)}) + device.turn_on(**{ATTR_RGB_COLOR: (0xFF, 0xBF, 0x7F)}) - assert device.white_value == 0 assert color.data == "#ffbf7f0000" + assert device.rgbw_color == (0xFF, 0xBF, 0x7F, 0x00) def test_zw098_set_color_temp(mock_openzwave): @@ -355,7 +361,8 @@ def test_rgb_not_supported(mock_openzwave): values = MockLightValues(primary=value, color=color, color_channels=color_channels) device = light.get_device(node=node, values=values, node_config={}) - assert device.hs_color is None + assert device.rgb_color is None + assert device.rgbw_color is None def test_no_color_value(mock_openzwave): @@ -365,7 +372,8 @@ def test_no_color_value(mock_openzwave): values = MockLightValues(primary=value) device = light.get_device(node=node, values=values, node_config={}) - assert device.hs_color is None + assert device.rgb_color is None + assert device.rgbw_color is None def test_no_color_channels_value(mock_openzwave): @@ -376,7 +384,8 @@ def test_no_color_channels_value(mock_openzwave): values = MockLightValues(primary=value, color=color) device = light.get_device(node=node, values=values, node_config={}) - assert device.hs_color is None + assert device.rgb_color is None + assert device.rgbw_color is None def test_rgb_value_changed(mock_openzwave): @@ -389,12 +398,12 @@ def test_rgb_value_changed(mock_openzwave): values = MockLightValues(primary=value, color=color, color_channels=color_channels) device = light.get_device(node=node, values=values, node_config={}) - assert device.hs_color == (0, 0) + assert device.rgb_color == (0, 0, 0) color.data = "#ffbf800000" value_changed(color) - assert device.hs_color == (29.764, 49.804) + assert device.rgb_color == (0xFF, 0xBF, 0x80) def test_rgbww_value_changed(mock_openzwave): @@ -407,14 +416,12 @@ def test_rgbww_value_changed(mock_openzwave): values = MockLightValues(primary=value, color=color, color_channels=color_channels) device = light.get_device(node=node, values=values, node_config={}) - assert device.hs_color == (0, 0) - assert device.white_value == 0 + assert device.rgbw_color == (0, 0, 0, 0) color.data = "#c86400c800" value_changed(color) - assert device.hs_color == (30, 100) - assert device.white_value == 200 + assert device.rgbw_color == (0xC8, 0x64, 0x00, 0xC8) def test_rgbcw_value_changed(mock_openzwave): @@ -427,14 +434,12 @@ def test_rgbcw_value_changed(mock_openzwave): values = MockLightValues(primary=value, color=color, color_channels=color_channels) device = light.get_device(node=node, values=values, node_config={}) - assert device.hs_color == (0, 0) - assert device.white_value == 0 + assert device.rgbw_color == (0, 0, 0, 0) color.data = "#c86400c800" value_changed(color) - assert device.hs_color == (30, 100) - assert device.white_value == 200 + assert device.rgbw_color == (0xC8, 0x64, 0x00, 0xC8) def test_ct_value_changed(mock_openzwave): @@ -451,14 +456,21 @@ def test_ct_value_changed(mock_openzwave): values = MockLightValues(primary=value, color=color, color_channels=color_channels) device = light.get_device(node=node, values=values, node_config={}) - assert device.color_temp == light.TEMP_MID_HASS + assert device.color_mode == COLOR_MODE_RGB + assert device.color_temp is None color.data = "#000000ff00" value_changed(color) + assert device.color_mode == COLOR_MODE_COLOR_TEMP assert device.color_temp == light.TEMP_WARM_HASS color.data = "#00000000ff" value_changed(color) + assert device.color_mode == COLOR_MODE_COLOR_TEMP assert device.color_temp == light.TEMP_COLD_HASS + + color.data = "#ff00000000" + value_changed(color) + assert device.color_mode == COLOR_MODE_RGB diff --git a/tests/components/zwave/test_sensor.py b/tests/components/zwave/test_sensor.py index 4f995131d157c1..83ebcaa3a4a4d4 100644 --- a/tests/components/zwave/test_sensor.py +++ b/tests/components/zwave/test_sensor.py @@ -1,4 +1,5 @@ """Test Z-Wave sensor.""" +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.zwave import const, sensor import homeassistant.const @@ -67,7 +68,7 @@ def test_get_device_detects_battery_sensor(mock_openzwave): device = sensor.get_device(node=node, values=values, node_config={}) assert isinstance(device, sensor.ZWaveBatterySensor) - assert device.device_class == homeassistant.const.DEVICE_CLASS_BATTERY + assert device.device_class is SensorDeviceClass.BATTERY def test_multilevelsensor_value_changed_temp_fahrenheit(hass, mock_openzwave): @@ -87,7 +88,7 @@ def test_multilevelsensor_value_changed_temp_fahrenheit(hass, mock_openzwave): device.hass = hass assert device.state == 191.0 assert device.unit_of_measurement == homeassistant.const.TEMP_FAHRENHEIT - assert device.device_class == homeassistant.const.DEVICE_CLASS_TEMPERATURE + assert device.device_class is SensorDeviceClass.TEMPERATURE value.data = 197.95555 value_changed(value) assert device.state == 198.0 @@ -109,7 +110,7 @@ def test_multilevelsensor_value_changed_temp_celsius(hass, mock_openzwave): device.hass = hass assert device.state == 38.9 assert device.unit_of_measurement == homeassistant.const.TEMP_CELSIUS - assert device.device_class == homeassistant.const.DEVICE_CLASS_TEMPERATURE + assert device.device_class is SensorDeviceClass.TEMPERATURE value.data = 37.95555 value_changed(value) assert device.state == 38.0 diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 4ae1d509c69b86..4f21f616ae131a 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -919,6 +919,14 @@ def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state): return node +@pytest.fixture(name="fortrezz_ssa3_siren") +def fortrezz_ssa3_siren_fixture(client, fortrezz_ssa3_siren_state): + """Mock a fortrezz ssa3 siren node.""" + node = Node(client, copy.deepcopy(fortrezz_ssa3_siren_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="firmware_file") def firmware_file_fixture(): """Return mock firmware file stream.""" diff --git a/tests/components/zwave_js/fixtures/fortrezz_ssa3_siren_state.json b/tests/components/zwave_js/fixtures/fortrezz_ssa3_siren_state.json new file mode 100644 index 00000000000000..fb31f838667128 --- /dev/null +++ b/tests/components/zwave_js/fixtures/fortrezz_ssa3_siren_state.json @@ -0,0 +1,355 @@ +{ + "nodeId": 61, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 132, + "productId": 267, + "productType": 817, + "firmwareVersion": "1.11", + "deviceConfig": { + "filename": "/data/db/devices/0x0084/ssa3.json", + "isEmbedded": true, + "manufacturer": "FortrezZ LLC", + "manufacturerId": 132, + "label": "SSA3", + "description": "Siren and Strobe Alarm", + "devices": [ + { + "productType": 833, + "productId": 517 + }, + { + "productType": 817, + "productId": 267 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + } + }, + "label": "SSA3", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 61, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [ + 32, + 38 + ], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 0, + "max": 99 + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 132 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 817 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 267 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "2.97" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "1.11" + ] + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Delay before accept of Basic Set Off", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Delay before accept of Basic Set Off", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + } + ], + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [ + 40000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [ + 32, + 38 + ], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0084:0x0331:0x010b:1.11", + "statistics": { + "commandsTX": 12, + "commandsRX": 11, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 1 + }, + "highestSecurityClass": -1 +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 4ca733786dc82f..40f60b9018a48c 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -6,7 +6,6 @@ import pytest from zwave_js_server.const import ( - CommandClass, InclusionStrategy, LogLevel, Protocols, @@ -27,7 +26,6 @@ QRProvisioningInformation, ) from zwave_js_server.model.node import Node -from zwave_js_server.model.value import _get_value_id_from_dict, get_value_id from homeassistant.components.websocket_api.const import ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( @@ -72,8 +70,6 @@ ) from homeassistant.helpers import device_registry as dr -from .common import PROPERTY_ULTRAVIOLET - async def test_network_status(hass, integration, hass_ws_client): """Test the network status websocket command.""" @@ -203,86 +199,6 @@ async def test_node_status(hass, multisensor_6, integration, hass_ws_client): assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_node_state(hass, multisensor_6, integration, hass_ws_client): - """Test the node_state websocket command.""" - entry = integration - ws_client = await hass_ws_client(hass) - - node = multisensor_6 - - # Update a value and ensure it is reflected in the node state - value_id = get_value_id(node, CommandClass.SENSOR_MULTILEVEL, PROPERTY_ULTRAVIOLET) - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Multilevel Sensor", - "commandClass": 49, - "endpoint": 0, - "property": PROPERTY_ULTRAVIOLET, - "newValue": 1, - "prevValue": 0, - "propertyName": PROPERTY_ULTRAVIOLET, - }, - }, - ) - node.receive_event(event) - - await ws_client.send_json( - { - ID: 3, - TYPE: "zwave_js/node_state", - ENTRY_ID: entry.entry_id, - NODE_ID: node.node_id, - } - ) - msg = await ws_client.receive_json() - - # Assert that the data returned doesn't match the stale node state data - assert msg["result"] != node.data - - # Replace data for the value we updated and assert the new node data is the same - # as what's returned - updated_node_data = node.data.copy() - for n, value in enumerate(updated_node_data["values"]): - if _get_value_id_from_dict(node, value) == value_id: - updated_node_data["values"][n] = node.values[value_id].data.copy() - assert msg["result"] == updated_node_data - - # Test getting non-existent node fails - await ws_client.send_json( - { - ID: 4, - TYPE: "zwave_js/node_state", - ENTRY_ID: entry.entry_id, - NODE_ID: 99999, - } - ) - msg = await ws_client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == ERR_NOT_FOUND - - # Test sending command with not loaded entry fails - await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - - await ws_client.send_json( - { - ID: 5, - TYPE: "zwave_js/node_state", - ENTRY_ID: entry.entry_id, - NODE_ID: node.node_id, - } - ) - msg = await ws_client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == ERR_NOT_LOADED - - async def test_node_metadata(hass, wallmote_central_scene, integration, hass_ws_client): """Test the node metadata websocket command.""" entry = integration @@ -629,19 +545,19 @@ async def test_add_node( "options": { "strategy": InclusionStrategy.SECURITY_S2, "provisioning": QRProvisioningInformation( - QRCodeVersion.S2, - [SecurityClass.S2_UNAUTHENTICATED], - "test", - 1, - 1, - 1, - 1, - 1, - 1, - "test", - None, - None, - None, + version=QRCodeVersion.S2, + security_classes=[SecurityClass.S2_UNAUTHENTICATED], + dsk="test", + generic_device_class=1, + specific_device_class=1, + installer_icon_type=1, + manufacturer_id=1, + product_type=1, + product_id=1, + application_version="test", + max_inclusion_request_interval=None, + uuid=None, + supported_protocols=None, ).to_dict(), }, } @@ -932,19 +848,19 @@ async def test_provision_smart_start_node(hass, integration, client, hass_ws_cli assert client.async_send_command.call_args[0][0] == { "command": "controller.provision_smart_start_node", "entry": QRProvisioningInformation( - QRCodeVersion.SMART_START, - [SecurityClass.S2_UNAUTHENTICATED], - "test", - 1, - 1, - 1, - 1, - 1, - 1, - "test", - None, - None, - None, + version=QRCodeVersion.SMART_START, + security_classes=[SecurityClass.S2_UNAUTHENTICATED], + dsk="test", + generic_device_class=1, + specific_device_class=1, + installer_icon_type=1, + manufacturer_id=1, + product_type=1, + product_id=1, + application_version="test", + max_inclusion_request_interval=None, + uuid=None, + supported_protocols=None, ).to_dict(), } @@ -1263,6 +1179,7 @@ async def test_parse_qr_code_string(hass, integration, client, hass_ws_client): "max_inclusion_request_interval": 1, "uuid": "test", "supported_protocols": [Protocols.ZWAVE], + "additional_properties": {}, } assert len(client.async_send_command.call_args_list) == 1 @@ -1762,19 +1679,19 @@ async def test_replace_failed_node( "options": { "strategy": InclusionStrategy.SECURITY_S2, "provisioning": QRProvisioningInformation( - QRCodeVersion.S2, - [SecurityClass.S2_UNAUTHENTICATED], - "test", - 1, - 1, - 1, - 1, - 1, - 1, - "test", - None, - None, - None, + version=QRCodeVersion.S2, + security_classes=[SecurityClass.S2_UNAUTHENTICATED], + dsk="test", + generic_device_class=1, + specific_device_class=1, + installer_icon_type=1, + manufacturer_id=1, + product_type=1, + product_id=1, + application_version="test", + max_inclusion_request_interval=None, + uuid=None, + supported_protocols=None, ).to_dict(), }, } @@ -2799,69 +2716,6 @@ async def test_get_config_parameters(hass, multisensor_6, integration, hass_ws_c assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_dump_view(integration, hass_client): - """Test the HTTP dump view.""" - client = await hass_client() - with patch( - "zwave_js_server.dump.dump_msgs", - return_value=[{"hello": "world"}, {"second": "msg"}], - ): - resp = await client.get(f"/api/zwave_js/dump/{integration.entry_id}") - assert resp.status == HTTPStatus.OK - assert json.loads(await resp.text()) == [{"hello": "world"}, {"second": "msg"}] - - -async def test_version_info(hass, integration, hass_ws_client, version_state): - """Test the HTTP dump node view.""" - entry = integration - ws_client = await hass_ws_client(hass) - - version_info = { - "driver_version": version_state["driverVersion"], - "server_version": version_state["serverVersion"], - "min_schema_version": 0, - "max_schema_version": 0, - } - - await ws_client.send_json( - { - ID: 3, - TYPE: "zwave_js/version_info", - ENTRY_ID: entry.entry_id, - } - ) - msg = await ws_client.receive_json() - assert msg["result"] == version_info - - # Test getting non-existent entry fails - await ws_client.send_json( - { - ID: 4, - TYPE: "zwave_js/version_info", - ENTRY_ID: "INVALID", - } - ) - msg = await ws_client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == ERR_NOT_FOUND - - # Test sending command with not loaded entry fails - await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - - await ws_client.send_json( - { - ID: 5, - TYPE: "zwave_js/version_info", - ENTRY_ID: entry.entry_id, - } - ) - msg = await ws_client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == ERR_NOT_LOADED - - async def test_firmware_upload_view( hass, multisensor_6, integration, hass_client, firmware_file ): @@ -2906,21 +2760,6 @@ async def test_firmware_upload_view_invalid_payload( assert resp.status == HTTPStatus.BAD_REQUEST -@pytest.mark.parametrize( - "method, url", - [("get", "/api/zwave_js/dump/{}")], -) -async def test_view_non_admin_user( - integration, hass_client, hass_admin_user, method, url -): - """Test config entry level views for non-admin users.""" - client = await hass_client() - # Verify we require admin user - hass_admin_user.groups = [] - resp = await client.request(method, url.format(integration.entry_id)) - assert resp.status == HTTPStatus.UNAUTHORIZED - - @pytest.mark.parametrize( "method, url", [("post", "/api/zwave_js/firmware/upload/{}/{}")], @@ -2941,7 +2780,6 @@ async def test_node_view_non_admin_user( @pytest.mark.parametrize( "method, url", [ - ("get", "/api/zwave_js/dump/INVALID"), ("post", "/api/zwave_js/firmware/upload/INVALID/1"), ], ) diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index d5aab6cd0f9935..292bbc2da37bfe 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -2,19 +2,11 @@ from zwave_js_server.event import Event from zwave_js_server.model.node import Node -from homeassistant.components.binary_sensor import ( - DEVICE_CLASS_DOOR, - DEVICE_CLASS_MOTION, - DEVICE_CLASS_TAMPER, -) -from homeassistant.const import ( - DEVICE_CLASS_BATTERY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_OFF, - STATE_ON, -) +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory from .common import ( DISABLED_LEGACY_BINARY_SENSOR, @@ -34,13 +26,13 @@ async def test_low_battery_sensor(hass, multisensor_6, integration): assert state assert state.state == STATE_OFF - assert state.attributes["device_class"] == DEVICE_CLASS_BATTERY + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY registry = er.async_get(hass) entity_entry = registry.async_get(LOW_BATTERY_BINARY_SENSOR) assert entity_entry - assert entity_entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC async def test_enabled_legacy_sensor(hass, ecolink_door_sensor, integration): @@ -52,7 +44,7 @@ async def test_enabled_legacy_sensor(hass, ecolink_door_sensor, integration): state = hass.states.get(ENABLED_LEGACY_BINARY_SENSOR) assert state assert state.state == STATE_OFF - assert state.attributes.get("device_class") is None + assert state.attributes.get(ATTR_DEVICE_CLASS) is None # Test state updates from value updated event event = Event( @@ -89,7 +81,7 @@ async def test_disabled_legacy_sensor(hass, multisensor_6, integration): entry = registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling legacy entity updated_entry = registry.async_update_entity( @@ -105,19 +97,19 @@ async def test_notification_sensor(hass, multisensor_6, integration): assert state assert state.state == STATE_ON - assert state.attributes["device_class"] == DEVICE_CLASS_MOTION + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.MOTION state = hass.states.get(TAMPER_SENSOR) assert state assert state.state == STATE_OFF - assert state.attributes["device_class"] == DEVICE_CLASS_TAMPER + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.TAMPER registry = er.async_get(hass) entity_entry = registry.async_get(TAMPER_SENSOR) assert entity_entry - assert entity_entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC async def test_notification_off_state( @@ -141,7 +133,7 @@ async def test_notification_off_state( door_states = [ state for state in hass.states.async_all("binary_sensor") - if state.attributes.get("device_class") == DEVICE_CLASS_DOOR + if state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.DOOR ] # Only one entity should be created for the Door state notification states. @@ -159,7 +151,7 @@ async def test_property_sensor_door_status(hass, lock_august_pro, integration): state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) assert state assert state.state == STATE_OFF - assert state.attributes["device_class"] == DEVICE_CLASS_DOOR + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR # open door event = Event( diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index d6d3376d0e603b..0958e259ab0984 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -4,13 +4,10 @@ from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, - DEVICE_CLASS_BLIND, - DEVICE_CLASS_GARAGE, - DEVICE_CLASS_SHUTTER, - DEVICE_CLASS_WINDOW, DOMAIN, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, + CoverDeviceClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -35,7 +32,7 @@ async def test_window_cover(hass, client, chain_actuator_zws12, integration): state = hass.states.get(WINDOW_COVER_ENTITY) assert state - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_WINDOW + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.WINDOW assert state.state == "closed" assert state.attributes[ATTR_CURRENT_POSITION] == 0 @@ -315,7 +312,7 @@ async def test_fibaro_FGR222_shutter_cover( """Test tilt function of the Fibaro Shutter devices.""" state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) assert state - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_SHUTTER + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER assert state.state == "open" assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 @@ -396,7 +393,7 @@ async def test_aeotec_nano_shutter_cover( state = hass.states.get(AEOTEC_SHUTTER_COVER_ENTITY) assert state - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_WINDOW + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.WINDOW assert state.state == "closed" assert state.attributes[ATTR_CURRENT_POSITION] == 0 @@ -602,7 +599,7 @@ async def test_blind_cover(hass, client, iblinds_v2, integration): state = hass.states.get(BLIND_COVER_ENTITY) assert state - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_BLIND + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.BLIND async def test_shutter_cover(hass, client, qubino_shutter, integration): @@ -610,7 +607,7 @@ async def test_shutter_cover(hass, client, qubino_shutter, integration): state = hass.states.get(SHUTTER_COVER_ENTITY) assert state - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_SHUTTER + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER async def test_motor_barrier_cover(hass, client, gdc_zw062, integration): @@ -619,7 +616,7 @@ async def test_motor_barrier_cover(hass, client, gdc_zw062, integration): state = hass.states.get(GDC_COVER_ENTITY) assert state - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_GARAGE + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.GARAGE assert state.state == STATE_CLOSED diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index 965c7207fcf18e..5377d420268c1a 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -8,6 +8,7 @@ from zwave_js_server.model.node import Node from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.zwave_js import DOMAIN, device_action from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import ConfigEntry @@ -69,7 +70,9 @@ async def test_get_actions( "subtype": f"{node.node_id}-112-0-3 (Beeper)", }, ] - actions = await async_get_device_automations(hass, "action", device.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device.id + ) for action in expected_actions: assert action in actions @@ -85,7 +88,9 @@ async def test_get_actions_meter( dev_reg = device_registry.async_get(hass) device = dev_reg.async_get_device({get_device_id(client, node)}) assert device - actions = await async_get_device_automations(hass, "action", device.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device.id + ) filtered_actions = [action for action in actions if action["type"] == "reset_meter"] assert len(filtered_actions) > 0 @@ -600,7 +605,9 @@ async def test_unavailable_entity_actions( dev_reg = device_registry.async_get(hass) device = dev_reg.async_get_device({get_device_id(client, node)}) assert device - actions = await async_get_device_automations(hass, "action", device.id) + actions = await async_get_device_automations( + hass, DeviceAutomationType.ACTION, device.id + ) assert not any( action.get("entity_id") == entity_id_unavailable for action in actions ) diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 725d9574605772..3919edbd3401e1 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -10,6 +10,7 @@ from zwave_js_server.event import Event from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) @@ -60,7 +61,9 @@ async def test_get_conditions(hass, client, lock_schlage_be469, integration) -> "device_id": device.id, }, ] - conditions = await async_get_device_automations(hass, "condition", device.id) + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device.id + ) for condition in expected_conditions: assert condition in conditions diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 22496d3deedee9..19c86af22edcdc 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -8,6 +8,7 @@ from zwave_js_server.model.node import Node from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, ) @@ -50,7 +51,9 @@ async def test_get_notification_notification_triggers( "device_id": device.id, "command_class": CommandClass.NOTIFICATION, } - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) assert expected_trigger in triggers @@ -314,7 +317,9 @@ async def test_get_node_status_triggers(hass, client, lock_schlage_be469, integr "device_id": device.id, "entity_id": entity_id, } - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) assert expected_trigger in triggers @@ -466,7 +471,9 @@ async def test_get_basic_value_notification_triggers( "endpoint": 0, "subtype": "Endpoint 0", } - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) assert expected_trigger in triggers @@ -631,7 +638,9 @@ async def test_get_central_scene_value_notification_triggers( "endpoint": 0, "subtype": "Endpoint 0 Scene 001", } - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) assert expected_trigger in triggers @@ -802,7 +811,9 @@ async def test_get_scene_activation_value_notification_triggers( "endpoint": 0, "subtype": "Endpoint 0", } - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) assert expected_trigger in triggers @@ -962,7 +973,9 @@ async def test_get_value_updated_value_triggers( "type": "zwave_js.value_updated.value", "device_id": device.id, } - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) assert expected_trigger in triggers @@ -1121,7 +1134,9 @@ async def test_get_value_updated_config_parameter_triggers( "command_class": CommandClass.CONFIGURATION.value, "subtype": f"{node.node_id}-112-0-3 (Beeper)", } - triggers = await async_get_device_automations(hass, "trigger", device.id) + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) assert expected_trigger in triggers diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py new file mode 100644 index 00000000000000..b41292a15fc1ba --- /dev/null +++ b/tests/components/zwave_js/test_diagnostics.py @@ -0,0 +1,99 @@ +"""Test the Z-Wave JS diagnostics.""" +from unittest.mock import patch + +import pytest +from zwave_js_server.const import CommandClass +from zwave_js_server.event import Event +from zwave_js_server.model.value import _get_value_id_from_dict, get_value_id + +from homeassistant.components.zwave_js.diagnostics import async_get_device_diagnostics +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.helpers.device_registry import async_get + +from .common import PROPERTY_ULTRAVIOLET + +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) + + +async def test_config_entry_diagnostics(hass, hass_client, integration): + """Test the config entry level diagnostics data dump.""" + with patch( + "homeassistant.components.zwave_js.diagnostics.dump_msgs", + return_value=[{"hello": "world"}, {"second": "msg"}], + ): + assert await get_diagnostics_for_config_entry( + hass, hass_client, integration + ) == [{"hello": "world"}, {"second": "msg"}] + + +async def test_device_diagnostics( + hass, + client, + multisensor_6, + integration, + hass_client, + version_state, +): + """Test the device level diagnostics data dump.""" + dev_reg = async_get(hass) + device = dev_reg.async_get_device({get_device_id(client, multisensor_6)}) + assert device + + # Update a value and ensure it is reflected in the node state + value_id = get_value_id( + multisensor_6, CommandClass.SENSOR_MULTILEVEL, PROPERTY_ULTRAVIOLET + ) + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": multisensor_6.node_id, + "args": { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 0, + "property": PROPERTY_ULTRAVIOLET, + "newValue": 1, + "prevValue": 0, + "propertyName": PROPERTY_ULTRAVIOLET, + }, + }, + ) + multisensor_6.receive_event(event) + + diagnostics_data = await get_diagnostics_for_device( + hass, hass_client, integration, device + ) + assert diagnostics_data["versionInfo"] == { + "driverVersion": version_state["driverVersion"], + "serverVersion": version_state["serverVersion"], + "minSchemaVersion": 0, + "maxSchemaVersion": 0, + } + + # Assert that the data returned doesn't match the stale node state data + assert diagnostics_data["state"] != multisensor_6.data + + # Replace data for the value we updated and assert the new node data is the same + # as what's returned + updated_node_data = multisensor_6.data.copy() + for idx, value in enumerate(updated_node_data["values"]): + if _get_value_id_from_dict(multisensor_6, value) == value_id: + updated_node_data["values"][idx] = multisensor_6.values[ + value_id + ].data.copy() + assert diagnostics_data["state"] == updated_node_data + + +async def test_device_diagnostics_error(hass, integration): + """Test the device diagnostics raises exception when an invalid device is used.""" + dev_reg = async_get(hass) + device = dev_reg.async_get_or_create( + config_entry_id=integration.entry_id, identifiers={("test", "test")} + ) + with pytest.raises(ValueError): + await async_get_device_diagnostics(hass, integration, device) diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index ad176d0168eda7..37cdbf3386dd7e 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -71,6 +71,11 @@ async def test_lock_popp_electric_strike_lock_control( ) +async def test_fortrez_ssa3_siren(hass, client, fortrezz_ssa3_siren, integration): + """Test Fortrezz SSA3 siren gets discovered correctly.""" + assert hass.states.get("select.siren_and_strobe_alarm") is not None + + async def test_firmware_version_range_exception(hass): """Test FirmwareVersionRange exception.""" with pytest.raises(ValueError): diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index ab29dfde23f1de..7e39b7845337e3 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -10,7 +10,7 @@ from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id -from homeassistant.config_entries import DISABLED_USER, ConfigEntryState +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers import ( area_registry as ar, @@ -667,7 +667,9 @@ async def test_stop_addon( assert entry.state is ConfigEntryState.LOADED - await hass.config_entries.async_set_disabled_by(entry.entry_id, DISABLED_USER) + await hass.config_entries.async_set_disabled_by( + entry.entry_id, ConfigEntryDisabler.USER + ) await hass.async_block_till_done() assert entry.state == entry_state diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index 6d9458d096cf74..e987bfbebc6deb 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -174,4 +174,4 @@ async def test_disabled_basic_number(hass, ge_in_wall_dimmer_switch, integration assert entity_entry assert entity_entry.disabled - assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index 82f84372ee00f3..e5b415d1341e4a 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -5,8 +5,9 @@ from zwave_js_server.model.node import Node from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENTITY_CATEGORY_CONFIG, STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory import homeassistant.helpers.entity_registry as er DEFAULT_TONE_SELECT_ENTITY = "select.indoor_siren_6_default_tone_2" @@ -63,7 +64,7 @@ async def test_default_tone_select( entity_entry = entity_registry.async_get(DEFAULT_TONE_SELECT_ENTITY) assert entity_entry - assert entity_entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entity_entry.entity_category is EntityCategory.CONFIG # Test select option with string value await hass.services.async_call( @@ -146,7 +147,7 @@ async def test_protection_select( entity_entry = entity_registry.async_get(PROTECTION_SELECT_ENTITY) assert entity_entry - assert entity_entry.entity_category == ENTITY_CATEGORY_CONFIG + assert entity_entry.entity_category is EntityCategory.CONFIG # Test select option with string value await hass.services.async_call( diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index de290e14760c00..2d120411513ca3 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -7,8 +7,8 @@ from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, + SensorDeviceClass, + SensorStateClass, ) from homeassistant.components.zwave_js.const import ( ATTR_METER_TYPE, @@ -21,22 +21,16 @@ ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_ICON, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, + ATTR_UNIT_OF_MEASUREMENT, ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, - ENTITY_CATEGORY_DIAGNOSTIC, POWER_WATT, STATE_UNAVAILABLE, TEMP_CELSIUS, ) from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory from .common import ( AIR_TEMPERATURE_SENSOR, @@ -59,27 +53,27 @@ async def test_numeric_sensor(hass, multisensor_6, integration): assert state assert state.state == "9.0" - assert state.attributes["unit_of_measurement"] == TEMP_CELSIUS - assert state.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE state = hass.states.get(BATTERY_SENSOR) assert state assert state.state == "100.0" - assert state.attributes["unit_of_measurement"] == "%" - assert state.attributes["device_class"] == DEVICE_CLASS_BATTERY + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(BATTERY_SENSOR) assert entity_entry - assert entity_entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC state = hass.states.get(HUMIDITY_SENSOR) assert state assert state.state == "65.0" - assert state.attributes["unit_of_measurement"] == "%" - assert state.attributes["device_class"] == DEVICE_CLASS_HUMIDITY + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.HUMIDITY async def test_energy_sensors(hass, hank_binary_switch, integration): @@ -88,31 +82,31 @@ async def test_energy_sensors(hass, hank_binary_switch, integration): assert state assert state.state == "0.0" - assert state.attributes["unit_of_measurement"] == POWER_WATT - assert state.attributes["device_class"] == DEVICE_CLASS_POWER - assert state.attributes["state_class"] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT state = hass.states.get(ENERGY_SENSOR) assert state assert state.state == "0.16" - assert state.attributes["unit_of_measurement"] == ENERGY_KILO_WATT_HOUR - assert state.attributes["device_class"] == DEVICE_CLASS_ENERGY - assert state.attributes["state_class"] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING state = hass.states.get(VOLTAGE_SENSOR) assert state assert state.state == "122.96" - assert state.attributes["unit_of_measurement"] == ELECTRIC_POTENTIAL_VOLT - assert state.attributes["device_class"] == DEVICE_CLASS_VOLTAGE + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ELECTRIC_POTENTIAL_VOLT + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.VOLTAGE state = hass.states.get(CURRENT_SENSOR) assert state assert state.state == "0.0" - assert state.attributes["unit_of_measurement"] == ELECTRIC_CURRENT_AMPERE - assert state.attributes["device_class"] == DEVICE_CLASS_CURRENT + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ELECTRIC_CURRENT_AMPERE + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.CURRENT async def test_disabled_notification_sensor(hass, multisensor_6, integration): @@ -122,7 +116,7 @@ async def test_disabled_notification_sensor(hass, multisensor_6, integration): assert entity_entry assert entity_entry.disabled - assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity updated_entry = ent_reg.async_update_entity( @@ -149,7 +143,7 @@ async def test_disabled_indcator_sensor( assert entity_entry assert entity_entry.disabled - assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration): @@ -168,7 +162,7 @@ async def test_node_status_sensor(hass, client, lock_id_lock_as_id150, integrati entity_entry = ent_reg.async_get(NODE_STATUS_ENTITY) assert not entity_entry.disabled - assert entity_entry.entity_category == ENTITY_CATEGORY_DIAGNOSTIC + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" # Test transitions work @@ -295,8 +289,8 @@ async def test_meter_attributes( assert state assert state.attributes[ATTR_METER_TYPE] == MeterType.ELECTRIC.value assert state.attributes[ATTR_METER_TYPE_NAME] == MeterType.ELECTRIC.name - assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING async def test_special_meters(hass, aeon_smart_switch_6_state, client, integration): @@ -358,9 +352,71 @@ async def test_special_meters(hass, aeon_smart_switch_6_state, client, integrati state = hass.states.get("sensor.smart_switch_6_electric_consumed_kvah_10") assert state assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING state = hass.states.get("sensor.smart_switch_6_electric_consumed_kva_reactive_11") assert state assert ATTR_DEVICE_CLASS not in state.attributes - assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT + + +async def test_unit_change(hass, zp3111, client, integration): + """Test unit change via metadata updated event is handled by numeric sensors.""" + entity_id = "sensor.4_in_1_sensor_air_temperature" + state = hass.states.get(entity_id) + assert state + assert state.state == "21.98" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + event = Event( + "metadata updated", + { + "source": "node", + "event": "metadata updated", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 0, + "property": "Air temperature", + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Air temperature", + "ccSpecific": {"sensorType": 1, "scale": 1}, + "unit": "°F", + }, + "propertyName": "Air temperature", + "nodeId": zp3111.node_id, + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == "21.98" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + event = Event( + "value updated", + { + "source": "node", + "event": "value updated", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 0, + "property": "Air temperature", + "newValue": 212, + "prevValue": 21.98, + "propertyName": "Air temperature", + }, + }, + ) + zp3111.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == "100.0" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS diff --git a/tests/conftest.py b/tests/conftest.py index 0107e218335ab4..9f0958e6aced30 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ import socket import ssl import threading -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from aiohttp.test_utils import make_mocked_request import freezegun @@ -503,25 +503,18 @@ def hass_ws_client(aiohttp_client, hass_access_token, hass, socket_enabled): async def create_client(hass=hass, access_token=hass_access_token): """Create a websocket client.""" assert await async_setup_component(hass, "websocket_api", {}) - client = await aiohttp_client(hass.http.app) + websocket = await client.ws_connect(URL) + auth_resp = await websocket.receive_json() + assert auth_resp["type"] == TYPE_AUTH_REQUIRED - with patch("homeassistant.components.http.auth.setup_auth"): - websocket = await client.ws_connect(URL) - auth_resp = await websocket.receive_json() - assert auth_resp["type"] == TYPE_AUTH_REQUIRED - - if access_token is None: - await websocket.send_json( - {"type": TYPE_AUTH, "access_token": "incorrect"} - ) - else: - await websocket.send_json( - {"type": TYPE_AUTH, "access_token": access_token} - ) + if access_token is None: + await websocket.send_json({"type": TYPE_AUTH, "access_token": "incorrect"}) + else: + await websocket.send_json({"type": TYPE_AUTH, "access_token": access_token}) - auth_ok = await websocket.receive_json() - assert auth_ok["type"] == TYPE_AUTH_OK + auth_ok = await websocket.receive_json() + assert auth_ok["type"] == TYPE_AUTH_OK # wrap in client websocket.client = client @@ -798,3 +791,30 @@ def setup_recorder(config=None): yield setup_recorder hass.stop() + + +@pytest.fixture +def mock_integration_frame(): + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame diff --git a/tests/helpers/conftest.py b/tests/helpers/conftest.py deleted file mode 100644 index 4b3b9bf465d1f0..00000000000000 --- a/tests/helpers/conftest.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Fixtures for helpers.""" -from unittest.mock import Mock, patch - -import pytest - - -@pytest.fixture -def mock_integration_frame(): - """Mock as if we're calling code from inside an integration.""" - correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", - lineno="23", - line="self.light.is_on", - ) - with patch( - "homeassistant.helpers.frame.extract_stack", - return_value=[ - Mock( - filename="/home/paulus/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - correct_frame, - Mock( - filename="/home/paulus/aiohue/lights.py", - lineno="2", - line="something()", - ), - ], - ): - yield correct_frame diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index f68c7ba2181a6c..bfd933a4afdad6 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -107,6 +107,7 @@ async def test_get_clientsession_patched_close(hass): assert mock_close.call_count == 0 +@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_integration(hass, caplog): """Test log warning message when closing the session from integration context.""" with patch( @@ -138,6 +139,7 @@ async def test_warning_close_session_integration(hass, caplog): ) in caplog.text +@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_custom(hass, caplog): """Test log warning message when closing the session from custom context.""" with patch( diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index c4ceff89b6459d..d958c633b621fd 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -6,7 +6,7 @@ from homeassistant.components import sun import homeassistant.components.automation as automation -from homeassistant.components.sensor import DEVICE_CLASS_TIMESTAMP +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, CONF_CONDITION, @@ -854,12 +854,12 @@ async def test_time_using_sensor(hass): hass.states.async_set( "sensor.am", "2021-06-03 13:00:00.000000+00:00", # 6 am local time - {ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP}, + {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, ) hass.states.async_set( "sensor.pm", "2020-06-01 01:00:00.000000+00:00", # 6 pm local time - {ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP}, + {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, ) hass.states.async_set( "sensor.no_device_class", @@ -868,7 +868,7 @@ async def test_time_using_sensor(hass): hass.states.async_set( "sensor.invalid_timestamp", "This is not a timestamp", - {ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP}, + {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, ) with patch( diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 7f54ba99c8ad1c..78b28a8ef106a7 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,5 +1,5 @@ """Tests for the Config Entry Flow helper.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock, PropertyMock, patch import pytest @@ -297,7 +297,7 @@ async def test_webhook_config_flow_registers_webhook(hass, webhook_flow_conf): async def test_webhook_create_cloudhook(hass, webhook_flow_conf): - """Test only a single entry is allowed.""" + """Test cloudhook will be created if subscribed.""" assert await setup.async_setup_component(hass, "cloud", {}) async_setup_entry = Mock(return_value=True) @@ -323,11 +323,15 @@ async def test_webhook_create_cloudhook(hass, webhook_flow_conf): "hass_nabucasa.cloudhooks.Cloudhooks.async_create", return_value={"cloudhook_url": "https://example.com"}, ) as mock_create, patch( - "homeassistant.components.cloud.async_active_subscription", return_value=True + "hass_nabucasa.Cloud.subscription_expired", + new_callable=PropertyMock(return_value=False), + ), patch( + "hass_nabucasa.Cloud.is_logged_in", + new_callable=PropertyMock(return_value=True), ), patch( - "homeassistant.components.cloud.async_is_logged_in", return_value=True + "hass_nabucasa.iot_base.BaseIoT.connected", + new_callable=PropertyMock(return_value=True), ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -346,6 +350,49 @@ async def test_webhook_create_cloudhook(hass, webhook_flow_conf): assert result["require_restart"] is False +async def test_webhook_create_cloudhook_aborts_not_connected(hass, webhook_flow_conf): + """Test cloudhook aborts if subscribed but not connected.""" + assert await setup.async_setup_component(hass, "cloud", {}) + + async_setup_entry = Mock(return_value=True) + async_unload_entry = Mock(return_value=True) + + mock_integration( + hass, + MockModule( + "test_single", + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + async_remove_entry=config_entry_flow.webhook_async_remove_entry, + ), + ) + mock_entity_platform(hass, "config_flow.test_single", None) + + result = await hass.config_entries.flow.async_init( + "test_single", context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + with patch( + "hass_nabucasa.cloudhooks.Cloudhooks.async_create", + return_value={"cloudhook_url": "https://example.com"}, + ), patch( + "hass_nabucasa.Cloud.subscription_expired", + new_callable=PropertyMock(return_value=False), + ), patch( + "hass_nabucasa.Cloud.is_logged_in", + new_callable=PropertyMock(return_value=True), + ), patch( + "hass_nabucasa.iot_base.BaseIoT.connected", + new_callable=PropertyMock(return_value=False), + ): + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cloud_not_connected" + + async def test_warning_deprecated_connection_class(hass, caplog): """Test that we log a warning when the connection_class is used.""" discovery_function = Mock() diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 8327eb2e3200d8..62ae79ec5cc49e 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1184,12 +1184,61 @@ def test_key_value_schemas(): schema({"mode": mode, "data": data}) +def test_key_value_schemas_with_default(): + """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}), + }, + vol.Schema({"mode": cv.dynamic_template}), + "a cool template", + ) + ) + + with pytest.raises(vol.Invalid) as excinfo: + schema(True) + assert str(excinfo.value) == "Expected a dictionary" + + for mode in None, {"a": "dict"}, "invalid": + with pytest.raises(vol.Invalid) as excinfo: + schema({"mode": mode}) + assert ( + str(excinfo.value) + == f"Unexpected value for mode: '{mode}'. Expected number, string, a cool template" + ) + + 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}) + schema({"mode": "{{ 1 + 1}}"}) + + 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'"), + ( + {"condition": "not", "conditions": {"condition": "invalid"}}, + "Unexpected value for condition: 'invalid'", + ), + # The validation error message could be improved to explain that this is not + # a valid shorthand template + ( + {"condition": "not", "conditions": "not a dynamic template"}, + "Expected a dictionary", + ), ({"event": None}, "string value is None for dictionary value @ data['event']"), ( {"device_id": None}, diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index ca58c014c757e2..a0949bad03ce01 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -185,6 +185,7 @@ async def test_loading_from_storage(hass, hass_storage): "name_by_user": "Test Friendly Name", "name": "name", "sw_version": "version", + "hw_version": "hw_version", "via_device_id": None, } ], @@ -215,6 +216,7 @@ async def test_loading_from_storage(hass, hass_storage): assert entry.id == "abcdefghijklm" assert entry.area_id == "12345A" assert entry.name_by_user == "Test Friendly Name" + assert entry.hw_version == "hw_version" assert entry.entry_type is device_registry.DeviceEntryType.SERVICE assert entry.disabled_by is device_registry.DeviceEntryDisabler.USER assert isinstance(entry.config_entries, set) @@ -235,8 +237,8 @@ async def test_loading_from_storage(hass, hass_storage): @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_1_to_1_2(hass, hass_storage): - """Test migration from version 1.1 to 1.2.""" +async def test_migration_1_1_to_1_3(hass, hass_storage): + """Test migration from version 1.1 to 1.3.""" hass_storage[device_registry.STORAGE_KEY] = { "version": 1, "minor_version": 1, @@ -266,6 +268,19 @@ async def test_migration_1_1_to_1_2(hass, hass_storage): "sw_version": None, }, ], + "deleted_devices": [ + { + "config_entries": ["123456"], + "connections": [], + "entry_type": "service", + "id": "deletedid", + "identifiers": [["serial", "12:34:56:AB:CD:FF"]], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "sw_version": "version", + } + ], }, } @@ -311,6 +326,132 @@ async def test_migration_1_1_to_1_2(hass, hass_storage): "name": "name", "name_by_user": None, "sw_version": "new_version", + "hw_version": None, + "via_device_id": None, + }, + { + "area_id": None, + "config_entries": [None], + "configuration_url": None, + "connections": [], + "disabled_by": None, + "entry_type": None, + "id": "invalid-entry-type", + "identifiers": [["serial", "mock-id-invalid-entry"]], + "manufacturer": None, + "model": None, + "name_by_user": None, + "name": None, + "sw_version": None, + "hw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [ + { + "config_entries": ["123456"], + "connections": [], + "id": "deletedid", + "identifiers": [["serial", "12:34:56:AB:CD:FF"]], + "orphaned_timestamp": None, + } + ], + }, + } + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_1_2_to_1_3(hass, hass_storage): + """Test migration from version 1.2 to 1.3.""" + hass_storage[device_registry.STORAGE_KEY] = { + "version": 1, + "minor_version": 2, + "key": device_registry.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": ["1234"], + "configuration_url": None, + "connections": [["Zigbee", "01.23.45.67.89"]], + "disabled_by": None, + "entry_type": "service", + "id": "abcdefghijklm", + "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "name_by_user": None, + "sw_version": "new_version", + "hw_version": None, + "via_device_id": None, + }, + { + "area_id": None, + "config_entries": [None], + "configuration_url": None, + "connections": [], + "disabled_by": None, + "entry_type": None, + "id": "invalid-entry-type", + "identifiers": [["serial", "mock-id-invalid-entry"]], + "manufacturer": None, + "model": None, + "name_by_user": None, + "name": None, + "sw_version": None, + "hw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + await device_registry.async_load(hass) + registry = device_registry.async_get(hass) + + # Test data was loaded + entry = registry.async_get_or_create( + config_entry_id="1234", + connections={("Zigbee", "01.23.45.67.89")}, + identifiers={("serial", "12:34:56:AB:CD:EF")}, + ) + assert entry.id == "abcdefghijklm" + + # Update to trigger a store + entry = registry.async_get_or_create( + config_entry_id="1234", + connections={("Zigbee", "01.23.45.67.89")}, + identifiers={("serial", "12:34:56:AB:CD:EF")}, + hw_version="new_version", + ) + assert entry.id == "abcdefghijklm" + + # Check we store migrated data + await flush_store(registry._store) + + assert hass_storage[device_registry.STORAGE_KEY] == { + "version": device_registry.STORAGE_VERSION_MAJOR, + "minor_version": device_registry.STORAGE_VERSION_MINOR, + "key": device_registry.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": ["1234"], + "configuration_url": None, + "connections": [["Zigbee", "01.23.45.67.89"]], + "disabled_by": None, + "entry_type": "service", + "id": "abcdefghijklm", + "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "name_by_user": None, + "sw_version": "new_version", + "hw_version": "new_version", "via_device_id": None, }, { @@ -327,6 +468,7 @@ async def test_migration_1_1_to_1_2(hass, hass_storage): "name_by_user": None, "name": None, "sw_version": None, + "hw_version": None, "via_device_id": None, }, ], @@ -875,6 +1017,24 @@ async def test_update_sw_version(registry): assert updated_entry.sw_version == sw_version +async def test_update_hw_version(registry): + """Verify that we can update hardware 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.hw_version + hw_version = "0x20020263" + + with patch.object(registry, "async_schedule_save") as mock_save: + updated_entry = registry.async_update_device(entry.id, hw_version=hw_version) + + assert mock_save.call_count == 1 + assert updated_entry != entry + assert updated_entry.hw_version == hw_version + + async def test_update_suggested_area(registry, area_registry): """Verify that we can update the suggested area version of a device.""" entry = registry.async_get_or_create( @@ -1352,7 +1512,7 @@ async def test_disable_config_entry_disables_devices(hass, registry): assert entry2.disabled await hass.config_entries.async_set_disabled_by( - config_entry.entry_id, config_entries.DISABLED_USER + config_entry.entry_id, config_entries.ConfigEntryDisabler.USER ) await hass.async_block_till_done() @@ -1392,7 +1552,7 @@ async def test_only_disable_device_if_all_config_entries_are_disabled(hass, regi assert not entry1.disabled await hass.config_entries.async_set_disabled_by( - config_entry1.entry_id, config_entries.DISABLED_USER + config_entry1.entry_id, config_entries.ConfigEntryDisabler.USER ) await hass.async_block_till_done() @@ -1400,7 +1560,7 @@ async def test_only_disable_device_if_all_config_entries_are_disabled(hass, regi assert not entry1.disabled await hass.config_entries.async_set_disabled_by( - config_entry2.entry_id, config_entries.DISABLED_USER + config_entry2.entry_id, config_entries.ConfigEntryDisabler.USER ) await hass.async_block_till_done() diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index d0d8580d69de2d..73376cb8580abb 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -1,212 +1,189 @@ """Test discovery helpers.""" from unittest.mock import patch +import pytest + from homeassistant import setup +from homeassistant.const import Platform from homeassistant.core import callback from homeassistant.helpers import discovery -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.util.async_ import run_callback_threadsafe +from homeassistant.helpers.dispatcher import async_dispatcher_send from tests.common import ( MockModule, MockPlatform, - get_test_home_assistant, - mock_coro, mock_entity_platform, mock_integration, ) -class TestHelpersDiscovery: - """Tests for discovery helper methods.""" - - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - - @patch("homeassistant.setup.async_setup_component", return_value=mock_coro()) - def test_listen(self, mock_setup_component): - """Test discovery listen/discover combo.""" - helpers = self.hass.helpers - calls_single = [] - - @callback - def callback_single(service, info): - """Service discovered callback.""" - calls_single.append((service, info)) - - self.hass.add_job( - helpers.discovery.async_listen, "test service", callback_single - ) - - self.hass.add_job( - helpers.discovery.async_discover, - "test service", - "discovery info", - "test_component", - {}, - ) - self.hass.block_till_done() - - assert mock_setup_component.called - assert mock_setup_component.call_args[0] == (self.hass, "test_component", {}) - assert len(calls_single) == 1 - assert calls_single[0] == ("test service", "discovery info") - - @patch("homeassistant.setup.async_setup_component", return_value=mock_coro(True)) - def test_platform(self, mock_setup_component): - """Test discover platform method.""" - calls = [] - - @callback - def platform_callback(platform, info): - """Platform callback method.""" - calls.append((platform, info)) - - run_callback_threadsafe( - self.hass.loop, - discovery.async_listen_platform, - self.hass, - "test_component", - platform_callback, - ).result() - +@pytest.fixture +def mock_setup_component(): + """Mock setup component.""" + with patch("homeassistant.setup.async_setup_component", return_value=True) as mock: + yield mock + + +async def test_listen(hass, mock_setup_component): + """Test discovery listen/discover combo.""" + calls_single = [] + + @callback + def callback_single(service, info): + """Service discovered callback.""" + calls_single.append((service, info)) + + discovery.async_listen(hass, "test service", callback_single) + + await discovery.async_discover( + hass, + "test service", + "discovery info", + "test_component", + {}, + ) + await hass.async_block_till_done() + + assert mock_setup_component.called + assert mock_setup_component.call_args[0] == (hass, "test_component", {}) + assert len(calls_single) == 1 + assert calls_single[0] == ("test service", "discovery info") + + +async def test_platform(hass, mock_setup_component): + """Test discover platform method.""" + calls = [] + + @callback + def platform_callback(platform, info): + """Platform callback method.""" + calls.append((platform, info)) + + discovery.async_listen_platform( + hass, + "test_component", + platform_callback, + ) + + await discovery.async_load_platform( + hass, + "test_component", + "test_platform", + "discovery info", + {"test_component": {}}, + ) + await hass.async_block_till_done() + assert mock_setup_component.called + assert mock_setup_component.call_args[0] == ( + hass, + "test_component", + {"test_component": {}}, + ) + await hass.async_block_till_done() + + await hass.async_add_executor_job( + discovery.load_platform, + hass, + "test_component_2", + "test_platform", + "discovery info", + {"test_component": {}}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0] == ("test_platform", "discovery info") + + async_dispatcher_send( + hass, + discovery.SIGNAL_PLATFORM_DISCOVERED, + {"service": discovery.EVENT_LOAD_PLATFORM.format("test_component")}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_circular_import(hass): + """Test we don't break doing circular import. + + This test will have test_component discover the switch.test_circular + component while setting up. + + The supplied config will load test_component and will load + switch.test_circular. + + That means that after startup, we will have test_component and switch + setup. The test_circular platform has been loaded twice. + """ + component_calls = [] + platform_calls = [] + + def component_setup(hass, config): + """Set up mock component.""" discovery.load_platform( - self.hass, - "test_component", - "test_platform", - "discovery info", - {"test_component": {}}, + hass, Platform.SWITCH, "test_circular", {"key": "value"}, config ) - self.hass.block_till_done() - assert mock_setup_component.called - assert mock_setup_component.call_args[0] == ( - self.hass, - "test_component", - {"test_component": {}}, + component_calls.append(1) + return True + + def setup_platform(hass, config, add_entities_callback, discovery_info=None): + """Set up mock platform.""" + platform_calls.append("disc" if discovery_info else "component") + + mock_integration(hass, MockModule("test_component", setup=component_setup)) + + # dependencies are only set in component level + # since we are using manifest to hold them + mock_integration(hass, MockModule("test_circular", dependencies=["test_component"])) + mock_entity_platform(hass, "switch.test_circular", MockPlatform(setup_platform)) + + await setup.async_setup_component( + hass, + "test_component", + {"test_component": None, "switch": [{"platform": "test_circular"}]}, + ) + + await hass.async_block_till_done() + + # test_component will only be setup once + assert len(component_calls) == 1 + # The platform will be setup once via the config in `setup_component` + # and once via the discovery inside test_component. + assert len(platform_calls) == 2 + assert "test_component" in hass.config.components + assert "switch" in hass.config.components + + +async def test_1st_discovers_2nd_component(hass): + """Test that we don't break if one component discovers the other. + + If the first component fires a discovery event to set up the + second component while the second component is about to be set up, + it should not set up the second component twice. + """ + component_calls = [] + + async def component1_setup(hass, config): + """Set up mock component.""" + print("component1 setup") + await discovery.async_discover( + hass, "test_component2", {}, "test_component2", {} ) - self.hass.block_till_done() + return True - discovery.load_platform( - self.hass, - "test_component_2", - "test_platform", - "discovery info", - {"test_component": {}}, - ) - self.hass.block_till_done() - - assert len(calls) == 1 - assert calls[0] == ("test_platform", "discovery info") - - dispatcher_send( - self.hass, - discovery.SIGNAL_PLATFORM_DISCOVERED, - {"service": discovery.EVENT_LOAD_PLATFORM.format("test_component")}, - ) - self.hass.block_till_done() + def component2_setup(hass, config): + """Set up mock component.""" + component_calls.append(1) + return True - assert len(calls) == 1 + mock_integration(hass, MockModule("test_component1", async_setup=component1_setup)) - def test_circular_import(self): - """Test we don't break doing circular import. + mock_integration(hass, MockModule("test_component2", setup=component2_setup)) - This test will have test_component discover the switch.test_circular - component while setting up. - - The supplied config will load test_component and will load - switch.test_circular. - - That means that after startup, we will have test_component and switch - setup. The test_circular platform has been loaded twice. - """ - component_calls = [] - platform_calls = [] - - def component_setup(hass, config): - """Set up mock component.""" - discovery.load_platform(hass, "switch", "test_circular", "disc", config) - component_calls.append(1) - return True - - def setup_platform(hass, config, add_entities_callback, discovery_info=None): - """Set up mock platform.""" - platform_calls.append("disc" if discovery_info else "component") - - mock_integration(self.hass, MockModule("test_component", setup=component_setup)) - - # dependencies are only set in component level - # since we are using manifest to hold them - mock_integration( - self.hass, MockModule("test_circular", dependencies=["test_component"]) - ) - mock_entity_platform( - self.hass, "switch.test_circular", MockPlatform(setup_platform) - ) - - setup.setup_component( - self.hass, - "test_component", - {"test_component": None, "switch": [{"platform": "test_circular"}]}, - ) - - self.hass.block_till_done() - - # test_component will only be setup once - assert len(component_calls) == 1 - # The platform will be setup once via the config in `setup_component` - # and once via the discovery inside test_component. - assert len(platform_calls) == 2 - assert "test_component" in self.hass.config.components - assert "switch" in self.hass.config.components - - @patch("homeassistant.helpers.signal.async_register_signal_handling") - def test_1st_discovers_2nd_component(self, mock_signal): - """Test that we don't break if one component discovers the other. - - If the first component fires a discovery event to set up the - second component while the second component is about to be set up, - it should not set up the second component twice. - """ - component_calls = [] - - async def component1_setup(hass, config): - """Set up mock component.""" - print("component1 setup") - await discovery.async_discover( - hass, "test_component2", {}, "test_component2", {} - ) - return True - - def component2_setup(hass, config): - """Set up mock component.""" - component_calls.append(1) - return True - - mock_integration( - self.hass, MockModule("test_component1", async_setup=component1_setup) - ) - - mock_integration( - self.hass, MockModule("test_component2", setup=component2_setup) - ) + hass.async_create_task(setup.async_setup_component(hass, "test_component1", {})) + hass.async_create_task(setup.async_setup_component(hass, "test_component2", {})) + await hass.async_block_till_done() - @callback - def do_setup(): - """Set up 2 components.""" - self.hass.async_add_job( - setup.async_setup_component(self.hass, "test_component1", {}) - ) - self.hass.async_add_job( - setup.async_setup_component(self.hass, "test_component2", {}) - ) - - self.hass.add_job(do_setup) - self.hass.block_till_done() - - # test_component will only be setup once - assert len(component_calls) == 1 + # test_component will only be setup once + assert len(component_calls) == 1 diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index d4051613c0317b..1d28c50b9a1b3e 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -580,7 +580,7 @@ async def test_warn_disabled(hass, caplog): entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", - disabled_by=entity_registry.DISABLED_USER, + disabled_by=entity_registry.RegistryEntryDisabler.USER, ) mock_registry(hass, {"hello.world": entry}) @@ -622,7 +622,7 @@ async def test_disabled_in_entity_registry(hass): assert hass.states.get("hello.world") is not None entry2 = registry.async_update_entity( - "hello.world", disabled_by=entity_registry.DISABLED_USER + "hello.world", disabled_by=entity_registry.RegistryEntryDisabler.USER ) await hass.async_block_till_done() assert entry2 != entry diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index baaea35b62c76a..9aa0a849e5a875 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -519,7 +519,7 @@ async def test_registry_respect_entity_disabled(hass): unique_id="1234", # Using component.async_add_entities is equal to platform "domain" platform="test_platform", - disabled_by=er.DISABLED_USER, + disabled_by=er.RegistryEntryDisabler.USER, ) }, ) @@ -838,6 +838,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "model": "test-model", "name": "test-name", "sw_version": "test-sw", + "hw_version": "test-hw", "suggested_area": "Heliport", "entry_type": dr.DeviceEntryType.SERVICE, "via_device": ("hue", "via-id"), @@ -869,6 +870,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): assert device.name == "test-name" assert device.suggested_area == "Heliport" assert device.sw_version == "test-sw" + assert device.hw_version == "test-hw" assert device.via_device_id == via.id @@ -1077,7 +1079,7 @@ async def test_entity_disabled_by_integration(hass): entry_default = registry.async_get_or_create(DOMAIN, DOMAIN, "default") assert entry_default.disabled_by is None entry_disabled = registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") - assert entry_disabled.disabled_by == er.DISABLED_INTEGRATION + assert entry_disabled.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_entity_disabled_by_device(hass: HomeAssistant): @@ -1115,7 +1117,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): registry = er.async_get(hass) entry_disabled = registry.async_get_or_create(DOMAIN, DOMAIN, "disabled") - assert entry_disabled.disabled_by == er.DISABLED_DEVICE + assert entry_disabled.disabled_by is er.RegistryEntryDisabler.DEVICE async def test_entity_info_added_to_entity_registry(hass): diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index e34e33db005351..f299177a08ecf6 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -76,7 +76,7 @@ def test_get_or_create_updates_data(registry): capabilities={"max": 100}, config_entry=orig_config_entry, device_id="mock-dev-id", - disabled_by=er.DISABLED_HASS, + disabled_by=er.RegistryEntryDisabler.HASS, entity_category="config", original_device_class="mock-device-class", original_icon="initial-original_icon", @@ -94,7 +94,7 @@ def test_get_or_create_updates_data(registry): config_entry_id=orig_config_entry.entry_id, device_class=None, device_id="mock-dev-id", - disabled_by=er.DISABLED_HASS, + disabled_by=er.RegistryEntryDisabler.HASS, entity_category="config", icon=None, id=orig_entry.id, @@ -116,7 +116,7 @@ def test_get_or_create_updates_data(registry): capabilities={"new-max": 100}, config_entry=new_config_entry, device_id="new-mock-dev-id", - disabled_by=er.DISABLED_USER, + disabled_by=er.RegistryEntryDisabler.USER, entity_category=None, original_device_class="new-mock-device-class", original_icon="updated-original_icon", @@ -134,7 +134,7 @@ def test_get_or_create_updates_data(registry): config_entry_id=new_config_entry.entry_id, device_class=None, device_id="new-mock-dev-id", - disabled_by=er.DISABLED_HASS, # Should not be updated + disabled_by=er.RegistryEntryDisabler.HASS, # Should not be updated entity_category="config", icon=None, id=orig_entry.id, @@ -188,7 +188,7 @@ async def test_loading_saving_data(hass, registry): capabilities={"max": 100}, config_entry=mock_config, device_id="mock-dev-id", - disabled_by=er.DISABLED_HASS, + disabled_by=er.RegistryEntryDisabler.HASS, entity_category="config", original_device_class="mock-device-class", original_icon="hass:original-icon", @@ -196,12 +196,16 @@ async def test_loading_saving_data(hass, registry): supported_features=5, unit_of_measurement="initial-unit_of_measurement", ) - orig_entry2 = registry.async_update_entity( + registry.async_update_entity( orig_entry2.entity_id, device_class="user-class", name="User Name", icon="hass:user-icon", ) + registry.async_update_entity_options( + orig_entry2.entity_id, "light", {"minimum_brightness": 20} + ) + orig_entry2 = registry.async_get(orig_entry2.entity_id) assert len(registry.entities) == 2 @@ -223,10 +227,11 @@ async def test_loading_saving_data(hass, registry): assert new_entry2.config_entry_id == mock_config.entry_id assert new_entry2.device_class == "user-class" assert new_entry2.device_id == "mock-dev-id" - assert new_entry2.disabled_by == er.DISABLED_HASS + assert new_entry2.disabled_by is er.RegistryEntryDisabler.HASS assert new_entry2.entity_category == "config" assert new_entry2.icon == "hass:user-icon" assert new_entry2.name == "User Name" + assert new_entry2.options == {"light": {"minimum_brightness": 20}} assert new_entry2.original_device_class == "mock-device-class" assert new_entry2.original_icon == "hass:original-icon" assert new_entry2.original_name == "Original Name" @@ -277,19 +282,19 @@ async def test_loading_extra_values(hass, hass_storage): "entity_id": "test.disabled_user", "platform": "super_platform", "unique_id": "disabled-user", - "disabled_by": er.DISABLED_USER, + "disabled_by": er.RegistryEntryDisabler.USER, }, { "entity_id": "test.disabled_hass", "platform": "super_platform", "unique_id": "disabled-hass", - "disabled_by": er.DISABLED_HASS, + "disabled_by": er.RegistryEntryDisabler.HASS, }, { "entity_id": "test.invalid__entity", "platform": "super_platform", "unique_id": "invalid-hass", - "disabled_by": er.DISABLED_HASS, + "disabled_by": er.RegistryEntryDisabler.HASS, }, ] }, @@ -317,9 +322,9 @@ async def test_loading_extra_values(hass, hass_storage): "test", "super_platform", "disabled-user" ) assert entry_disabled_hass.disabled - assert entry_disabled_hass.disabled_by == er.DISABLED_HASS + assert entry_disabled_hass.disabled_by is er.RegistryEntryDisabler.HASS assert entry_disabled_user.disabled - assert entry_disabled_user.disabled_by == er.DISABLED_USER + assert entry_disabled_user.disabled_by is er.RegistryEntryDisabler.USER def test_async_get_entity_id(registry): @@ -399,7 +404,7 @@ async def test_migration_yaml_to_json(hass): "unique_id": "test-unique", "platform": "test-platform", "name": "Test Name", - "disabled_by": er.DISABLED_HASS, + "disabled_by": er.RegistryEntryDisabler.HASS, } } with patch("os.path.isfile", return_value=True), patch("os.remove"), patch( @@ -416,7 +421,7 @@ async def test_migration_yaml_to_json(hass): config_entry=mock_config, ) assert entry.name == "Test Name" - assert entry.disabled_by == er.DISABLED_HASS + assert entry.disabled_by is er.RegistryEntryDisabler.HASS assert entry.config_entry_id == "test-config-id" @@ -554,7 +559,7 @@ async def test_update_entity(registry): for attr_name, new_value in ( ("name", "new name"), ("icon", "new icon"), - ("disabled_by", er.DISABLED_USER), + ("disabled_by", er.RegistryEntryDisabler.USER), ): changes = {attr_name: new_value} updated_entry = registry.async_update_entity(entry.entity_id, **changes) @@ -570,17 +575,42 @@ async def test_update_entity(registry): entry = updated_entry +async def test_update_entity_options(registry): + """Test updating entity.""" + mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") + entry = registry.async_get_or_create( + "light", "hue", "5678", config_entry=mock_config + ) + + registry.async_update_entity_options( + entry.entity_id, "light", {"minimum_brightness": 20} + ) + new_entry_1 = registry.async_get(entry.entity_id) + + assert entry.options == {} + assert new_entry_1.options == {"light": {"minimum_brightness": 20}} + + registry.async_update_entity_options( + entry.entity_id, "light", {"minimum_brightness": 30} + ) + new_entry_2 = registry.async_get(entry.entity_id) + + assert entry.options == {} + assert new_entry_1.options == {"light": {"minimum_brightness": 20}} + assert new_entry_2.options == {"light": {"minimum_brightness": 30}} + + async def test_disabled_by(registry): """Test that we can disable an entry when we create it.""" entry = registry.async_get_or_create( - "light", "hue", "5678", disabled_by=er.DISABLED_HASS + "light", "hue", "5678", disabled_by=er.RegistryEntryDisabler.HASS ) - assert entry.disabled_by == er.DISABLED_HASS + assert entry.disabled_by is er.RegistryEntryDisabler.HASS entry = registry.async_get_or_create( - "light", "hue", "5678", disabled_by=er.DISABLED_INTEGRATION + "light", "hue", "5678", disabled_by=er.RegistryEntryDisabler.INTEGRATION ) - assert entry.disabled_by == er.DISABLED_HASS + assert entry.disabled_by is er.RegistryEntryDisabler.HASS entry2 = registry.async_get_or_create("light", "hue", "1234") assert entry2.disabled_by is None @@ -596,16 +626,16 @@ async def test_disabled_by_config_entry_pref(registry): entry = registry.async_get_or_create( "light", "hue", "AAAA", config_entry=mock_config ) - assert entry.disabled_by == er.DISABLED_INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION entry2 = registry.async_get_or_create( "light", "hue", "BBBB", config_entry=mock_config, - disabled_by=er.DISABLED_USER, + disabled_by=er.RegistryEntryDisabler.USER, ) - assert entry2.disabled_by == er.DISABLED_USER + assert entry2.disabled_by is er.RegistryEntryDisabler.USER async def test_restore_states(hass): @@ -626,7 +656,7 @@ async def test_restore_states(hass): "hue", "5678", suggested_object_id="disabled", - disabled_by=er.DISABLED_HASS, + disabled_by=er.RegistryEntryDisabler.HASS, ) registry.async_get_or_create( "light", @@ -836,7 +866,7 @@ async def test_disable_device_disables_entities(hass, registry): "ABCD", config_entry=config_entry, device_id=device_entry.id, - disabled_by=er.DISABLED_USER, + disabled_by=er.RegistryEntryDisabler.USER, ) entry3 = registry.async_get_or_create( "light", @@ -844,7 +874,7 @@ async def test_disable_device_disables_entities(hass, registry): "EFGH", config_entry=config_entry, device_id=device_entry.id, - disabled_by=er.DISABLED_CONFIG_ENTRY, + disabled_by=er.RegistryEntryDisabler.CONFIG_ENTRY, ) assert not entry1.disabled @@ -858,13 +888,13 @@ async def test_disable_device_disables_entities(hass, registry): entry1 = registry.async_get(entry1.entity_id) assert entry1.disabled - assert entry1.disabled_by == er.DISABLED_DEVICE + assert entry1.disabled_by is er.RegistryEntryDisabler.DEVICE entry2 = registry.async_get(entry2.entity_id) assert entry2.disabled - assert entry2.disabled_by == er.DISABLED_USER + assert entry2.disabled_by is er.RegistryEntryDisabler.USER entry3 = registry.async_get(entry3.entity_id) assert entry3.disabled - assert entry3.disabled_by == er.DISABLED_CONFIG_ENTRY + assert entry3.disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY device_registry.async_update_device(device_entry.id, disabled_by=None) await hass.async_block_till_done() @@ -873,10 +903,10 @@ async def test_disable_device_disables_entities(hass, registry): assert not entry1.disabled entry2 = registry.async_get(entry2.entity_id) assert entry2.disabled - assert entry2.disabled_by == er.DISABLED_USER + assert entry2.disabled_by is er.RegistryEntryDisabler.USER entry3 = registry.async_get(entry3.entity_id) assert entry3.disabled - assert entry3.disabled_by == er.DISABLED_CONFIG_ENTRY + assert entry3.disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY async def test_disable_config_entry_disables_entities(hass, registry): @@ -903,7 +933,7 @@ async def test_disable_config_entry_disables_entities(hass, registry): "ABCD", config_entry=config_entry, device_id=device_entry.id, - disabled_by=er.DISABLED_USER, + disabled_by=er.RegistryEntryDisabler.USER, ) entry3 = registry.async_get_or_create( "light", @@ -911,7 +941,7 @@ async def test_disable_config_entry_disables_entities(hass, registry): "EFGH", config_entry=config_entry, device_id=device_entry.id, - disabled_by=er.DISABLED_DEVICE, + disabled_by=er.RegistryEntryDisabler.DEVICE, ) assert not entry1.disabled @@ -919,19 +949,19 @@ async def test_disable_config_entry_disables_entities(hass, registry): assert entry3.disabled await hass.config_entries.async_set_disabled_by( - config_entry.entry_id, config_entries.DISABLED_USER + config_entry.entry_id, config_entries.ConfigEntryDisabler.USER ) await hass.async_block_till_done() entry1 = registry.async_get(entry1.entity_id) assert entry1.disabled - assert entry1.disabled_by == er.DISABLED_CONFIG_ENTRY + assert entry1.disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY entry2 = registry.async_get(entry2.entity_id) assert entry2.disabled - assert entry2.disabled_by == er.DISABLED_USER + assert entry2.disabled_by is er.RegistryEntryDisabler.USER entry3 = registry.async_get(entry3.entity_id) assert entry3.disabled - assert entry3.disabled_by == er.DISABLED_DEVICE + assert entry3.disabled_by is er.RegistryEntryDisabler.DEVICE await hass.config_entries.async_set_disabled_by(config_entry.entry_id, None) await hass.async_block_till_done() @@ -940,7 +970,7 @@ async def test_disable_config_entry_disables_entities(hass, registry): assert not entry1.disabled entry2 = registry.async_get(entry2.entity_id) assert entry2.disabled - assert entry2.disabled_by == er.DISABLED_USER + assert entry2.disabled_by is er.RegistryEntryDisabler.USER # The device was re-enabled, so entity disabled by the device will be re-enabled too entry3 = registry.async_get(entry3.entity_id) assert not entry3.disabled_by @@ -970,7 +1000,7 @@ async def test_disabled_entities_excluded_from_entity_list(hass, registry): "ABCD", config_entry=config_entry, device_id=device_entry.id, - disabled_by=er.DISABLED_USER, + disabled_by=er.RegistryEntryDisabler.USER, ) entries = er.async_entries_for_device(registry, device_entry.id) diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 5d28295e3a0c09..9576c7d95b6cb0 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -2,6 +2,7 @@ from homeassistant.helpers.entityfilter import ( FILTER_SCHEMA, INCLUDE_EXCLUDE_FILTER_SCHEMA, + EntityFilter, generate_filter, ) @@ -267,5 +268,38 @@ def test_filter_schema_include_exclude(): }, } filt = INCLUDE_EXCLUDE_FILTER_SCHEMA(conf) - assert filt.config == conf + assert filt.config == { + "include_domains": ["light"], + "include_entity_globs": ["sensor.kitchen_*"], + "include_entities": ["switch.kitchen"], + "exclude_domains": ["cover"], + "exclude_entity_globs": ["sensor.weather_*"], + "exclude_entities": ["light.kitchen"], + } assert not filt.empty_filter + + +def test_exlictly_included(): + """Test if an entity is explicitly included.""" + conf = { + "include": { + "domains": ["light"], + "entity_globs": ["sensor.kitchen_*"], + "entities": ["switch.kitchen"], + }, + "exclude": { + "domains": ["cover"], + "entity_globs": ["sensor.weather_*"], + "entities": ["light.kitchen"], + }, + } + filt: EntityFilter = INCLUDE_EXCLUDE_FILTER_SCHEMA(conf) + assert not filt.explicitly_included("light.any") + assert not filt.explicitly_included("switch.other") + assert filt.explicitly_included("sensor.kitchen_4") + assert filt.explicitly_included("switch.kitchen") + + assert not filt.explicitly_excluded("light.any") + assert not filt.explicitly_excluded("switch.other") + assert filt.explicitly_excluded("sensor.weather_5") + assert filt.explicitly_excluded("light.kitchen") diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index a47463b6b98945..cdb650f76860cf 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -93,6 +93,7 @@ async def test_get_async_client_context_manager(hass): assert mock_aclose.call_count == 0 +@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_integration(hass, caplog): """Test log warning message when closing the session from integration context.""" with patch( @@ -125,6 +126,7 @@ async def test_warning_close_session_integration(hass, caplog): ) in caplog.text +@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_custom(hass, caplog): """Test log warning message when closing the session from custom context.""" with patch( diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index b0a93c85b2b3a8..2ee4213a688d61 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1501,6 +1501,61 @@ async def test_condition_basic(hass, caplog): ) +async def test_shorthand_template_condition(hass, caplog): + """Test if we can use shorthand template conditions in a script.""" + event = "test_event" + events = async_capture_events(hass, event) + alias = "condition step" + sequence = cv.SCRIPT_SCHEMA( + [ + {"event": event}, + { + "alias": alias, + "condition": "{{ states.test.entity.state == 'hello' }}", + }, + {"event": event}, + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + hass.states.async_set("test.entity", "hello") + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert f"Test condition {alias}: True" in caplog.text + caplog.clear() + assert len(events) == 2 + + assert_action_trace( + { + "0": [{"result": {"event": "test_event", "event_data": {}}}], + "1": [{"result": {"entities": ["test.entity"], "result": True}}], + "2": [{"result": {"event": "test_event", "event_data": {}}}], + } + ) + + hass.states.async_set("test.entity", "goodbye") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert f"Test condition {alias}: False" in caplog.text + assert len(events) == 3 + + assert_action_trace( + { + "0": [{"result": {"event": "test_event", "event_data": {}}}], + "1": [ + { + "error_type": script._StopScript, + "result": {"entities": ["test.entity"], "result": False}, + } + ], + }, + expected_script_execution="aborted", + ) + + async def test_condition_validation(hass, caplog): """Test if we can use conditions which validate late in a script.""" registry = er.async_get(hass) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 16f7ad4825a133..54507d9b3bda2a 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -29,6 +29,7 @@ from tests.common import ( MockEntity, + async_mock_service, get_test_home_assistant, mock_device_registry, mock_registry, @@ -375,6 +376,27 @@ def test_fail_silently_if_no_service(self, mock_log): assert mock_log.call_count == 3 +async def test_service_call_entry_id(hass): + """Test service call with entity specified by entity registry ID.""" + registry = ent_reg.async_get(hass) + calls = async_mock_service(hass, "test_domain", "test_service") + entry = registry.async_get_or_create( + "hello", "hue", "1234", suggested_object_id="world" + ) + + assert entry.entity_id == "hello.world" + + config = { + "service": "test_domain.test_service", + "target": {"entity_id": entry.id}, + } + + await service.async_call_from_config(hass, config) + await hass.async_block_till_done() + + assert dict(calls[0].data) == {"entity_id": ["hello.world"]} + + async def test_extract_entity_ids(hass): """Test extract_entity_ids method.""" hass.states.async_set("light.Bowl", STATE_ON) diff --git a/tests/helpers/test_significant_change.py b/tests/helpers/test_significant_change.py index 79f3dd3fe3e4bd..b707e36993101a 100644 --- a/tests/helpers/test_significant_change.py +++ b/tests/helpers/test_significant_change.py @@ -1,7 +1,7 @@ """Test significant change helper.""" import pytest -from homeassistant.components.sensor import DEVICE_CLASS_BATTERY +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import State from homeassistant.helpers import significant_change @@ -26,7 +26,7 @@ def async_check_significant_change( async def test_signicant_change(hass, checker): """Test initialize helper works.""" ent_id = "test_domain.test_entity" - attrs = {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY} + attrs = {ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY} assert checker.async_is_significant_change(State(ent_id, "100", attrs)) @@ -50,7 +50,7 @@ async def test_signicant_change(hass, checker): async def test_significant_change_extra(hass, checker): """Test extra significant checker works.""" ent_id = "test_domain.test_entity" - attrs = {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY} + attrs = {ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY} assert checker.async_is_significant_change(State(ent_id, "100", attrs), extra_arg=1) assert checker.async_is_significant_change(State(ent_id, "200", attrs), extra_arg=1) diff --git a/tests/helpers/test_start.py b/tests/helpers/test_start.py index 35838f1ceaa931..55f98cf60ebe6d 100644 --- a/tests/helpers/test_start.py +++ b/tests/helpers/test_start.py @@ -4,8 +4,9 @@ from homeassistant.helpers import start -async def test_at_start_when_running(hass): +async def test_at_start_when_running_awaitable(hass): """Test at start when already running.""" + assert hass.state == core.CoreState.running assert hass.is_running calls = [] @@ -18,8 +19,37 @@ async def cb_at_start(hass): await hass.async_block_till_done() assert len(calls) == 1 + hass.state = core.CoreState.starting + assert hass.is_running + + start.async_at_start(hass, cb_at_start) + await hass.async_block_till_done() + assert len(calls) == 2 + + +async def test_at_start_when_running_callback(hass): + """Test at start when already running.""" + assert hass.state == core.CoreState.running + assert hass.is_running + + calls = [] + + @core.callback + def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_start(hass, cb_at_start) + assert len(calls) == 1 + + hass.state = core.CoreState.starting + assert hass.is_running + + start.async_at_start(hass, cb_at_start) + assert len(calls) == 2 -async def test_at_start_when_starting(hass): + +async def test_at_start_when_starting_awaitable(hass): """Test at start when yet to start.""" hass.state = core.CoreState.not_running assert not hass.is_running @@ -37,3 +67,24 @@ async def cb_at_start(hass): hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() assert len(calls) == 1 + + +async def test_at_start_when_starting_callback(hass): + """Test at start when yet to start.""" + hass.state = core.CoreState.not_running + assert not hass.is_running + + calls = [] + + @core.callback + def cb_at_start(hass): + """Home Assistant is started.""" + calls.append(1) + + start.async_at_start(hass, cb_at_start) + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert len(calls) == 1 diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 0478c17e299749..53c1b8a4677dd6 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -73,7 +73,9 @@ def default(self, o): return "9" store = storage.Store(hass, MOCK_VERSION, MOCK_KEY, encoder=JSONEncoder) - await store.async_save(Mock()) + with pytest.raises(ValueError): + await store.async_save(Mock()) + await store.async_save(object()) data = await store.async_load() assert data == "9" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 4a97b99d05d46d..42834b3c149634 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -21,6 +21,7 @@ TEMP_CELSIUS, VOLUME_LITERS, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import device_registry as dr, entity, template from homeassistant.helpers.entity_platform import EntityPlatform @@ -313,6 +314,12 @@ def test_isnumber(hass, value, expected): ) == expected ) + assert ( + template.Template("{{ value is is_number }}", hass).async_render( + {"value": value} + ) + == expected + ) def test_rounding_value(hass): @@ -834,6 +841,15 @@ def test_min(hass): assert template.Template("{{ min([1, 2, 3]) }}", hass).async_render() == 1 assert template.Template("{{ min(1, 2, 3) }}", hass).async_render() == 1 + with pytest.raises(TemplateError): + template.Template("{{ 1 | min }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ min() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ min(1) }}", hass).async_render() + def test_max(hass): """Test the max filter.""" @@ -841,6 +857,82 @@ def test_max(hass): assert template.Template("{{ max([1, 2, 3]) }}", hass).async_render() == 3 assert template.Template("{{ max(1, 2, 3) }}", hass).async_render() == 3 + with pytest.raises(TemplateError): + template.Template("{{ 1 | max }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ max() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ max(1) }}", hass).async_render() + + +@pytest.mark.parametrize( + "attribute", + ( + "a", + "b", + "c", + ), +) +def test_min_max_attribute(hass, attribute): + """Test the min and max filters with attribute.""" + hass.states.async_set( + "test.object", + "test", + { + "objects": [ + { + "a": 1, + "b": 2, + "c": 3, + }, + { + "a": 2, + "b": 1, + "c": 2, + }, + { + "a": 3, + "b": 3, + "c": 1, + }, + ], + }, + ) + assert ( + template.Template( + "{{ (state_attr('test.object', 'objects') | min(attribute='%s'))['%s']}}" + % (attribute, attribute), + hass, + ).async_render() + == 1 + ) + assert ( + template.Template( + "{{ (min(state_attr('test.object', 'objects'), attribute='%s'))['%s']}}" + % (attribute, attribute), + hass, + ).async_render() + == 1 + ) + assert ( + template.Template( + "{{ (state_attr('test.object', 'objects') | max(attribute='%s'))['%s']}}" + % (attribute, attribute), + hass, + ).async_render() + == 3 + ) + assert ( + template.Template( + "{{ (max(state_attr('test.object', 'objects'), attribute='%s'))['%s']}}" + % (attribute, attribute), + hass, + ).async_render() + == 3 + ) + def test_ord(hass): """Test the ord filter.""" @@ -865,6 +957,26 @@ def test_base64_decode(hass): ) +def test_slugify(hass): + """Test the slugify filter.""" + assert ( + template.Template('{{ slugify("Home Assistant") }}', hass).async_render() + == "home_assistant" + ) + assert ( + template.Template('{{ "Home Assistant" | slugify }}', hass).async_render() + == "home_assistant" + ) + assert ( + template.Template('{{ slugify("Home Assistant", "-") }}', hass).async_render() + == "home-assistant" + ) + assert ( + template.Template('{{ "Home Assistant" | slugify("-") }}', hass).async_render() + == "home-assistant" + ) + + def test_ordinal(hass): """Test the ordinal filter.""" tests = [ @@ -3081,6 +3193,38 @@ def test_urlencode(hass): assert tpl.async_render() == "the%20quick%20brown%20fox%20%3D%20true" +def test_iif(hass: HomeAssistant) -> None: + """Test the immediate if function/filter.""" + tpl = template.Template("{{ (1 == 1) | iif }}", hass) + assert tpl.async_render() is True + + tpl = template.Template("{{ (1 == 2) | iif }}", hass) + assert tpl.async_render() is False + + tpl = template.Template("{{ (1 == 1) | iif('yes') }}", hass) + assert tpl.async_render() == "yes" + + tpl = template.Template("{{ (1 == 2) | iif('yes') }}", hass) + assert tpl.async_render() is False + + tpl = template.Template("{{ (1 == 2) | iif('yes', 'no') }}", hass) + assert tpl.async_render() == "no" + + tpl = template.Template("{{ not_exists | default(None) | iif('yes', 'no') }}", hass) + assert tpl.async_render() == "no" + + tpl = template.Template( + "{{ not_exists | default(None) | iif('yes', 'no', 'unknown') }}", hass + ) + assert tpl.async_render() == "unknown" + + tpl = template.Template("{{ iif(1 == 1) }}", hass) + assert tpl.async_render() is True + + tpl = template.Template("{{ iif(1 == 2, 'yes', 'no') }}", hass) + assert tpl.async_render() == "no" + + async def test_cache_garbage_collection(): """Test caching a template.""" template_string = ( diff --git a/tests/pylint/__init__.py b/tests/pylint/__init__.py new file mode 100644 index 00000000000000..d6bdd6675f0ebd --- /dev/null +++ b/tests/pylint/__init__.py @@ -0,0 +1 @@ +"""Tests for pylint.""" diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py new file mode 100644 index 00000000000000..fe60ed022f48ed --- /dev/null +++ b/tests/pylint/test_enforce_type_hints.py @@ -0,0 +1,41 @@ +"""Tests for pylint hass_enforce_type_hints plugin.""" +# pylint:disable=protected-access + +from importlib.machinery import SourceFileLoader +import re + +import pytest + +loader = SourceFileLoader( + "hass_enforce_type_hints", "pylint/plugins/hass_enforce_type_hints.py" +) +hass_enforce_type_hints = loader.load_module(None) +_TYPE_HINT_MATCHERS: dict[str, re.Pattern] = hass_enforce_type_hints._TYPE_HINT_MATCHERS + + +@pytest.mark.parametrize( + ("string", "expected_x", "expected_y", "expected_z"), + [ + ("Callable[..., None]", "Callable", "...", "None"), + ("Callable[..., Awaitable[None]]", "Callable", "...", "Awaitable[None]"), + ], +) +def test_regex_x_of_y_comma_z(string, expected_x, expected_y, expected_z): + """Test x_of_y_comma_z regexes.""" + assert (match := _TYPE_HINT_MATCHERS["x_of_y_comma_z"].match(string)) + assert match.group(0) == string + assert match.group(1) == expected_x + assert match.group(2) == expected_y + assert match.group(3) == expected_z + + +@pytest.mark.parametrize( + ("string", "expected_a", "expected_b"), + [("DiscoveryInfoType | None", "DiscoveryInfoType", "None")], +) +def test_regex_a_or_b(string, expected_a, expected_b): + """Test a_or_b regexes.""" + assert (match := _TYPE_HINT_MATCHERS["a_or_b"].match(string)) + assert match.group(0) == string + assert match.group(1) == expected_a + assert match.group(2) == expected_b diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1ad64e58bd778b..87d93c1a1ac0b4 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -8,8 +8,8 @@ import pytest from homeassistant import bootstrap, core, runner -from homeassistant.bootstrap import SIGNAL_BOOTSTRAP_INTEGRATONS import homeassistant.config as config_util +from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATONS from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.dt as dt_util diff --git a/tests/test_config.py b/tests/test_config.py index 9f2cc56b1b78ce..41e9bc50038035 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -27,7 +27,7 @@ CONF_UNIT_SYSTEM_METRIC, __version__, ) -from homeassistant.core import SOURCE_STORAGE, HomeAssistantError +from homeassistant.core import ConfigSource, HomeAssistantError from homeassistant.helpers import config_validation as cv import homeassistant.helpers.check_config as check_config from homeassistant.helpers.entity import Entity @@ -41,7 +41,6 @@ YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) SECRET_PATH = os.path.join(CONFIG_DIR, SECRET_YAML) VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) -GROUP_PATH = os.path.join(CONFIG_DIR, config_util.GROUP_CONFIG_PATH) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, config_util.AUTOMATION_CONFIG_PATH) SCRIPTS_PATH = os.path.join(CONFIG_DIR, config_util.SCRIPT_CONFIG_PATH) SCENES_PATH = os.path.join(CONFIG_DIR, config_util.SCENE_CONFIG_PATH) @@ -67,9 +66,6 @@ def teardown(): if os.path.isfile(VERSION_PATH): os.remove(VERSION_PATH) - if os.path.isfile(GROUP_PATH): - os.remove(GROUP_PATH) - if os.path.isfile(AUTOMATIONS_PATH): os.remove(AUTOMATIONS_PATH) @@ -87,7 +83,6 @@ async def test_create_default_config(hass): assert os.path.isfile(YAML_PATH) assert os.path.isfile(SECRET_PATH) assert os.path.isfile(VERSION_PATH) - assert os.path.isfile(GROUP_PATH) assert os.path.isfile(AUTOMATIONS_PATH) @@ -395,7 +390,7 @@ async def test_loading_configuration_from_storage(hass, hass_storage): assert hass.config.currency == "EUR" assert len(hass.config.allowlist_external_dirs) == 3 assert "/etc" in hass.config.allowlist_external_dirs - assert hass.config.config_source == SOURCE_STORAGE + assert hass.config.config_source is ConfigSource.STORAGE async def test_loading_configuration_from_storage_with_yaml_only(hass, hass_storage): @@ -425,7 +420,7 @@ async def test_loading_configuration_from_storage_with_yaml_only(hass, hass_stor assert len(hass.config.allowlist_external_dirs) == 3 assert "/etc" in hass.config.allowlist_external_dirs assert hass.config.media_dirs == {"mymedia": "/usr"} - assert hass.config.config_source == SOURCE_STORAGE + assert hass.config.config_source is ConfigSource.STORAGE async def test_updating_configuration(hass, hass_storage): @@ -486,7 +481,7 @@ async def test_override_stored_configuration(hass, hass_storage): assert hass.config.time_zone == "Europe/Copenhagen" assert len(hass.config.allowlist_external_dirs) == 3 assert "/etc" in hass.config.allowlist_external_dirs - assert hass.config.config_source == config_util.SOURCE_YAML + assert hass.config.config_source is ConfigSource.YAML async def test_loading_configuration(hass): @@ -521,7 +516,7 @@ async def test_loading_configuration(hass): assert "/etc" in hass.config.allowlist_external_dirs assert "/usr" in hass.config.allowlist_external_dirs assert hass.config.media_dirs == {"mymedia": "/usr"} - assert hass.config.config_source == config_util.SOURCE_YAML + assert hass.config.config_source is ConfigSource.YAML assert hass.config.legacy_templates is True assert hass.config.currency == "EUR" @@ -550,7 +545,7 @@ async def test_loading_configuration_temperature_unit(hass): assert hass.config.time_zone == "America/New_York" assert hass.config.external_url == "https://www.example.com" assert hass.config.internal_url == "http://example.local" - assert hass.config.config_source == config_util.SOURCE_YAML + assert hass.config.config_source is ConfigSource.YAML assert hass.config.currency == "EUR" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 0e743fda91e1e1..cad92a5d92d888 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -529,7 +529,7 @@ async def test_domains_gets_domains_excludes_ignore_and_disabled(manager): ).add_to_manager(manager) MockConfigEntry(domain="test3").add_to_manager(manager) MockConfigEntry( - domain="disabled", disabled_by=config_entries.DISABLED_USER + domain="disabled", disabled_by=config_entries.ConfigEntryDisabler.USER ).add_to_manager(manager) assert manager.async_domains() == ["test", "test2", "test3"] assert manager.async_domains(include_ignore=False) == ["test", "test2", "test3"] @@ -886,7 +886,7 @@ async def test_setup_raise_not_ready(hass, caplog): mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_entity_platform(hass, "config_flow.test", None) - with patch("homeassistant.helpers.event.async_call_later") as mock_call: + with patch("homeassistant.config_entries.async_call_later") as mock_call: await entry.async_setup(hass) assert len(mock_call.mock_calls) == 1 @@ -921,7 +921,7 @@ async def test_setup_raise_not_ready_from_exception(hass, caplog): mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_entity_platform(hass, "config_flow.test", None) - with patch("homeassistant.helpers.event.async_call_later") as mock_call: + with patch("homeassistant.config_entries.async_call_later") as mock_call: await entry.async_setup(hass) assert len(mock_call.mock_calls) == 1 @@ -939,7 +939,7 @@ async def test_setup_retrying_during_unload(hass): mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_entity_platform(hass, "config_flow.test", None) - with patch("homeassistant.helpers.event.async_call_later") as mock_call: + with patch("homeassistant.config_entries.async_call_later") as mock_call: await entry.async_setup(hass) assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY @@ -1323,7 +1323,7 @@ async def test_entry_disable_succeed(hass, manager): # Disable assert await manager.async_set_disabled_by( - entry.entry_id, config_entries.DISABLED_USER + entry.entry_id, config_entries.ConfigEntryDisabler.USER ) assert len(async_unload_entry.mock_calls) == 1 assert len(async_setup.mock_calls) == 0 @@ -1358,7 +1358,7 @@ async def test_entry_disable_without_reload_support(hass, manager): # Disable assert not await manager.async_set_disabled_by( - entry.entry_id, config_entries.DISABLED_USER + entry.entry_id, config_entries.ConfigEntryDisabler.USER ) assert len(async_setup.mock_calls) == 0 assert len(async_setup_entry.mock_calls) == 0 @@ -1374,7 +1374,9 @@ async def test_entry_disable_without_reload_support(hass, manager): async def test_entry_enable_without_reload_support(hass, manager): """Test that we can disable an entry without reload support.""" - entry = MockConfigEntry(domain="comp", disabled_by=config_entries.DISABLED_USER) + entry = MockConfigEntry( + domain="comp", disabled_by=config_entries.ConfigEntryDisabler.USER + ) entry.add_to_hass(hass) async_setup = AsyncMock(return_value=True) @@ -1398,7 +1400,7 @@ async def test_entry_enable_without_reload_support(hass, manager): # Disable assert not await manager.async_set_disabled_by( - entry.entry_id, config_entries.DISABLED_USER + entry.entry_id, config_entries.ConfigEntryDisabler.USER ) assert len(async_setup.mock_calls) == 1 assert len(async_setup_entry.mock_calls) == 1 @@ -1433,7 +1435,9 @@ async def test_reload_entry_entity_registry_ignores_no_entry(hass): # Test we ignore entities without config entry entry = registry.async_get_or_create("light", "hue", "123") - registry.async_update_entity(entry.entity_id, disabled_by=er.DISABLED_USER) + registry.async_update_entity( + entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER + ) await hass.async_block_till_done() assert not handler.changed assert handler._remove_call_later is None @@ -1472,7 +1476,9 @@ async def test_reload_entry_entity_registry_works(hass): assert handler._remove_call_later is None # Disable entity, we should not do anything, only act when enabled. - registry.async_update_entity(entity_entry.entity_id, disabled_by=er.DISABLED_USER) + registry.async_update_entity( + entity_entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER + ) await hass.async_block_till_done() assert not handler.changed assert handler._remove_call_later is None @@ -2733,6 +2739,7 @@ async def test_setup_raise_auth_failed(hass, caplog): assert len(flows) == 1 assert flows[0]["context"]["entry_id"] == entry.entry_id assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH + assert flows[0]["context"]["title_placeholders"] == {"name": "test_title"} caplog.clear() entry.state = config_entries.ConfigEntryState.NOT_LOADED @@ -2966,3 +2973,21 @@ async def test_loading_old_data(hass, hass_storage): assert entry.title == "Mock title" assert entry.data == {"my": "data"} assert entry.pref_disable_new_entities is True + + +async def test_deprecated_disabled_by_str_ctor(hass, caplog): + """Test deprecated str disabled_by constructor enumizes and logs a warning.""" + entry = MockConfigEntry(disabled_by=config_entries.ConfigEntryDisabler.USER.value) + assert entry.disabled_by is config_entries.ConfigEntryDisabler.USER + assert " str for config entry disabled_by. This is deprecated " in caplog.text + + +async def test_deprecated_disabled_by_str_set(hass, manager, caplog): + """Test deprecated str set disabled_by enumizes and logs a warning.""" + entry = MockConfigEntry() + entry.add_to_manager(manager) + assert await manager.async_set_disabled_by( + entry.entry_id, config_entries.ConfigEntryDisabler.USER.value + ) + assert entry.disabled_by is config_entries.ConfigEntryDisabler.USER + assert " str for config entry disabled_by. This is deprecated " in caplog.text diff --git a/tests/test_core.py b/tests/test_core.py index 641a5e0dfda4d5..c2d99967a4b4e4 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -902,7 +902,7 @@ def test_config_defaults(): assert config.time_zone == "UTC" assert config.internal_url is None assert config.external_url is None - assert config.config_source == "default" + assert config.config_source is ha.ConfigSource.DEFAULT assert config.skip_pip is False assert config.components == set() assert config.api is None @@ -948,7 +948,7 @@ def test_config_as_dict(): "allowlist_external_dirs": set(), "allowlist_external_urls": set(), "version": __version__, - "config_source": "default", + "config_source": ha.ConfigSource.DEFAULT, "safe_mode": False, "state": "RUNNING", "external_url": None, diff --git a/tests/test_loader.py b/tests/test_loader.py index 2bffee75d1ecef..68946a9de0123e 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -157,6 +157,21 @@ async def test_get_integration(hass): assert hue_light == integration.get_platform("light") +async def test_get_integration_exceptions(hass): + """Test resolving integration.""" + integration = await loader.async_get_integration(hass, "hue") + + with pytest.raises(ImportError), patch( + "homeassistant.loader.importlib.import_module", side_effect=ValueError("Boom") + ): + assert hue == integration.get_component() + + with pytest.raises(ImportError), patch( + "homeassistant.loader.importlib.import_module", side_effect=ValueError("Boom") + ): + assert hue_light == integration.get_platform("light") + + async def test_get_integration_legacy(hass, enable_custom_integrations): """Test resolving integration.""" integration = await loader.async_get_integration(hass, "test_embedded") @@ -329,6 +344,33 @@ def _get_test_integration_with_zeroconf_matcher(hass, name, config_flow): ) +def _get_test_integration_with_legacy_zeroconf_matcher(hass, name, config_flow): + """Return a generated test integration with a legacy zeroconf matcher.""" + return loader.Integration( + hass, + f"homeassistant.components.{name}", + None, + { + "name": name, + "domain": name, + "config_flow": config_flow, + "dependencies": [], + "requirements": [], + "zeroconf": [ + { + "type": f"_{name}._tcp.local.", + "macaddress": "AABBCC*", + "manufacturer": "legacy*", + "model": "legacy*", + "name": f"{name}*", + } + ], + "homekit": {"models": [name]}, + "ssdp": [{"manufacturer": name, "modelName": name}], + }, + ) + + def _get_test_integration_with_dhcp_matcher(hass, name, config_flow): """Return a generated test integration with a dhcp matcher.""" return loader.Integration( @@ -435,6 +477,33 @@ async def test_get_zeroconf(hass): ] +async def test_get_zeroconf_back_compat(hass): + """Verify that custom components with zeroconf are found and legacy matchers are converted.""" + test_1_integration = _get_test_integration(hass, "test_1", True) + test_2_integration = _get_test_integration_with_legacy_zeroconf_matcher( + hass, "test_2", True + ) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test_1": test_1_integration, + "test_2": test_2_integration, + } + zeroconf = await loader.async_get_zeroconf(hass) + assert zeroconf["_test_1._tcp.local."] == [{"domain": "test_1"}] + assert zeroconf["_test_2._tcp.local."] == [ + { + "domain": "test_2", + "name": "test_2*", + "properties": { + "macaddress": "aabbcc*", + "model": "legacy*", + "manufacturer": "legacy*", + }, + } + ] + + async def test_get_dhcp(hass): """Verify that custom components with dhcp are found.""" test_1_integration = _get_test_integration_with_dhcp_matcher(hass, "test_1", True) diff --git a/tests/test_setup.py b/tests/test_setup.py index d245c9818363b6..f71ba01410b6ba 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -2,7 +2,6 @@ # pylint: disable=protected-access import asyncio import datetime -import os import threading from unittest.mock import AsyncMock, Mock, patch @@ -10,7 +9,6 @@ import voluptuous as vol from homeassistant import config_entries, setup -import homeassistant.config as config_util from homeassistant.const import EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START from homeassistant.core import callback from homeassistant.helpers import discovery @@ -18,24 +16,18 @@ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, MockModule, MockPlatform, assert_setup_component, - get_test_config_dir, - get_test_home_assistant, mock_entity_platform, mock_integration, ) -ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE -VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) - -@pytest.fixture(autouse=True) +@pytest.fixture def mock_handlers(): """Mock config flows.""" @@ -48,438 +40,429 @@ class MockFlowHandler(config_entries.ConfigFlow): yield -class TestSetup: - """Test the bootstrap utils.""" +async def test_validate_component_config(hass): + """Test validating component configuration.""" + config_schema = vol.Schema({"comp_conf": {"hello": str}}, required=True) + mock_integration(hass, MockModule("comp_conf", config_schema=config_schema)) + + with assert_setup_component(0): + assert not await setup.async_setup_component(hass, "comp_conf", {}) - hass = None - backup_cache = None + hass.data.pop(setup.DATA_SETUP) - # pylint: disable=invalid-name, no-self-use - def setup_method(self, method): - """Set up the test.""" - self.hass = get_test_home_assistant() + with assert_setup_component(0): + assert not await setup.async_setup_component( + hass, "comp_conf", {"comp_conf": None} + ) - def teardown_method(self, method): - """Clean up.""" - self.hass.stop() + hass.data.pop(setup.DATA_SETUP) - def test_validate_component_config(self): - """Test validating component configuration.""" - config_schema = vol.Schema({"comp_conf": {"hello": str}}, required=True) - mock_integration( - self.hass, MockModule("comp_conf", config_schema=config_schema) + with assert_setup_component(0): + assert not await setup.async_setup_component( + hass, "comp_conf", {"comp_conf": {}} ) - with assert_setup_component(0): - assert not setup.setup_component(self.hass, "comp_conf", {}) + hass.data.pop(setup.DATA_SETUP) - self.hass.data.pop(setup.DATA_SETUP) + with assert_setup_component(0): + assert not await setup.async_setup_component( + hass, + "comp_conf", + {"comp_conf": {"hello": "world", "invalid": "extra"}}, + ) - with assert_setup_component(0): - assert not setup.setup_component( - self.hass, "comp_conf", {"comp_conf": None} - ) + hass.data.pop(setup.DATA_SETUP) - self.hass.data.pop(setup.DATA_SETUP) + with assert_setup_component(1): + assert await setup.async_setup_component( + hass, "comp_conf", {"comp_conf": {"hello": "world"}} + ) - with assert_setup_component(0): - assert not setup.setup_component(self.hass, "comp_conf", {"comp_conf": {}}) - self.hass.data.pop(setup.DATA_SETUP) +async def test_validate_platform_config(hass, caplog): + """Test validating platform configuration.""" + platform_schema = PLATFORM_SCHEMA.extend({"hello": str}) + platform_schema_base = PLATFORM_SCHEMA_BASE.extend({}) + mock_integration( + hass, + MockModule("platform_conf", platform_schema_base=platform_schema_base), + ) + mock_entity_platform( + hass, + "platform_conf.whatever", + MockPlatform(platform_schema=platform_schema), + ) - with assert_setup_component(0): - assert not setup.setup_component( - self.hass, - "comp_conf", - {"comp_conf": {"hello": "world", "invalid": "extra"}}, - ) + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, + "platform_conf", + {"platform_conf": {"platform": "not_existing", "hello": "world"}}, + ) - self.hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("platform_conf") - with assert_setup_component(1): - assert setup.setup_component( - self.hass, "comp_conf", {"comp_conf": {"hello": "world"}} - ) + with assert_setup_component(1): + assert await setup.async_setup_component( + hass, + "platform_conf", + {"platform_conf": {"platform": "whatever", "hello": "world"}}, + ) + + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("platform_conf") - def test_validate_platform_config(self, caplog): - """Test validating platform configuration.""" - platform_schema = PLATFORM_SCHEMA.extend({"hello": str}) - platform_schema_base = PLATFORM_SCHEMA_BASE.extend({}) - mock_integration( - self.hass, - MockModule("platform_conf", platform_schema_base=platform_schema_base), + with assert_setup_component(1): + assert await setup.async_setup_component( + hass, + "platform_conf", + {"platform_conf": [{"platform": "whatever", "hello": "world"}]}, ) - mock_entity_platform( - self.hass, - "platform_conf.whatever", - MockPlatform(platform_schema=platform_schema), + + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("platform_conf") + + # Any falsey platform config will be ignored (None, {}, etc) + with assert_setup_component(0) as config: + assert await setup.async_setup_component( + hass, "platform_conf", {"platform_conf": None} ) + assert "platform_conf" in hass.config.components + assert not config["platform_conf"] # empty - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "platform_conf", - {"platform_conf": {"platform": "not_existing", "hello": "world"}}, - ) + assert await setup.async_setup_component( + hass, "platform_conf", {"platform_conf": {}} + ) + assert "platform_conf" in hass.config.components + assert not config["platform_conf"] # empty - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove("platform_conf") - with assert_setup_component(1): - assert setup.setup_component( - self.hass, - "platform_conf", - {"platform_conf": {"platform": "whatever", "hello": "world"}}, - ) +async def test_validate_platform_config_2(hass, caplog): + """Test component PLATFORM_SCHEMA_BASE prio over PLATFORM_SCHEMA.""" + platform_schema = PLATFORM_SCHEMA.extend({"hello": str}) + platform_schema_base = PLATFORM_SCHEMA_BASE.extend({"hello": "world"}) + mock_integration( + hass, + MockModule( + "platform_conf", + platform_schema=platform_schema, + platform_schema_base=platform_schema_base, + ), + ) - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove("platform_conf") + mock_entity_platform( + hass, + "platform_conf.whatever", + MockPlatform("whatever", platform_schema=platform_schema), + ) - with assert_setup_component(1): - assert setup.setup_component( - self.hass, - "platform_conf", - {"platform_conf": [{"platform": "whatever", "hello": "world"}]}, - ) + with assert_setup_component(1): + assert await setup.async_setup_component( + hass, + "platform_conf", + { + # pass + "platform_conf": {"platform": "whatever", "hello": "world"}, + # fail: key hello violates component platform_schema_base + "platform_conf 2": {"platform": "whatever", "hello": "there"}, + }, + ) - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove("platform_conf") - # Any falsey platform config will be ignored (None, {}, etc) - with assert_setup_component(0) as config: - assert setup.setup_component( - self.hass, "platform_conf", {"platform_conf": None} - ) - assert "platform_conf" in self.hass.config.components - assert not config["platform_conf"] # empty +async def test_validate_platform_config_3(hass, caplog): + """Test fallback to component PLATFORM_SCHEMA.""" + component_schema = PLATFORM_SCHEMA_BASE.extend({"hello": str}) + platform_schema = PLATFORM_SCHEMA.extend({"cheers": str, "hello": "world"}) + mock_integration( + hass, MockModule("platform_conf", platform_schema=component_schema) + ) - assert setup.setup_component( - self.hass, "platform_conf", {"platform_conf": {}} - ) - assert "platform_conf" in self.hass.config.components - assert not config["platform_conf"] # empty - - def test_validate_platform_config_2(self, caplog): - """Test component PLATFORM_SCHEMA_BASE prio over PLATFORM_SCHEMA.""" - platform_schema = PLATFORM_SCHEMA.extend({"hello": str}) - platform_schema_base = PLATFORM_SCHEMA_BASE.extend({"hello": "world"}) - mock_integration( - self.hass, - MockModule( - "platform_conf", - platform_schema=platform_schema, - platform_schema_base=platform_schema_base, - ), - ) + mock_entity_platform( + hass, + "platform_conf.whatever", + MockPlatform("whatever", platform_schema=platform_schema), + ) - mock_entity_platform( - self.hass, - "platform_conf.whatever", - MockPlatform("whatever", platform_schema=platform_schema), + with assert_setup_component(1): + assert await setup.async_setup_component( + hass, + "platform_conf", + { + # pass + "platform_conf": {"platform": "whatever", "hello": "world"}, + # fail: key hello violates component platform_schema + "platform_conf 2": {"platform": "whatever", "hello": "there"}, + }, ) - with assert_setup_component(1): - assert setup.setup_component( - self.hass, - "platform_conf", - { - # pass - "platform_conf": {"platform": "whatever", "hello": "world"}, - # fail: key hello violates component platform_schema_base - "platform_conf 2": {"platform": "whatever", "hello": "there"}, - }, - ) - def test_validate_platform_config_3(self, caplog): - """Test fallback to component PLATFORM_SCHEMA.""" - component_schema = PLATFORM_SCHEMA_BASE.extend({"hello": str}) - platform_schema = PLATFORM_SCHEMA.extend({"cheers": str, "hello": "world"}) - mock_integration( - self.hass, MockModule("platform_conf", platform_schema=component_schema) - ) +async def test_validate_platform_config_4(hass): + """Test entity_namespace in PLATFORM_SCHEMA.""" + component_schema = PLATFORM_SCHEMA_BASE + platform_schema = PLATFORM_SCHEMA + mock_integration( + hass, + MockModule("platform_conf", platform_schema_base=component_schema), + ) + + mock_entity_platform( + hass, + "platform_conf.whatever", + MockPlatform(platform_schema=platform_schema), + ) - mock_entity_platform( - self.hass, - "platform_conf.whatever", - MockPlatform("whatever", platform_schema=platform_schema), + with assert_setup_component(1): + assert await setup.async_setup_component( + hass, + "platform_conf", + { + "platform_conf": { + # pass: entity_namespace accepted by PLATFORM_SCHEMA + "platform": "whatever", + "entity_namespace": "yummy", + } + }, ) - with assert_setup_component(1): - assert setup.setup_component( - self.hass, - "platform_conf", - { - # pass - "platform_conf": {"platform": "whatever", "hello": "world"}, - # fail: key hello violates component platform_schema - "platform_conf 2": {"platform": "whatever", "hello": "there"}, - }, - ) + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("platform_conf") - def test_validate_platform_config_4(self): - """Test entity_namespace in PLATFORM_SCHEMA.""" - component_schema = PLATFORM_SCHEMA_BASE - platform_schema = PLATFORM_SCHEMA - mock_integration( - self.hass, - MockModule("platform_conf", platform_schema_base=component_schema), - ) - mock_entity_platform( - self.hass, - "platform_conf.whatever", - MockPlatform(platform_schema=platform_schema), - ) +async def test_component_not_found(hass): + """setup_component should not crash if component doesn't exist.""" + assert await setup.async_setup_component(hass, "non_existing", {}) is False - with assert_setup_component(1): - assert setup.setup_component( - self.hass, - "platform_conf", - { - "platform_conf": { - # pass: entity_namespace accepted by PLATFORM_SCHEMA - "platform": "whatever", - "entity_namespace": "yummy", - } - }, - ) - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove("platform_conf") +async def test_component_not_double_initialized(hass): + """Test we do not set up a component twice.""" + mock_setup = Mock(return_value=True) - def test_component_not_found(self): - """setup_component should not crash if component doesn't exist.""" - assert setup.setup_component(self.hass, "non_existing", {}) is False + mock_integration(hass, MockModule("comp", setup=mock_setup)) - def test_component_not_double_initialized(self): - """Test we do not set up a component twice.""" - mock_setup = Mock(return_value=True) + assert await setup.async_setup_component(hass, "comp", {}) + assert mock_setup.called - mock_integration(self.hass, MockModule("comp", setup=mock_setup)) + mock_setup.reset_mock() - assert setup.setup_component(self.hass, "comp", {}) - assert mock_setup.called + assert await setup.async_setup_component(hass, "comp", {}) + assert not mock_setup.called - mock_setup.reset_mock() - assert setup.setup_component(self.hass, "comp", {}) - assert not mock_setup.called +async def test_component_not_installed_if_requirement_fails(hass): + """Component setup should fail if requirement can't install.""" + hass.config.skip_pip = False + mock_integration(hass, MockModule("comp", requirements=["package==0.0.1"])) - @patch("homeassistant.util.package.install_package", return_value=False) - def test_component_not_installed_if_requirement_fails(self, mock_install): - """Component setup should fail if requirement can't install.""" - self.hass.config.skip_pip = False - mock_integration(self.hass, MockModule("comp", requirements=["package==0.0.1"])) + with patch("homeassistant.util.package.install_package", return_value=False): + assert not await setup.async_setup_component(hass, "comp", {}) - assert not setup.setup_component(self.hass, "comp", {}) - assert "comp" not in self.hass.config.components + assert "comp" not in hass.config.components - def test_component_not_setup_twice_if_loaded_during_other_setup(self): - """Test component setup while waiting for lock is not set up twice.""" - result = [] - async def async_setup(hass, config): - """Tracking Setup.""" - result.append(1) +async def test_component_not_setup_twice_if_loaded_during_other_setup(hass): + """Test component setup while waiting for lock is not set up twice.""" + result = [] - mock_integration(self.hass, MockModule("comp", async_setup=async_setup)) + async def async_setup(hass, config): + """Tracking Setup.""" + result.append(1) - def setup_component(): - """Set up the component.""" - setup.setup_component(self.hass, "comp", {}) + mock_integration(hass, MockModule("comp", async_setup=async_setup)) - thread = threading.Thread(target=setup_component) - thread.start() - setup.setup_component(self.hass, "comp", {}) + def setup_component(): + """Set up the component.""" + setup.setup_component(hass, "comp", {}) - thread.join() + thread = threading.Thread(target=setup_component) + thread.start() + await setup.async_setup_component(hass, "comp", {}) - assert len(result) == 1 + await hass.async_add_executor_job(thread.join) - def test_component_not_setup_missing_dependencies(self): - """Test we do not set up a component if not all dependencies loaded.""" - deps = ["maybe_existing"] - mock_integration(self.hass, MockModule("comp", dependencies=deps)) + assert len(result) == 1 - assert not setup.setup_component(self.hass, "comp", {}) - assert "comp" not in self.hass.config.components - self.hass.data.pop(setup.DATA_SETUP) +async def test_component_not_setup_missing_dependencies(hass): + """Test we do not set up a component if not all dependencies loaded.""" + deps = ["maybe_existing"] + mock_integration(hass, MockModule("comp", dependencies=deps)) - mock_integration(self.hass, MockModule("comp2", dependencies=deps)) - mock_integration(self.hass, MockModule("maybe_existing")) + assert not await setup.async_setup_component(hass, "comp", {}) + assert "comp" not in hass.config.components - assert setup.setup_component(self.hass, "comp2", {}) + hass.data.pop(setup.DATA_SETUP) - def test_component_failing_setup(self): - """Test component that fails setup.""" - mock_integration( - self.hass, MockModule("comp", setup=lambda hass, config: False) - ) + mock_integration(hass, MockModule("comp2", dependencies=deps)) + mock_integration(hass, MockModule("maybe_existing")) - assert not setup.setup_component(self.hass, "comp", {}) - assert "comp" not in self.hass.config.components + assert await setup.async_setup_component(hass, "comp2", {}) - def test_component_exception_setup(self): - """Test component that raises exception during setup.""" - def exception_setup(hass, config): - """Raise exception.""" - raise Exception("fail!") +async def test_component_failing_setup(hass): + """Test component that fails setup.""" + mock_integration(hass, MockModule("comp", setup=lambda hass, config: False)) - mock_integration(self.hass, MockModule("comp", setup=exception_setup)) + assert not await setup.async_setup_component(hass, "comp", {}) + assert "comp" not in hass.config.components - assert not setup.setup_component(self.hass, "comp", {}) - assert "comp" not in self.hass.config.components - def test_component_setup_with_validation_and_dependency(self): - """Test all config is passed to dependencies.""" +async def test_component_exception_setup(hass): + """Test component that raises exception during setup.""" - def config_check_setup(hass, config): - """Test that config is passed in.""" - if config.get("comp_a", {}).get("valid", False): - return True - raise Exception(f"Config not passed in: {config}") + def exception_setup(hass, config): + """Raise exception.""" + raise Exception("fail!") - platform = MockPlatform() + mock_integration(hass, MockModule("comp", setup=exception_setup)) - mock_integration(self.hass, MockModule("comp_a", setup=config_check_setup)) - mock_integration( - self.hass, - MockModule("platform_a", setup=config_check_setup, dependencies=["comp_a"]), - ) + assert not await setup.async_setup_component(hass, "comp", {}) + assert "comp" not in hass.config.components - mock_entity_platform(self.hass, "switch.platform_a", platform) - setup.setup_component( - self.hass, - "switch", - {"comp_a": {"valid": True}, "switch": {"platform": "platform_a"}}, - ) - self.hass.block_till_done() - assert "comp_a" in self.hass.config.components +async def test_component_setup_with_validation_and_dependency(hass): + """Test all config is passed to dependencies.""" - def test_platform_specific_config_validation(self): - """Test platform that specifies config.""" - platform_schema = PLATFORM_SCHEMA.extend( - {"valid": True}, extra=vol.PREVENT_EXTRA - ) + def config_check_setup(hass, config): + """Test that config is passed in.""" + if config.get("comp_a", {}).get("valid", False): + return True + raise Exception(f"Config not passed in: {config}") - mock_setup = Mock(spec_set=True) + platform = MockPlatform() - mock_entity_platform( - self.hass, - "switch.platform_a", - MockPlatform(platform_schema=platform_schema, setup_platform=mock_setup), - ) + mock_integration(hass, MockModule("comp_a", setup=config_check_setup)) + mock_integration( + hass, + MockModule("platform_a", setup=config_check_setup, dependencies=["comp_a"]), + ) - with assert_setup_component(0, "switch"): - assert setup.setup_component( - self.hass, - "switch", - {"switch": {"platform": "platform_a", "invalid": True}}, - ) - self.hass.block_till_done() - assert mock_setup.call_count == 0 - - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove("switch") - - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "switch", - { - "switch": { - "platform": "platform_a", - "valid": True, - "invalid_extra": True, - } - }, - ) - self.hass.block_till_done() - assert mock_setup.call_count == 0 + mock_entity_platform(hass, "switch.platform_a", platform) - self.hass.data.pop(setup.DATA_SETUP) - self.hass.config.components.remove("switch") + await setup.async_setup_component( + hass, + "switch", + {"comp_a": {"valid": True}, "switch": {"platform": "platform_a"}}, + ) + await hass.async_block_till_done() + assert "comp_a" in hass.config.components - with assert_setup_component(1, "switch"): - assert setup.setup_component( - self.hass, - "switch", - {"switch": {"platform": "platform_a", "valid": True}}, - ) - self.hass.block_till_done() - assert mock_setup.call_count == 1 - def test_disable_component_if_invalid_return(self): - """Test disabling component if invalid return.""" - mock_integration( - self.hass, MockModule("disabled_component", setup=lambda hass, config: None) +async def test_platform_specific_config_validation(hass): + """Test platform that specifies config.""" + platform_schema = PLATFORM_SCHEMA.extend({"valid": True}, extra=vol.PREVENT_EXTRA) + + mock_setup = Mock(spec_set=True) + + mock_entity_platform( + hass, + "switch.platform_a", + MockPlatform(platform_schema=platform_schema, setup_platform=mock_setup), + ) + + with assert_setup_component(0, "switch"): + assert await setup.async_setup_component( + hass, + "switch", + {"switch": {"platform": "platform_a", "invalid": True}}, ) + await hass.async_block_till_done() + assert mock_setup.call_count == 0 - assert not setup.setup_component(self.hass, "disabled_component", {}) - assert "disabled_component" not in self.hass.config.components + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("switch") - self.hass.data.pop(setup.DATA_SETUP) - mock_integration( - self.hass, - MockModule("disabled_component", setup=lambda hass, config: False), + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, + "switch", + { + "switch": { + "platform": "platform_a", + "valid": True, + "invalid_extra": True, + } + }, ) + await hass.async_block_till_done() + assert mock_setup.call_count == 0 - assert not setup.setup_component(self.hass, "disabled_component", {}) - assert "disabled_component" not in self.hass.config.components + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("switch") - self.hass.data.pop(setup.DATA_SETUP) - mock_integration( - self.hass, MockModule("disabled_component", setup=lambda hass, config: True) + with assert_setup_component(1, "switch"): + assert await setup.async_setup_component( + hass, + "switch", + {"switch": {"platform": "platform_a", "valid": True}}, ) + await hass.async_block_till_done() + assert mock_setup.call_count == 1 - assert setup.setup_component(self.hass, "disabled_component", {}) - assert "disabled_component" in self.hass.config.components - def test_all_work_done_before_start(self): - """Test all init work done till start.""" - call_order = [] +async def test_disable_component_if_invalid_return(hass): + """Test disabling component if invalid return.""" + mock_integration( + hass, MockModule("disabled_component", setup=lambda hass, config: None) + ) - async def component1_setup(hass, config): - """Set up mock component.""" - await discovery.async_discover( - hass, "test_component2", {}, "test_component2", {} - ) - await discovery.async_discover( - hass, "test_component3", {}, "test_component3", {} - ) - return True + assert not await setup.async_setup_component(hass, "disabled_component", {}) + assert "disabled_component" not in hass.config.components - def component_track_setup(hass, config): - """Set up mock component.""" - call_order.append(1) - return True + hass.data.pop(setup.DATA_SETUP) + mock_integration( + hass, + MockModule("disabled_component", setup=lambda hass, config: False), + ) - mock_integration( - self.hass, MockModule("test_component1", async_setup=component1_setup) - ) + assert not await setup.async_setup_component(hass, "disabled_component", {}) + assert "disabled_component" not in hass.config.components + + hass.data.pop(setup.DATA_SETUP) + mock_integration( + hass, MockModule("disabled_component", setup=lambda hass, config: True) + ) + + assert await setup.async_setup_component(hass, "disabled_component", {}) + assert "disabled_component" in hass.config.components - mock_integration( - self.hass, MockModule("test_component2", setup=component_track_setup) - ) - mock_integration( - self.hass, MockModule("test_component3", setup=component_track_setup) +async def test_all_work_done_before_start(hass): + """Test all init work done till start.""" + call_order = [] + + async def component1_setup(hass, config): + """Set up mock component.""" + await discovery.async_discover( + hass, "test_component2", {}, "test_component2", {} + ) + await discovery.async_discover( + hass, "test_component3", {}, "test_component3", {} ) + return True - @callback - def track_start(event): - """Track start event.""" - call_order.append(2) + def component_track_setup(hass, config): + """Set up mock component.""" + call_order.append(1) + return True + + mock_integration(hass, MockModule("test_component1", async_setup=component1_setup)) + + mock_integration(hass, MockModule("test_component2", setup=component_track_setup)) - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, track_start) + mock_integration(hass, MockModule("test_component3", setup=component_track_setup)) - self.hass.add_job(setup.async_setup_component(self.hass, "test_component1", {})) - self.hass.block_till_done() - self.hass.start() - assert call_order == [1, 1, 2] + @callback + def track_start(event): + """Track start event.""" + call_order.append(2) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, track_start) + + hass.add_job(setup.async_setup_component(hass, "test_component1", {})) + await hass.async_block_till_done() + await hass.async_start() + assert call_order == [1, 1, 2] async def test_component_warn_slow_setup(hass): @@ -513,7 +496,7 @@ async def test_platform_no_warn_slow(hass): async def test_platform_error_slow_setup(hass, caplog): """Don't block startup more than SLOW_SETUP_MAX_WAIT.""" - with patch.object(setup, "SLOW_SETUP_MAX_WAIT", 1): + with patch.object(setup, "SLOW_SETUP_MAX_WAIT", 0.1): called = [] async def async_setup(*args): @@ -525,7 +508,7 @@ async def async_setup(*args): result = await setup.async_setup_component(hass, "test_component1", {}) assert len(called) == 1 assert not result - assert "test_component1 is taking longer than 1 seconds" in caplog.text + assert "test_component1 is taking longer than 0.1 seconds" in caplog.text async def test_when_setup_already_loaded(hass): @@ -594,12 +577,12 @@ async def mock_callback(hass, component): async def test_setup_import_blows_up(hass): """Test that we handle it correctly when importing integration blows up.""" with patch( - "homeassistant.loader.Integration.get_component", side_effect=ValueError + "homeassistant.loader.Integration.get_component", side_effect=ImportError ): assert not await setup.async_setup_component(hass, "sun", {}) -async def test_parallel_entry_setup(hass): +async def test_parallel_entry_setup(hass, mock_handlers): """Test config entries are set up in parallel.""" MockConfigEntry(domain="comp", data={"value": 1}).add_to_hass(hass) MockConfigEntry(domain="comp", data={"value": 2}).add_to_hass(hass) diff --git a/tests/testing_config/custom_components/test/device_tracker.py b/tests/testing_config/custom_components/test/device_tracker.py index e4853d156cec4f..d5f34f48ec8bf7 100644 --- a/tests/testing_config/custom_components/test/device_tracker.py +++ b/tests/testing_config/custom_components/test/device_tracker.py @@ -18,7 +18,7 @@ def __init__(self): self.connected = False self._hostname = "test.hostname.org" self._ip_address = "0.0.0.0" - self._mac_address = "ad:de:ef:be:ed:fe:" + self._mac_address = "ad:de:ef:be:ed:fe" @property def source_type(self): diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index ea3b0fe7080675..4ad2580ad8bc32 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -3,12 +3,18 @@ Call init before using it in your tests to ensure clean test data. """ -import homeassistant.components.sensor as sensor +from homeassistant.components.sensor import ( + DEVICE_CLASSES, + SensorDeviceClass, + SensorEntity, +) from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, FREQUENCY_GIGAHERTZ, PERCENTAGE, + POWER_VOLT_AMPERE, + POWER_VOLT_AMPERE_REACTIVE, PRESSURE_HPA, SIGNAL_STRENGTH_DECIBELS, VOLUME_CUBIC_METERS, @@ -16,34 +22,35 @@ from tests.common import MockEntity -DEVICE_CLASSES = list(sensor.DEVICE_CLASSES) DEVICE_CLASSES.append("none") UNITS_OF_MEASUREMENT = { - sensor.DEVICE_CLASS_BATTERY: PERCENTAGE, # % of battery that is left - sensor.DEVICE_CLASS_CO: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO concentration - sensor.DEVICE_CLASS_CO2: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO2 concentration - sensor.DEVICE_CLASS_HUMIDITY: PERCENTAGE, # % of humidity in the air - sensor.DEVICE_CLASS_ILLUMINANCE: "lm", # current light level (lx/lm) - sensor.DEVICE_CLASS_NITROGEN_DIOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen dioxide - sensor.DEVICE_CLASS_NITROGEN_MONOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen monoxide - sensor.DEVICE_CLASS_NITROUS_OXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen oxide - sensor.DEVICE_CLASS_OZONE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of ozone - sensor.DEVICE_CLASS_PM1: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM1 - sensor.DEVICE_CLASS_PM10: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM10 - sensor.DEVICE_CLASS_PM25: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM2.5 - sensor.DEVICE_CLASS_SIGNAL_STRENGTH: SIGNAL_STRENGTH_DECIBELS, # signal strength (dB/dBm) - sensor.DEVICE_CLASS_SULPHUR_DIOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of sulphur dioxide - sensor.DEVICE_CLASS_TEMPERATURE: "C", # temperature (C/F) - sensor.DEVICE_CLASS_PRESSURE: PRESSURE_HPA, # pressure (hPa/mbar) - sensor.DEVICE_CLASS_POWER: "kW", # power (W/kW) - sensor.DEVICE_CLASS_CURRENT: "A", # current (A) - sensor.DEVICE_CLASS_ENERGY: "kWh", # energy (Wh/kWh) - sensor.DEVICE_CLASS_FREQUENCY: FREQUENCY_GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) - sensor.DEVICE_CLASS_POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) - sensor.DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of vocs - sensor.DEVICE_CLASS_VOLTAGE: "V", # voltage (V) - sensor.DEVICE_CLASS_GAS: VOLUME_CUBIC_METERS, # gas (m³) + SensorDeviceClass.APPARENT_POWER: POWER_VOLT_AMPERE, # apparent power (VA) + SensorDeviceClass.BATTERY: PERCENTAGE, # % of battery that is left + SensorDeviceClass.CO: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO concentration + SensorDeviceClass.CO2: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO2 concentration + SensorDeviceClass.HUMIDITY: PERCENTAGE, # % of humidity in the air + SensorDeviceClass.ILLUMINANCE: "lm", # current light level (lx/lm) + SensorDeviceClass.NITROGEN_DIOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen dioxide + SensorDeviceClass.NITROGEN_MONOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen monoxide + SensorDeviceClass.NITROUS_OXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of nitrogen oxide + SensorDeviceClass.OZONE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of ozone + SensorDeviceClass.PM1: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM1 + SensorDeviceClass.PM10: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM10 + SensorDeviceClass.PM25: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of PM2.5 + SensorDeviceClass.SIGNAL_STRENGTH: SIGNAL_STRENGTH_DECIBELS, # signal strength (dB/dBm) + SensorDeviceClass.SULPHUR_DIOXIDE: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of sulphur dioxide + SensorDeviceClass.TEMPERATURE: "C", # temperature (C/F) + SensorDeviceClass.PRESSURE: PRESSURE_HPA, # pressure (hPa/mbar) + SensorDeviceClass.POWER: "kW", # power (W/kW) + SensorDeviceClass.CURRENT: "A", # current (A) + SensorDeviceClass.ENERGY: "kWh", # energy (Wh/kWh/MWh) + SensorDeviceClass.FREQUENCY: FREQUENCY_GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) + SensorDeviceClass.POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) + SensorDeviceClass.REACTIVE_POWER: POWER_VOLT_AMPERE_REACTIVE, # reactive power (var) + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of vocs + SensorDeviceClass.VOLTAGE: "V", # voltage (V) + SensorDeviceClass.GAS: VOLUME_CUBIC_METERS, # gas (m³) } ENTITIES = {} @@ -75,7 +82,7 @@ async def async_setup_platform( async_add_entities_callback(list(ENTITIES.values())) -class MockSensor(MockEntity, sensor.SensorEntity): +class MockSensor(MockEntity, SensorEntity): """Mock Sensor class.""" @property diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index e7b5ef73c32b7f..cd156705ddf235 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -1,4 +1,5 @@ """Test aiohttp request helper.""" +from aiohttp import web from homeassistant.util import aiohttp @@ -25,3 +26,53 @@ async def test_request_post_query(): assert request.method == "POST" assert await request.post() == {"hello": "2", "post": "true"} assert request.query == {"get": "true"} + + +def test_serialize_text(): + """Test serializing a text response.""" + response = web.Response(status=201, text="Hello") + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": {"Content-Type": "text/plain; charset=utf-8"}, + } + + +def test_serialize_body_str(): + """Test serializing a response with a str as body.""" + response = web.Response(status=201, body="Hello") + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": {"Content-Length": "5", "Content-Type": "text/plain; charset=utf-8"}, + } + + +def test_serialize_body_None(): + """Test serializing a response with a str as body.""" + response = web.Response(status=201, body=None) + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": None, + "headers": {}, + } + + +def test_serialize_body_bytes(): + """Test serializing a response with a str as body.""" + response = web.Response(status=201, body=b"Hello") + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": {}, + } + + +def test_serialize_json(): + """Test serializing a JSON response.""" + response = web.json_response({"how": "what"}) + assert aiohttp.serialize_response(response) == { + "status": 200, + "body": '{"how": "what"}', + "headers": {"Content-Type": "application/json; charset=utf-8"}, + } diff --git a/tests/util/test_async.py b/tests/util/test_async.py index cae47835cd83c7..f02d3c03b4b713 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -5,6 +5,7 @@ import pytest +from homeassistant import block_async_io from homeassistant.util import async_ as hasync @@ -70,14 +71,18 @@ def test_run_callback_threadsafe_from_inside_event_loop(mock_ident, _): assert len(loop.call_soon_threadsafe.mock_calls) == 2 +def banned_function(): + """Mock banned function.""" + + async def test_check_loop_async(): """Test check_loop detects when called from event loop without integration context.""" with pytest.raises(RuntimeError): - hasync.check_loop() + hasync.check_loop(banned_function) async def test_check_loop_async_integration(caplog): - """Test check_loop detects when called from event loop from integration context.""" + """Test check_loop detects and raises when called from event loop from integration context.""" with pytest.raises(RuntimeError), patch( "homeassistant.util.async_.extract_stack", return_value=[ @@ -98,9 +103,42 @@ async def test_check_loop_async_integration(caplog): ), ], ): - hasync.check_loop() + hasync.check_loop(banned_function) + assert ( + "Detected blocking call inside the event loop. This is causing stability issues. " + "Please report issue for hue doing blocking calls at " + "homeassistant/components/hue/light.py, line 23: self.light.is_on" + in caplog.text + ) + + +async def test_check_loop_async_integration_non_strict(caplog): + """Test check_loop detects when called from event loop from integration context.""" + with patch( + "homeassistant.util.async_.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + hasync.check_loop(banned_function, strict=False) assert ( - "Detected I/O inside the event loop. This is causing stability issues. Please report issue for hue doing I/O at homeassistant/components/hue/light.py, line 23: self.light.is_on" + "Detected blocking call inside the event loop. This is causing stability issues. " + "Please report issue for hue doing blocking calls at " + "homeassistant/components/hue/light.py, line 23: self.light.is_on" in caplog.text ) @@ -127,26 +165,55 @@ async def test_check_loop_async_custom(caplog): ), ], ): - hasync.check_loop() + hasync.check_loop(banned_function) assert ( - "Detected I/O inside the event loop. This is causing stability issues. Please report issue to the custom component author for hue doing I/O at custom_components/hue/light.py, line 23: self.light.is_on" - in caplog.text + "Detected blocking call inside the event loop. This is causing stability issues. " + "Please report issue to the custom component author for hue doing blocking calls " + "at custom_components/hue/light.py, line 23: self.light.is_on" in caplog.text ) def test_check_loop_sync(caplog): """Test check_loop does nothing when called from thread.""" - hasync.check_loop() - assert "Detected I/O inside the event loop" not in caplog.text + hasync.check_loop(banned_function) + assert "Detected blocking call inside the event loop" not in caplog.text def test_protect_loop_sync(): """Test protect_loop calls check_loop.""" - calls = [] - with patch("homeassistant.util.async_.check_loop") as mock_loop: - hasync.protect_loop(calls.append)(1) - assert len(mock_loop.mock_calls) == 1 - assert calls == [1] + func = Mock() + with patch("homeassistant.util.async_.check_loop") as mock_check_loop: + hasync.protect_loop(func)(1, test=2) + mock_check_loop.assert_called_once_with(func, strict=True) + func.assert_called_once_with(1, test=2) + + +async def test_protect_loop_debugger_sleep(caplog): + """Test time.sleep injected by the debugger is not reported.""" + block_async_io.enable() + + with patch( + "homeassistant.util.async_.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/.venv/blah/pydevd.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/homeassistant/util/async.py", + lineno="123", + line="protected_loop_func", + ), + Mock( + filename="/home/paulus/homeassistant/util/async.py", + lineno="123", + line="check_loop()", + ), + ], + ): + time.sleep(0) + assert "Detected blocking call inside the event loop" not in caplog.text async def test_gather_with_concurrency(): diff --git a/tests/util/test_color.py b/tests/util/test_color.py index d806a941965dd1..3177878167673b 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -401,3 +401,51 @@ def test_color_rgb_to_rgbww(): assert color_util.color_rgb_to_rgbww(64, 64, 64, 154, 370) == (0, 14, 25, 64, 64) assert color_util.color_rgb_to_rgbww(32, 64, 16, 154, 370) == (9, 64, 0, 38, 38) assert color_util.color_rgb_to_rgbww(0, 0, 0, 154, 370) == (0, 0, 0, 0, 0) + assert color_util.color_rgb_to_rgbww(0, 0, 0, 0, 100) == (0, 0, 0, 0, 0) + assert color_util.color_rgb_to_rgbww(255, 255, 255, 1, 5) == (103, 69, 0, 255, 255) + + +def test_color_temperature_to_rgbww(): + """Test color temp to warm, cold conversion.""" + assert color_util.color_temperature_to_rgbww(153, 255, 153, 500) == ( + 0, + 0, + 0, + 0, + 255, + ) + assert color_util.color_temperature_to_rgbww(153, 128, 153, 500) == ( + 0, + 0, + 0, + 0, + 128, + ) + assert color_util.color_temperature_to_rgbww(500, 255, 153, 500) == ( + 0, + 0, + 0, + 255, + 0, + ) + assert color_util.color_temperature_to_rgbww(500, 128, 153, 500) == ( + 0, + 0, + 0, + 128, + 0, + ) + assert color_util.color_temperature_to_rgbww(347, 255, 153, 500) == ( + 0, + 0, + 0, + 143, + 112, + ) + assert color_util.color_temperature_to_rgbww(347, 128, 153, 500) == ( + 0, + 0, + 0, + 72, + 56, + ) diff --git a/tests/util/test_json.py b/tests/util/test_json.py index 752e93b39cd2d2..d8851868719879 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -4,9 +4,7 @@ from json import JSONEncoder, dumps import math import os -import sys from tempfile import mkdtemp -import unittest from unittest.mock import Mock import pytest @@ -53,10 +51,6 @@ def test_save_and_load(): assert data == TEST_JSON_A -# Skipped on Windows -@unittest.skipIf( - sys.platform.startswith("win"), "private permissions not supported on Windows" -) def test_save_and_load_private(): """Test we can load private files and that they are protected.""" fname = _path_for("test2") diff --git a/tests/util/test_location.py b/tests/util/test_location.py index d25e68597279d9..3dff36744eebd1 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -4,6 +4,7 @@ import aiohttp import pytest +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.util.location as location_util from tests.common import load_fixture @@ -27,7 +28,7 @@ @pytest.fixture async def session(hass): """Return aioclient session.""" - return hass.helpers.aiohttp_client.async_get_clientsession() + return async_get_clientsession(hass) @pytest.fixture diff --git a/tests/util/test_pressure.py b/tests/util/test_pressure.py index 0109d045a553b8..e3b6ed92e4dbcc 100644 --- a/tests/util/test_pressure.py +++ b/tests/util/test_pressure.py @@ -7,6 +7,7 @@ PRESSURE_INHG, PRESSURE_KPA, PRESSURE_MBAR, + PRESSURE_MMHG, PRESSURE_PA, PRESSURE_PSI, ) @@ -24,6 +25,7 @@ def test_convert_same_unit(): assert pressure_util.convert(5, PRESSURE_INHG, PRESSURE_INHG) == 5 assert pressure_util.convert(6, PRESSURE_KPA, PRESSURE_KPA) == 6 assert pressure_util.convert(7, PRESSURE_CBAR, PRESSURE_CBAR) == 7 + assert pressure_util.convert(8, PRESSURE_MMHG, PRESSURE_MMHG) == 8 def test_convert_invalid_unit(): @@ -108,3 +110,32 @@ def test_convert_from_inhg(): assert pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_CBAR) == pytest.approx( 101.59167 ) + assert pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_MMHG) == pytest.approx( + 762.002 + ) + + +def test_convert_from_mmhg(): + """Test conversion from mmHg to other units.""" + inhg = 30 + assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_PSI) == pytest.approx( + 0.580102 + ) + assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_KPA) == pytest.approx( + 3.99966 + ) + assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_HPA) == pytest.approx( + 39.9966 + ) + assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_PA) == pytest.approx( + 3999.66 + ) + assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_MBAR) == pytest.approx( + 39.9966 + ) + assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_CBAR) == pytest.approx( + 3.99966 + ) + assert pressure_util.convert(inhg, PRESSURE_MMHG, PRESSURE_INHG) == pytest.approx( + 1.181099 + ) diff --git a/tox.ini b/tox.ini index 0532d67b247c3e..af2f9961956158 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38, py39, lint, pylint, typing, cov +envlist = py39, lint, pylint, typing, cov skip_missing_interpreters = True ignore_basepython_conflict = True