diff --git a/.core_files.yaml b/.core_files.yaml index 9eb52b635f348..52e2a458bae15 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 @@ -102,17 +102,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 2113ea0c2022e..3c70bf1e4d856 100644 --- a/.coveragerc +++ b/.coveragerc @@ -66,7 +66,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 @@ -121,6 +120,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 @@ -179,7 +179,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 @@ -379,6 +378,7 @@ omit = homeassistant/components/fritz/sensor.py homeassistant/components/fritz/services.py homeassistant/components/fritz/switch.py + homeassistant/components/fritz/wrapper.py homeassistant/components/fritzbox_callmonitor/__init__.py homeassistant/components/fritzbox_callmonitor/const.py homeassistant/components/fritzbox_callmonitor/base.py @@ -391,6 +391,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 @@ -399,6 +401,11 @@ omit = homeassistant/components/glances/sensor.py homeassistant/components/gntp/notify.py homeassistant/components/goalfeed/* + 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 @@ -572,6 +579,7 @@ 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/sensor.py homeassistant/components/lcn/binary_sensor.py @@ -606,6 +614,7 @@ omit = 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/sensor.py homeassistant/components/lupusec/* @@ -724,7 +733,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 @@ -803,13 +814,18 @@ omit = 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/coordinator.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 @@ -825,6 +841,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 @@ -862,7 +879,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 @@ -942,6 +958,13 @@ 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 @@ -1167,6 +1190,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 @@ -1174,6 +1198,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 @@ -1201,7 +1226,10 @@ 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/vasttrafik/sensor.py homeassistant/components/velbus/__init__.py homeassistant/components/velbus/binary_sensor.py @@ -1230,6 +1258,7 @@ 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 @@ -1260,7 +1289,10 @@ 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/webostv/__init__.py + homeassistant/components/webostv/media_player.py + homeassistant/components/webostv/notify.py + homeassistant/components/whois/__init__.py homeassistant/components/whois/sensor.py homeassistant/components/wiffi/* homeassistant/components/wirelesstag/* @@ -1310,8 +1342,11 @@ 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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 974022834fbf2..92de30ffe5a14 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 dba25b44d7505..aae640381a2d3 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 f7e5426c73ffb..2f019ca6158af 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 103a293c39fad..f1ce91a5bdf6d 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -4,7 +4,7 @@ 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 @@ -13,9 +13,23 @@ 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__) @@ -23,7 +37,7 @@ 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 e753d548987b0..263df7b492479 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 d8a677c147f3e..2bea3b1ef70a0 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -6,13 +6,17 @@ 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 @@ -24,7 +28,6 @@ class WallboxNumberEntityDescription(NumberEntityDescription): CONF_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( key=CONF_MAX_CHARGING_CURRENT_KEY, name="Max. Charging Current", - device_class=DEVICE_CLASS_CURRENT, min_value=6, ), } @@ -52,7 +55,7 @@ async def async_setup_entry( ) -class WallboxNumber(CoordinatorEntity, NumberEntity): +class WallboxNumber(WallboxEntity, NumberEntity): """Representation of the Wallbox portal.""" entity_description: WallboxNumberEntityDescription @@ -69,6 +72,7 @@ def __init__( self.entity_description = description self._coordinator = coordinator 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 max_value(self) -> float: diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 89119bbc6719a..d19ea7347ca54 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -22,9 +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 +from . import WallboxCoordinator, WallboxEntity from .const import ( CONF_ADDED_ENERGY_KEY, CONF_ADDED_RANGE_KEY, @@ -32,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, @@ -147,7 +148,7 @@ async def async_setup_entry( ) -class WallboxSensor(CoordinatorEntity, SensorEntity): +class WallboxSensor(WallboxEntity, SensorEntity): """Representation of the Wallbox portal.""" entity_description: WallboxSensorEntityDescription @@ -163,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 0000000000000..2012a46f9efc1 --- /dev/null +++ b/homeassistant/components/wallbox/translations/el.json @@ -0,0 +1,7 @@ +{ + "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" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wallbox/translations/es.json b/homeassistant/components/wallbox/translations/es.json index 3adfd671804c6..72c7b0587d6ca 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/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 331e689f14a6f..8a3b9f046ce30 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 @@ -18,10 +20,13 @@ ATTR_TIME, CONF_TOKEN, ) +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__) @@ -66,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 = [] diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 0a9174fa6eac8..57358f3d60154 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 diff --git a/homeassistant/components/water_heater/reproduce_state.py b/homeassistant/components/water_heater/reproduce_state.py index 5fbe3f935f8d4..b3be0f6227310 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/waterfurnace/__init__.py b/homeassistant/components/waterfurnace/__init__.py index 9ae5836f69dd2..1da170f2b75a1 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 1e543d15c605e..15f3f64c9ba9b 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -1,4 +1,5 @@ """Support for Waterfurnace.""" +from __future__ import annotations from homeassistant.components.sensor import ( ENTITY_ID_FORMAT, @@ -6,7 +7,9 @@ SensorEntity, ) from homeassistant.const import PERCENTAGE, POWER_WATT, TEMP_FAHRENHEIT -from homeassistant.core import callback +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 @@ -71,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 cd3599683d0ee..6271e3b9b82cd 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/translations/es.json b/homeassistant/components/watttime/translations/es.json index 922aed60d97c5..189ea8b70cbed 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/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index d040f595fd637..45aeada2a7a5d 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,23 +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, - 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/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 832ec8a12e38d..7991cbccbb4d7 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -2,7 +2,7 @@ "domain": "waze_travel_time", "name": "Waze Travel Time", "documentation": "https://www.home-assistant.io/integrations/waze_travel_time", - "requirements": ["WazeRouteCalculator==0.13"], + "requirements": ["WazeRouteCalculator==0.14"], "codeowners": [], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 3c6dba586d1dc..c983be49765bd 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -6,27 +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.location import find_coordinates -from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( CONF_AVOID_FERRIES, @@ -47,65 +42,12 @@ DEFAULT_VEHICLE_TYPE, DOMAIN, ENTITY_ID_PATTERN, - REGIONS, - UNITS, - VEHICLE_TYPES, ) _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, diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 0a562a40f64eb..15250059fecc3 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 diff --git a/homeassistant/components/weather/translations/pt.json b/homeassistant/components/weather/translations/pt.json index b0cf7848faa0d..5875b8a719225 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 983ead616f253..46fdc89871f26 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 4e17c5e9e3452..4eaf60595a590 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 db5a618ff5ca6..634dca3b48caf 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,141 @@ _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 + + 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: + wait_time = 2 ** min(tries, 4) * 5 + tries += 1 + await asyncio.sleep(wait_time) + except WebOsTvPairError: + return + else: + break + + 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 + + +def _async_migrate_options_from_data(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Migrate options from data.""" + if entry.options: + return + + config = entry.data + options = {} + + # Get Preferred Sources + if sources := config.get(CONF_CUSTOMIZE, {}).get(CONF_SOURCES): + options[CONF_SOURCES] = sources + if not isinstance(sources, list): + options[CONF_SOURCES] = sources.split(",") + + hass.config_entries.async_update_entry(entry, options=options) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set the config entry up.""" + _async_migrate_options_from_data(hass, entry) + + host = entry.data[CONF_HOST] + key = entry.data[CONF_CLIENT_SECRET] - async def async_service_handler(service): - method = SERVICE_TO_METHOD.get(service.service) + 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 +254,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 0000000000000..91574d768bb6e --- /dev/null +++ b/homeassistant/components/webostv/config_flow.py @@ -0,0 +1,197 @@ +"""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_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 + +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() + _LOGGER.debug("WebOS Smart TV host %s imported from YAML config", self._host) + return self.async_create_entry(title=self._name, data=data) + + 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 = self.options.get(CONF_SOURCES, "") + sources_list = await async_get_sources(self.host, self.key) + if not sources_list: + errors["base"] = "cannot_retrieve" + + 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 + ) + + +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 [] + + return [ + *(app["title"] for app in client.apps.values()), + *(app["label"] for app in client.inputs.values()), + ] diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 9091491a29da9..9be44d8646943 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 0000000000000..47cdf974cc76e --- /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 0000000000000..d3f5fec6826f4 --- /dev/null +++ b/homeassistant/components/webostv/helpers.py @@ -0,0 +1,83 @@ +"""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 +from .const import DATA_CONFIG_ENTRY, DOMAIN + + +@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 diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 9697f903926b4..ee083578db979 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -1,9 +1,10 @@ { "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.1", "sqlalchemy==1.4.27"], "codeowners": ["@bendavid", "@thecode"], + "ssdp": [{"st": "urn:lge-com:service:webos-second-screen:1"}], "iot_class": "local_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 5c79942f279ca..bc42ff7ad01dc 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -1,12 +1,15 @@ """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 ( @@ -27,26 +30,28 @@ SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) +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.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_ON_ACTION, CONF_SOURCES, + DATA_CONFIG_ENTRY, DOMAIN, LIVE_TV_APP_ID, + WEBOSTV_EXCEPTIONS, ) _LOGGER = logging.getLogger(__name__) @@ -68,40 +73,38 @@ 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) + async_add_entities([LgWebOSMediaPlayerEntity(wrapper, name, sources, unique_id)]) - client = hass.data[DOMAIN][host]["client"] - on_script = Script(hass, turn_on_action, name, DOMAIN) if turn_on_action else None - entity = LgWebOSMediaPlayerEntity(client, name, customize, on_script) +_T = TypeVar("_T", bound="LgWebOSMediaPlayerEntity") +_P = ParamSpec("_P") - async_add_entities([entity], update_before_add=False) - -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: + await func(self, *args, **kwargs) + except WEBOSTV_EXCEPTIONS as exc: # If TV is off, we expect calls to fail. - if obj.state == STATE_OFF: + if self.state == STATE_OFF: level = logging.INFO else: level = logging.ERROR @@ -109,23 +112,29 @@ async def wrapper(obj, *args, **kwargs): level, "Error calling %s on entity %s: %r", func.__name__, - obj.entity_id, + self.entity_id, 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 @@ -133,19 +142,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 @@ -158,23 +169,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 ( @@ -188,7 +198,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 ( @@ -198,10 +208,11 @@ 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 + # special handling of live tv since this might + # not appear in the app or input lists in some cases if 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 ( @@ -215,37 +226,31 @@ def update_sources(self): 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 MediaPlayerDeviceClass.TV @property - def state(self): + def state(self) -> str: """Return the state of the device.""" if self._client.is_on: return STATE_ON @@ -253,57 +258,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 @@ -312,56 +317,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() @@ -369,7 +396,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) @@ -380,7 +407,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) @@ -415,46 +444,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 34277eb3c0913..df2ed7e50634c 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 0000000000000..41755e94f0142 --- /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/ca.json b/homeassistant/components/webostv/translations/ca.json new file mode 100644 index 0000000000000..512165b6ba795 --- /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/de.json b/homeassistant/components/webostv/translations/de.json new file mode 100644 index 0000000000000..6586ed6390048 --- /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 0000000000000..115f2d4cdf83a --- /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 0000000000000..bd39d6899eefe --- /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 0000000000000..1cadc13a06ba8 --- /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/ja.json b/homeassistant/components/webostv/translations/ja.json new file mode 100644 index 0000000000000..75d085fc98d08 --- /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 0000000000000..8601e6192dc9f --- /dev/null +++ b/homeassistant/components/webostv/translations/nl.json @@ -0,0 +1,31 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken, zet uw TV aan of controleer het ip adres" + }, + "step": { + "user": { + "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 0000000000000..3cb8a11154c6d --- /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 0000000000000..6f974191dc744 --- /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 0000000000000..a8c2a5dadfdfd --- /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 0000000000000..94e5d3abef3b8 --- /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 0000000000000..2908d0a85ce22 --- /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 0000000000000..1ad7058e1de9e --- /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 0000000000000..710caffef7a8d --- /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 0000000000000..71949ce58cefb --- /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/commands.py b/homeassistant/components/websocket_api/commands.py index aa38f8c8e3e4c..4020601dc3f0f 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -9,8 +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.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, @@ -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/const.py b/homeassistant/components/websocket_api/const.py index 69716b97076b0..9428d6fd87d3e 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/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 8d75b9bddae46..b847e2ac85561 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -2,8 +2,9 @@ from __future__ import annotations from collections.abc import Sequence +from datetime import datetime import logging -from typing import Any, Optional, Tuple +from typing import Optional import pywemo import voluptuous as vol @@ -42,7 +43,7 @@ _LOGGER = logging.getLogger(__name__) -HostPortTuple = Tuple[str, Optional[int]] +HostPortTuple = tuple[str, Optional[int]] def coerce_host_port(value: str) -> HostPortTuple: @@ -197,7 +198,9 @@ def __init__( self._scan_delay = 0 self._static_config = static_config - async def async_discover_and_schedule(self, *_: tuple[Any]) -> 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: diff --git a/homeassistant/components/wemo/config_flow.py b/homeassistant/components/wemo/config_flow.py index 6783d870cdcf8..73f4303cfd675 100644 --- a/homeassistant/components/wemo/config_flow.py +++ b/homeassistant/components/wemo/config_flow.py @@ -5,7 +5,7 @@ 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: HomeAssistant) -> bool: diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index 44f6b5a4e638a..9884b5b340c3b 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -8,7 +8,6 @@ from pywemo.exceptions import ActionException -from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -20,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. @@ -31,7 +32,6 @@ 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) -> str | None: @@ -46,11 +46,6 @@ def name(self) -> str: return f"{wemo_name} {suffix}" return wemo_name - @property - def available(self) -> bool: - """Return true if the device is available.""" - return super().available and self._available - @property def unique_id_suffix(self) -> str | None: """Suffix to append to the WeMo device's unique ID.""" @@ -71,20 +66,23 @@ 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): diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 03c5964a3136f..93da8d158a23b 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -160,11 +160,9 @@ def turn_on( 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.set_state(WEMO_FAN_OFF) - self.schedule_update_ha_state() - def set_percentage(self, percentage: int | None) -> None: """Set the fan_mode of the Humidifier.""" if percentage is None: @@ -174,11 +172,9 @@ def set_percentage(self, percentage: int | None) -> None: else: named_speed = 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: @@ -192,14 +188,10 @@ def set_humidity(self, target_humidity: float) -> None: elif target_humidity >= 100: pywemo_humidity = WEMO_HUMIDITY_100 - 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 fbb1c84e44923..b7c6aefa86803 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -169,7 +169,7 @@ def turn_on(self, **kwargs: Any) -> None: "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) @@ -180,17 +180,13 @@ def turn_on(self, **kwargs: Any) -> None: self.light.turn_on(**turn_on_kwargs) - self.schedule_update_ha_state() - 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(WemoBinaryStateEntity, LightEntity): """Representation of a WeMo dimmer.""" @@ -213,17 +209,13 @@ def turn_on(self, **kwargs: Any) -> None: 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: 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/switch.py b/homeassistant/components/wemo/switch.py index 9982274bbc03b..8f8e5dcb5e3ff 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -154,14 +154,10 @@ def icon(self) -> str | None: 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: 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 267ea84e308bd..3ca47544fd74f 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -123,6 +123,12 @@ 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( diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index ceb68ec29eb6c..40d3e80d3531e 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/whois/__init__.py b/homeassistant/components/whois/__init__.py index 3f3ffefde4877..520c9ec0bfe4d 100644 --- a/homeassistant/components/whois/__init__.py +++ b/homeassistant/components/whois/__init__.py @@ -1 +1,16 @@ -"""The whois component.""" +"""The Whois integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/whois/config_flow.py b/homeassistant/components/whois/config_flow.py new file mode 100644 index 0000000000000..0f030a2b9b7c5 --- /dev/null +++ b/homeassistant/components/whois/config_flow.py @@ -0,0 +1,52 @@ +"""Config flow to configure the Whois integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +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.""" + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_DOMAIN].lower()) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self.imported_name or user_input[CONF_DOMAIN], + data={ + CONF_DOMAIN: user_input[CONF_DOMAIN].lower(), + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DOMAIN): str, + } + ), + ) + + 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 0000000000000..b48fc3e1feee3 --- /dev/null +++ b/homeassistant/components/whois/const.py @@ -0,0 +1,19 @@ +"""Constants for the Whois integration.""" +from __future__ import annotations + +import logging +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "whois" +PLATFORMS = [Platform.SENSOR] + +LOGGER = logging.getLogger(__package__) + +DEFAULT_NAME = "Whois" + +ATTR_EXPIRES = "expires" +ATTR_NAME_SERVERS = "name_servers" +ATTR_REGISTRAR = "registrar" +ATTR_UPDATED = "updated" diff --git a/homeassistant/components/whois/manifest.json b/homeassistant/components/whois/manifest.json index f591d7bb47824..64922252565e1 100644 --- a/homeassistant/components/whois/manifest.json +++ b/homeassistant/components/whois/manifest.json @@ -3,6 +3,7 @@ "name": "Whois", "documentation": "https://www.home-assistant.io/integrations/whois", "requirements": ["python-whois==0.7.3"], - "codeowners": [], + "config_flow": true, + "codeowners": ["@frenck"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 3742ed90131d8..2848e27e8682a 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -1,22 +1,28 @@ """Get WHOIS information for a given host.""" +from __future__ import annotations + from datetime import timedelta -import logging import voluptuous as vol import whois from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +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 - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Whois" - -ATTR_EXPIRES = "expires" -ATTR_NAME_SERVERS = "name_servers" -ATTR_REGISTRAR = "registrar" -ATTR_UPDATED = "updated" +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import ( + ATTR_EXPIRES, + ATTR_NAME_SERVERS, + ATTR_REGISTRAR, + ATTR_UPDATED, + DEFAULT_NAME, + DOMAIN, + LOGGER, +) SCAN_INTERVAL = timedelta(hours=24) @@ -28,23 +34,47 @@ ) -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 WHOIS sensor.""" - domain = config.get(CONF_DOMAIN) - name = config.get(CONF_NAME) - + 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.""" + domain = entry.data[CONF_DOMAIN] 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 - ) - return + info = await hass.async_add_executor_job(whois.whois, domain) except whois.BaseException as ex: # pylint: disable=broad-except - _LOGGER.error("Exception %s occurred during WHOIS lookup for %s", ex, domain) + LOGGER.error("Exception %s occurred during WHOIS lookup for %s", ex, domain) return + if "expiration_date" not in info: + LOGGER.error("WHOIS lookup for %s didn't contain an expiration date", domain) + return + + async_add_entities([WhoisSensor(domain)], True) + class WhoisSensor(SensorEntity): """Implementation of a WHOIS sensor.""" @@ -52,29 +82,29 @@ class WhoisSensor(SensorEntity): _attr_icon = "mdi:calendar-clock" _attr_native_unit_of_measurement = TIME_DAYS - def __init__(self, name, domain): + def __init__(self, domain: str) -> None: """Initialize the sensor.""" + self._attr_name = domain self.whois = whois.whois self._domain = domain - self._attr_name = name - def _empty_value_and_attributes(self): + def _empty_value_and_attributes(self) -> None: """Empty the state and attributes on an error.""" self._attr_native_value = None - self._attr_extra_state_attributes = None + self._attr_extra_state_attributes = {} - def update(self): + def update(self) -> None: """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) + LOGGER.error("Exception %s occurred during WHOIS lookup", ex) self._empty_value_and_attributes() return if response: if "expiration_date" not in response: - _LOGGER.error( + LOGGER.error( "Failed to find expiration_date in whois lookup response. " "Did find: %s", ", ".join(response.keys()), @@ -83,7 +113,7 @@ def update(self): return if not response["expiration_date"]: - _LOGGER.error("Whois response contains empty expiration_date") + LOGGER.error("Whois response contains empty expiration_date") self._empty_value_and_attributes() return diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json new file mode 100644 index 0000000000000..bdd1375aee3d6 --- /dev/null +++ b/homeassistant/components/whois/strings.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "domain": "Domain name" + } + } + }, + "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 0000000000000..058ed137c3b85 --- /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 0000000000000..82a1b1e3826a3 --- /dev/null +++ b/homeassistant/components/whois/translations/ca.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "El servei ja est\u00e0 configurat" + }, + "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 0000000000000..8440070c91a4e --- /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 0000000000000..faa4246ad2993 --- /dev/null +++ b/homeassistant/components/whois/translations/de.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Der Dienst ist bereits konfiguriert" + }, + "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 0000000000000..56906ea9bb352 --- /dev/null +++ b/homeassistant/components/whois/translations/el.json @@ -0,0 +1,11 @@ +{ + "config": { + "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 0000000000000..8379ebe957954 --- /dev/null +++ b/homeassistant/components/whois/translations/en.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured" + }, + "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 0000000000000..712c85865cd28 --- /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 0000000000000..4e35679da969a --- /dev/null +++ b/homeassistant/components/whois/translations/et.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Teenus on juba seadistatud" + }, + "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 0000000000000..e8a3ba46991e2 --- /dev/null +++ b/homeassistant/components/whois/translations/fr.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Service d\u00e9j\u00e0 configur\u00e9" + }, + "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 0000000000000..48a6eeeea33b1 --- /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 0000000000000..478b443a682ec --- /dev/null +++ b/homeassistant/components/whois/translations/hu.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "domain": "Domain n\u00e9v" + } + } + } + } +} \ 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 0000000000000..1213bebbcb320 --- /dev/null +++ b/homeassistant/components/whois/translations/it.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Il servizio \u00e8 gi\u00e0 configurato" + }, + "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 0000000000000..7102e95a25d5f --- /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 0000000000000..dff9c747d55b7 --- /dev/null +++ b/homeassistant/components/whois/translations/nl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Service is al geconfigureerd" + }, + "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 0000000000000..8f750b1be4dc0 --- /dev/null +++ b/homeassistant/components/whois/translations/no.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Tjenesten er allerede konfigurert" + }, + "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 0000000000000..7baabf47a982d --- /dev/null +++ b/homeassistant/components/whois/translations/pl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana" + }, + "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 0000000000000..81c048354a166 --- /dev/null +++ b/homeassistant/components/whois/translations/ru.json @@ -0,0 +1,14 @@ +{ + "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." + }, + "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 0000000000000..c5d4a425a5bf2 --- /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 0000000000000..ff356008a37ec --- /dev/null +++ b/homeassistant/components/whois/translations/tr.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Hizmet zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "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 0000000000000..6cde9142266c8 --- /dev/null +++ b/homeassistant/components/whois/translations/zh-Hant.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u670d\u52d9\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "step": { + "user": { + "data": { + "domain": "\u7db2\u57df\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index f6063b3c202ca..d0647b2529701 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 f9456f25df2ce..5087915181eb6 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 e65debedc364b..e4c0597d1be87 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -1,13 +1,14 @@ """Sensor platform support for wiffi devices.""" - from homeassistant.components.sensor import ( 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 @@ -35,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 diff --git a/homeassistant/components/wiffi/strings.json b/homeassistant/components/wiffi/strings.json index d4dc66972c768..ebc5dd67a2019 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 f7644524c153a..2e5c6026e0b58 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 6fe2792888c51..185bcaffd7429 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 c94122cac5e9d..101fed5f6a606 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 0000000000000..bc2189ec6a82e --- /dev/null +++ b/homeassistant/components/wiffi/translations/el.json @@ -0,0 +1,7 @@ +{ + "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." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wiffi/translations/en.json b/homeassistant/components/wiffi/translations/en.json index 046f37de2c749..6732e5102dab3 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 6ab1f5c54079c..392392e0f3112 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 d15b8d895f034..fe3229574d061 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 3d24f7791c0b6..1126783a92dd7 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 902fabcbc8558..be0cdf50de0de 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 0022f83b0a1b3..18f5ab5ae2700 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 bab9e83931019..a3a2c3620fbc8 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": { diff --git a/homeassistant/components/wiffi/translations/ja.json b/homeassistant/components/wiffi/translations/ja.json index ba9f63c6e7c8d..edcf8f231afbb 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 966f9a18e416a..67c82350b91ba 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 06e8f3449e14c..89f39e0b97276 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 f9c2e2c39793f..94ac2490a8a15 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 f703edbf23b54..3b72b703b9ec0 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 281300caee363..c1efc71bc1bdb 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 ea02e179337ba..81382d9f036e0 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/cover.py b/homeassistant/components/wilight/cover.py index 93c9a8c450307..ad94c22451802 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 b549eb2f81328..e3f3ab055f9a4 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 0c7206be00cde..3236b3b3851a2 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/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index a7555d15bb9b0..c94c518709d6a 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 da901f31cd664..dde1a22f6220e 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 5c7de69cc2aa2..9cec276f26ad4 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -13,9 +13,11 @@ SensorStateClass, ) from homeassistant.const import CONF_MONITORED_CONDITIONS -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, SIGNAL_TAG_UPDATE, WirelessTagBaseSensor @@ -66,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 ca5391fdf969f..a6e4a85559cd1 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/common.py b/homeassistant/components/withings/common.py index 608f20a4fb368..8da67a0b77aa7 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, diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 659df1baad9e5..5a25a461fd6b0 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -23,15 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WLED from a config entry.""" 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 - ) + 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 +36,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/config_flow.py b/homeassistant/components/wled/config_flow.py index 485afef4f6cea..3665a6304cf5b 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -1,19 +1,15 @@ """Config flow to configure the WLED integration.""" from __future__ import annotations +import asyncio 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 +21,8 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a WLED config flow.""" VERSION = 1 + discovered_host: str + discovered_device: Device @staticmethod @callback @@ -36,98 +34,84 @@ 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: + 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.""" - - # Hostname is format: wled-livingroom.local. - host = discovery_info.hostname.rstrip(".") - name, _ = host.rsplit(".") + # 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, asyncio.TimeoutError): + return self.async_abort(reason="cannot_connect") + + 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/light.py b/homeassistant/components/wled/light.py index b42c7b0a8b473..b30e20810d90b 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 a99278a80c610..181d2761cb120 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.11.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 a71491daf7a41..664bc309e33f0 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,6 +16,9 @@ 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, diff --git a/homeassistant/components/wled/translations/select.es.json b/homeassistant/components/wled/translations/select.es.json new file mode 100644 index 0000000000000..b7b345a524546 --- /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/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index e9ecf4c3e55e4..f1a94cbbe20e9 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, @@ -10,20 +12,27 @@ ) 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)) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index fc726d56f0456..c6ac7aceae16b 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 f75e25dd97eb8..6140abf4f2aef 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 74da12f7f610e..069ca5c55e7af 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 4d7a32605b054..533328490c84f 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 98b7470b7a8fa..47617dc609e09 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 e0866f6f67767..309b9a6a75811 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 3943c081eef2a..0676b249e1b35 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 299d70529343d..17d861d643221 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 01095822d1f57..b163908599317 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 859feee495bf5..93f5f866f2f32 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 6993bdbc0b709..1d1a4b99705dd 100644 --- a/homeassistant/components/xbee/sensor.py +++ b/homeassistant/components/xbee/sensor.py @@ -1,4 +1,6 @@ """Support for XBee Zigbee sensors.""" +from __future__ import annotations + from binascii import hexlify import logging @@ -7,6 +9,9 @@ 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] diff --git a/homeassistant/components/xbee/switch.py b/homeassistant/components/xbee/switch.py index b97d9f315d572..9cc25fbf7d2a5 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/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index 4965e9705d1fc..592909f3aac55 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 cdeb016d6040a..4cc7ebda545a2 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 04f25c5f6323d..897836e5c42eb 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 854c0b007f6fa..edcc4a8c13521 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 bbd44498daba5..f9283b459e9cf 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 049b4bfcbc0f2..31b80618d9e6b 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 4cdd5cc6e001b..15d92fb714de5 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -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)]) diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index c1e38f64c5332..c21f21e1f9b6f 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 ce3e5d72e0dd3..0ce7b6c4d0fce 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -6,6 +6,7 @@ from xiaomi_gateway import XiaomiGateway, XiaomiGatewayDiscovery from homeassistant import config_entries, core +from homeassistant.components import persistent_notification from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, @@ -17,12 +18,13 @@ 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 ( @@ -74,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) @@ -89,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) diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index afbeeece49611..13d65cb21f48d 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -5,7 +5,9 @@ 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 . import XiaomiDevice @@ -25,7 +27,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] diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index db41a4d719aa0..422d9b21e0d75 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 4064df5f259a7..637055144dba9 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 b7167452b65fd..e21967a9f06d6 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 c21dd3bcf861f..9c295c3fe0aba 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -8,6 +8,7 @@ SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, LIGHT_LUX, @@ -16,6 +17,8 @@ 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 @@ -70,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] diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index 139a7a57dbc16..86acd4100a2a8 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 0000000000000..a741644fb0f61 --- /dev/null +++ b/homeassistant/components/xiaomi_aqara/translations/el.json @@ -0,0 +1,36 @@ +{ + "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_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 1d45b456611b3..04da1a8caf5c8 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_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 271beae131c9c..fdc62076c258c 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 1fccbcf8056ba..f7dbf60f63aac 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 c9fd97f209b74..83bea4be9b1f2 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -10,8 +10,10 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -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 ( @@ -158,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 8c83c8015b201..b361e8ba1b306 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 a6c1c7e5a28d6..3f819d7ab7d57 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 07ec46132707e..a12a8a6063bb5 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/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index f674cc770b806..7c3110e190ad7 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -8,8 +8,10 @@ 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 ( @@ -55,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 diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 03722380a692b..cae7bacaba470 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/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 8de844cdd44b4..da2b94f5382a0 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi Miio", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", - "requirements": ["construct==2.10.56", "micloud==0.4", "python-miio==0.5.9.2"], + "requirements": ["construct==2.10.56", "micloud==0.5", "python-miio==0.5.9.2"], "codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"], "zeroconf": ["_miio._udp.local."], "iot_class": "local_polling" diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index cd05218760dbd..8939200d1073c 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -4,9 +4,13 @@ from dataclasses import dataclass from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number.const import DOMAIN as PLATFORM_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE, TIME_MINUTES -from homeassistant.core import callback +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, @@ -228,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: @@ -246,6 +254,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): return for feature, description in NUMBER_TYPES.items(): + if feature == FEATURE_SET_LED_BRIGHTNESS and model != MODEL_FAN_ZA5: + # Delete LED bightness entity created by mistake if it exists + entity_reg = er.async_get(hass) + entity_id = entity_reg.async_get_entity_id( + PLATFORM_DOMAIN, DOMAIN, f"{description.key}_{config_entry.unique_id}" + ) + if entity_id: + entity_reg.async_remove(entity_id) + continue if feature & features: if ( description.key == ATTR_OSCILLATION_ANGLE diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py index 5428d8a7bde4f..199f5dd6c5d92 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 d2a806f830538..a0ff320e228f2 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.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, @@ -68,7 +70,11 @@ class XiaomiMiioSelectDescription(SelectEntityDescription): } -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 1104ff90117e3..d6d1c8500ed41 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -20,6 +20,7 @@ SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( AREA_SQUARE_METERS, ATTR_BATTERY_LEVEL, @@ -37,8 +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 @@ -113,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" @@ -444,6 +448,22 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): name="Last Clean Area", 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, icon="mdi:timer-sand", @@ -550,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 = [] diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 1bbb5c65c491f..bd6482e891b7c 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -15,6 +15,7 @@ SwitchEntity, SwitchEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, @@ -22,9 +23,10 @@ CONF_HOST, CONF_TOKEN, ) -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, @@ -274,7 +276,11 @@ class XiaomiMiioSwitchDescription(SwitchEntityDescription): ) -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 = { diff --git a/homeassistant/components/xiaomi_miio/translations/el.json b/homeassistant/components/xiaomi_miio/translations/el.json new file mode 100644 index 0000000000000..ea37b30273387 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/el.json @@ -0,0 +1,17 @@ +{ + "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" + }, + "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 d70445d2aa5ea..ae59404a79367 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/it.json b/homeassistant/components/xiaomi_miio/translations/it.json index 5e32a09fb3b56..35bdecea0822d 100644 --- a/homeassistant/components/xiaomi_miio/translations/it.json +++ b/homeassistant/components/xiaomi_miio/translations/it.json @@ -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": "\u00c8 necessaria la Token API di 32 caratteri, vedi https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token per le istruzioni. Nota 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": { diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 2362fcf8996f1..a6fa6c399eb13 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 6c0a55f787d07..095eee571e59e 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 1d65b2bcfd1e7..67452ce94269c 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 238fe2b83428c..c05ce7e24f6cb 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 ed022f5b9e792..4855c8b8dcf63 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 a69c8e965eb5c..7547da9ebc557 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.core import HomeAssistant from homeassistant.helpers.entity import ToggleEntity +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] diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 87bfb2b86d735..626c7d0b206e2 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 4011e7dfbdc02..0348676904e49 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 0000000000000..b017c4e33e389 --- /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 7538c6e40ca0d..8994d0b2fbd27 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,65 @@ 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[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, default=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 6bf61cea610dc..0628e6aceb40f 100644 --- a/homeassistant/components/yale_smart_alarm/const.py +++ b/homeassistant/components/yale_smart_alarm/const.py @@ -15,8 +15,10 @@ ) 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" @@ -31,7 +33,7 @@ ATTR_ONLINE = "online" ATTR_STATUS = "status" -PLATFORMS = [Platform.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 3cef1876e3a03..2d476f920f962 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 0000000000000..318989a018caf --- /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 0000000000000..a7231d78dcef7 --- /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 a61a18889903d..6bc3846ea67a6 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 cec588a3cc8ee..5258e681c05a3 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/ca.json b/homeassistant/components/yale_smart_alarm/translations/ca.json index 6e14f2d6e20ee..e5dd15b877a57 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/de.json b/homeassistant/components/yale_smart_alarm/translations/de.json index 6050bafa64543..8f24160663f33 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 676d088900849..6a8ad33c53bc5 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 e198b0329b911..d710f4a217729 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 178b8209af728..4df58fda1b7f1 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 dd55b1ebd7d82..fd63eec8cb997 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/hu.json b/homeassistant/components/yale_smart_alarm/translations/hu.json index 6845e245f2d23..028f2cd6f0f11 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/it.json b/homeassistant/components/yale_smart_alarm/translations/it.json index bc08163c1a2a8..935073a09c6d5 100644 --- a/homeassistant/components/yale_smart_alarm/translations/it.json +++ b/homeassistant/components/yale_smart_alarm/translations/it.json @@ -25,5 +25,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 53d868fe35195..6fff3b2d5be71 100644 --- a/homeassistant/components/yale_smart_alarm/translations/ja.json +++ b/homeassistant/components/yale_smart_alarm/translations/ja.json @@ -25,5 +25,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/no.json b/homeassistant/components/yale_smart_alarm/translations/no.json index 4d306dc3cad28..579f61f2d71de 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 c6bd7ed558a04..31897a0d3c986 100644 --- a/homeassistant/components/yale_smart_alarm/translations/pl.json +++ b/homeassistant/components/yale_smart_alarm/translations/pl.json @@ -5,6 +5,7 @@ "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": { @@ -25,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/ru.json b/homeassistant/components/yale_smart_alarm/translations/ru.json index 4a9132c7546f0..b982aa8cea689 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 376de5dc2ffad..5cc52dfc37273 100644 --- a/homeassistant/components/yale_smart_alarm/translations/tr.json +++ b/homeassistant/components/yale_smart_alarm/translations/tr.json @@ -5,12 +5,13 @@ "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": "Parola", "username": "Kullan\u0131c\u0131 Ad\u0131" @@ -25,5 +26,18 @@ } } } + }, + "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 5d7c14b07b2fb..a5b7f43b6c49d 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 4bf830ed68d08..4a816b99acaa4 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 98fed4d0f63de..d984aaceb96f9 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -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/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index b1d0bdcd2e9e8..bcd1a0a2c1ffe 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/select.py b/homeassistant/components/yamaha_musiccast/select.py index d64682c8eb47c..d57d1c07f68d7 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -59,4 +59,4 @@ def options(self): @property def current_option(self): """Return the currently selected option.""" - return self.capability.options[self.capability.current] + return self.capability.options.get(self.capability.current) diff --git a/homeassistant/components/yamaha_musiccast/translations/es.json b/homeassistant/components/yamaha_musiccast/translations/es.json index 46f8a02f33dc4..a83d97db703e1 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.es.json b/homeassistant/components/yamaha_musiccast/translations/select.es.json new file mode 100644 index 0000000000000..f97bea3e3eb98 --- /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.hu.json b/homeassistant/components/yamaha_musiccast/translations/select.hu.json index 82186ebb4e5f1..7ff67f3ddf323 100644 --- a/homeassistant/components/yamaha_musiccast/translations/select.hu.json +++ b/homeassistant/components/yamaha_musiccast/translations/select.hu.json @@ -41,7 +41,7 @@ "dts_neo6_cinema": "DTS Neo:6 Mozi", "dts_neo6_music": "DTS Neo:6 Zene", "dts_neural_x": "DTS Neural:X", - "toggle": "V\u00e1lt\u00e1s" + "toggle": "Kapcsol\u00e1s" }, "yamaha_musiccast__zone_tone_control_mode": { "auto": "Automatikus", diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 9140791c2c86f..3fdca47ef02f6 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 @@ -12,8 +13,11 @@ 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__) @@ -39,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] diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 89eb910f94239..cd312715b9dbf 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/light.py b/homeassistant/components/yeelight/light.py index 75735beed3494..84b76d9865848 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 447a4ae317768..5320b8023e9c5 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.8", "async-upnp-client==0.23.2"], + "requirements": ["yeelight==0.7.8", "async-upnp-client==0.23.4"], "codeowners": ["@zewelor", "@shenxn", "@starkillerOG"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/components/yeelight/translations/es.json b/homeassistant/components/yeelight/translations/es.json index c58d863fa1396..bc9f26f665ab3 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/zh-Hans.json b/homeassistant/components/yeelight/translations/zh-Hans.json index 43fb1d9fe25e9..36add653d1de8 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 00ea467c0d166..a682032124325 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 bceb9b999aa40..0537c268aa42d 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -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) diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 49a5b6187e65b..f5713c51680ef 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -3,7 +3,7 @@ "name": "YouLess", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/youless", - "requirements": ["youless-api==0.15"], + "requirements": ["youless-api==0.16"], "codeowners": ["@gjong"], "iot_class": "local_polling" } diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 82807c4aceefd..21c3edd56bf74 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 36207842285df..6d1b0b186d1e5 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 51963530e51f1..87a1175b7cd06 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -8,7 +8,7 @@ import json import logging import os -from typing import Type, Union +from typing import Union from aiohttp.hdrs import USER_AGENT import requests @@ -34,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__) @@ -50,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 @@ -198,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) @@ -213,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 c1a0ab62cc52e..6a5d7ccdf8170 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 30776eabbb362..0a4392ff8556b 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 98d371d2d2ff1..1dc70cde610d2 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 @@ -72,7 +79,6 @@ # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] ATTR_PROPERTIES_ID: Final = "id" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( @@ -340,6 +346,17 @@ def _match_against_props(matcher: dict[str, str], props: dict[str, str]) -> bool ) +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.""" @@ -417,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. @@ -429,14 +447,19 @@ 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] = {} diff --git a/homeassistant/components/zerproc/__init__.py b/homeassistant/components/zerproc/__init__.py index 459024ec34ca9..e8cc6962a0bd5 100644 --- a/homeassistant/components/zerproc/__init__.py +++ b/homeassistant/components/zerproc/__init__.py @@ -1,15 +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 = [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 36e13a780d966..3c6b7c7186de7 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 42f14e639272d..0a80f5014d3ea 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -72,7 +72,7 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up ZHA from config.""" hass.data[DATA_ZHA] = {} diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 4d4cba3599c64..17dc47ebefa24 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -61,7 +61,7 @@ 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][Platform.ALARM_CONTROL_PANEL] diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 48e70c86c1f6a..86dad9d6bd0b3 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 4bc7c156f379f..730748d74aeb5 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -43,7 +43,7 @@ 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][Platform.BINARY_SENSOR] diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index f895ca1a733f3..65de5fd04cc79 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -60,6 +60,7 @@ DATA_ZHA, PRESET_COMPLEX, PRESET_SCHEDULE, + PRESET_TEMP_MANUAL, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -124,7 +125,7 @@ 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][Platform.CLIMATE] unsub = async_dispatcher_connect( @@ -601,7 +602,6 @@ class CentralitePearl(ZenWithinThermostat): "_TZE200_ckud7u2l", "_TZE200_ywdxldoj", "_TZE200_cwnjrr72", - "_TZE200_b6wax7g0", "_TZE200_2atgpdho", "_TZE200_pvvbommb", "_TZE200_4eeyebrt", @@ -685,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 3661d3b17d95b..d60c38c69a60b 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 b6981b4cd7484..f79000d0646af 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/const.py b/homeassistant/components/zha/core/const.py index 67a79d9dea7a8..79d61f809b122 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -7,6 +7,7 @@ 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 @@ -110,6 +111,7 @@ Platform.LIGHT, Platform.LOCK, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, @@ -209,8 +211,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" @@ -387,3 +390,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/discovery.py b/homeassistant/components/zha/core/discovery.py index dcc932a76a586..26323793e1346 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -25,6 +25,7 @@ light, lock, number, + select, sensor, siren, switch, diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 2e8c7ab45eaed..9f62d4b9c02db 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -49,7 +49,7 @@ 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][Platform.COVER] diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 4219bfd628807..c08491ab782dc 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -1,4 +1,6 @@ """Support for the ZHA platform.""" +from __future__ import annotations + import functools import time @@ -8,6 +10,7 @@ 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 @@ -28,7 +31,7 @@ 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][Platform.DEVICE_TRACKER] @@ -103,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 ee5d293918529..0b7f95efb6408 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -166,8 +166,7 @@ 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}" diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 729a0f7e61857..10b64caf9746d 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -56,7 +56,7 @@ 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][Platform.FAN] diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2c3b6249cf948..6855db2257287 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -110,7 +110,7 @@ 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][Platform.LIGHT] diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index f3a7f4e375549..7766406c9b44a 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -6,7 +6,7 @@ "requirements": [ "bellows==0.29.0", "pyserial==3.5", - "pyserial-asyncio==0.5", + "pyserial-asyncio==0.6", "zha-quirks==0.0.65", "zigpy-deconz==0.14.0", "zigpy==0.42.0", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index e3de49756c2e3..3c5a374e588eb 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -239,7 +239,7 @@ 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][Platform.NUMBER] diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py new file mode 100644 index 0000000000000..ff76023d96d82 --- /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 8b2de754ad831..3f46196d1c799 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -306,6 +306,7 @@ class ElectricalMeasurementApparentPower( """Apparent power measurement.""" SENSOR_ATTR = "apparent_power" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER _unit = POWER_VOLT_AMPERE _div_mul_prefix = "ac_power" diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index ce558e8f1a5cb..5ba83dbef12ed 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -5,6 +5,8 @@ import functools from typing import Any +from zigpy.zcl.clusters.security import IasWd as WD + from homeassistant.components.siren import ( ATTR_DURATION, SUPPORT_DURATION, @@ -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, Platform.SIREN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SIREN) DEFAULT_DURATION = 5 # seconds @@ -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 b67ab72fa3508..29fb08b9bc067 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -32,7 +32,7 @@ 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][Platform.SWITCH] diff --git a/homeassistant/components/zha/translations/el.json b/homeassistant/components/zha/translations/el.json index 0df3306e84a5d..b501b5158d4ff 100644 --- a/homeassistant/components/zha/translations/el.json +++ b/homeassistant/components/zha/translations/el.json @@ -9,5 +9,17 @@ "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name} ;" } } + }, + "device_automation": { + "trigger_type": { + "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)" + } } } \ No newline at end of file diff --git a/homeassistant/components/zha/translations/es.json b/homeassistant/components/zha/translations/es.json index f04614f1f722e..36ab27c53b591 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/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index ad7b81dfe7b60..6c04775d233b2 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 d3493f0dc3565..59e6e56e2c5ca 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 c19b7a45ac242..35d4d2eefbfb0 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/translations/el.json b/homeassistant/components/zone/translations/el.json new file mode 100644 index 0000000000000..c71e66f6434af --- /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 0000000000000..392551912ec3f --- /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/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index c631406b0e34f..5e9c881af8576 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 0bcd90311884b..21f4588555c20 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 ( 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): diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index 0f9f5e2f679e0..70e9548414eed 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 90c5f8d78eb8c..53ed16c037ba9 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 b7ba3f48f10b3..bd7f55915d1cf 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/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index cd15f632d0b89..cd0bda6735ff2 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 @@ -17,7 +19,7 @@ EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import CoreState, HomeAssistant, 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 ( @@ -35,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 @@ -316,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 @@ -581,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 @@ -642,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) @@ -658,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) @@ -685,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 @@ -754,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) @@ -762,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) @@ -771,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 @@ -783,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 @@ -801,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) @@ -831,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) @@ -860,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) @@ -868,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) @@ -876,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() diff --git a/homeassistant/components/zwave/binary_sensor.py b/homeassistant/components/zwave/binary_sensor.py index e85daffec3ce5..26944b6661dd4 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 diff --git a/homeassistant/components/zwave/climate.py b/homeassistant/components/zwave/climate.py index 0519d42a59c38..d56910e1b74d4 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 fc88db56ae74a..2a49a34554b62 100644 --- a/homeassistant/components/zwave/cover.py +++ b/homeassistant/components/zwave/cover.py @@ -9,8 +9,10 @@ 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, @@ -30,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 diff --git a/homeassistant/components/zwave/fan.py b/homeassistant/components/zwave/fan.py index 7fb0fb8e8bee5..b368e829eb7d7 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 c03ac9d229ed4..a029fa35a6501 100644 --- a/homeassistant/components/zwave/light.py +++ b/homeassistant/components/zwave/light.py @@ -16,9 +16,11 @@ SUPPORT_TRANSITION, 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 +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import CONF_REFRESH_DELAY, CONF_REFRESH_VALUE, ZWaveDeviceEntity, const @@ -60,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 diff --git a/homeassistant/components/zwave/lock.py b/homeassistant/components/zwave/lock.py index bc49f9c0bd222..06ce59a1f9eb1 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 894bac8a292eb..1f32f8bb68198 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 DOMAIN, SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import 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 -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 diff --git a/homeassistant/components/zwave/switch.py b/homeassistant/components/zwave/switch.py index 06606dac938b0..f7d3471e2ab89 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 b047ad7158af2..1663d4975a9f6 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_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 7d2af4af126ec..10088f6241435 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -88,7 +88,12 @@ async_discover_node_values, async_discover_single_value, ) -from .helpers import async_enable_statistics, get_device_id, get_unique_id +from .helpers import ( + async_enable_statistics, + get_device_id, + get_device_id_ext, + get_unique_id, +) from .migrate import async_migrate_discovered_value from .services import ZWaveServices @@ -116,17 +121,27 @@ def register_node_in_dev_reg( ) -> device_registry.DeviceEntry: """Register node in dev reg.""" device_id = get_device_id(client, node) - # If a device already exists but it doesn't match the new node, it means the node - # was replaced with a different device and the device needs to be removeed so the - # new device can be created. Otherwise if the device exists and the node is the same, - # the node was replaced with the same device model and we can reuse the device. - if (device := dev_reg.async_get_device({device_id})) and ( - device.model != node.device_config.label - or device.manufacturer != node.device_config.manufacturer + device_id_ext = get_device_id_ext(client, node) + device = dev_reg.async_get_device({device_id}) + + # Replace the device if it can be determined that this node is not the + # same product as it was previously. + if ( + device_id_ext + and device + and len(device.identifiers) == 2 + and device_id_ext not in device.identifiers ): remove_device_func(device) + device = None + + if device_id_ext: + ids = {device_id, device_id_ext} + else: + ids = {device_id} + params = { - ATTR_IDENTIFIERS: {device_id}, + ATTR_IDENTIFIERS: ids, ATTR_SW_VERSION: node.firmware_version, ATTR_NAME: node.name or node.device_config.description @@ -338,7 +353,14 @@ def async_on_node_removed(event: dict) -> None: device = dev_reg.async_get_device({dev_id}) # We assert because we know the device exists assert device - if not replaced: + if replaced: + discovered_value_ids.pop(device.id, None) + + async_dispatcher_send( + hass, + f"{DOMAIN}_{client.driver.controller.home_id}.{node.node_id}.node_status_remove_entity", + ) + else: remove_device(device) @callback diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 38fdee9a05153..7552ee117cc0e 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 de2662bfa2786..b47930ed4d02e 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, @@ -350,7 +348,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()) @@ -1779,35 +1776,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( { diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index baae6af54ce9a..dc72b5453961b 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -177,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 diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index 14d64f87eb7d5..9f6fa7fc35cf7 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -14,7 +14,15 @@ from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_DOMAIN, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + STATE_UNAVAILABLE, +) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry @@ -165,7 +173,17 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict) - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in entity_registry.async_entries_for_device( + registry, device_id, include_disabled_entities=False + ): + # If an entry is unavailable, it is possible that the underlying value + # is no longer valid. Additionally, if an entry is disabled, its + # underlying value is not being monitored by HA so we shouldn't allow + # actions against it. + if ( + state := hass.states.get(entry.entity_id) + ) and state.state == STATE_UNAVAILABLE: + continue entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id} actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE}) if entry.domain == LOCK_DOMAIN: @@ -180,10 +198,9 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: value_id = entry.unique_id.split(".")[1] # If this unique ID doesn't have a value ID, we know it is the node status # sensor which doesn't have any relevant actions - if re.match(VALUE_ID_REGEX, value_id): - value = node.values[value_id] - else: + if not re.match(VALUE_ID_REGEX, value_id): continue + value = node.values[value_id] # If the value has the meterType CC specific value, we can add a reset_meter # action for it if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific: @@ -227,7 +244,22 @@ async def async_call_action_from_config( if action_type not in ACTION_TYPES: raise HomeAssistantError(f"Unhandled action type {action_type}") - service_data = {k: v for k, v in config.items() if v not in (None, "")} + # Don't include domain, subtype or any null/empty values in the service call + service_data = { + k: v + for k, v in config.items() + if k not in (ATTR_DOMAIN, CONF_SUBTYPE) and v not in (None, "") + } + + # Entity services (including refresh value which is a fake entity service) expects + # just an entity ID + if action_type in ( + SERVICE_REFRESH_VALUE, + SERVICE_SET_LOCK_USERCODE, + SERVICE_CLEAR_LOCK_USERCODE, + SERVICE_RESET_METER, + ): + service_data.pop(ATTR_DEVICE_ID) await hass.services.async_call( DOMAIN, service, service_data, blocking=True, context=context ) @@ -283,7 +315,10 @@ async def async_get_action_capabilities( "extra_fields": vol.Schema( { vol.Required(ATTR_COMMAND_CLASS): vol.In( - {cc.value: cc.name for cc in CommandClass} + { + CommandClass(cc.id).value: cc.name + for cc in sorted(node.command_classes, key=lambda cc: cc.name) # type: ignore[no-any-return] + } ), vol.Required(ATTR_PROPERTY): cv.string, vol.Optional(ATTR_PROPERTY_KEY): cv.string, diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index 4b1843782e282..9840d89dc9d3c 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 0000000000000..52f1b836833b4 --- /dev/null +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -0,0 +1,19 @@ +"""Provides diagnostics for Z-Wave JS.""" +from __future__ import annotations + +from zwave_js_server.dump import dump_msgs + +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 + + +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 diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index cf15f32932b01..87e9f3adbbd09 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -20,6 +20,7 @@ LOGGER = logging.getLogger(__name__) EVENT_VALUE_UPDATED = "value updated" +EVENT_VALUE_REMOVED = "value removed" EVENT_DEAD = "dead" EVENT_ALIVE = "alive" @@ -99,6 +100,10 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self.info.node.on(EVENT_VALUE_UPDATED, self._value_changed) ) + self.async_on_remove( + self.info.node.on(EVENT_VALUE_REMOVED, self._value_removed) + ) + for status_event in (EVENT_ALIVE, EVENT_DEAD): self.async_on_remove( self.info.node.on(status_event, self._node_status_alive_or_dead) @@ -171,7 +176,7 @@ def _node_status_alive_or_dead(self, event_data: dict) -> None: @callback def _value_changed(self, event_data: dict) -> None: - """Call when (one of) our watched values changes. + """Call when a value associated with our node changes. Should not be overridden by subclasses. """ @@ -193,6 +198,25 @@ def _value_changed(self, event_data: dict) -> None: self.on_value_update() self.async_write_ha_state() + @callback + def _value_removed(self, event_data: dict) -> None: + """Call when a value associated with our node is removed. + + Should not be overridden by subclasses. + """ + value_id = event_data["value"].value_id + + if value_id != self.info.primary_value.value_id: + return + + LOGGER.debug( + "[%s] Primary value %s is being removed", + self.entity_id, + value_id, + ) + + self.hass.async_create_task(self.async_remove()) + @callback def get_zwave_value( self, diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index aa6db53261668..363762ac72b11 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -66,6 +66,19 @@ def get_device_id(client: ZwaveClient, node: ZwaveNode) -> tuple[str, str]: return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}") +@callback +def get_device_id_ext(client: ZwaveClient, node: ZwaveNode) -> tuple[str, str] | None: + """Get extended device registry identifier for Z-Wave node.""" + if None in (node.manufacturer_id, node.product_type, node.product_id): + return None + + domain, dev_id = get_device_id(client, node) + return ( + domain, + f"{dev_id}-{node.manufacturer_id}:{node.product_type}:{node.product_id}", + ) + + @callback def get_home_and_node_id_from_device_id(device_id: tuple[str, ...]) -> list[str]: """ diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 206ba8bee5ed7..c6bc11a480497 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 @@ -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 1fb7b93397295..76cb6fd22e911 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -56,7 +56,10 @@ SERVICE_RESET_METER, ) from .discovery import ZwaveDiscoveryInfo -from .discovery_data_template import NumericSensorDataTemplateData +from .discovery_data_template import ( + NumericSensorDataTemplate, + NumericSensorDataTemplateData, +) from .entity import ZWaveBaseEntity from .helpers import get_device_id @@ -296,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.""" @@ -502,4 +514,11 @@ async def async_added_to_hass(self) -> None: self.async_poll_value, ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_remove_entity", + self.async_remove, + ) + ) self.async_write_ha_state() diff --git a/homeassistant/components/zwave_js/translations/es.json b/homeassistant/components/zwave_js/translations/es.json index e1a9cd081ba32..1002d0ad0b368 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/hu.json b/homeassistant/components/zwave_js/translations/hu.json index 7e4ba47bff73a..1803cfa4a26cf 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": { @@ -34,10 +34,10 @@ "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" }, "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 kieg\u00e9sz\u00edt\u0151 konfigur\u00e1ci\u00f3j\u00e1t" + "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" @@ -90,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.", @@ -105,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": { @@ -121,10 +121,10 @@ "usb_path": "USB eszk\u00f6z \u00fatvonala" }, "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 kieg\u00e9sz\u00edt\u0151 konfigur\u00e1ci\u00f3j\u00e1t" + "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/config.py b/homeassistant/config.py index ed9ffa7c47d56..74a8055e97188 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -74,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" @@ -94,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} @@ -262,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: @@ -298,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) @@ -316,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("[]") @@ -642,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: @@ -656,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 @@ -666,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): @@ -747,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") @@ -889,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, diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 063040dc3983f..32014be77748c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2,23 +2,24 @@ 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 . import data_entry_flow, loader from .backports.enum import StrEnum -from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP -from .core import CALLBACK_TYPE, CoreState, HomeAssistant, callback +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 Event +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 @@ -70,6 +71,8 @@ SAVE_DELAY = 1 +_T = TypeVar("_T", bound="ConfigEntryState") + class ConfigEntryState(Enum): """Config entry state.""" @@ -89,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: @@ -321,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( @@ -373,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( @@ -460,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) @@ -471,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 @@ -499,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", @@ -536,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 @@ -545,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 @@ -603,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, } @@ -654,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 @@ -754,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. " @@ -763,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 " @@ -1049,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, @@ -1076,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) @@ -1097,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 @@ -1126,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( @@ -1138,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: @@ -1169,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) @@ -1375,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( @@ -1553,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: diff --git a/homeassistant/const.py b/homeassistant/const.py index 6dcda3243b175..8dbde276e4cf1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -10,10 +10,10 @@ PATCH_VERSION: Final = "0.dev0" __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}" @@ -765,3 +765,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 9566ad6c5968f..30e98da763731 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,7 +25,16 @@ 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 @@ -85,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 @@ -164,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 @@ -174,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") @@ -187,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 @@ -226,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) @@ -235,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 @@ -344,10 +363,33 @@ 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: Coroutine[Any, Any, _R], *args: Any + ) -> asyncio.Future[_R] | None: + ... + @callback def async_add_job( - self, target: Callable[..., Any], *args: Any - ) -> asyncio.Future | None: + 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 @@ -362,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, *args: Any) -> asyncio.Future | None: + 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[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 @@ -390,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. @@ -398,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) @@ -415,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) @@ -435,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, *args: Any) -> asyncio.Future | None: + 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[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. @@ -445,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: Coroutine[Any, Any, _R], *args: Any + ) -> asyncio.Future[_R] | None: + ... + @callback def async_run_job( - self, target: Callable[..., None | Awaitable], *args: Any - ) -> asyncio.Future | None: + 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. @@ -462,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: @@ -672,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 @@ -696,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: @@ -725,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 @@ -748,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`` @@ -768,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. @@ -785,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) @@ -801,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. @@ -821,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`` @@ -831,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: @@ -853,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. @@ -879,6 +994,9 @@ def _async_remove_listener( ) +_StateT = TypeVar("_StateT", bound="State") + + class State: """Object to represent a state within the state machine. @@ -945,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. @@ -970,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. @@ -1041,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. @@ -1061,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. @@ -1077,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. @@ -1260,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: @@ -1278,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.""" @@ -1330,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: """ @@ -1347,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: """ @@ -1402,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. @@ -1424,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. @@ -1461,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", @@ -1517,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.""" @@ -1542,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: @@ -1646,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. @@ -1693,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.""" diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 2a82c2652edb5..ff8fb295cd5bb 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.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fa828cc63684d..fbc4035d23029 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -60,6 +60,7 @@ "control4", "coolmaster", "coronavirus", + "cpuspeed", "crownstone", "daikin", "deconz", @@ -114,9 +115,11 @@ "geonetnz_quakes", "geonetnz_volcano", "gios", + "github", "glances", "goalzero", "gogogate2", + "goodwe", "google_travel_time", "gpslogger", "gree", @@ -134,6 +137,7 @@ "homekit", "homekit_controller", "homematicip_cloud", + "homewizard", "honeywell", "huawei_lte", "hue", @@ -164,6 +168,7 @@ "kostal_plenticore", "kraken", "kulersky", + "launch_library", "life360", "lifx", "litejet", @@ -216,6 +221,7 @@ "nzbget", "octoprint", "omnilogic", + "oncue", "ondilo_ico", "onewire", "onvif", @@ -244,6 +250,7 @@ "progettihwsw", "prosegur", "ps4", + "pvoutput", "pvpc_hourly_pricing", "rachio", "rainforest_eagle", @@ -260,10 +267,12 @@ "roomba", "roon", "rpi_power", + "rtsp_to_webrtc", "ruckus_unleashed", "samsungtv", "screenlogic", "sense", + "senseme", "sensibo", "sentry", "sharkiq", @@ -293,6 +302,7 @@ "squeezebox", "srp_energy", "starline", + "steamist", "stookalert", "subaru", "surepetcare", @@ -323,10 +333,12 @@ "twilio", "twinkly", "unifi", + "unifiprotect", "upb", "upcloud", "upnp", "uptimerobot", + "vallox", "velbus", "venstar", "vera", @@ -341,8 +353,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 e5bb255142767..5467571a0d4b8 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -335,6 +335,11 @@ "hostname": "squeezebox*", "macaddress": "000420*" }, + { + "domain": "steamist", + "macaddress": "001E0C*", + "hostname": "my[45]50*" + }, { "domain": "tado", "hostname": "tado*" @@ -541,6 +546,42 @@ "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 9434bc11f618b..1a243d954b9f0 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 d9d9da5aff030..1ba9b235f85b7 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -11,6 +11,26 @@ "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", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index bc4a83f32615d..22c968e6340d0 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -179,6 +179,11 @@ "domain": "hue" } ], + "_hwenergy._tcp.local.": [ + { + "domain": "homewizard" + } + ], "_ipp._tcp.local.": [ { "domain": "ipp" diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index 93383f49b1e13..f74aec0efe87e 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/area_registry.py b/homeassistant/helpers/area_registry.py index 8c4179dd9405b..3f3e526b0c2bf 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -14,8 +14,6 @@ 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 6e0415b2a5421..3da444a8549db 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -159,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 57e34a02d965d..f6f9c968f104b 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 diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 404eea5ea4a39..80bed9137d0a0 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -3,21 +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, @@ -70,8 +70,6 @@ ) from .typing import ConfigType, TemplateVarsType -# mypy: disallow-any-generics - ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" FROM_CONFIG_FORMAT = "{}_from_config" VALIDATE_CONFIG_FORMAT = "{}_validate_config" @@ -687,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. @@ -702,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, ): @@ -726,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, ): @@ -880,7 +878,7 @@ async def async_device_from_config( return trace_condition_function( cast( ConditionCheckerType, - platform.async_condition_from_config(hass, config), # type: ignore + platform.async_condition_from_config(hass, config), ) ) @@ -946,7 +944,7 @@ async def async_validate_condition_config( ) 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( diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index a3e7ae4869d3c..6f07e5d665e31 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -1,8 +1,9 @@ """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 @@ -11,6 +12,9 @@ from .typing import UNDEFINED, DiscoveryInfoType, UndefinedType +if TYPE_CHECKING: + import asyncio + DiscoveryFunctionType = Callable[[HomeAssistant], Union[Awaitable[bool], bool]] _LOGGER = logging.getLogger(__name__) @@ -55,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: diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index b3a569aa0712b..04e11ab99be55 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 @@ -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 ffd1f7586c07d..ed3c50cdb0020 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 @@ -245,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: @@ -296,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) @@ -875,11 +896,11 @@ 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)) + return cast(dict[Hashable, Any], default_schema(value)) alternatives = ", ".join(str(key) for key in value_schemas) if default_description: @@ -957,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 @@ -1019,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), diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index bf8ef5fbd0eee..96425b2ea93bc 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -372,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, @@ -396,45 +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, - hw_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, - hw_version=hw_version, - via_device_id=via_device_id, - ) - - @callback - def _async_update_device( self, device_id: str, *, @@ -522,6 +483,8 @@ 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), @@ -530,12 +493,6 @@ def _async_update_device( 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 @@ -572,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} ) @@ -596,7 +553,9 @@ async def async_load(self) -> None: configuration_url=device["configuration_url"], # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={tuple(conn) for conn in device["connections"]}, # type: ignore[misc] - disabled_by=device["disabled_by"], + disabled_by=DeviceEntryDisabler(device["disabled_by"]) + if device["disabled_by"] + else None, entry_type=DeviceEntryType(device["entry_type"]) if device["entry_type"] else None, @@ -673,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: @@ -715,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 1923ba9556c43..ed90b5b893b58 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 474d079edbc9d..db9484233bc13 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -35,7 +35,7 @@ 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.loader import bind_hass from homeassistant.util import dt as dt_util, ensure_unique_string, slugify @@ -43,7 +43,7 @@ from . import entity_registry as er from .device_registry import DeviceEntryType from .entity_platform import EntityPlatform -from .event import Event, async_track_entity_registry_updated_event +from .event import async_track_entity_registry_updated_event from .typing import StateType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 66d733619d0c5..da6732d05e76f 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -121,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() @@ -198,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_registry.py b/homeassistant/helpers/entity_registry.py index 7c49e88464698..87c89cfdec916 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -57,7 +57,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 @@ -109,6 +109,9 @@ class RegistryEntry: 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 +168,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 +199,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 +211,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 +230,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 @@ -335,7 +335,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, @@ -460,43 +460,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, *, @@ -520,8 +483,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 +550,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 +563,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) @@ -631,6 +617,7 @@ async def async_load(self) -> None: 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 +637,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 +652,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 +681,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 +773,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 +792,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 +871,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 8f11fbf31161b..d489a4b1d379f 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/event.py b/homeassistant/helpers/event.py index 289d3321a41e9..14b45a52ecd5e 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, @@ -61,6 +62,8 @@ _LOGGER = logging.getLogger(__name__) +_P = ParamSpec("_P") + @dataclass class TrackStates: @@ -110,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: @@ -233,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 @@ -309,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] @@ -329,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. @@ -391,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] @@ -414,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 @@ -466,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 @@ -533,7 +536,7 @@ def __init__( """Handle removal / refresh of tracker init.""" self.hass = hass self._action = action - self._listeners: dict[str, Callable] = {} + self._listeners: dict[str, Callable[[], None]] = {} self._last_track_states: TrackStates = track_states @callback @@ -680,7 +683,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 @@ -721,7 +724,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() @@ -769,7 +772,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.""" @@ -786,7 +789,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.""" @@ -986,7 +989,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() @@ -1075,8 +1078,8 @@ def _apply_update( TrackTemplateResultListener = Callable[ [ - Event, - List[TrackTemplateResult], + Union[Event, None], + list[TrackTemplateResult], ], None, ] @@ -1152,7 +1155,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: @@ -1221,7 +1224,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.""" @@ -1242,7 +1246,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.""" @@ -1254,7 +1259,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 @@ -1294,7 +1299,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): @@ -1309,7 +1315,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.""" @@ -1351,7 +1357,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) @@ -1409,7 +1415,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) @@ -1423,7 +1429,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) @@ -1441,7 +1447,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, @@ -1456,7 +1462,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) @@ -1507,7 +1513,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/integration_platform.py b/homeassistant/helpers/integration_platform.py index 0e619fe551b82..c1d7487abb68c 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 0c111fd9afa12..ca154d20b75ce 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable import logging import re -from typing import Any, Dict +from typing import Any import voluptuous as vol @@ -16,7 +16,7 @@ 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/reload.py b/homeassistant/helpers/reload.py index baa31bb41fc99..5a826d4129e14 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -17,8 +17,6 @@ from .entity_platform import EntityPlatform, async_get_platforms from .typing import ConfigType -# mypy: disallow-any-generics - _LOGGER = logging.getLogger(__name__) @@ -79,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 56b5b10627859..4857210f125ed 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -4,14 +4,14 @@ 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 import homeassistant.util.dt as dt_util -from . import entity_registry, start +from . import start from .entity import Entity from .event import async_track_time_interval from .json import JSONEncoder @@ -31,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.""" @@ -45,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: @@ -118,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 @@ -127,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 77e4fd385082b..8e54e294f4bfa 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 @@ -248,9 +248,9 @@ async def async_validate_action_config( 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) @@ -915,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: @@ -1216,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 3dae84166f6b2..6c5edfd0ac36c 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 13ec3cb5df50f..be8e316878fa1 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 @@ -217,7 +218,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}" @@ -319,7 +323,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) @@ -706,7 +710,7 @@ async def _handle_entity_call( func, entity.entity_id, ) - await result # type: ignore + await result @bind_hass @@ -715,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 53802a2a1196a..9fd643a775747 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 d2791def987e7..c1dbaf8c6e46e 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 a3cde0b2f27c4..7012241fe4eca 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 805ac19383467..4560119a685ac 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/system_info.py b/homeassistant/helpers/system_info.py index 7e65ab858ad53..e137d0f673ef6 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 ce7984c03bb17..916d203782ab3 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 @@ -1525,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. @@ -1865,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 @@ -1909,14 +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/translation.py b/homeassistant/helpers/translation.py index e10c814389bec..2f20e1404d81a 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/typing.py b/homeassistant/helpers/typing.py index 7d01b0b6a77fb..a7430d1fe696c 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/loader.py b/homeassistant/loader.py index 6fd66dab2fae1..da23d1141bde8 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, @@ -34,8 +35,6 @@ if TYPE_CHECKING: from .core import HomeAssistant -# mypy: disallow-any-generics - CALLABLE_T = TypeVar( # pylint: disable=invalid-name "CALLABLE_T", bound=Callable[..., Any] ) @@ -159,9 +158,9 @@ 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]: @@ -318,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(): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cb76eb8ada2eb..eac78fd63e0a9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,40 +4,38 @@ aiodiscover==1.4.5 aiohttp==3.8.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.23.2 -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 +awesomeversion==22.1.0 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 cryptography==35.0.0 emoji==1.5.0 -hass-nabucasa==0.50.0 -home-assistant-frontend==20211220.0 +hass-nabucasa==0.51.0 +home-assistant-frontend==20220118.0 httpx==0.21.0 ifaddr==0.1.7 jinja2==3.0.3 paho-mqtt==1.6.1 +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 +yarl==1.7.2 zeroconf==0.38.1 -# Constrain pillow to 8.2.0 because later versions are causing issues in nightly builds. -# https://github.com/home-assistant/core/issues/61756 -pillow==8.2.0 - # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 @@ -52,15 +50,15 @@ h11>=0.12.0 # 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 @@ -86,9 +84,11 @@ regex==2021.8.28 # 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 - # 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 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 6b9c0bd066143..7631586d62601 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -13,8 +13,6 @@ from .loader import Integration, IntegrationNotFound, async_get_integration from .util import package as pkg_util -# mypy: disallow-any-generics - PIP_TIMEOUT = 60 # The default is too low when the internet connection is satellite or high latency MAX_INSTALL_FAILURES = 3 DATA_PIP_LOCK = "pip_lock" diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 7caf6be2c8f98..571111d107728 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -14,8 +14,6 @@ from .util.executor import InterruptibleThreadPoolExecutor from .util.thread import deadlock_safe_shutdown -# mypy: disallow-any-generics - # # Python 3.8 has significantly less workers by default # than Python 3.7. In order to be consistent between diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index 69ca1d6083bf3..5b781d4eb37e2 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/setup.py b/homeassistant/setup.py index 44dc6fe1014a9..1c3702a4725dc 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -7,6 +7,7 @@ import logging.handlers from timeit import default_timer as timer from types import ModuleType +from typing import Any from . import config as conf_util, core, loader, requirements from .config import async_notify_setup_error @@ -21,8 +22,6 @@ from .helpers.typing import ConfigType from .util import dt as dt_util, ensure_unique_string -# mypy: disallow-any-generics - _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT = "component" @@ -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() @@ -210,15 +209,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.") diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index b7758df0cb0af..3c82639251a8b 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 b23e5cf29e8f7..aa1aea1abc370 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 bf7250b68e6d8..229a8fef366bc 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(strict: bool = True) -> None: + """Warn if called inside the event loop. Raise if `strict` is True.""" try: get_running_loop() in_loop = True @@ -116,7 +116,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 +131,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(strict=strict) return func(*args, **kwargs) return protected_loop_func diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index ccb4980492f92..f308595adbdcd 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], ) diff --git a/homeassistant/util/executor.py b/homeassistant/util/executor.py index 8451e71e0ec39..5a8f15f434f6e 100644 --- a/homeassistant/util/executor.py +++ b/homeassistant/util/executor.py @@ -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 dd3cb119c6bbe..9216993eb5359 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 a0b5c2832ad2c..a1ee2b9f58453 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/process.py b/homeassistant/util/process.py index f89b2eb96eea3..3affa28e90963 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 824e324095db5..dfe73b0e937ae 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -40,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 6ccb0b8106c4e..9f76bfa97399e 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,60 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = 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 @@ -44,6 +98,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = 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 disallow_incomplete_defs = true @@ -286,6 +351,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 @@ -352,6 +428,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 @@ -682,6 +769,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 @@ -814,6 +912,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 @@ -1023,6 +1132,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 @@ -1067,6 +1187,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 @@ -1100,6 +1231,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 @@ -1265,6 +1407,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 @@ -1309,6 +1462,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 @@ -1342,6 +1506,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 @@ -1386,6 +1561,17 @@ 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 @@ -1562,6 +1748,17 @@ 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 @@ -1584,6 +1781,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 @@ -1705,6 +1913,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 @@ -1727,6 +1946,17 @@ 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 @@ -1785,9 +2015,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 @@ -1809,12 +2036,6 @@ 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 @@ -1824,24 +2045,12 @@ 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 @@ -1863,9 +2072,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 @@ -1881,18 +2087,12 @@ 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 @@ -1953,9 +2153,6 @@ 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 @@ -1965,9 +2162,6 @@ 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 @@ -1983,9 +2177,6 @@ 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 @@ -2001,9 +2192,6 @@ 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 @@ -2073,9 +2261,6 @@ 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 @@ -2091,9 +2276,6 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index d5be195d2b20b..4f46f01854607 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ forced_separate = [ combine_as_imports = true [tool.pylint.MASTER] -py-version = "3.8" +py-version = "3.9" ignore = [ "tests", ] diff --git a/requirements.txt b/requirements.txt index 4c6af849ce88f..b0319897a4830 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,10 +3,10 @@ # 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 +awesomeversion==22.1.0 backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2021.5.30 @@ -19,7 +19,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 3bb5004e3a351..dc67604cf3591 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -14,10 +14,10 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.adax -Adax-local==0.1.1 +Adax-local==0.1.3 # homeassistant.components.homekit -HAP-python==4.3.0 +HAP-python==4.4.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -31,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 @@ -49,17 +46,17 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -# PySwitchbot==0.13.0 +# PySwitchbot==0.13.2 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 # homeassistant.components.camera # homeassistant.components.stream -PyTurboJPEG==1.6.3 +PyTurboJPEG==1.6.5 # homeassistant.components.vicare -PyViCare==2.13.1 +PyViCare==2.15.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 @@ -87,7 +84,7 @@ TwitterAPI==2.7.5 WSDiscovery==2.0.0 # homeassistant.components.waze_travel_time -WazeRouteCalculator==0.13 +WazeRouteCalculator==0.14 # homeassistant.components.abode abodepy==1.2.0 @@ -150,7 +147,7 @@ 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 @@ -178,13 +175,13 @@ 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 # homeassistant.components.harmony -aioharmony==0.2.8 +aioharmony==0.2.9 # homeassistant.components.homekit_controller aiohomekit==0.6.10 @@ -194,7 +191,10 @@ aiohomekit==0.6.10 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==3.0.10 +aiohue==3.0.11 + +# homeassistant.components.homewizard +aiohwenergy==0.6.0 # homeassistant.components.imap aioimaplib==0.9.0 @@ -232,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 @@ -241,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==2021.12.2 +# homeassistant.components.senseme +aiosenseme==0.6.0 + # homeassistant.components.shelly -aioshelly==1.0.5 +aioshelly==1.0.7 + +# homeassistant.components.steamist +aiosteamist==0.3.1 # homeassistant.components.switcher_kis aioswitcher==2.0.6 @@ -263,7 +269,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.2 # homeassistant.components.unifi -aiounifi==28 +aiounifi==29 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -271,6 +277,9 @@ aiovlc==0.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webostv +aiowebostv==0.1.1 + # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -320,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 @@ -341,13 +350,13 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.23.2 +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 @@ -355,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 @@ -392,7 +404,7 @@ beautifulsoup4==4.10.0 bellows==0.29.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.8.7 +bimmer_connected==0.8.10 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -426,11 +438,11 @@ blockchain==1.4.4 bond-api==0.1.15 # homeassistant.components.bosch_shc -boschshcpy==0.2.27 +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 @@ -445,7 +457,7 @@ 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 @@ -463,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 @@ -545,7 +557,7 @@ denonavr==0.10.9 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 @@ -556,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 @@ -566,7 +581,7 @@ 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 @@ -584,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 @@ -605,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 @@ -666,7 +681,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.27.13 +flux_led==0.28.4 # homeassistant.components.homekit fnvhash==0.1.0 @@ -688,7 +703,7 @@ 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 @@ -728,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 @@ -736,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.5.1 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -761,10 +779,10 @@ gpiozero==1.5.1 gps3==0.33.3 # homeassistant.components.gree -greeclimate==0.12.5 +greeclimate==1.0.2 # homeassistant.components.greeneye_monitor -greeneye_monitor==2.1 +greeneye_monitor==3.0.1 # homeassistant.components.greenwave greenwavereality==0.5.1 @@ -778,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 @@ -791,10 +806,10 @@ ha-philipsjs==2.7.6 habitipy==0.2.0 # homeassistant.components.hangouts -hangups==0.4.16 +hangups==0.4.17 # homeassistant.components.cloud -hass-nabucasa==0.50.0 +hass-nabucasa==0.51.0 # homeassistant.components.splunk hass_splunk==0.1.1 @@ -824,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==20211220.0 +home-assistant-frontend==20220118.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -869,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 @@ -893,10 +908,10 @@ ihcsdk==2.7.0 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.iotawatt iotawattpy==0.1.0 @@ -1013,13 +1028,13 @@ meteofrance-api==1.0.2 mficlient==0.3.0 # homeassistant.components.xiaomi_miio -micloud==0.4 +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 @@ -1073,7 +1088,7 @@ nettigo-air-monitor==1.2.1 neurio==0.3.1 # homeassistant.components.nexia -nexia==0.9.12 +nexia==0.9.13 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 @@ -1116,7 +1131,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 @@ -1231,7 +1246,7 @@ 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 @@ -1292,7 +1307,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==0.1.0 +pvo==0.2.0 # homeassistant.components.rpi_gpio_pwm pwmled==1.6.7 @@ -1313,7 +1328,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 @@ -1341,7 +1356,7 @@ pyRFXtrx==0.27.0 # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.21.4 +pyTibber==0.21.7 # homeassistant.components.dlink pyW215==0.7.0 @@ -1359,7 +1374,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 @@ -1377,7 +1392,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==6.2.1 +pyatmo==6.2.2 # homeassistant.components.atome pyatome==0.1.1 @@ -1398,10 +1413,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 @@ -1410,7 +1425,7 @@ pycfdns==1.2.2 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==10.2.2 +pychromecast==10.2.3 # homeassistant.components.pocketcasts pycketcasts==1.0.0 @@ -1437,7 +1452,7 @@ 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 @@ -1473,7 +1488,7 @@ 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 @@ -1491,7 +1506,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 @@ -1549,7 +1564,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 @@ -1612,10 +1627,10 @@ pylacrosse==0.4 pylast==4.2.1 # homeassistant.components.launch_library -pylaunches==1.2.0 +pylaunches==1.2.1 # homeassistant.components.lg_netcast -pylgnetcast==0.3.5 +pylgnetcast==0.3.7 # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 @@ -1639,7 +1654,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.2.2 +pymazda==0.3.0 # homeassistant.components.mediaroom pymediaroom==0.6.4.1 @@ -1675,7 +1690,7 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.8.0 +pynetgear==0.9.0 # homeassistant.components.netio pynetio==0.1.9.1 @@ -1684,7 +1699,7 @@ pynetio==0.1.9.1 pynina==0.1.4 # homeassistant.components.nuki -pynuki==1.4.1 +pynuki==1.5.2 # homeassistant.components.nut pynut2==2.1.2 @@ -1702,7 +1717,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 @@ -1725,7 +1740,7 @@ pyotgw==1.1b1 pyotp==2.6.0 # homeassistant.components.overkiz -pyoverkiz==1.0.0 +pyoverkiz==1.1.1 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1737,7 +1752,7 @@ pyownet==0.10.0.post1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.7.11 +pypck==0.7.13 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1798,7 +1813,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 @@ -1819,7 +1834,7 @@ pysiaalarm==3.0.2 pysignalclirestapi==0.3.4 # homeassistant.components.sky_hub -pyskyqhub==0.1.3 +pyskyqhub==0.1.4 # homeassistant.components.sma pysma==0.6.10 @@ -1837,7 +1852,7 @@ pysmartthings==0.7.6 pysmarty==0.8 # homeassistant.components.edl21 -pysml==0.0.5 +pysml==0.0.6 # homeassistant.components.snmp pysnmp==4.4.12 @@ -1948,7 +1963,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.smarttub -python-smarttub==0.0.28 +python-smarttub==0.0.29 # homeassistant.components.sochain python-sochain-api==0.0.2 @@ -1975,7 +1990,7 @@ python-whois==0.7.3 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 @@ -1990,7 +2005,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 @@ -1999,6 +2014,9 @@ pytrafikverket==0.1.6.2 # homeassistant.components.usb pyudev==0.22.0 +# homeassistant.components.unifiprotect +pyunifiprotect==3.1.1 + # homeassistant.components.uptimerobot pyuptimerobot==21.11.0 @@ -2012,7 +2030,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 @@ -2063,7 +2081,7 @@ 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 @@ -2090,7 +2108,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.8.5 +rokuecp==0.10.0 # homeassistant.components.roomba roombapy==1.6.5 @@ -2107,6 +2125,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.4.0 + # homeassistant.components.russound_rnet russound==0.1.9 @@ -2132,7 +2153,7 @@ scapy==2.4.5 schiene==0.23 # homeassistant.components.screenlogic -screenlogicpy==0.5.3 +screenlogicpy==0.5.4 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2148,7 +2169,7 @@ sense-hat==2.2.0 sense_energy==0.9.3 # homeassistant.components.sentry -sentry-sdk==1.5.1 +sentry-sdk==1.5.2 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -2157,7 +2178,7 @@ sharkiqpy==0.1.8 sharp_aquos_rc==0.3.2 # homeassistant.components.shodan -shodan==1.25.0 +shodan==1.26.0 # homeassistant.components.sighthound simplehound==0.3 @@ -2204,7 +2225,7 @@ smhi-pkg==1.0.15 snapcast==2.1.3 # homeassistant.components.sonos -soco==0.25.2 +soco==0.25.3 # homeassistant.components.solaredge_local solaredge-local==0.2.0 @@ -2238,6 +2259,7 @@ spotipy==2.19.0 # homeassistant.components.recorder # homeassistant.components.sql +# homeassistant.components.webostv sqlalchemy==1.4.27 # homeassistant.components.srp_energy @@ -2354,8 +2376,11 @@ 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.3 +tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu twentemilieu==0.5.0 @@ -2363,12 +2388,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.0 + # homeassistant.components.unifiled unifiled==0.11 @@ -2389,7 +2414,7 @@ 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.3.1 @@ -2450,7 +2475,7 @@ wirelesstagpy==0.8.1 withings-api==2.3.2 # homeassistant.components.wled -wled==0.10.1 +wled==0.11.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 @@ -2465,7 +2490,7 @@ xbox-webapi==2.0.11 xboxapi==2.0.1 # homeassistant.components.knx -xknx==0.18.15 +xknx==0.19.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2479,10 +2504,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.16 +yalexs==1.1.19 # homeassistant.components.yeelight yeelight==0.7.8 @@ -2491,10 +2516,10 @@ yeelight==0.7.8 yeelightsunflower==0.0.10 # homeassistant.components.youless -youless-api==0.15 +youless-api==0.16 # homeassistant.components.media_extractor -youtube_dl==2021.06.06 +youtube_dl==2021.12.17 # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test.txt b/requirements_test.txt index 6f580cb5159db..8841421245283 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,8 +12,8 @@ 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 +mypy==0.931 +pre-commit==2.17.0 pylint==2.12.1 pipdeptree==2.2.0 pylint-strict-informational==0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c52d554e836d..c7eac7e90e984 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,10 +7,10 @@ AEMET-OpenData==0.2.1 # homeassistant.components.adax -Adax-local==0.1.1 +Adax-local==0.1.3 # homeassistant.components.homekit -HAP-python==4.3.0 +HAP-python==4.4.0 # homeassistant.components.flick_electric PyFlick==0.0.2 @@ -27,17 +27,17 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -# PySwitchbot==0.13.0 +# PySwitchbot==0.13.2 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 # homeassistant.components.camera # homeassistant.components.stream -PyTurboJPEG==1.6.3 +PyTurboJPEG==1.6.5 # homeassistant.components.vicare -PyViCare==2.13.1 +PyViCare==2.15.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 @@ -49,7 +49,7 @@ RtmAPI==0.7.2 WSDiscovery==2.0.0 # homeassistant.components.waze_travel_time -WazeRouteCalculator==0.13 +WazeRouteCalculator==0.14 # homeassistant.components.abode abodepy==1.2.0 @@ -100,7 +100,7 @@ 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 @@ -124,11 +124,14 @@ aioesphomeapi==10.6.0 # homeassistant.components.flo aioflo==2021.11.0 +# homeassistant.components.github +aiogithubapi==22.1.0 + # homeassistant.components.guardian aioguardian==2021.11.0 # homeassistant.components.harmony -aioharmony==0.2.8 +aioharmony==0.2.9 # homeassistant.components.homekit_controller aiohomekit==0.6.10 @@ -138,7 +141,10 @@ aiohomekit==0.6.10 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==3.0.10 +aiohue==3.0.11 + +# homeassistant.components.homewizard +aiohwenergy==0.6.0 # homeassistant.components.apache_kafka aiokafka==0.6.0 @@ -161,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 @@ -170,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==2021.12.2 +# homeassistant.components.senseme +aiosenseme==0.6.0 + # homeassistant.components.shelly -aioshelly==1.0.5 +aioshelly==1.0.7 + +# homeassistant.components.steamist +aiosteamist==0.3.1 # homeassistant.components.switcher_kis aioswitcher==2.0.6 @@ -192,7 +204,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.2 # homeassistant.components.unifi -aiounifi==28 +aiounifi==29 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -200,6 +212,9 @@ aiovlc==0.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webostv +aiowebostv==0.1.1 + # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -231,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 @@ -240,7 +255,7 @@ arcam-fmj==0.12.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.23.2 +async-upnp-client==0.23.4 # homeassistant.components.aurora auroranoaa==0.0.2 @@ -248,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 @@ -261,7 +279,7 @@ base36==0.1.1 bellows==0.29.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.8.7 +bimmer_connected==0.8.10 # homeassistant.components.blebox blebox_uniapi==1.3.3 @@ -273,7 +291,7 @@ blinkpy==0.18.0 bond-api==0.1.15 # homeassistant.components.bosch_shc -boschshcpy==0.2.27 +boschshcpy==0.2.28 # homeassistant.components.braviatv bravia-tv==1.0.11 @@ -285,7 +303,7 @@ 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 @@ -294,7 +312,7 @@ bsblan==0.4.0 buienradar==1.0.5 # homeassistant.components.caldav -caldav==0.7.1 +caldav==0.8.2 # homeassistant.components.co2signal co2signal==0.4.2 @@ -349,22 +367,25 @@ denonavr==0.10.9 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 @@ -406,7 +427,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.1 # homeassistant.components.flux_led -flux_led==0.27.13 +flux_led==0.28.4 # homeassistant.components.homekit fnvhash==0.1.0 @@ -422,7 +443,7 @@ 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 @@ -456,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.5.1 # homeassistant.components.google_travel_time googlemaps==2.5.1 # homeassistant.components.gree -greeclimate==0.12.5 +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 @@ -485,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 @@ -498,10 +519,10 @@ ha-philipsjs==2.7.6 habitipy==0.2.0 # homeassistant.components.hangouts -hangups==0.4.16 +hangups==0.4.17 # homeassistant.components.cloud -hass-nabucasa==0.50.0 +hass-nabucasa==0.51.0 # homeassistant.components.tasmota hatasmota==0.3.1 @@ -519,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==20211220.0 +home-assistant-frontend==20220118.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -550,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 @@ -559,10 +580,10 @@ 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.iotawatt iotawattpy==0.1.0 @@ -619,10 +640,10 @@ meteofrance-api==1.0.2 mficlient==0.3.0 # homeassistant.components.xiaomi_miio -micloud==0.4 +micloud==0.5 # homeassistant.components.mill -mill-local==0.1.0 +mill-local==0.1.1 # homeassistant.components.mill millheater==0.9.0 @@ -661,7 +682,7 @@ netmap==0.7.0.2 nettigo-air-monitor==1.2.1 # homeassistant.components.nexia -nexia==0.9.12 +nexia==0.9.13 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.3 @@ -686,7 +707,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 @@ -747,7 +768,7 @@ 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 @@ -789,9 +810,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 @@ -799,7 +826,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 @@ -818,7 +845,7 @@ pyMetno==0.9.0 pyRFXtrx==0.27.0 # homeassistant.components.tibber -pyTibber==0.21.4 +pyTibber==0.21.7 # homeassistant.components.nextbus py_nextbusnext==0.1.5 @@ -842,7 +869,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==6.2.1 +pyatmo==6.2.2 # homeassistant.components.apple_tv pyatv==0.9.8 @@ -854,13 +881,13 @@ pybalboa==0.13 pyblackbird==0.5 # homeassistant.components.neato -pybotvac==0.0.22 +pybotvac==0.0.23 # homeassistant.components.cloudflare pycfdns==1.2.2 # homeassistant.components.cast -pychromecast==10.2.2 +pychromecast==10.2.3 # homeassistant.components.climacell pyclimacell==0.18.2 @@ -872,7 +899,7 @@ pycomfoconnect==0.4 pycoolmasternet-async==0.1.2 # homeassistant.components.daikin -pydaikin==2.6.0 +pydaikin==2.7.0 # homeassistant.components.deconz pydeconz==85 @@ -896,7 +923,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 @@ -904,6 +931,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 @@ -942,7 +972,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 @@ -983,6 +1013,9 @@ pykulersky==0.5.2 # homeassistant.components.lastfm pylast==4.2.1 +# homeassistant.components.launch_library +pylaunches==1.2.1 + # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 @@ -1002,7 +1035,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.2.2 +pymazda==0.3.0 # homeassistant.components.melcloud pymelcloud==2.5.6 @@ -1029,13 +1062,13 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.8.0 +pynetgear==0.9.0 # 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 @@ -1050,7 +1083,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 @@ -1067,7 +1100,7 @@ pyotgw==1.1b1 pyotp==2.6.0 # homeassistant.components.overkiz -pyoverkiz==1.0.0 +pyoverkiz==1.1.1 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1076,7 +1109,7 @@ pyowm==3.2.0 pyownet==0.10.0.post1 # homeassistant.components.lcn -pypck==0.7.11 +pypck==0.7.13 # homeassistant.components.plaato pyplaato==0.0.15 @@ -1110,7 +1143,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 @@ -1176,7 +1209,7 @@ python-openzwave-mqtt[mqtt-client]==1.4.0 python-picnic-api==1.1.0 # homeassistant.components.smarttub -python-smarttub==0.0.28 +python-smarttub==0.0.29 # homeassistant.components.songpal python-songpal==0.12 @@ -1187,6 +1220,9 @@ python-tado==0.12.0 # homeassistant.components.twitch python-twitch-client==0.6.0 +# homeassistant.components.whois +python-whois==0.7.3 + # homeassistant.components.awair python_awair==0.2.1 @@ -1197,7 +1233,7 @@ pytile==2021.12.0 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 @@ -1206,6 +1242,9 @@ pytrafikverket==0.1.6.2 # homeassistant.components.usb pyudev==0.22.0 +# homeassistant.components.unifiprotect +pyunifiprotect==3.1.1 + # homeassistant.components.uptimerobot pyuptimerobot==21.11.0 @@ -1213,7 +1252,7 @@ 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 @@ -1237,7 +1276,7 @@ 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 @@ -1252,7 +1291,7 @@ rflink==0.0.58 ring_doorbell==0.7.2 # homeassistant.components.roku -rokuecp==0.8.5 +rokuecp==0.10.0 # homeassistant.components.roomba roombapy==1.6.5 @@ -1263,6 +1302,9 @@ roonapi==0.0.38 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 +# homeassistant.components.rtsp_to_webrtc +rtsp-to-webrtc==0.4.0 + # homeassistant.components.yamaha rxv==0.7.0 @@ -1276,14 +1318,14 @@ samsungtvws==1.6.0 scapy==2.4.5 # homeassistant.components.screenlogic -screenlogicpy==0.5.3 +screenlogicpy==0.5.4 # homeassistant.components.emulated_kasa # homeassistant.components.sense sense_energy==0.9.3 # homeassistant.components.sentry -sentry-sdk==1.5.1 +sentry-sdk==1.5.2 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -1310,7 +1352,7 @@ smarthab==0.21 smhi-pkg==1.0.15 # homeassistant.components.sonos -soco==0.25.2 +soco==0.25.3 # homeassistant.components.solaredge solaredge==0.0.2 @@ -1338,6 +1380,7 @@ spotipy==2.19.0 # homeassistant.components.recorder # homeassistant.components.sql +# homeassistant.components.webostv sqlalchemy==1.4.27 # homeassistant.components.srp_energy @@ -1394,8 +1437,11 @@ total_connect_client==2021.12 # homeassistant.components.transmission transmissionrpc==0.11 +# homeassistant.components.twinkly +ttls==1.4.2 + # homeassistant.components.tuya -tuya-iot-py-sdk==0.6.3 +tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu twentemilieu==0.5.0 @@ -1403,12 +1449,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.0 + # homeassistant.components.upb upb_lib==0.4.12 @@ -1422,6 +1468,9 @@ 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.3.1 @@ -1460,7 +1509,7 @@ wiffi==1.1.0 withings-api==2.3.2 # homeassistant.components.wled -wled==0.10.1 +wled==0.11.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 @@ -1469,7 +1518,7 @@ wolf_smartset==0.1.11 xbox-webapi==2.0.11 # homeassistant.components.knx -xknx==0.18.15 +xknx==0.19.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -1480,16 +1529,16 @@ 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.16 +yalexs==1.1.19 # homeassistant.components.yeelight yeelight==0.7.8 # homeassistant.components.youless -youless-api==0.15 +youless-api==0.16 # homeassistant.components.zeroconf zeroconf==0.38.1 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 244add03cc42c..ea979ff024bf6 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -5,12 +5,12 @@ black==21.12b0 codespell==2.0.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 02ef8d929da5b..7452af2ba2f7b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -61,10 +61,6 @@ os.path.dirname(__file__), "../homeassistant/package_constraints.txt" ) CONSTRAINT_BASE = """ -# Constrain pillow to 8.2.0 because later versions are causing issues in nightly builds. -# https://github.com/home-assistant/core/issues/61756 -pillow==8.2.0 - # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 @@ -79,15 +75,15 @@ # 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 @@ -113,12 +109,14 @@ # 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 - # 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 """ IGNORE_PRE_COMMIT_HOOK_ID = ( @@ -136,22 +134,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): diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index a0c078e325bc1..54d4944cf7034 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -52,6 +52,7 @@ "default_config", "device_automation", "device_tracker", + "diagnostics", "discovery", "downloader", "fan", diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 80e2022243d2f..bf1d008a05922 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -17,7 +17,6 @@ # 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.*", @@ -25,17 +24,11 @@ "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.*", @@ -43,16 +36,13 @@ "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.*", @@ -73,23 +63,19 @@ "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.*", @@ -113,13 +99,11 @@ "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.*", @@ -176,6 +160,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 +176,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 +205,7 @@ 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 for module in all_modules: if module.endswith(".*"): module_path = Path(module[:-2].replace(".", os.path.sep)) @@ -235,6 +233,12 @@ def generate_and_validate(config: Config) -> str: for key in STRICT_SETTINGS: mypy_config.set(general_section, key, "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) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 2da82762240d6..09afd11b147f7 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 841638a971808..d174f238217d9 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/pip_check b/script/pip_check new file mode 100755 index 0000000000000..5864d6f00c012 --- /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=14 + +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/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py index ab5c93364e1a7..dc92ecc1d1511 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 c9f56b3919b36..d7fb1e56eefa2 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 8b1bdc9374904..b580e609bba11 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/reproduce_state/integration/reproduce_state.py b/script/scaffold/templates/reproduce_state/integration/reproduce_state.py index 19e046f4c92ed..4247a1dc8d2d6 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/setup.cfg b/setup.cfg index ad1e6650a59b3..f285902985c0e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,6 @@ classifier = 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 diff --git a/setup.py b/setup.py index 270f5c58f5844..62729ab898ca0 100755 --- a/setup.py +++ b/setup.py @@ -34,10 +34,10 @@ REQUIRES = [ "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", + "awesomeversion==22.1.0", 'backports.zoneinfo;python_version<"3.9"', "bcrypt==3.1.7", "certifi>=2021.5.30", @@ -51,10 +51,11 @@ "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", ] MIN_PY_VERSION = ".".join(map(str, hass_const.REQUIRED_PYTHON_VER)) diff --git a/tests/common.py b/tests/common.py index 73b67a63ebc82..3ea4cde2cecb0 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 @@ -286,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 @@ -1055,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): @@ -1240,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 c134552ccd41a..dd9b889fe2702 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 472587781caca..e41cf3ec5874e 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/test_alarm_control_panel.py b/tests/components/abode/test_alarm_control_panel.py index 55ff22b9ee392..74d647311288d 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 d8f19a19b77d8..6d7ffec438b51 100644 --- a/tests/components/abode/test_binary_sensor.py +++ b/tests/components/abode/test_binary_sensor.py @@ -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) diff --git a/tests/components/abode/test_camera.py b/tests/components/abode/test_camera.py index 7dc943a0a741a..fd490c4a1c2a2 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 44582692c7322..adbea237d34e2 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 edd40a867079a..bd7104bff3fd8 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 130f0c6791ebb..32bb8bf7c706f 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 b5160aece2a26..d27a07227d0f9 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 c688b6f02bcda..837b62e06cdeb 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 3d68e4d3d8a2c..ba163ba87d9f0 100644 --- a/tests/components/abode/test_sensor.py +++ b/tests/components/abode/test_sensor.py @@ -8,12 +8,13 @@ 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) @@ -22,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) diff --git a/tests/components/abode/test_switch.py b/tests/components/abode/test_switch.py index 829c5e8ae374e..74fa6491f662a 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 dc7c978d554ed..a1c454ad0b7d7 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -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 b1c87c7d40438..6c1bc76e9b1f5 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 d35a18cdacc00..998e4df8b15a8 100644 --- a/tests/components/adax/test_config_flow.py +++ b/tests/components/adax/test_config_flow.py @@ -52,7 +52,7 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result3["type"] == "create_entry" - assert result3["title"] == TEST_DATA["account_id"] + assert result3["title"] == str(TEST_DATA["account_id"]) assert result3["data"] == { ACCOUNT_ID: TEST_DATA["account_id"], CONF_PASSWORD: TEST_DATA["password"], @@ -93,7 +93,7 @@ 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) diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 434c7e5022f3a..c8ad6782ce86c 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 2c53ce36d1b9b..3e8206aa76e4a 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -158,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/test_config_flow.py b/tests/components/airvisual/test_config_flow.py index 65534f1f16c18..b497422383010 100644 --- a/tests/components/airvisual/test_config_flow.py +++ b/tests/components/airvisual/test_config_flow.py @@ -246,7 +246,7 @@ async def test_options_flow(hass): async def test_step_geography_by_coords(hass): - """Test setting up a geopgraphy entry by latitude/longitude.""" + """Test setting up a geography entry by latitude/longitude.""" conf = { CONF_API_KEY: "abcde12345", CONF_LATITUDE: 51.528308, @@ -276,7 +276,7 @@ async def test_step_geography_by_coords(hass): async def test_step_geography_by_name(hass): - """Test setting up a geopgraphy entry by city/state/country.""" + """Test setting up a geography entry by city/state/country.""" conf = { CONF_API_KEY: "abcde12345", CONF_CITY: "Beijing", diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index 5b1706c15e251..1d8289b5ec09a 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 @@ -187,7 +201,7 @@ async def reported_properties(hass, endpoint): 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() return ReportedProperties(msg["context"]["properties"]) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index d4d6bec62a995..566917d7c39b0 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -29,9 +29,9 @@ ) from . import ( - DEFAULT_CONFIG, assert_request_calls_service, assert_request_fails, + get_default_config, get_new_request, reported_properties, ) @@ -56,7 +56,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 @@ -86,7 +86,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 +112,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 +140,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 +168,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 diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 9a1ef03276277..54e48df8e8eba 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 d74233c2bd920..7ebba26113df6 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 @@ -3965,3 +3975,14 @@ async def test_button(hass, domain): await assert_scene_controller_works( 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 29624e7d1ff65..cbadb8697b850 100644 --- a/tests/components/alexa/test_state_report.py +++ b/tests/components/alexa/test_state_report.py @@ -1,10 +1,12 @@ """Test report state.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +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 +19,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 +43,95 @@ async def test_report_state(hass, aioclient_mock): assert call_json["event"]["endpoint"]["endpointId"] == "binary_sensor#test_contact" +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 +149,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 +218,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 +246,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 +272,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", @@ -219,7 +312,8 @@ async def test_doorbell_event(hass, aioclient_mock): 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 +364,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/amberelectric/__init__.py b/tests/components/amberelectric/__init__.py new file mode 100644 index 0000000000000..9eae18c65aaf2 --- /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 9aa4782b9a4e4..856dcdc473e19 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 71c40b4cf758b..ce474be1b3d22 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 5085f9c50f86c..bc80d3674d61c 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 deafcb70fb77f..fa8cffe2c734f 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/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index a781cb4c6626e..2534c4678e8ec 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 e48d662594d35..24abb63b00203 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/test_config_flow.py b/tests/components/androidtv/test_config_flow.py index 3da1a113887ce..757be8f6d8d99 100644 --- a/tests/components/androidtv/test_config_flow.py +++ b/tests/components/androidtv/test_config_flow.py @@ -3,6 +3,8 @@ 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, @@ -92,7 +94,8 @@ async def adb_close(self): self.available = False -async def _test_user(hass, config): +@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} @@ -117,16 +120,6 @@ async def _test_user(hass, config): assert len(mock_setup_entry.mock_calls) == 1 -async def test_user_python_adb(hass): - """Test user config for Python ADB.""" - await _test_user(hass, CONFIG_PYTHON_ADB) - - -async def test_user_adb_server(hass): - """Test user config for ADB server.""" - await _test_user(hass, CONFIG_ADB_SERVER) - - async def test_import(hass): """Test import config.""" @@ -179,7 +172,6 @@ async def test_import_data(hass): config_data[CONF_PLATFORM] = DOMAIN config_data[CONF_ADBKEY] = ADBKEY config_data[CONF_TURN_OFF_COMMAND] = "off" - config_data[CONF_STATE_DETECTION_RULES] = {"a": "b"} platform_data = {MP_DOMAIN: config_data} with patch( @@ -278,7 +270,7 @@ async def test_error_invalid_host(hass): async def test_invalid_serial(hass): - """Test for invallid serialno.""" + """Test for invalid serialno.""" with patch( CONNECT_METHOD, return_value=(MockConfigDevice(eth_mac=""), None), diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index f8f08fa2060bc..98f63dd8b4c2b 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -163,7 +163,16 @@ async def test_setup_with_properties(hass): assert state is not None -async def _test_reconnect(hass, caplog, config): +@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 @@ -180,7 +189,6 @@ async def _test_reconnect(hass, caplog, config): patch_key ], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER: assert await hass.config_entries.async_setup(config_entry.entry_id) - # assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() await hass.helpers.entity_component.async_update_entity(entity_id) @@ -225,10 +233,17 @@ 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. @@ -256,88 +271,6 @@ 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.""" @@ -359,7 +292,14 @@ 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].setdefault(CONF_OPTIONS, {}).update( @@ -436,18 +376,6 @@ 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.""" @@ -756,7 +684,14 @@ 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, config_entry = _setup(config) config_entry.add_to_hass(hass) @@ -772,18 +707,6 @@ async def _test_setup_fail(hass, config): 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_adb_command(hass): """Test sending a command via the `androidtv.adb_command` service.""" @@ -838,7 +761,6 @@ async def test_adb_command_unicode_decode_error(hass): 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 @@ -1270,7 +1192,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 c4285a0cc652d..3f594b3fce3a0 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/apns/test_notify.py b/tests/components/apns/test_notify.py index 301ef1362faeb..ad55a5697ad2b 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/aprs/test_device_tracker.py b/tests/components/aprs/test_device_tracker.py index 3da8cb825e49b..6301b3d7d9bd4 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/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index b8537a5e6a63c..bfb62dae7e050 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/august/mocks.py b/tests/components/august/mocks.py index 13d8f18d0d994..2d572b886f3e3 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -2,8 +2,6 @@ import json import os import time - -# from unittest.mock import AsyncMock from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from yalexs.activity import ( @@ -164,10 +162,17 @@ def unlock_return_activities_side_effect(access_token, device_id): "unlock_return_activities" ] = unlock_return_activities_side_effect - return await _mock_setup_august_with_api_side_effects( + api_instance, entry = await _mock_setup_august_with_api_side_effects( hass, api_call_side_effects, pubnub ) + if device_data["locks"]: + # Ensure we sync status when the integration is loaded if there + # are any locks + assert api_instance.async_status_async.mock_calls + + return entry + async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects, pubnub): api_instance = MagicMock(name="Api") @@ -207,9 +212,12 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects, side_effect=api_call_side_effects["unlock_return_activities"] ) + api_instance.async_unlock_async = AsyncMock() + api_instance.async_lock_async = AsyncMock() + api_instance.async_status_async = AsyncMock() api_instance.async_get_user = AsyncMock(return_value={"UserID": "abc"}) - return await _mock_setup_august(hass, api_instance, pubnub) + return api_instance, await _mock_setup_august(hass, api_instance, pubnub) def _mock_august_authentication(token_text, token_timestamp, state): diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 9d1c34d917a80..56f55138e36be 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -154,6 +154,86 @@ async def test_one_lock_operation(hass): ) +async def test_one_lock_operation_pubnub_connected(hass): + """Test lock and unlock operations are async when pubnub is connected.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + assert lock_one.pubsub_channel == "pubsub" + + pubnub = AugustPubNub() + await _create_august_with_devices(hass, [lock_one], pubnub=pubnub) + pubnub.connected = True + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_LOCKED + + assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 + assert ( + lock_online_with_doorsense_name.attributes.get("friendly_name") + == "online_with_doorsense Name" + ) + + data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} + assert await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True + ) + await hass.async_block_till_done() + + pubnub.message( + pubnub, + Mock( + channel=lock_one.pubsub_channel, + timetoken=(dt_util.utcnow().timestamp() + 1) * 10000000, + message={ + "status": "kAugLockState_Unlocked", + }, + ), + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + + assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 + assert ( + lock_online_with_doorsense_name.attributes.get("friendly_name") + == "online_with_doorsense Name" + ) + + assert await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True + ) + await hass.async_block_till_done() + + pubnub.message( + pubnub, + Mock( + channel=lock_one.pubsub_channel, + timetoken=(dt_util.utcnow().timestamp() + 2) * 10000000, + message={ + "status": "kAugLockState_Locked", + }, + ), + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + assert lock_online_with_doorsense_name.state == STATE_LOCKED + + # No activity means it will be unavailable until the activity feed has data + entity_registry = er.async_get(hass) + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == STATE_UNKNOWN + ) + + async def test_lock_jammed(hass): """Test lock gets jammed on unlock.""" @@ -273,6 +353,7 @@ async def test_lock_update_via_pubnub(hass): config_entry = await _create_august_with_devices( hass, [lock_one], activities=activities, pubnub=pubnub ) + pubnub.connected = True lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") diff --git a/tests/components/aws/test_init.py b/tests/components/aws/test_init.py index e50c0aa546b7c..2186cd5d8828b 100644 --- a/tests/components/aws/test_init.py +++ b/tests/components/aws/test_init.py @@ -29,10 +29,14 @@ 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): + with async_patch("aiobotocore.session.AioSession", new=MockAioSession): await async_setup_component(hass, "aws", {"aws": {}}) await hass.async_block_till_done() @@ -47,7 +51,7 @@ async def test_empty_config(hass): async def test_empty_credential(hass): """Test a default config will be create for empty credential section.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + with async_patch("aiobotocore.session.AioSession", new=MockAioSession): await async_setup_component( hass, "aws", @@ -80,7 +84,7 @@ async def test_empty_credential(hass): async def test_profile_credential(hass): """Test credentials with profile name.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + with async_patch("aiobotocore.session.AioSession", new=MockAioSession): await async_setup_component( hass, "aws", @@ -118,7 +122,7 @@ async def test_profile_credential(hass): async def test_access_key_credential(hass): """Test credentials with access key.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + with async_patch("aiobotocore.session.AioSession", new=MockAioSession): await async_setup_component( hass, "aws", @@ -163,7 +167,7 @@ async def test_access_key_credential(hass): async def test_notify_credential(hass): """Test notify service can use access key directly.""" - with async_patch("aiobotocore.AioSession", new=MockAioSession): + with async_patch("aiobotocore.session.AioSession", new=MockAioSession): await async_setup_component( hass, "aws", @@ -197,7 +201,7 @@ 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): + with async_patch("aiobotocore.session.AioSession", new=MockAioSession): await async_setup_component( hass, "aws", @@ -229,7 +233,7 @@ 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): + with async_patch("aiobotocore.session.AioSession", new=MockAioSession): await async_setup_component( hass, "aws", diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 84a8a2c4c1945..fbc7faf4d30f6 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -25,7 +25,7 @@ def client_fixture() -> Generator[None, MagicMock, None]: client.get_model_name.return_value = "FakeSpa" client.get_ssid.return_value = "V0.0" - # constants should preferebly be moved in the library + # constants should preferably be moved in the library # to be class attributes or further refactored client.TSCALE_C = 1 client.TSCALE_F = 0 diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index fcef3cfd41822..d06eac43e7c43 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -5,7 +5,7 @@ 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 @@ -53,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", @@ -72,7 +72,7 @@ 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( @@ -90,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", @@ -109,7 +109,7 @@ 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( diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index c4cd7df9d91a8..082943e96c74d 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -4,7 +4,7 @@ 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 @@ -53,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", @@ -72,7 +72,7 @@ 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( @@ -93,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", @@ -112,7 +112,7 @@ 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( diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 6dfd445c63404..51b8184354a16 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_light.py b/tests/components/bond/test_light.py index e0ca9a0542595..695a98a927e4f 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/broadlink/test_remote.py b/tests/components/broadlink/test_remote.py index 1ee480636139f..3c97f8ea47a9e 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/test_sensor.py b/tests/components/brother/test_sensor.py index 2ffaf3a2a23d0..7ef87a2e396ef 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -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/camera/test_init.py b/tests/components/camera/test_init.py index 13ec69bcd4e85..403cacec1f104 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -28,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): @@ -57,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 @@ -85,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.""" @@ -189,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): @@ -234,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, @@ -503,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() @@ -511,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( @@ -526,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() @@ -575,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() @@ -604,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() @@ -628,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() @@ -668,7 +712,7 @@ async def test_stream_unavailable(hass, hass_ws_client, mock_camera, mock_stream await client.receive_json() assert mock_update_callback.called - # Simluate the stream going unavailable + # Simulate the stream going unavailable callback = mock_update_callback.call_args.args[0] with patch( "homeassistant.components.camera.Stream.available", new_callable=lambda: False @@ -690,3 +734,145 @@ async def test_stream_unavailable(hass, hass_ws_client, mock_camera, mock_stream 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/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 9bf1175c1b940..9532f56309a47 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -592,7 +592,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 +621,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 +639,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 +744,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) @@ -774,7 +774,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 +820,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 +862,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 +934,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 +1105,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 +1150,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 +1178,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 +1218,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 +1247,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 +1298,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) diff --git a/tests/components/climacell/test_const.py b/tests/components/climacell/test_const.py index 2719426a7a059..d0354c6a10741 100644 --- a/tests/components/climacell/test_const.py +++ b/tests/components/climacell/test_const.py @@ -6,7 +6,7 @@ async def test_post_init(): - """Test post initiailization check for ClimaCellSensorEntityDescription.""" + """Test post initialization check for ClimaCellSensorEntityDescription.""" with pytest.raises(RuntimeError): ClimaCellSensorEntityDescription( diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 8d08e924be762..9e24eaa764d4d 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -4,7 +4,9 @@ import pytest +from homeassistant.components.alexa import errors from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.util.dt import utcnow @@ -48,8 +50,9 @@ async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs, cloud_stub): alexa_entity_configs={"light.kitchen": entity_conf}, alexa_default_expose=["light"], alexa_enabled=True, + alexa_report_state=False, ) - conf = alexa_config.AlexaConfig( + conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() @@ -84,10 +87,14 @@ async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs, cloud_stub): async def test_alexa_config_report_state(hass, cloud_prefs, cloud_stub): """Test Alexa config should expose using prefs.""" - conf = alexa_config.AlexaConfig( + await cloud_prefs.async_update( + alexa_report_state=False, + ) + conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) await conf.async_initialize() + await conf.set_authorized(True) assert cloud_prefs.alexa_report_state is False assert conf.should_report_state is False @@ -119,7 +126,7 @@ async def test_alexa_config_invalidate_token(hass, cloud_prefs, aioclient_mock): "expires_in": 30, }, ) - conf = alexa_config.AlexaConfig( + conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", @@ -127,7 +134,7 @@ async def test_alexa_config_invalidate_token(hass, cloud_prefs, aioclient_mock): Mock( alexa_access_token_url="http://example/alexa_token", auth=Mock(async_check_token=AsyncMock()), - websession=hass.helpers.aiohttp_client.async_get_clientsession(), + websession=async_get_clientsession(hass), ), ) @@ -146,6 +153,110 @@ async def test_alexa_config_invalidate_token(hass, cloud_prefs, aioclient_mock): assert len(aioclient_mock.mock_calls) == 2 +@pytest.mark.parametrize( + "reject_reason,expected_exception", + [ + ("RefreshTokenNotFound", errors.RequireRelink), + ("UnknownRegion", errors.RequireRelink), + ("OtherReason", errors.NoTokenAvailable), + ], +) +async def test_alexa_config_fail_refresh_token( + hass, + cloud_prefs, + aioclient_mock, + reject_reason, + expected_exception, +): + """Test Alexa config failing to refresh token.""" + + aioclient_mock.post( + "http://example/alexa_token", + json={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + }, + ) + aioclient_mock.post("http://example.com/alexa_endpoint", text="", status=202) + await cloud_prefs.async_update( + alexa_report_state=False, + ) + conf = alexa_config.CloudAlexaConfig( + hass, + ALEXA_SCHEMA({}), + "mock-user-id", + cloud_prefs, + Mock( + alexa_access_token_url="http://example/alexa_token", + auth=Mock(async_check_token=AsyncMock()), + websession=async_get_clientsession(hass), + ), + ) + await conf.async_initialize() + await conf.set_authorized(True) + + assert cloud_prefs.alexa_report_state is False + assert conf.should_report_state is False + assert conf.is_reporting_states is False + + hass.states.async_set("fan.test_fan", "off") + + # Enable state reporting + await cloud_prefs.async_update(alexa_report_state=True) + await hass.async_block_till_done() + + assert cloud_prefs.alexa_report_state is True + assert conf.should_report_state is True + assert conf.is_reporting_states is True + + # Change states to trigger event listener + hass.states.async_set("fan.test_fan", "on") + await hass.async_block_till_done() + + # Invalidate the token and try to fetch another + conf.async_invalidate_access_token() + aioclient_mock.clear_requests() + aioclient_mock.post( + "http://example/alexa_token", + json={"reason": reject_reason}, + status=400, + ) + + # Change states to trigger event listener + hass.states.async_set("fan.test_fan", "off") + await hass.async_block_till_done() + + # Check state reporting is still wanted in cloud prefs, but disabled for Alexa + assert cloud_prefs.alexa_report_state is True + assert conf.should_report_state is False + assert conf.is_reporting_states is False + + # Simulate we're again authorized, but token update fails + with pytest.raises(expected_exception): + await conf.set_authorized(True) + + assert cloud_prefs.alexa_report_state is True + assert conf.should_report_state is False + assert conf.is_reporting_states is False + + # Simulate we're again authorized and token update succeeds + # State reporting should now be re-enabled for Alexa + aioclient_mock.clear_requests() + aioclient_mock.post( + "http://example/alexa_token", + json={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + }, + ) + await conf.set_authorized(True) + assert cloud_prefs.alexa_report_state is True + assert conf.should_report_state is True + assert conf.is_reporting_states is True + + @contextlib.contextmanager def patch_sync_helper(): """Patch sync helper. @@ -161,7 +272,7 @@ def sync_helper(to_upd, to_rem): return True with patch("homeassistant.components.cloud.alexa_config.SYNC_DELAY", 0), patch( - "homeassistant.components.cloud.alexa_config.AlexaConfig._sync_helper", + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig._sync_helper", side_effect=sync_helper, ): yield to_update, to_remove @@ -169,7 +280,10 @@ def sync_helper(to_upd, to_rem): async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): """Test Alexa config responds to updating exposed entities.""" - await alexa_config.AlexaConfig( + await cloud_prefs.async_update( + alexa_report_state=False, + ) + await alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ).async_initialize() @@ -204,7 +318,7 @@ async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): """Test Alexa config responds to entity registry.""" - await alexa_config.AlexaConfig( + await alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ).async_initialize() @@ -256,14 +370,19 @@ async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): async def test_alexa_update_report_state(hass, cloud_prefs, cloud_stub): """Test Alexa config responds to reporting state.""" - await alexa_config.AlexaConfig( + await cloud_prefs.async_update( + alexa_report_state=False, + ) + conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub - ).async_initialize() + ) + await conf.async_initialize() + await conf.set_authorized(True) with patch( - "homeassistant.components.cloud.alexa_config.AlexaConfig.async_sync_entities", + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.async_sync_entities", ) as mock_sync, patch( - "homeassistant.components.cloud.alexa_config.AlexaConfig.async_enable_proactive_mode", + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.async_enable_proactive_mode", ): await cloud_prefs.async_update(alexa_report_state=True) await hass.async_block_till_done() @@ -277,7 +396,7 @@ def test_enabled_requires_valid_sub(hass, mock_expired_cloud_login, cloud_prefs) assert hass.data["cloud"].is_logged_in assert hass.data["cloud"].subscription_expired - config = alexa_config.AlexaConfig( + config = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"] ) @@ -286,7 +405,7 @@ def test_enabled_requires_valid_sub(hass, mock_expired_cloud_login, cloud_prefs) async def test_alexa_handle_logout(hass, cloud_prefs, cloud_stub): """Test Alexa config responds to logging out.""" - aconf = alexa_config.AlexaConfig( + aconf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub ) diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index c3890fb17ec42..f56a1c86d4d19 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -8,7 +8,11 @@ from homeassistant.components.cloud import DOMAIN from homeassistant.components.cloud.client import CloudClient -from homeassistant.components.cloud.const import PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE +from homeassistant.components.cloud.const import ( + PREF_ALEXA_REPORT_STATE, + PREF_ENABLE_ALEXA, + PREF_ENABLE_GOOGLE, +) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import State from homeassistant.setup import async_setup_component @@ -47,7 +51,7 @@ async def test_handler_alexa(hass): }, ) - mock_cloud_prefs(hass) + mock_cloud_prefs(hass, {PREF_ALEXA_REPORT_STATE: False}) cloud = hass.data["cloud"] resp = await cloud.client.async_alexa_message( diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 42a498528cea6..d3d71666bf22a 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -12,9 +12,10 @@ from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa.entities import LightCapabilities -from homeassistant.components.cloud.const import DOMAIN, RequireRelink +from homeassistant.components.cloud.const import DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.core import State +from homeassistant.util.location import LocationInfo from . import mock_cloud, mock_cloud_prefs @@ -203,16 +204,60 @@ async def test_logout_view_unknown_error(hass, cloud_client): assert req.status == HTTPStatus.BAD_GATEWAY -async def test_register_view(mock_cognito, cloud_client): - """Test logging out.""" - req = await cloud_client.post( - "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"} - ) +async def test_register_view_no_location(mock_cognito, cloud_client): + """Test register without location.""" + with patch( + "homeassistant.components.cloud.http_api.async_detect_location_info", + return_value=None, + ): + req = await cloud_client.post( + "/api/cloud/register", + json={"email": "hello@bla.com", "password": "falcon42"}, + ) assert req.status == HTTPStatus.OK assert len(mock_cognito.register.mock_calls) == 1 - result_email, result_pass = mock_cognito.register.mock_calls[0][1] + call = mock_cognito.register.mock_calls[0] + result_email, result_pass = call.args assert result_email == "hello@bla.com" assert result_pass == "falcon42" + assert call.kwargs["client_metadata"] is None + + +async def test_register_view_with_location(mock_cognito, cloud_client): + """Test register with location.""" + with patch( + "homeassistant.components.cloud.http_api.async_detect_location_info", + return_value=LocationInfo( + **{ + "country_code": "XX", + "zip_code": "12345", + "region_code": "GH", + "ip": "1.2.3.4", + "city": "Gotham", + "region_name": "Gotham", + "time_zone": "Earth/Gotham", + "currency": "XXX", + "latitude": "12.34567", + "longitude": "12.34567", + "use_metric": True, + } + ), + ): + req = await cloud_client.post( + "/api/cloud/register", + json={"email": "hello@bla.com", "password": "falcon42"}, + ) + assert req.status == HTTPStatus.OK + assert len(mock_cognito.register.mock_calls) == 1 + call = mock_cognito.register.mock_calls[0] + result_email, result_pass = call.args + assert result_email == "hello@bla.com" + assert result_pass == "falcon42" + assert call.kwargs["client_metadata"] == { + "NC_COUNTRY_CODE": "XX", + "NC_REGION_CODE": "GH", + "NC_ZIP_CODE": "12345", + } async def test_register_view_bad_data(mock_cognito, cloud_client): @@ -356,8 +401,8 @@ async def test_websocket_status( "google_default_expose": None, "alexa_default_expose": None, "alexa_entity_configs": {}, - "alexa_report_state": False, - "google_report_state": False, + "alexa_report_state": True, + "google_report_state": True, "remote_enabled": False, "tts_default_voice": ["en-US", "female"], }, @@ -369,6 +414,7 @@ async def test_websocket_status( "exclude_entity_globs": [], "exclude_entities": [], }, + "alexa_registered": False, "google_entities": { "include_domains": ["light"], "include_entity_globs": [], @@ -464,6 +510,28 @@ async def test_websocket_update_preferences( assert setup_api.tts_default_voice == ("en-GB", "male") +async def test_websocket_update_preferences_alexa_report_state( + hass, hass_ws_client, aioclient_mock, setup_api, mock_cloud_login +): + """Test updating alexa_report_state sets alexa authorized.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" + ".async_get_access_token", + ), patch( + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" + ) as set_authorized_mock: + set_authorized_mock.assert_not_called() + await client.send_json( + {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} + ) + response = await client.receive_json() + set_authorized_mock.assert_called_once_with(True) + + assert response["success"] + + async def test_websocket_update_preferences_require_relink( hass, hass_ws_client, aioclient_mock, setup_api, mock_cloud_login ): @@ -471,14 +539,18 @@ async def test_websocket_update_preferences_require_relink( client = await hass_ws_client(hass) with patch( - "homeassistant.components.cloud.alexa_config.AlexaConfig" + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" ".async_get_access_token", - side_effect=RequireRelink, - ): + side_effect=alexa_errors.RequireRelink, + ), patch( + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" + ) as set_authorized_mock: + set_authorized_mock.assert_not_called() await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(False) assert not response["success"] assert response["error"]["code"] == "alexa_relink" @@ -491,14 +563,18 @@ async def test_websocket_update_preferences_no_token( client = await hass_ws_client(hass) with patch( - "homeassistant.components.cloud.alexa_config.AlexaConfig" + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" ".async_get_access_token", side_effect=alexa_errors.NoTokenAvailable, - ): + ), patch( + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" + ) as set_authorized_mock: + set_authorized_mock.assert_not_called() await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(False) assert not response["success"] assert response["error"]["code"] == "alexa_relink" @@ -693,7 +769,7 @@ async def test_sync_alexa_entities_timeout( """Test that timeout syncing Alexa entities.""" client = await hass_ws_client(hass) with patch( - "homeassistant.components.cloud.alexa_config.AlexaConfig" + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" ".async_sync_entities", side_effect=asyncio.TimeoutError, ): @@ -710,7 +786,7 @@ async def test_sync_alexa_entities_no_token( """Test sync Alexa entities when we have no token.""" client = await hass_ws_client(hass) with patch( - "homeassistant.components.cloud.alexa_config.AlexaConfig" + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" ".async_sync_entities", side_effect=alexa_errors.NoTokenAvailable, ): @@ -727,7 +803,7 @@ async def test_enable_alexa_state_report_fail( """Test enable Alexa entities state reporting when no token available.""" client = await hass_ws_client(hass) with patch( - "homeassistant.components.cloud.alexa_config.AlexaConfig" + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" ".async_sync_entities", side_effect=alexa_errors.NoTokenAvailable, ): diff --git a/tests/components/cloud/test_utils.py b/tests/components/cloud/test_utils.py deleted file mode 100644 index d23b99cbb5d7e..0000000000000 --- a/tests/components/cloud/test_utils.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Test aiohttp request helper.""" -from aiohttp import web - -from homeassistant.components.cloud import utils - - -def test_serialize_text(): - """Test serializing a text response.""" - response = web.Response(status=201, text="Hello") - assert utils.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 utils.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 utils.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 utils.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 utils.aiohttp_serialize_response(response) == { - "status": 200, - "body": '{"how": "what"}', - "headers": {"Content-Type": "application/json; charset=utf-8"}, - } diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 2749fff812762..532e14573f405 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 0a37449c184fb..9d4f5b60c8bbb 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 be897fa2408f9..c9a4860b987d1 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 910d990d07d0e..f918c7500ad66 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 3bd86280750ff..65741fd86ba5d 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_group.py b/tests/components/config/test_group.py deleted file mode 100644 index 4d1d28020bb66..0000000000000 --- 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/cpuspeed/__init__.py b/tests/components/cpuspeed/__init__.py new file mode 100644 index 0000000000000..b65adbc70eb4b --- /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 0000000000000..a5d7d1837ba3d --- /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 0000000000000..14563c82bffa7 --- /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_init.py b/tests/components/cpuspeed/test_init.py new file mode 100644 index 0000000000000..2352e411b8e07 --- /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 0000000000000..134d19b31ea2e --- /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 05fde6109e731..fdc0df108eee8 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 5bafdc2fbb63f..7e83e02760733 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 32c7f1c3eb9c8..04c5335ffd5a9 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -126,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", }, @@ -137,12 +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] == BinarySensorDeviceClass.TAMPER ) + assert ( + ent_reg.async_get("binary_sensor.presence_sensor_tampered").entity_category + is EntityCategory.DIAGNOSTIC + ) event_changed_sensor = { "t": "event", @@ -155,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 - == EntityCategory.DIAGNOSTIC - ) await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index ca76631728e90..76babab36beba 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_sensor.py b/tests/components/deconz/test_sensor.py index d188d5504ca9c..ee66a159c1802 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -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/device_automation/test_init.py b/tests/components/device_automation/test_init.py index fe656663ca845..f7b1339014a3f 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", @@ -395,14 +402,14 @@ async def test_async_get_device_automations_single_device_trigger( hass, device_automation.DeviceAutomationType.TRIGGER, [device_entry.id] ) assert device_entry.id in result - assert len(result[device_entry.id]) == 2 + 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( @@ -421,7 +428,7 @@ async def test_async_get_device_automations_all_devices_trigger( 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( @@ -520,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 0000000000000..4e125b9bf1077 --- /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 9b6a85cf8a0c8..3c8efad5b05f3 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_entities.py b/tests/components/device_tracker/test_entities.py index 88e1dccdb342c..12059cad601f3 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_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index 7d115a26b15d1..7cd1ba5222c63 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/diagnostics/__init__.py b/tests/components/diagnostics/__init__.py new file mode 100644 index 0000000000000..5cf56913f6034 --- /dev/null +++ b/tests/components/diagnostics/__init__.py @@ -0,0 +1,24 @@ +"""Tests for the Diagnostics integration.""" +from http import HTTPStatus + +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def get_diagnostics_for_config_entry(hass, hass_client, domain_or_config_entry): + """Return the diagnostics config entry for the specified domain.""" + if isinstance(domain_or_config_entry, str): + config_entry = MockConfigEntry(domain=domain_or_config_entry) + config_entry.add_to_hass(hass) + else: + config_entry = domain_or_config_entry + + 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() diff --git a/tests/components/diagnostics/test_init.py b/tests/components/diagnostics/test_init.py new file mode 100644 index 0000000000000..60bd28a862a15 --- /dev/null +++ b/tests/components/diagnostics/test_init.py @@ -0,0 +1,51 @@ +"""Test the Diagnostics integration.""" +from unittest.mock import AsyncMock, Mock + +import pytest + +from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.setup import async_setup_component + +from . import get_diagnostics_for_config_entry + +from tests.common import 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={ + "hello": "info", + } + ), + ), + ) + assert await async_setup_component(hass, "diagnostics", {}) + + +async def test_websocket_info(hass, hass_ws_client): + """Test camera_thumbnail 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}} + ] + + +async def test_download_diagnostics(hass, hass_client): + """Test record service.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, "fake_integration" + ) == {"hello": "info"} diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 9ef6bccfab57c..e0299d68f2bd2 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 02c27369f09e8..692870d70374f 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -99,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 diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index b0b1f9c11839c..65c52e14d39c9 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -309,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 @@ -334,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}] ), } @@ -510,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 @@ -528,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}] ), } @@ -576,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 f8a83e4c9057b..0af5fd150e321 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 03abf6af02a16..0000000000000 --- 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 afe3897eff9ef..0000000000000 --- 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/elgato/__init__.py b/tests/components/elgato/__init__.py index 12df481d18265..7d69fa68f736d 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 fe86f26b535f3..efae0739c7bc7 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 b49a6f6dd80d7..d9b2567928dd8 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 0000000000000..5b3d7690d85e7 --- /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 f6180e14238cc..0000000000000 --- 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 80a906e114fb0..f44299285d265 100644 --- a/tests/components/elgato/test_button.py +++ b/tests/components/elgato/test_button.py @@ -1,26 +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.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") @@ -33,18 +34,29 @@ async def test_button_identify( assert entry.unique_id == "CN11A1A00001_identify" assert entry.entity_category == EntityCategory.CONFIG - 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, - ) + 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)" + + 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 @@ -52,22 +64,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 2ea2dab6acf57..dffd59cedcc45 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_init.py b/tests/components/elgato/test_init.py index f764ecdba80a2..d2633ddf36c1c 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.info.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.info.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 c85c71aa723fc..b96dfcceea7f9 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,30 @@ 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)" + + +@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 +101,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/test_config_flow.py b/tests/components/elmax/test_config_flow.py index 4584ab679f435..5b8d42799e905 100644 --- a/tests/components/elmax/test_config_flow.py +++ b/tests/components/elmax/test_config_flow.py @@ -1,4 +1,4 @@ -"""Tests for the Abode config flow.""" +"""Tests for the Elmax config flow.""" from unittest.mock import patch from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError @@ -13,8 +13,8 @@ DOMAIN, ) from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.data_entry_flow import FlowResult +from tests.common import MockConfigEntry from tests.components.elmax import ( MOCK_PANEL_ID, MOCK_PANEL_NAME, @@ -26,97 +26,76 @@ CONF_POLLING = "polling" -def _has_error(errors): - return errors is not None and len(errors.keys()) > 0 - - -async def _bootstrap( - hass, - source=config_entries.SOURCE_USER, - username=MOCK_USERNAME, - password=MOCK_PASSWORD, - panel_name=MOCK_PANEL_NAME, - panel_pin=MOCK_PANEL_PIN, -) -> FlowResult: - +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} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - if result["type"] != data_entry_flow.RESULT_TYPE_FORM or _has_error( - result["errors"] - ): - return result - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_ELMAX_USERNAME: username, - CONF_ELMAX_PASSWORD: password, - }, + 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} ) - if result2["type"] != data_entry_flow.RESULT_TYPE_FORM or _has_error( - result2["errors"] + with patch( + "homeassistant.components.elmax.async_setup_entry", + return_value=True, ): - return result2 - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_ELMAX_PANEL_NAME: panel_name, - CONF_ELMAX_PANEL_PIN: panel_pin, - }, - ) - return result3 - + 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 _reauth(hass): - # Trigger reauth - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH}, +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_PANEL_PIN: MOCK_PANEL_PIN, CONF_ELMAX_USERNAME: MOCK_USERNAME, CONF_ELMAX_PASSWORD: MOCK_PASSWORD, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, }, - ) - if result2["type"] != data_entry_flow.RESULT_TYPE_FORM or _has_error( - result2["errors"] - ): - return result2 + unique_id=MOCK_PANEL_ID, + ).add_to_hass(hass) - # Perform reauth confirm step - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + # 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_PANEL_ID: MOCK_PANEL_ID, - CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, CONF_ELMAX_USERNAME: MOCK_USERNAME, CONF_ELMAX_PASSWORD: MOCK_PASSWORD, }, ) - return result3 - - -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} + 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_FORM - assert result["step_id"] == "user" - - -async def test_one_config_allowed(hass): - """Test that only one Elmax configuration is allowed for each panel.""" - # Setup once. - attempt1 = await _bootstrap(hass) - assert attempt1["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - - # Attempt to add another instance of the integration for the very same panel, it must fail. - attempt2 = await _bootstrap(hass) - assert attempt2["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert attempt2["reason"] == "already_configured" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" async def test_invalid_credentials(hass): @@ -125,12 +104,19 @@ async def test_invalid_credentials(hass): "elmax_api.http.Elmax.login", side_effect=ElmaxBadLoginError(), ): - result = await _bootstrap( - hass, username="wrong_user_name@email.com", password="incorrect_password" + show_form_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "bad_auth"} + 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): @@ -139,12 +125,19 @@ async def test_connection_error(hass): "elmax_api.http.Elmax.login", side_effect=ElmaxNetworkError(), ): - result = await _bootstrap( - hass, username="wrong_user_name@email.com", password="incorrect_password" + show_form_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "network_error"} + 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): @@ -153,10 +146,26 @@ async def test_unhandled_error(hass): "elmax_api.http.Elmax.get_panel_status", side_effect=Exception(), ): - result = await _bootstrap(hass) + 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_error"} + assert result["errors"] == {"base": "unknown"} async def test_invalid_pin(hass): @@ -166,7 +175,23 @@ async def test_invalid_pin(hass): "elmax_api.http.Elmax.get_panel_status", side_effect=ElmaxBadPinError(), ): - result = await _bootstrap(hass) + 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"} @@ -179,22 +204,19 @@ async def test_no_online_panel(hass): "elmax_api.http.Elmax.list_control_panels", return_value=[], ): - result = await _bootstrap(hass) - assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "no_panel_online"} - - -async def test_step_user(hass): - """Test that the user step works.""" - result = await _bootstrap(hass) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["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, - } + 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): @@ -215,24 +237,84 @@ async def test_show_reauth(hass): async def test_reauth_flow(hass): """Test that the reauth flow works.""" - # Simulate a first setup - await _bootstrap(hass) + 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 - result = await _reauth(hass) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "reauth_successful" + 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 - await _bootstrap(hass) + 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=[], ): - result = await _reauth(hass) + 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"} @@ -240,14 +322,41 @@ async def test_reauth_panel_disappeared(hass): async def test_reauth_invalid_pin(hass): """Test that the case where panel is no longer associated with the user.""" - # Simulate a first setup - await _bootstrap(hass) + 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(), ): - result = await _reauth(hass) + 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"} @@ -255,14 +364,41 @@ async def test_reauth_invalid_pin(hass): async def test_reauth_bad_login(hass): """Test bad login attempt at reauth time.""" - # Simulate a first setup - await _bootstrap(hass) + 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(), ): - result = await _reauth(hass) + 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": "bad_auth"} + assert result["errors"] == {"base": "invalid_auth"} 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 0000000000000..980cfa4eab857 --- /dev/null +++ b/tests/components/evil_genius_labs/test_diagnostics.py @@ -0,0 +1,19 @@ +"""Test evil genius labs diagnostics.""" +import pytest + +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_trigger.py b/tests/components/fan/test_device_trigger.py index 0d9edaf6fab4b..ca947f1e82230 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -66,6 +66,13 @@ 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, DeviceAutomationType.TRIGGER, device_entry.id @@ -144,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 }}" + ) + }, + }, + }, ] }, ) @@ -151,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/feedreader/test_init.py b/tests/components/feedreader/test_init.py index a10018f9b2e9c..34f27c36a6ccb 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/flic/__init__.py b/tests/components/flic/__init__.py new file mode 100644 index 0000000000000..0e92d271a93f7 --- /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 0000000000000..463dbf4a9d750 --- /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/flux/test_switch.py b/tests/components/flux/test_switch.py index e331a788e9cbf..275c10c5592aa 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 ae03726fdfa02..e1abebd40f1fc 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, PowerRestoreState, PowerRestoreStates +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,7 +64,7 @@ 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, @@ -63,6 +75,16 @@ ) +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) @@ -73,12 +95,29 @@ 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() @@ -101,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)" @@ -130,7 +169,18 @@ async def _save_setup_callback(callback: Callable) -> None: 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() diff --git a/tests/components/flux_led/test_button.py b/tests/components/flux_led/test_button.py index 1117373fcd624..992d8b18ce686 100644 --- a/tests/components/flux_led/test_button.py +++ b/tests/components/flux_led/test_button.py @@ -8,8 +8,11 @@ from . import ( DEFAULT_ENTRY_TITLE, + FLUX_DISCOVERY, IP_ADDRESS, MAC_ADDRESS, + _mock_config_entry_for_bulb, + _mocked_bulb, _mocked_switch, _patch_discovery, _patch_wifibulb, @@ -18,7 +21,7 @@ from tests.common import MockConfigEntry -async def test_switch_reboot(hass: HomeAssistant) -> None: +async def test_button_reboot(hass: HomeAssistant) -> None: """Test a smart plug can be rebooted.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -39,3 +42,21 @@ async def test_switch_reboot(hass: HomeAssistant) -> None: 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 32e1707f7eb46..dcab5cc01add3 100644 --- a/tests/components/flux_led/test_config_flow.py +++ b/tests/components/flux_led/test_config_flow.py @@ -13,6 +13,9 @@ 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, @@ -32,6 +35,8 @@ IP_ADDRESS, MAC_ADDRESS, MODEL, + MODEL_DESCRIPTION, + MODEL_NUM, MODULE, _patch_discovery, _patch_wifibulb, @@ -91,6 +96,85 @@ async def test_discovery(hass: HomeAssistant): 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, @@ -171,6 +255,9 @@ async def test_discovery_with_existing_device_present(hass: HomeAssistant): 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, @@ -245,6 +332,9 @@ async def test_manual_working_discovery(hass: HomeAssistant): 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, @@ -283,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): @@ -352,6 +447,9 @@ async def test_discovered_by_discovery(hass): 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, @@ -387,6 +485,9 @@ async def test_discovered_by_dhcp_udp_responds(hass): 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, @@ -419,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 @@ -448,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 diff --git a/tests/components/flux_led/test_init.py b/tests/components/flux_led/test_init.py index 1b4864de78830..7981f2cef11ce 100644 --- a/tests/components/flux_led/test_init.py +++ b/tests/components/flux_led/test_init.py @@ -1,6 +1,7 @@ """Tests for the flux_led component.""" from __future__ import annotations +from datetime import timedelta from unittest.mock import patch import pytest @@ -19,6 +20,7 @@ FLUX_DISCOVERY_PARTIAL, IP_ADDRESS, MAC_ADDRESS, + _mocked_bulb, _patch_discovery, _patch_wifibulb, ) @@ -138,3 +140,19 @@ def _mock_getBulbInfo(*args, **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 diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index 66bcf8bd212c2..c77cfa956c071 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -12,6 +12,7 @@ COLOR_MODES_RGB_W as FLUX_COLOR_MODES_RGB_W, MODE_MUSIC, MultiColorEffects, + WhiteChannelType, ) from flux_led.protocol import MusicMode import pytest @@ -25,6 +26,7 @@ CONF_EFFECT, CONF_SPEED_PCT, CONF_TRANSITION, + CONF_WHITE_CHANNEL_TYPE, DOMAIN, TRANSITION_JUMP, ) @@ -46,6 +48,9 @@ ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_WHITE, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, DOMAIN as LIGHT_DOMAIN, ) from homeassistant.const import ( @@ -252,9 +257,11 @@ async def test_rgb_light(hass: HomeAssistant) -> None: blocking=True, ) # If the bulb is on and we are using existing brightness - # and brightness was 0 it means we could not read it because - # an effect is in progress so we use 255 - bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=255) + # and brightness was 0 older devices will not be able to turn on + # so we need to make sure its at least 1 and that we + # call it before the turn on command since the device + # does not support auto on + bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=1) bulb.async_set_levels.reset_mock() bulb.brightness = 128 @@ -309,9 +316,9 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None: assert state.state == STATE_ON attributes = state.attributes assert attributes[ATTR_BRIGHTNESS] == 128 - assert attributes[ATTR_COLOR_MODE] == "rgb" + assert attributes[ATTR_COLOR_MODE] == COLOR_MODE_RGB assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["rgb"] + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_RGB] assert attributes[ATTR_HS_COLOR] == (0, 100) await hass.services.async_call( @@ -336,6 +343,19 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None: bulb.async_set_levels.reset_mock() bulb.async_turn_on.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: (0, 0, 0)}, + blocking=True, + ) + # 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.reset_mock() + bulb.async_turn_on.reset_mock() + # Should still be called with no kwargs await hass.services.async_call( LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -362,10 +382,11 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None: blocking=True, ) # If the bulb is on and we are using existing brightness - # and brightness was 0 it means we could not read it because - # an effect is in progress so we use 255 + # and brightness was 0 we need to set it to at least 1 + # or the device may not turn on bulb.async_turn_on.assert_not_called() - bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=255) + bulb.async_set_brightness.assert_not_called() + bulb.async_set_levels.assert_called_with(10, 10, 30, brightness=1) bulb.async_set_levels.reset_mock() bulb.brightness = 128 @@ -400,6 +421,236 @@ async def test_rgb_light_auto_on(hass: HomeAssistant) -> None: bulb.async_set_effect.reset_mock() +async def test_rgbw_light_auto_on(hass: HomeAssistant) -> None: + """Test an rgbw light that does not need the turn on command sent.""" + 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.requires_turn_on = False + bulb.raw_state = bulb.raw_state._replace(model_num=0x33) # RGB only model + bulb.color_modes = {FLUX_COLOR_MODE_RGBW} + 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] == COLOR_MODE_RGBW + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_RGBW] + assert attributes[ATTR_HS_COLOR] == (0.0, 83.529) + + 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 + + bulb.brightness = 0 + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: (10, 10, 30, 0)}, + blocking=True, + ) + # 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(10, 10, 30, 0) + bulb.async_set_levels.reset_mock() + bulb.async_turn_on.reset_mock() + + # Should still be called with no kwargs + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_on.assert_called_once() + await async_mock_device_turn_on(hass, bulb) + assert hass.states.get(entity_id).state == STATE_ON + bulb.async_turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: (0, 0, 0, 0)}, + 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 + 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.reset_mock() + + bulb.brightness = 128 + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_levels.assert_called_with(110, 19, 0, 255) + 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_turn_on.assert_not_called() + 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"}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_effect.assert_called_with("purple_fade", 50, 50) + bulb.async_set_effect.reset_mock() + + +async def test_rgbww_light_auto_on(hass: HomeAssistant) -> None: + """Test an rgbww light that does not need the turn on command sent.""" + 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.requires_turn_on = False + bulb.raw_state = bulb.raw_state._replace(model_num=0x33) # RGB only model + bulb.color_modes = {FLUX_COLOR_MODE_RGBWW} + bulb.color_mode = FLUX_COLOR_MODE_RGBWW + 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] == COLOR_MODE_RGBWW + assert attributes[ATTR_EFFECT_LIST] == bulb.effect_list + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [COLOR_MODE_RGBWW] + assert attributes[ATTR_HS_COLOR] == (3.237, 94.51) + + 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 + + bulb.brightness = 0 + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBWW_COLOR: (10, 10, 30, 0, 0)}, + blocking=True, + ) + # 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(10, 10, 30, 0, 0) + bulb.async_set_levels.reset_mock() + bulb.async_turn_on.reset_mock() + + # Should still be called with no kwargs + await hass.services.async_call( + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + bulb.async_turn_on.assert_called_once() + await async_mock_device_turn_on(hass, bulb) + assert hass.states.get(entity_id).state == STATE_ON + bulb.async_turn_on.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_brightness.assert_called_with(100) + bulb.async_set_brightness.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_RGBWW_COLOR: (0, 0, 0, 0, 0)}, + 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 + 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.reset_mock() + + bulb.brightness = 128 + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_levels.assert_called_with(14, 0, 30, 255, 255) + 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_turn_on.assert_not_called() + 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"}, + blocking=True, + ) + bulb.async_turn_on.assert_not_called() + bulb.async_set_effect.assert_called_with("purple_fade", 50, 50) + bulb.async_set_effect.reset_mock() + + async def test_rgb_cct_light(hass: HomeAssistant) -> None: """Test an rgb cct light.""" config_entry = MockConfigEntry( @@ -520,11 +771,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) @@ -622,6 +877,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( @@ -811,8 +1208,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, @@ -820,8 +1217,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, @@ -829,8 +1226,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, diff --git a/tests/components/flux_led/test_number.py b/tests/components/flux_led/test_number.py index 325307f1f32d7..a4b23f47fcc75 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 @@ -225,3 +234,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 index 01a92e5a3501d..5a34e36dc6514 100644 --- a/tests/components/flux_led/test_select.py +++ b/tests/components/flux_led/test_select.py @@ -1,8 +1,16 @@ """Tests for select platform.""" -from flux_led.protocol import PowerRestoreState +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 DOMAIN +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 @@ -10,8 +18,11 @@ from . import ( DEFAULT_ENTRY_TITLE, + FLUX_DISCOVERY, IP_ADDRESS, MAC_ADDRESS, + _mock_config_entry_for_bulb, + _mocked_bulb, _mocked_switch, _patch_discovery, _patch_wifibulb, @@ -47,3 +58,195 @@ async def test_switch_power_restore_state(hass: HomeAssistant) -> None: switch.async_set_power_restore.assert_called_once_with( channel1=PowerRestoreState.ALWAYS_ON ) + + +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 0000000000000..b06a6330fde2d --- /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/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 0be3f4bde0b17..6e1adc14b7fc5 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/freebox/conftest.py b/tests/components/freebox/conftest.py index 3220552b6cf9d..2d9f0844115f2 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/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 3981a3d2685d5..edb03c516037a 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -17,12 +17,7 @@ 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.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( @@ -48,7 +43,6 @@ 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", @@ -487,43 +481,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 1b8bc927800c2..58ad5ae177caf 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 a1ccc2bd0f296..2f1aaf65e07c3 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -14,6 +14,7 @@ ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, PERCENTAGE, + STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) @@ -35,13 +36,32 @@ 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_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") assert state assert state.state == "23" @@ -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 fc3b15c4199ee..92143662c0a0f 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 diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 60828e83801c1..e0dfcdd26630f 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): diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 07b54765c9a49..2126263663537 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -4,11 +4,7 @@ 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, SensorStateClass from homeassistant.const import ( ATTR_FRIENDLY_NAME, @@ -40,8 +36,6 @@ 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] == SensorStateClass.MEASUREMENT diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index f6f81d5f24ca7..de49a5cd89ee2 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -4,11 +4,7 @@ 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, @@ -50,16 +46,12 @@ 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] == SensorStateClass.MEASUREMENT diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py new file mode 100644 index 0000000000000..04de1aedca913 --- /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 c52b4bf6e8fdd..60e68a1e7b1e6 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/github/__init__.py b/tests/components/github/__init__.py new file mode 100644 index 0000000000000..55c9fb8699456 --- /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 0000000000000..9686fa8544dd7 --- /dev/null +++ b/tests/components/github/common.py @@ -0,0 +1,3 @@ +"""Common helpers for GitHub integration tests.""" + +MOCK_ACCESS_TOKEN = "gho_16C7e42F292c6912E7710c838347Ae178B4a" diff --git a/tests/components/github/conftest.py b/tests/components/github/conftest.py new file mode 100644 index 0000000000000..bcceea56bc9d5 --- /dev/null +++ b/tests/components/github/conftest.py @@ -0,0 +1,34 @@ +"""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, + DEFAULT_REPOSITORIES, + DOMAIN, +) + +from .common import MOCK_ACCESS_TOKEN + +from tests.common import MockConfigEntry + + +@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: DEFAULT_REPOSITORIES}, + ) + + +@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 diff --git a/tests/components/github/test_config_flow.py b/tests/components/github/test_config_flow.py new file mode 100644 index 0000000000000..f040cbd29980b --- /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", + side_effect=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", + side_effect=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_init.py b/tests/components/github/test_init.py new file mode 100644 index 0000000000000..95bad95fd4c4c --- /dev/null +++ b/tests/components/github/test_init.py @@ -0,0 +1,31 @@ +"""Test the GitHub init file.""" +from pytest import LogCaptureFixture + +from homeassistant.components.github import async_cleanup_device_registry +from homeassistant.components.github.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, mock_device_registry + + +async def test_device_registry_cleanup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + caplog: LogCaptureFixture, +) -> None: + """Test that we remove untracked repositories from the decvice registry.""" + registry = mock_device_registry(hass) + + device = registry.async_get_or_create( + identifiers={(DOMAIN, "test/repository")}, + config_entry_id=mock_config_entry.entry_id, + ) + + assert registry.async_get_device({(DOMAIN, "test/repository")}) == device + await async_cleanup_device_registry(hass, mock_config_entry) + + assert ( + f"Unlinking device {device.id} for untracked repository test/repository from config entry {mock_config_entry.entry_id}" + in caplog.text + ) + assert registry.async_get_device({(DOMAIN, "test/repository")}) is None diff --git a/tests/components/goodwe/__init__.py b/tests/components/goodwe/__init__.py new file mode 100644 index 0000000000000..21c1ce6f5438d --- /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 0000000000000..89dfd68a78316 --- /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 3b5aa7365dc67..01bd179e2ea11 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Callable import copy from http import HTTPStatus -from typing import Any, Callable +from typing import Any from unittest.mock import Mock, patch import httplib2 diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 562bd4e16cdff..2edd750a6e0b8 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 a260ef039487f..2dbf43b7d1a28 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 @@ -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, + "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,8 +146,11 @@ 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, ) @@ -171,35 +183,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 +292,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 1d62d03470363..520b736d7bb3a 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 911f66bb428e7..5ec43b37550f5 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" diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 83fcaa59b19a1..a56a8f967e6fa 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -800,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, {}) @@ -3046,8 +3046,8 @@ async def test_sensorstate(hass): """Test SensorState trait support for sensor domain.""" sensor_types = { sensor.SensorDeviceClass.AQI: ("AirQuality", "AQI"), - sensor.SensorDeviceClass.CO: ("CarbonDioxideLevel", "PARTS_PER_MILLION"), - sensor.SensorDeviceClass.CO2: ("CarbonMonoxideLevel", "PARTS_PER_MILLION"), + 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: ( diff --git a/tests/components/greeneye_monitor/common.py b/tests/components/greeneye_monitor/common.py index ac00ccbfc0b27..0a19b79795f6b 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 a1ef0a9d89dcb..00b534bb06d63 100644 --- a/tests/components/greeneye_monitor/conftest.py +++ b/tests/components/greeneye_monitor/conftest.py @@ -1,5 +1,5 @@ """Common fixtures for testing greeneye_monitor.""" -from typing import Any, Dict +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -20,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) diff --git a/tests/components/greeneye_monitor/test_init.py b/tests/components/greeneye_monitor/test_init.py index 143fb14f28ce6..c8e1371493958 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 401e9e9304869..ac1fe92873a58 100644 --- a/tests/components/greeneye_monitor/test_sensor.py +++ b/tests/components/greeneye_monitor/test_sensor.py @@ -13,6 +13,7 @@ ) from .common import ( + MULTI_MONITOR_CONFIG, SINGLE_MONITOR_CONFIG_POWER_SENSORS, SINGLE_MONITOR_CONFIG_PULSE_COUNTERS, SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS, @@ -64,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( @@ -80,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") @@ -154,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) diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index efa983c344032..89a8c6f5c5106 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 a6b5c11dc9e43..d303df2619f32 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 610bc371b2510..d7ab62a6847ad 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 b47ab223be8ad..103ee9ea2dadc 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 f076d8e00ae98..ffd223d1d2ae3 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -2,11 +2,18 @@ from unittest.mock import patch from homeassistant import config_entries, data_entry_flow -from homeassistant.components.homekit.const import DOMAIN, SHORT_BRIDGE_NAME +from homeassistant.components.homekit.const import ( + CONF_FILTER, + DOMAIN, + SHORT_BRIDGE_NAME, +) from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.helpers.entityfilter import CONF_INCLUDE_DOMAINS from homeassistant.setup import async_setup_component +from .util import PATH_HOMEKIT, async_init_entry + from tests.common import MockConfigEntry @@ -345,6 +352,10 @@ async def test_options_flow_exclude_mode_basic(hass, mock_get_source_ip): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "include_exclude" + # Inject garbage to ensure the options data + # is being deep copied and we cannot mutate it in flight + config_entry.options[CONF_FILTER][CONF_INCLUDE_DOMAINS].append("garbage") + result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"entities": ["climate.old"], "include_exclude_mode": "exclude"}, @@ -1065,11 +1076,13 @@ async def test_options_flow_blocked_when_from_yaml(hass, mock_get_source_ip): assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_options_flow_include_mode_basic_accessory(hass, mock_get_source_ip): +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +async def test_options_flow_include_mode_basic_accessory( + port_mock, hass, mock_get_source_ip, hk_driver, mock_async_zeroconf +): """Test config flow options in include mode with a single accessory.""" - config_entry = _mock_config_entry_with_options_populated() - config_entry.add_to_hass(hass) + await async_init_entry(hass, config_entry) hass.states.async_set("media_player.tv", "off") hass.states.async_set("media_player.sonos", "off") @@ -1101,7 +1114,48 @@ async def test_options_flow_include_mode_basic_accessory(hass, mock_get_source_i assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "include_exclude" - assert _get_schema_default(result2["data_schema"].schema, "entities") == [] + assert _get_schema_default(result2["data_schema"].schema, "entities") is None + + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"entities": "media_player.tv"}, + ) + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "mode": "accessory", + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": [], + "include_entities": ["media_player.tv"], + }, + } + + # Now we check again to make sure the single entity is still + # preselected + + 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": ["media_player"], + "mode": "accessory", + } + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"domains": ["media_player"], "mode": "accessory"}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "include_exclude" + assert ( + _get_schema_default(result2["data_schema"].schema, "entities") + == "media_player.tv" + ) result3 = await hass.config_entries.options.async_configure( result2["flow_id"], diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 24b4cf5b37322..cb5a354bc6659 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -679,6 +679,11 @@ async def test_homekit_unpair(hass, device_reg, mock_async_zeroconf): state = homekit.driver.state state.add_paired_client("client1", "any", b"1") + state.add_paired_client("client2", "any", b"0") + state.add_paired_client("client3", "any", b"1") + state.add_paired_client("client4", "any", b"0") + state.add_paired_client("client5", "any", b"0") + formatted_mac = device_registry.format_mac(state.mac) hk_bridge_dev = device_reg.async_get_device( {}, {(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)} @@ -1104,6 +1109,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")}, @@ -1152,6 +1158,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", diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py index 49b3ee72784b1..e5cbf978c83b0 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, @@ -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 9c0d45126fc4d..8e7b60b0a47f1 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_remote.py b/tests/components/homekit/test_type_remote.py index 5c5b5ee6cd92f..e77dd5de54ca8 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 958306e026f5f..d864a90fe6123 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -366,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_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 20ed225552c25..a11aa9d6cb75a 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1266,6 +1266,7 @@ async def test_thermostat_hvac_modes_with_heat_only(hass, hk_driver): await hass.async_block_till_done() hap = acc.char_target_heat_cool.to_HAP() assert hap["valid-values"] == [HC_HEAT_COOL_OFF, HC_HEAT_COOL_HEAT] + assert acc.char_target_heat_cool.allow_invalid_client_values is True assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT acc.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) @@ -1303,6 +1304,29 @@ async def test_thermostat_hvac_modes_with_heat_only(hass, hk_driver): assert call_set_hvac_mode[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[0].data[ATTR_HVAC_MODE] == HVAC_MODE_HEAT + acc.char_target_heat_cool.client_update_value(HC_HEAT_COOL_OFF) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_OFF + hass.states.async_set( + entity_id, HVAC_MODE_OFF, {ATTR_HVAC_MODES: [HVAC_MODE_HEAT, HVAC_MODE_OFF]} + ) + await hass.async_block_till_done() + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_heat_cool_iid, + HAP_REPR_VALUE: HC_HEAT_COOL_AUTO, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert acc.char_target_heat_cool.value == HC_HEAT_COOL_HEAT + async def test_thermostat_hvac_modes_with_cool_only(hass, hk_driver): """Test if unsupported HVAC modes are deactivated in HomeKit and siri calls get converted to cool.""" diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index b6d46b8cda518..ad970b56ad459 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -3,6 +3,7 @@ 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 @@ -50,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 31efcc0b94836..0432fb27426a1 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 49c63a761c23d..68791e89af224 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 from unittest import mock from aiohomekit.model import Accessories, Accessory @@ -10,16 +15,76 @@ 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__) + + +@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 +236,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( + { + (DOMAIN, IDENTIFIER_SERIAL_NUMBER, expected.serial_number), + (DOMAIN, 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 174fc4f7b8d57..46b8a5de3e70b 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/hue_bridge.json b/tests/components/homekit_controller/fixtures/hue_bridge.json index 7ed3882be09fc..ed893cdad604f 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/specific_devices/test_anker_eufycam.py b/tests/components/homekit_controller/specific_devices/test_anker_eufycam.py index 652ac5b0559ea..b963d33c83e78 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,9 @@ """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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -11,45 +12,45 @@ 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="00:00:00:00:00:00", + 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", + 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" - assert device.hw_version == "1.0.0" - - # 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 fd9ef96752a65..70b6dc4870aab 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py @@ -9,11 +9,13 @@ 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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -22,131 +24,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) - device_registry = dr.async_get(hass) - - sensors = [ - ( - "alarm_control_panel.aqara_hub_1563", - "homekit-0000000123456789-66304", - "Aqara Hub-1563", - SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY, - None, - ), - ( - "light.aqara_hub_1563", - "homekit-0000000123456789-65792", - "Aqara Hub-1563", - SUPPORT_BRIGHTNESS | SUPPORT_COLOR, - None, + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id="00:00:00:00:00:00", + 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", + ), + ], ), - ( - "number.aqara_hub_1563_volume", - "homekit-0000000123456789-aid:1-sid:65536-cid:65541", - "Aqara Hub-1563 Volume", - None, - EntityCategory.CONFIG, - ), - ( - "switch.aqara_hub_1563_pairing_mode", - "homekit-0000000123456789-aid:1-sid:65536-cid:65538", - "Aqara Hub-1563 Pairing Mode", - None, - EntityCategory.CONFIG, - ), - ] - - device_ids = set() - - for (entity_id, unique_id, friendly_name, supported_features, category) in sensors: - entry = entity_registry.async_get(entity_id) - assert entry.unique_id == unique_id - assert entry.entity_category == category - - helper = Helper( - hass, - entity_id, - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == friendly_name - assert state.attributes.get("supported_features") == supported_features - - device = device_registry.async_get(entry.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 - - device_ids.add(entry.device_id) - - # All entities should be part of same device - assert len(device_ids) == 1 + ) 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") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - - sensors = [ - ( - "alarm_control_panel.aqara_hub_e1_00a0", - "homekit-00aa00000a0-16", - "Aqara-Hub-E1-00A0", - SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY, - None, - ), - ( - "number.aqara_hub_e1_00a0_volume", - "homekit-00aa00000a0-aid:1-sid:17-cid:1114116", - "Aqara-Hub-E1-00A0 Volume", - None, - EntityCategory.CONFIG, + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id="00:00:00:00:00:00", + 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", + ), + ], ), - ( - "switch.aqara_hub_e1_00a0_pairing_mode", - "homekit-00aa00000a0-aid:1-sid:17-cid:1114117", - "Aqara-Hub-E1-00A0 Pairing Mode", - None, - EntityCategory.CONFIG, - ), - ] - - device_ids = set() - - for (entity_id, unique_id, friendly_name, supported_features, category) in sensors: - entry = entity_registry.async_get(entity_id) - assert entry.unique_id == unique_id - assert entry.entity_category == category - - helper = Helper( - hass, - entity_id, - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert state.attributes["friendly_name"] == friendly_name - assert state.attributes.get("supported_features") == supported_features - - device = device_registry.async_get(entry.device_id) - assert device.manufacturer == "Aqara" - assert device.name == "Aqara-Hub-E1-00A0" - assert device.model == "HE1-G01" - assert device.sw_version == "3.3.0" - 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_aqara_switch.py b/tests/components/homekit_controller/specific_devices/test_aqara_switch.py index 945d950ecc93e..342dda263d89c 100644 --- a/tests/components/homekit_controller/specific_devices/test_aqara_switch.py +++ b/tests/components/homekit_controller/specific_devices/test_aqara_switch.py @@ -7,11 +7,13 @@ https://github.com/home-assistant/core/pull/39090 """ -from homeassistant.components.device_automation import DeviceAutomationType -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 ( + DeviceTestInfo, + DeviceTriggerInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -20,38 +22,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, DeviceAutomationType.TRIGGER, battery.device_id + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id="00:00:00:00:00:00", + 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"), + ], + ), ) - assert_lists_same(triggers, expected) 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 86fb9f65f11e7..815613e675e35 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,13 @@ """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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,73 +16,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="00:00:00:00:00:00", + 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 index 29559118fe60b..702ca8257525a 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -1,9 +1,16 @@ """Make sure that ConnectSense Smart Outlet2 / In-Wall Outlet is enumerated properly.""" -from homeassistant.helpers import device_registry as dr, entity_registry as er +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 ( - Helper, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,78 +19,80 @@ 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") - config_entry, pairing = await setup_test_accessories(hass, accessories) - - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) + await setup_test_accessories(hass, accessories) - entities = [ - ( - "sensor.inwall_outlet_0394de_real_time_current", - "homekit-1020301376-aid:1-sid:13-cid:18", - "InWall Outlet-0394DE - Real Time Current", - ), - ( - "sensor.inwall_outlet_0394de_real_time_energy", - "homekit-1020301376-aid:1-sid:13-cid:19", - "InWall Outlet-0394DE - Real Time Energy", - ), - ( - "sensor.inwall_outlet_0394de_energy_kwh", - "homekit-1020301376-aid:1-sid:13-cid:20", - "InWall Outlet-0394DE - Energy kWh", - ), - ( - "switch.inwall_outlet_0394de", - "homekit-1020301376-13", - "InWall Outlet-0394DE", - ), - ( - "sensor.inwall_outlet_0394de_real_time_current_2", - "homekit-1020301376-aid:1-sid:25-cid:30", - "InWall Outlet-0394DE - Real Time Current", - ), - ( - "sensor.inwall_outlet_0394de_real_time_energy_2", - "homekit-1020301376-aid:1-sid:25-cid:31", - "InWall Outlet-0394DE - Real Time Energy", - ), - ( - "sensor.inwall_outlet_0394de_energy_kwh_2", - "homekit-1020301376-aid:1-sid:25-cid:32", - "InWall Outlet-0394DE - Energy kWh", + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id="00:00:00:00:00:00", + 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_real_time_current", + friendly_name="InWall Outlet-0394DE - Real Time 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_real_time_energy", + friendly_name="InWall Outlet-0394DE - Real Time Energy", + 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_real_time_current_2", + friendly_name="InWall Outlet-0394DE - Real Time 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_real_time_energy_2", + friendly_name="InWall Outlet-0394DE - Real Time Energy", + 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", + ), + ], ), - ( - "switch.inwall_outlet_0394de_2", - "homekit-1020301376-25", - "InWall Outlet-0394DE", - ), - ] - - device_ids = set() - - for (entity_id, unique_id, friendly_name) in entities: - 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 == "ConnectSense" - assert device.name == "InWall Outlet-0394DE" - assert device.model == "CS-IWO" - assert device.sw_version == "1.0.0" - 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_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 94f3aabc12a05..5847d222e9e34 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -14,11 +14,15 @@ 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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, device_config_changed, setup_accessories_from_file, setup_test_accessories, @@ -29,71 +33,101 @@ 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="00:00:00:00:00:00", + 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", + 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", + 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", + 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): 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 63f5d22e04b2f..293ecd07dd239 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,10 @@ 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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -16,24 +16,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="00:00:00:00:00:00", + 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 312316e8870c3..466123a224eac 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,14 @@ """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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,63 +17,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="00:00:00:00:00:00", + 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_elevation", - "homekit-AA00A0A00000-aid:1-sid:30-cid:33", - "Eve Degree AA11 Elevation", - ), - ] - - 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_haa_fan.py b/tests/components/homekit_controller/specific_devices/test_haa_fan.py index 0339c61168fe0..cd12831210a1b 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,12 @@ """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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,65 +15,65 @@ 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="00:00:00:00:00:00", + 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", + 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 1cbfa23b64cfa..684b2ebde2764 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,11 @@ 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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -19,40 +20,46 @@ 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="00:00:00:00:00:00", + 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", + 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 5bc540a50a40a..2a8855ac9da6a 100644 --- a/tests/components/homekit_controller/specific_devices/test_hue_bridge.py +++ b/tests/components/homekit_controller/specific_devices/test_hue_bridge.py @@ -1,11 +1,12 @@ """Tests for handling accessories on a Hue bridge via HomeKit.""" -from homeassistant.components.device_automation import DeviceAutomationType -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, + DeviceTestInfo, + DeviceTriggerInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -14,57 +15,44 @@ 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 - ) - 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, DeviceAutomationType.TRIGGER, device.id + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id="00:00:00:00:00:00", + 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", + 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=[], + ), ) - 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 505ff2aacc7f8..ef655d79fdfaf 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,14 @@ import pytest from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR -from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed from tests.components.homekit_controller.common import ( + DeviceTestInfo, + EntityTestInfo, Helper, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -24,35 +26,31 @@ 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="00:00:00:00:00:00", + 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", + ), + ], + ), ) - 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 1761edb3c8ca3..1065cfe220988 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,12 @@ """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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -13,43 +15,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="00:00:00:00:00:00", + 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_real_time_energy", + friendly_name="Koogeek-P1-A00AA0 - Real Time Energy", + 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 768959e03312b..7a94a6652b9f9 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,49 @@ 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, + 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="00:00:00:00:00:00", + 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_real_time_energy", + friendly_name="Koogeek-SW2-187A91 - Real Time Energy", + 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 cdab08039e1c7..979fbd4802877 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,11 @@ 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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -20,30 +21,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="00:00:00:00:00:00", + 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 592ad2088b8f0..2841acac40279 100644 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -1,16 +1,15 @@ """Make sure that handling real world LG HomeKit characteristics isn't broken.""" -from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.media_player.const import ( SUPPORT_PAUSE, 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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -19,56 +18,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="00:00:00:00:00:00", + 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 - assert device.hw_version == "1" - - # A TV has media player device triggers - triggers = await async_get_device_automations( - hass, DeviceAutomationType.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 ea1c10840718e..e2fdb7b2a8f12 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,14 @@ """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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,80 +17,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="00:00:00:00:00:00", + 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 d213e72c59cca..969f03cdeb8f3 100644 --- a/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py +++ b/tests/components/homekit_controller/specific_devices/test_netamo_doorbell.py @@ -4,12 +4,11 @@ https://github.com/home-assistant/core/issues/44596 """ -from homeassistant.components.device_automation import DeviceAutomationType -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, + DeviceTestInfo, + DeviceTriggerInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -18,59 +17,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) - - # 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" + await setup_test_accessories(hass, accessories) - camera_helper = Helper( + await assert_devices_and_entities_created( hass, - "camera.netatmo_doorbell_g738658", - pairing, - accessories[0], - config_entry, - ) - 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, DeviceAutomationType.TRIGGER, doorbell.device_id + DeviceTestInfo( + unique_id="00:00:00:00:00:00", + 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"), + ], + ), ) - 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 ee008fbfa626d..5b29c2d52e729 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,10 @@ 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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -16,53 +16,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="00:00:00:00:00:00", + 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 - assert device.hw_version == "1" - - # 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 302ebe70ac271..54393d1ac4f33 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,143 +1,222 @@ """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, + 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" - assert device.hw_version == "1.0.0" - - 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" - assert bridge.hw_version == "0101.3521.0436" - - # 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="00:00:00:00:00:00", + name="RYSE SmartBridge", + model="RYSE SmartBridge", + manufacturer="RYSE Inc.", + sw_version="1.3.0", + hw_version="0101.3521.0436", + # This is an actual bug in the device.. + serial_number="0101.3521.0436", + devices=[ + DeviceTestInfo( + unique_id="00:00:00:00:00:00_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_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="00:00:00:00:00:00", + name="RYSE SmartBridge", + model="RYSE SmartBridge", + manufacturer="RYSE Inc.", + sw_version="1.3.0", + hw_version="0401.3521.0679", + # This is an actual bug in the device.. + serial_number="0401.3521.0679", + devices=[ + DeviceTestInfo( + unique_id="00:00:00:00:00:00_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_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_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_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 a21953202938b..1df0a0192db60 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,11 @@ """ 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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -17,35 +18,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="00:00:00:00:00:00", + 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 b93afdfbfa4ec..d6b9fe9cfbf1b 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,90 @@ 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, + 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="00:00:00:00:00:00", + 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", + 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", + 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 a58f306a241aa..3680437ada60d 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,16 @@ """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, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, setup_accessories_from_file, setup_test_accessories, ) @@ -12,88 +19,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_spray_quantity") - assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:38" - - helper = Helper( - hass, - "number.vocolinc_flowerbud_0d324b_spray_quantity", - pairing, - accessories[0], - config_entry, - ) - state = await helper.poll_and_get_state() - assert ( - state.attributes["friendly_name"] == "VOCOlinc-Flowerbud-0d324b Spray Quantity" - ) - - 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 device.hw_version == "0.1" - - # 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( + await assert_devices_and_entities_created( 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" + DeviceTestInfo( + unique_id="00:00:00:00:00:00", + 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", + ), + ], + ), ) - assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:33" - - helper = Helper( - hass, - "sensor.vocolinc_flowerbud_0d324b_current_humidity", - pairing, - accessories[0], - config_entry, - ) - 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/homewizard/__init__.py b/tests/components/homewizard/__init__.py new file mode 100644 index 0000000000000..bdd31419e12c7 --- /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 0000000000000..15993aa35ed49 --- /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 0000000000000..74d33c9e6098e --- /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 0000000000000..7364a0e632e4d --- /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 0000000000000..f7aa4de7adecc --- /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 0000000000000..6f5396f870285 --- /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/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 6ce8ff3e1c435..0aa032ddb0da7 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components import ssdp, zeroconf from homeassistant.components.hue import config_flow, const from homeassistant.components.hue.errors import CannotConnect +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -701,12 +702,33 @@ async def test_options_flow_v2(hass): """Test options config flow for a V2 bridge.""" entry = MockConfigEntry( domain="hue", - unique_id="v2bridge", + unique_id="aabbccddeeff", data={"host": "0.0.0.0", "api_version": 2}, ) entry.add_to_hass(hass) - assert config_flow.HueFlowHandler.async_supports_options_flow(entry) is False + dev_reg = dr.async_get(hass) + mock_dev_id = "aabbccddee" + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={(const.DOMAIN, mock_dev_id)} + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + schema = result["data_schema"].schema + assert _get_schema_default(schema, const.CONF_IGNORE_AVAILABILITY) == [] + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={const.CONF_IGNORE_AVAILABILITY: [mock_dev_id]}, + ) + + assert result["type"] == "create_entry" + assert result["data"] == { + const.CONF_IGNORE_AVAILABILITY: [mock_dev_id], + } async def test_bridge_zeroconf(hass, aioclient_mock): diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 936da661dd799..c7578df3a4912 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -121,7 +121,7 @@ async def test_light_turn_on_service(hass, mock_bridge_v2, v2_resources_test_dat assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is True assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 200 - # test again with sending flash/alert + # test again with sending long flash await hass.services.async_call( "light", "turn_on", @@ -129,9 +129,37 @@ async def test_light_turn_on_service(hass, mock_bridge_v2, v2_resources_test_dat blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 3 - assert mock_bridge_v2.mock_requests[2]["json"]["on"]["on"] is True assert mock_bridge_v2.mock_requests[2]["json"]["alert"]["action"] == "breathe" + # test again with sending short flash + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "flash": "short"}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 4 + assert mock_bridge_v2.mock_requests[3]["json"]["identify"]["action"] == "identify" + + # test again with sending a colortemperature which is out of range + # which should be normalized to the upper/lower bounds Hue can handle + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "color_temp": 50}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 5 + assert mock_bridge_v2.mock_requests[4]["json"]["color_temperature"]["mirek"] == 153 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "color_temp": 550}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 6 + assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 500 + async def test_light_turn_off_service(hass, mock_bridge_v2, v2_resources_test_data): """Test calling the turn off service on a light.""" @@ -177,6 +205,26 @@ async def test_light_turn_off_service(hass, mock_bridge_v2, v2_resources_test_da assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is False assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 200 + # test again with sending long flash + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": test_light_id, "flash": "long"}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 3 + assert mock_bridge_v2.mock_requests[2]["json"]["alert"]["action"] == "breathe" + + # test again with sending short flash + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": test_light_id, "flash": "short"}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 4 + assert mock_bridge_v2.mock_requests[3]["json"]["identify"]["action"] == "identify" + async def test_light_added(hass, mock_bridge_v2): """Test new light added to bridge.""" @@ -386,3 +434,65 @@ async def test_grouped_lights(hass, mock_bridge_v2, v2_resources_test_data): assert ( mock_bridge_v2.mock_requests[index]["json"]["dynamics"]["duration"] == 200 ) + + # Test sending short flash effect to a grouped light + mock_bridge_v2.mock_requests.clear() + test_light_id = "light.test_zone" + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": test_light_id, + "flash": "short", + }, + blocking=True, + ) + + # PUT request should have been sent to ALL group lights with correct params + assert len(mock_bridge_v2.mock_requests) == 3 + for index in range(0, 3): + assert ( + mock_bridge_v2.mock_requests[index]["json"]["identify"]["action"] + == "identify" + ) + + # Test sending long flash effect to a grouped light + mock_bridge_v2.mock_requests.clear() + test_light_id = "light.test_zone" + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": test_light_id, + "flash": "long", + }, + blocking=True, + ) + + # PUT request should have been sent to ALL group lights with correct params + assert len(mock_bridge_v2.mock_requests) == 3 + for index in range(0, 3): + assert ( + mock_bridge_v2.mock_requests[index]["json"]["alert"]["action"] == "breathe" + ) + + # Test sending flash effect in turn_off call + mock_bridge_v2.mock_requests.clear() + test_light_id = "light.test_zone" + await hass.services.async_call( + "light", + "turn_off", + { + "entity_id": test_light_id, + "flash": "short", + }, + blocking=True, + ) + + # PUT request should have been sent to ALL group lights with correct params + assert len(mock_bridge_v2.mock_requests) == 3 + for index in range(0, 3): + assert ( + mock_bridge_v2.mock_requests[index]["json"]["identify"]["action"] + == "identify" + ) diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index 8982b70fbfbf8..7f30fd25681ba 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/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index e11702a346856..144bbf3cdafeb 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -84,6 +84,13 @@ 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, DeviceAutomationType.TRIGGER, device_entry.id @@ -200,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", + ) + ) + }, + }, + }, ] }, ) @@ -225,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 2ab16fb1301fb..71e1e42cb1aa8 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 f91cc312ca611..0cb85aeb5e228 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, ) diff --git a/tests/components/iaqualink/conftest.py b/tests/components/iaqualink/conftest.py new file mode 100644 index 0000000000000..01fc78691b31d --- /dev/null +++ b/tests/components/iaqualink/conftest.py @@ -0,0 +1,82 @@ +"""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 = {} + + num = random.randint(0, 999) + data["name"] = f"name_{num:03}" + + 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 38dd2ec1a3aa6..2d00284775d4c 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 0000000000000..ea7dad86908c8 --- /dev/null +++ b/tests/components/iaqualink/test_init.py @@ -0,0 +1,341 @@ +"""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) + 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), + get_aqualink_device(system, AqualinkBinarySensor), + get_aqualink_device(system, AqualinkLightToggle), + get_aqualink_device(system, AqualinkSensor), + get_aqualink_device(system, AqualinkThermostat), + ] + 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={"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 0000000000000..56b239c0d9f31 --- /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 24ef68dd5bdd2..94071e849c280 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 ac88c3ab967a0..86a32877a792b 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/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index 9b628f4443af2..683e687ec85b1 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 3fb1cb2d48b82..9ca54ea8d8f19 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/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index e9471d5d144d7..879b5edb120e2 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 0b438487c49b4..18d5f3df1fb57 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 a692fa978140e..71a86f1e397fb 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_config_flow.py b/tests/components/knx/test_config_flow.py index 4f3e1734b69b3..aec757a108613 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1,6 +1,7 @@ """Test the KNX config flow.""" from unittest.mock import patch +import pytest from xknx import XKNX from xknx.io import DEFAULT_MCAST_GRP from xknx.io.gateway_scanner import GatewayDescriptor @@ -8,6 +9,7 @@ from homeassistant import config_entries from homeassistant.components.knx import ConnectionSchema from homeassistant.components.knx.config_flow import ( + CONF_DEFAULT_LOCAL_IP, CONF_KNX_GATEWAY, DEFAULT_ENTRY_DATA, ) @@ -585,6 +587,7 @@ async def test_options_flow( CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.255", CONF_HOST: "", + ConnectionSchema.CONF_KNX_LOCAL_IP: None, ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, ConnectionSchema.CONF_KNX_RATE_LIMIT: 20, @@ -643,14 +646,65 @@ async def test_tunneling_options_flow( 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, } +@pytest.mark.parametrize( + "user_input,config_entry_data", + [ + ( + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", + }, + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + CONF_HOST: "", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", + }, + ), + ( + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: CONF_DEFAULT_LOCAL_IP, + }, + { + CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", + CONF_HOST: "", + ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, + ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, + ConnectionSchema.CONF_KNX_STATE_UPDATER: False, + ConnectionSchema.CONF_KNX_LOCAL_IP: None, + }, + ), + ], +) async def test_advanced_options( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + user_input, + config_entry_data, ) -> None: """Test options config flow.""" mock_config_entry.add_to_hass(hass) @@ -668,28 +722,11 @@ async def test_advanced_options( result2 = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={ - CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, - CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", - ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, - ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, - ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, - ConnectionSchema.CONF_KNX_STATE_UPDATER: False, - ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", - }, + user_input=user_input, ) await hass.async_block_till_done() assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY assert not result2.get("data") - assert mock_config_entry.data == { - CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, - CONF_KNX_INDIVIDUAL_ADDRESS: "15.15.250", - CONF_HOST: "", - ConnectionSchema.CONF_KNX_MCAST_PORT: 3675, - ConnectionSchema.CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, - ConnectionSchema.CONF_KNX_RATE_LIMIT: 25, - ConnectionSchema.CONF_KNX_STATE_UPDATER: False, - ConnectionSchema.CONF_KNX_LOCAL_IP: "192.168.1.112", - } + assert mock_config_entry.data == config_entry_data diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py index c61dc54258630..039dd5986ca3f 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/launch_library/__init__.py b/tests/components/launch_library/__init__.py new file mode 100644 index 0000000000000..f6264de191403 --- /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 0000000000000..f60ee76d0f760 --- /dev/null +++ b/tests/components/launch_library/test_config_flow.py @@ -0,0 +1,64 @@ +"""Test launch_library config flow.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components.launch_library.const import DEFAULT_NAME, DOMAIN +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_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/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index d6e906abd7453..0e9b334c6d17f 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -51,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, @@ -161,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", + ) + ) + }, + }, + }, ] }, ) @@ -170,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/local_file/test_camera.py b/tests/components/local_file/test_camera.py index b9b2b19832e38..8432420244e33 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/logbook/test_init.py b/tests/components/logbook/test_init.py index 39277ef7aa744..fab1121542f2f 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/test_sensor.py b/tests/components/luftdaten/test_sensor.py index 46570d6cb699b..3cf4426d500b6 100644 --- a/tests/components/luftdaten/test_sensor.py +++ b/tests/components/luftdaten/test_sensor.py @@ -123,7 +123,7 @@ async def test_luftdaten_sensors( device_entry = device_registry.async_get(entry.device_id) assert device_entry assert device_entry.identifiers == {(DOMAIN, "12345")} - assert device_entry.manufacturer == "Luftdaten.info" + assert device_entry.manufacturer == "Sensor.Community" assert device_entry.name == "Sensor 12345" assert ( device_entry.configuration_url diff --git a/tests/components/mazda/__init__.py b/tests/components/mazda/__init__.py index 7a81a9224d703..9d20f78bc0005 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/get_ev_vehicle_status.json b/tests/components/mazda/fixtures/get_ev_vehicle_status.json new file mode 100644 index 0000000000000..6aeaa1ebda00f --- /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 f170b222b318f..1e74d7202ca11 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 871eeb9d2ecd0..887ae1194c5ba 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_init.py b/tests/components/mazda/test_init.py index 8b135f15e8079..e2d4661d36fc5 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 179ad96d533c6..8d4085930dd22 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_trigger.py b/tests/components/media_player/test_device_trigger.py index 842184d62efaa..0e58395319e6c 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -58,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", @@ -88,7 +95,7 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 5 + assert len(triggers) == 6 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( hass, DeviceAutomationType.TRIGGER, trigger @@ -109,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, @@ -137,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 caa562fc5a0d6..3f0efcd45aa3b 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( diff --git a/tests/components/mfi/test_switch.py b/tests/components/mfi/test_switch.py index 1e9f56853c3dd..8eed41f8a3257 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 191c59e556fd4..d07a4d8492e5b 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_add_job(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_add_job(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_add_job(hass.services.async_call(DOMAIN, SERVICE_TRAIN_GROUP, data)) def create_person(hass, group, name): @@ -54,7 +56,7 @@ 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_add_job(hass.services.async_call(DOMAIN, SERVICE_CREATE_PERSON, data)) def delete_person(hass, group, name): @@ -63,7 +65,7 @@ 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_add_job(hass.services.async_call(DOMAIN, SERVICE_DELETE_PERSON, data)) def face_person(hass, group, person, camera_entity): @@ -72,285 +74,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_add_job(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"), ) - 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="{}", - ) + 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 + + 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 - assert len(aioclient_mock.mock_calls) == 1 - @patch( +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"), + ) + + CONFIG["camera"] = {"platform": "demo"} + 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_group2/persons/" + "2ae4935b-9659-44c3-977f-61fac20d0538/persistedFaces" + ), + status=200, + text="{}", + ) + + 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 d2e0eb19d57b8..a68da5eca0049 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 856d308816c05..6b3098375cad8 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 fcd29c18682c7..f36129c223a8e 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/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index 86cc9f0ae679a..001da68dfbfdf 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_webhook.py b/tests/components/mobile_app/test_webhook.py index 41b939c71132b..48b61988de20c 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/modern_forms/__init__.py b/tests/components/modern_forms/__init__.py index c6d2a8b363716..65de87c333de5 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/mqtt/fixtures/configuration.yaml b/tests/components/mqtt/fixtures/configuration.yaml deleted file mode 100644 index 96c7e57f72bb4..0000000000000 --- 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 2a74a75c2414d..16e46faaef856 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 1a30836d074dd..a13f0781dfb66 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -28,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, @@ -35,6 +36,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_unique_id, @@ -758,6 +760,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]) @@ -829,3 +861,10 @@ 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) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 5eb92db7767da..7d4194ee5e618 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, @@ -321,3 +323,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", 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 = 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 5db4362b1fbf5..95e8c467a5298 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 61f04db99d9d8..0f4f9b209c67f 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.""" @@ -819,7 +927,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 +1102,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 +1280,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 16af5b8e48484..ba2c9e3871a51 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. @@ -1266,3 +1406,167 @@ 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_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" + new_yaml_config_file = tmp_path / "configuration.yaml" + new_yaml_config = yaml.dump({domain: [new_config_1, new_config_2, new_config_3]}) + 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 + + 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 794d143ac832a..3a6a4e74ac9c1 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, ) @@ -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_fan.py b/tests/components/mqtt/test_fan.py index c310db335ad6b..0a3b2499dc2c5 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 76c0b6e9f8e0f..62d29c12ee82b 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 32528881d64b7..f55a36ed189e6 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -12,10 +12,12 @@ 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, template @@ -158,7 +160,7 @@ async def test_publish(hass, mqtt_mock): async def test_convert_outgoing_payload(hass): """Test the converting of outgoing MQTT payloads without template.""" - command_template = mqtt.MqttCommandTemplate(None, hass) + command_template = mqtt.MqttCommandTemplate(None, hass=hass) assert command_template.async_render(b"\xde\xad\xbe\xef") == b"\xde\xad\xbe\xef" assert ( @@ -179,16 +181,92 @@ async def test_command_template_value(hass): variables = {"id": 1234, "some_var": "beer"} # test rendering value - tpl = template.Template("{{ value + 1 }}", hass) - cmd_tpl = mqtt.MqttCommandTemplate(tpl, hass) + 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) - cmd_tpl = mqtt.MqttCommandTemplate(tpl, hass) + 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 }}", hass) + 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 }}", hass) + val_tpl = mqtt.MqttValueTemplate(tpl, hass=hass) + assert ( + val_tpl.async_render_with_possible_json_value( + '{"id": 4321}', variables=variables + ) + == "4321 beer" + ) + + # 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" + ) + + async def test_service_call_without_topic_does_not_publish(hass, mqtt_mock): """Test the service call if topic is missing.""" with pytest.raises(vol.Invalid): diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 5d9b50252a471..59263037e657f 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 58b607bb7772e..dcff826311bbc 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 677509277c772..baad644bf6db2 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 fe2d9badf7de1..3a8ecd357c328 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 8d76e46f32bb4..f29222f97d58e 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 797a7b894fc36..c233bf14ab517 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 3d4cd0f5c2563..97f13ba90c09a 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 2f31ce788fd7a..c09c0aebca83b 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 106845dbd51fa..a511938f0d1a1 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,7 @@ help_test_entity_disabled_by_default, 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, @@ -270,6 +272,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 +303,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 +329,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 +353,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 +383,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 +926,34 @@ 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) + + +@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 f53f8ebdab326..5011f279470ec 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 a3ef29d0d080b..9519d7321ffdd 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/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 1843e49580113..6dd7add37e6c9 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 119d3c4eb4288..0774d480c98a4 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -1,7 +1,7 @@ """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 diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 0f1d47c687edc..180821a8d9e30 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -1,7 +1,7 @@ """Common libraries for test setup.""" +from collections.abc import Awaitable, Callable import time -from typing import Awaitable, Callable from unittest.mock import patch from google_nest_sdm.device_manager import DeviceManager @@ -117,7 +117,4 @@ async def async_setup_sdm_platform( ): 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 988d9d761fee3..22a89f5723521 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -1,5 +1,9 @@ """Common libraries for test setup.""" +import shutil +from unittest.mock import patch +import uuid + import aiohttp from google_nest_sdm.auth import AbstractAuth import pytest @@ -63,3 +67,12 @@ 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) diff --git a/tests/components/nest/test_camera_sdm.py b/tests/components/nest/test_camera_sdm.py index 1ac1b4ca6f960..a4539cf9f8172 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 888227b9cdeb6..8a775a3e71b6b 100644 --- a/tests/components/nest/test_climate_sdm.py +++ b/tests/components/nest/test_climate_sdm.py @@ -21,6 +21,7 @@ ATTR_TARGET_TEMP_LOW, CURRENT_HVAC_COOL, CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, FAN_LOW, FAN_OFF, @@ -356,7 +357,7 @@ async def test_thermostat_eco_heat_only(hass): 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, @@ -428,7 +429,7 @@ async def test_thermostat_set_hvac_mode(hass, auth): 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( @@ -733,7 +734,7 @@ async def test_thermostat_fan_on(hass): 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, @@ -769,7 +770,7 @@ async def test_thermostat_cool_with_fan(hass): 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, @@ -898,7 +899,7 @@ async def test_thermostat_invalid_fan_mode(hass): 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, @@ -1075,7 +1076,7 @@ async def test_thermostat_missing_temperature_trait(hass): 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, @@ -1143,7 +1144,7 @@ async def test_thermostat_missing_set_point(hass): 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, diff --git a/tests/components/nest/test_config_flow_legacy.py b/tests/components/nest/test_config_flow_legacy.py index ed4df2c7d84a4..d21920b9e6f10 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 d4af62cb255f5..687c0df3f9efd 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() @@ -473,7 +476,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 +545,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 +555,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 +567,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 await async_setup_configflow(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert entry.data["cloud_project_id"] == "other-cloud-project-id" + 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 a333a31c2d29a..a31a155b4bace 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_events.py b/tests/components/nest/test_events.py index 4a6259991554e..ee286242a8ce2 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 fbfd6305487cc..0ef1ca4e18dba 100644 --- a/tests/components/nest/test_init_sdm.py +++ b/tests/components/nest/test_init_sdm.py @@ -10,9 +10,10 @@ from unittest.mock import patch from google_nest_sdm.exceptions import ( + ApiException, AuthException, ConfigurationException, - GoogleNestException, + SubscriberException, ) from homeassistant.components.nest import DOMAIN @@ -66,7 +67,7 @@ async def test_setup_susbcriber_failure(hass, caplog): """Test configuration error.""" with patch( "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", - side_effect=GoogleNestException(), + side_effect=SubscriberException(), ), caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): result = await async_setup_sdm(hass) assert result @@ -83,7 +84,7 @@ async def test_setup_device_manager_failure(hass, caplog): "homeassistant.components.nest.api.GoogleNestSubscriber.start_async" ), patch( "homeassistant.components.nest.api.GoogleNestSubscriber.async_get_device_manager", - side_effect=GoogleNestException(), + side_effect=ApiException(), ), caplog.at_level( logging.ERROR, logger="homeassistant.components.nest" ): @@ -252,7 +253,7 @@ 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(), ): assert await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index e2c810cc873b3..8f968638d1d68 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -4,16 +4,17 @@ as media in the media source. """ +from collections.abc import Generator import datetime from http import HTTPStatus -import shutil -from typing import Generator +import io from unittest.mock import patch -import uuid 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 @@ -33,13 +34,15 @@ 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": { @@ -72,15 +75,52 @@ } IMAGE_BYTES_FROM_EVENT = b"test url image bytes" IMAGE_AUTHORIZATION_HEADERS = {"Authorization": "Basic g.0.eventToken"} +NEST_EVENT = "nest_event" -@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) +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" + # stream.options.update({"g": str(fps), "keyint_min": str(fps)}) + + 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=[]): @@ -96,10 +136,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 @@ -234,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 @@ -259,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" @@ -284,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 @@ -292,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 @@ -303,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 @@ -322,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") @@ -367,7 +422,6 @@ 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 @@ -375,14 +429,219 @@ async def test_event_order(hass, auth): # 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.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): """Test a media source request for an invalid device id.""" await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) @@ -462,28 +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() - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - BATTERY_CAMERA_TRAITS, - events=[ - create_event_message( - create_battery_event_data(MOTION_EVENT), - 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") @@ -494,6 +763,13 @@ 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"] + # Browse to the device browse = await media_source.async_browse_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" @@ -502,32 +778,58 @@ 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 ( + browse.thumbnail + == f"/api/nest/event_media/{device.id}/{event_identifier}/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): @@ -559,21 +861,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") @@ -591,10 +895,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, ( @@ -604,21 +904,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") @@ -629,7 +915,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({}) @@ -684,6 +970,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}", @@ -692,6 +982,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}" @@ -704,11 +995,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}" @@ -772,6 +1068,7 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): 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( @@ -779,16 +1076,16 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): ) assert len(browse.children) == 1 assert browse.children[0].domain == DOMAIN - assert browse.children[0].identifier == f"{device.id}/{EVENT_SESSION_ID}" 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}/{device.id}/{EVENT_SESSION_ID}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}" ) - assert media.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + assert media.url == f"/api/nest/event_media/{event_identifier}" assert media.mime_type == "video/mp4" # Fetch event media @@ -812,8 +1109,6 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): 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 with patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" @@ -835,16 +1130,16 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): ) assert len(browse.children) == 1 assert browse.children[0].domain == DOMAIN - assert browse.children[0].identifier == f"{device.id}/{EVENT_SESSION_ID}" 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}/{device.id}/{EVENT_SESSION_ID}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}" ) - assert media.url == f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" + assert media.url == f"/api/nest/event_media/{event_identifier}" assert media.mime_type == "video/mp4" # Verify media exists @@ -854,21 +1149,27 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): assert contents == IMAGE_BYTES_FROM_EVENT -async def test_media_store_filesystem_error(hass, auth, hass_client): - """Test a filesystem error read/writing event media.""" +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() - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - BATTERY_CAMERA_TRAITS, - events=[ + # 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") @@ -879,64 +1180,75 @@ async def test_media_store_filesystem_error(hass, auth, hass_client): assert device assert device.name == DEVICE_NAME - auth.responses = [ - aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), - ] + 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] - # 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): - response = await client.get( - f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}" - ) - assert response.status == HTTPStatus.OK, "Response not matched: %s" % response - contents = await response.read() - assert contents == IMAGE_BYTES_FROM_EVENT - await hass.async_block_till_done() + 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 the media again, and since the object does not exist in the cache it - # needs to be fetched again. The server returns an error to prove that it was - # not a cache read. A second attempt succeeds. - auth.responses = [ - aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR), - aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), - ] - # First attempt, server fails when fetching - response = await client.get(f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}") - assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR, ( + # 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 ) - # Second attempt, server responds success - response = await client.get(f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}") - assert response.status == HTTPStatus.OK, "Response not matched: %s" % response - contents = await response.read() - assert contents == IMAGE_BYTES_FROM_EVENT - # Third attempt reads from the disk cache with no server fetch - response = await client.get(f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}") - 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_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), ] - # Exercise a failure reading from the disk cache. Re-populate from server and write to disk ok + 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_SESSION_ID}" + f"/api/nest/event_media/{device.id}/{event_identifier}" + ) + assert response.status == HTTPStatus.NOT_FOUND, ( + "Response not matched: %s" % response ) - assert response.status == HTTPStatus.OK, "Response not matched: %s" % response - contents = await response.read() - assert contents == IMAGE_BYTES_FROM_EVENT - await hass.async_block_till_done() - - response = await client.get(f"/api/nest/event_media/{device.id}/{EVENT_SESSION_ID}") - assert response.status == HTTPStatus.OK, "Response not matched: %s" % response - contents = await response.read() - assert contents == IMAGE_BYTES_FROM_EVENT async def test_camera_event_media_eviction(hass, auth, hass_client): @@ -951,9 +1263,6 @@ async def test_camera_event_media_eviction(hass, auth, hass_client): BATTERY_CAMERA_TRAITS, ) - # Media fetched as soon as it is published - subscriber.cache_policy.fetch = True - device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) assert device @@ -1014,14 +1323,92 @@ async def test_camera_event_media_eviction(hass, auth, hass_client): 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 range(3, 8): - response = await client.get( - f"/api/nest/event_media/{device.id}/event-session-{i}" - ) + 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}/{device.id}" + ) + assert browse.domain == DOMAIN + assert browse.identifier == device.id + assert ( + browse.thumbnail + == f"/api/nest/event_media/{device.id}/{event_identifier}/thumbnail" + ) diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 55083171a1af8..784b428e8d0e2 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 45c8dc48b224d..b1cee88ee2ff0 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_init.py b/tests/components/netatmo/test_init.py index 418854a61a2fd..950a45f1e4a1a 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 6abbb646055ce..d28df01beccdf 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 f60b9be62a5ac..0000000000000 --- 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 b26dce8d93614..33c634e250a8f 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, ORBI_PORT -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} @@ -252,12 +260,44 @@ async def test_ssdp(hass, service): assert result["result"].unique_id == SERIAL assert result["title"] == TITLE assert result["data"].get(CONF_HOST) == HOST - assert result["data"].get(CONF_PORT) == ORBI_PORT + assert result["data"].get(CONF_PORT) == PORT_80 assert result["data"].get(CONF_SSL) == SSL assert result["data"].get(CONF_USERNAME) == DEFAULT_USER 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/network/test_init.py b/tests/components/network/test_init.py index 5a6802a14fbcb..1103c6fa85021 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, Mock, patch import ifaddr +import pytest from homeassistant.components import network from homeassistant.components.network.const import ( @@ -13,6 +14,7 @@ STORAGE_KEY, STORAGE_VERSION, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component _NO_LOOPBACK_IPADDR = "192.168.1.5" @@ -602,3 +604,49 @@ async def test_async_get_ipv4_broadcast_addresses_multiple(hass, hass_storage): IPv4Address("192.168.1.255"), IPv4Address("169.254.255.255"), } + + +async def test_async_get_source_ip_no_enabled_addresses(hass, hass_storage, caplog): + """Test getting the source ip address when all adapters are disabled.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, + } + + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[], + ), patch( + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket(["192.168.1.5"]), + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + + assert "source address detection may be inaccurate" in caplog.text + + +async def test_async_get_source_ip_cannot_be_determined_and_no_enabled_addresses( + hass, hass_storage, caplog +): + """Test getting the source ip address when all adapters are disabled and getting it fails.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, + } + + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[], + ), patch( + "homeassistant.components.network.util.socket.socket", + return_value=_mock_socket([None]), + ): + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + with pytest.raises(HomeAssistantError): + await network.async_get_source_ip(hass, MDNS_TARGET_IP) diff --git a/tests/components/nexia/test_switch.py b/tests/components/nexia/test_switch.py new file mode 100644 index 0000000000000..9b6661f0d3d9f --- /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/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py index 9b2bfd17cfb34..ebdd7ed4105ea 100644 --- a/tests/components/nina/test_binary_sensor.py +++ b/tests/components/nina/test_binary_sensor.py @@ -1,6 +1,6 @@ """Test the Nina binary sensor.""" import json -from typing import Any, Dict +from typing import Any from unittest.mock import patch from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -19,13 +19,13 @@ from tests.common import MockConfigEntry, load_fixture -ENTRY_DATA: Dict[str, Any] = { +ENTRY_DATA: dict[str, Any] = { "slots": 5, "corona_filter": True, "regions": {"083350000000": "Aach, Stadt"}, } -ENTRY_DATA_NO_CORONA: Dict[str, Any] = { +ENTRY_DATA_NO_CORONA: dict[str, Any] = { "slots": 5, "corona_filter": False, "regions": {"083350000000": "Aach, Stadt"}, @@ -35,7 +35,7 @@ async def test_sensors(hass: HomeAssistant) -> None: """Test the creation and values of the NINA sensors.""" - dummy_response: Dict[str, Any] = json.loads( + dummy_response: dict[str, Any] = json.loads( load_fixture("sample_warnings.json", "nina") ) @@ -125,7 +125,7 @@ async def test_sensors(hass: HomeAssistant) -> None: 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( + dummy_response: dict[str, Any] = json.loads( load_fixture("nina/sample_warnings.json") ) diff --git a/tests/components/nina/test_init.py b/tests/components/nina/test_init.py index 2f60c5d8e89e6..455d7465a8776 100644 --- a/tests/components/nina/test_init.py +++ b/tests/components/nina/test_init.py @@ -1,6 +1,6 @@ """Test the Nina init file.""" import json -from typing import Any, Dict +from typing import Any from unittest.mock import patch from pynina import ApiError @@ -12,7 +12,7 @@ from tests.common import MockConfigEntry, load_fixture -ENTRY_DATA: Dict[str, Any] = { +ENTRY_DATA: dict[str, Any] = { "slots": 5, "corona_filter": True, "regions": {"083350000000": "Aach, Stadt"}, @@ -22,7 +22,7 @@ async def init_integration(hass) -> MockConfigEntry: """Set up the NINA integration in Home Assistant.""" - dummy_response: Dict[str, Any] = json.loads( + dummy_response: dict[str, Any] = json.loads( load_fixture("sample_warnings.json", "nina") ) diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index aae48b80d1028..3016727f7becc 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/nuki/mock.py b/tests/components/nuki/mock.py index 30315915a73e6..e85d1de3933aa 100644 --- a/tests/components/nuki/mock.py +++ b/tests/components/nuki/mock.py @@ -7,6 +7,7 @@ MAC = "01:23:45:67:89:ab" HW_ID = 123456789 +ID_HEX = "75BCD15" MOCK_INFO = {"ids": {"hardwareId": HW_ID}} @@ -16,7 +17,7 @@ async def setup_nuki_integration(hass): entry = MockConfigEntry( domain="nuki", - unique_id=HW_ID, + unique_id=ID_HEX, data={"host": HOST, "port": 8080, "token": "test-token"}, ) entry.add_to_hass(hass) diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index afd941ef00a45..77966bd7e5ff3 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -39,7 +39,7 @@ async def test_form(hass): await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == 123456789 + assert result2["title"] == "75BCD15" assert result2["data"] == { "host": "1.1.1.1", "port": 8080, @@ -169,7 +169,7 @@ async def test_dhcp_flow(hass): ) assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == 123456789 + assert result2["title"] == "75BCD15" assert result2["data"] == { "host": "1.1.1.1", "port": 8080, diff --git a/tests/components/numato/test_binary_sensor.py b/tests/components/numato/test_binary_sensor.py index 5aa6aea2b8d68..41ddabc1a3cc3 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 c6d176dbc9072..45f3375c2e4f0 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 91cda5c2a3785..15324a27e475e 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/nx584/test_binary_sensor.py b/tests/components/nx584/test_binary_sensor.py index fd2e5b30bac30..83f8a49c091b5 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/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index 57e89955d58ea..422b47668aa46 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 a7da0579c1095..5f8a0edd37324 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/oncue/__init__.py b/tests/components/oncue/__init__.py new file mode 100644 index 0000000000000..48492a1993306 --- /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 0000000000000..020b914c76bac --- /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 0000000000000..df9de02a6b3dc --- /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 0000000000000..ea733bb13b550 --- /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 0000000000000..5fe8b807c1b4c --- /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/conftest.py b/tests/components/onewire/conftest.py index d951ff17cdf5d..189baa3e7da55 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 3eb0ef51742d6..e255e234f546d 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -92,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, @@ -1045,7 +1045,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/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index c9c745c96656f..9de94eab36c3b 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant.components.overkiz.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, mock_device_registry +from tests.common import MockConfigEntry TEST_EMAIL = "test@testdomain.com" TEST_EMAIL2 = "test@testdomain.nl" @@ -120,7 +120,7 @@ async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: """Test config flow allows Config Flow unique entries.""" MockConfigEntry( domain=DOMAIN, - unique_id="test2@testdomain.com", + unique_id=TEST_GATEWAY_ID2, data={"username": "test2@testdomain.com", "password": TEST_PASSWORD}, ).add_to_hass(hass) @@ -166,17 +166,11 @@ 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_EMAIL, + unique_id=TEST_GATEWAY_ID, data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ) config_entry.add_to_hass(hass) - device_registry = mock_device_registry(hass) - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, "1234-5678-9123")}, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, data=dhcp.DhcpServiceInfo( diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index bc552b71770d0..a26e243df1104 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/conftest.py b/tests/components/philips_js/conftest.py index fc7e142bf5314..e0069cf9b750d 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/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 4773206f5cfea..a4a52e50453eb 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -11,7 +11,12 @@ 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, STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CURRENCY_EURO, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.util import dt @@ -99,6 +104,7 @@ async def asyncSetUp(self): # Patch the api client self.picnic_patcher = patch("homeassistant.components.picnic.PicnicAPI") self.picnic_mock = self.picnic_patcher.start() + self.picnic_mock().session.auth_token = "3q29fpwhulzes" # Add a config entry and setup the integration config_data = { @@ -242,6 +248,11 @@ async def test_sensors_setup(self): "2021-02-26T20:14:00+00:00", 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", @@ -277,13 +288,11 @@ async def test_sensors_no_selected_time_slot(self): await self._setup_platform() # Assert sensors are unknown - self._assert_sensor("sensor.picnic_selected_slot_start", STATE_UNAVAILABLE) - self._assert_sensor("sensor.picnic_selected_slot_end", STATE_UNAVAILABLE) + self._assert_sensor("sensor.picnic_selected_slot_start", STATE_UNKNOWN) + self._assert_sensor("sensor.picnic_selected_slot_end", STATE_UNKNOWN) + self._assert_sensor("sensor.picnic_selected_slot_max_order_time", STATE_UNKNOWN) self._assert_sensor( - "sensor.picnic_selected_slot_max_order_time", STATE_UNAVAILABLE - ) - self._assert_sensor( - "sensor.picnic_selected_slot_min_order_value", STATE_UNAVAILABLE + "sensor.picnic_selected_slot_min_order_value", STATE_UNKNOWN ) async def test_sensors_last_order_in_future(self): @@ -300,7 +309,7 @@ async def test_sensors_last_order_in_future(self): await self._setup_platform() # Assert delivery time is not available, but eta is - self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNAVAILABLE) + self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNKNOWN) self._assert_sensor( "sensor.picnic_last_order_eta_start", "2021-02-26T19:54:00+00:00" ) @@ -308,6 +317,25 @@ async def test_sensors_last_order_in_future(self): "sensor.picnic_last_order_eta_end", "2021-02-26T20:14:00+00:00" ) + async def test_sensors_eta_date_malformed(self): + """Test sensor states when last order eta dates are malformed.""" + # Set-up platform with default mock responses + await self._setup_platform(use_default_responses=True) + + # Set non-datetime strings as eta + eta_dates: dict[str, str] = { + "start": "wrong-time", + "end": "other-malformed-datetime", + } + delivery_response = copy.deepcopy(DEFAULT_DELIVERY_RESPONSE) + delivery_response["eta2"] = eta_dates + self.picnic_mock().get_deliveries.return_value = [delivery_response] + await self._coordinator.async_refresh() + + # Assert eta times are not available due to malformed date strings + self._assert_sensor("sensor.picnic_last_order_eta_start", STATE_UNKNOWN) + self._assert_sensor("sensor.picnic_last_order_eta_end", STATE_UNKNOWN) + async def test_sensors_use_detailed_eta_if_available(self): """Test sensor states when last order is not yet delivered.""" # Set-up platform with default mock responses @@ -361,8 +389,27 @@ 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): + """Test sensor states when the delivery api returns not a list.""" + # Setup platform with default responses + await self._setup_platform(use_default_responses=True) + + # Change mock responses to empty data and refresh the coordinator + self.picnic_mock().get_deliveries.return_value = {"error": "message"} + await self._coordinator.async_refresh() + + # Assert all last-order sensors have STATE_UNAVAILABLE because the delivery info fetch failed + 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): """Test coordinator update fails when API yields ValueError.""" # Setup platform with default responses diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index cdd0d4dff3ec7..9d3e7a135362a 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") 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 0000000000000..ff2e458ff24ed --- /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 5802c58d4d4ee..7da5df4c1df4d 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_config_flow.py b/tests/components/plex/test_config_flow.py index 43df50924353a..c22890ebef3e0 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -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( diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 7ad945d2d2218..6b634701ff2f7 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -7,7 +7,7 @@ import prometheus_client import pytest -from homeassistant.components import climate, humidifier, lock, 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 @@ -20,6 +20,7 @@ DEGREE, ENERGY_KILO_WATT_HOUR, EVENT_STATE_CHANGED, + TEMP_CELSIUS, TEMP_FAHRENHEIT, ) from homeassistant.core import split_entity_id @@ -201,6 +202,13 @@ async def test_sensor_unit(hass, hass_client): 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) @@ -228,6 +236,12 @@ async def test_sensor_unit(hass, hass_client): '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.""" @@ -355,6 +369,11 @@ async def test_input_number(hass, hass_client): number2._attr_name = None await number2.async_update_ha_state() + 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() + await hass.async_block_till_done() body = await generate_latest_metrics(client) @@ -370,6 +389,12 @@ async def test_input_number(hass, hass_client): 'friendly_name="None"} 60.0' in body ) + assert ( + '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.""" @@ -662,6 +687,31 @@ async def test_lock(hass, hass_client): ) +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 + ) + + @pytest.fixture(name="mock_client") def mock_client_fixture(): """Mock the prometheus client.""" diff --git a/tests/components/pvoutput/__init__.py b/tests/components/pvoutput/__init__.py new file mode 100644 index 0000000000000..7b55b5c047178 --- /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 0000000000000..844bf1573424c --- /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 0000000000000..0c060a75a9d41 --- /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_init.py b/tests/components/pvoutput/test_init.py new file mode 100644 index 0000000000000..faaff3d4214f6 --- /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 0000000000000..a194326cd9d43 --- /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/recorder/conftest.py b/tests/components/recorder/conftest.py index e7786307b698d..b23bfee48d3ad 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/test_init.py b/tests/components/recorder/test_init.py index ef5ebff44c447..2bc0109e1b5ad 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 @@ -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_util.py b/tests/components/recorder/test_util.py index fa449aefefc24..ec74ea73975a2 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_trigger.py b/tests/components/remote/test_device_trigger.py index 00c444b232e60..0db45318bfaa1 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -51,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, @@ -159,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", + ) + ) + }, + }, + }, ] }, ) @@ -168,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/rest/test_switch.py b/tests/components/rest/test_switch.py index c422bfc841d84..3dbef91ffb5d6 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -21,8 +21,6 @@ from tests.common import assert_setup_component -"""Tests for setting up the REST switch platform.""" - NAME = "foo" DEVICE_CLASS = SwitchDeviceClass.SWITCH METHOD = "post" @@ -101,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 diff --git a/tests/components/roku/fixtures/rokutv-device-info.xml b/tests/components/roku/fixtures/rokutv-device-info.xml index 658fc130629dc..cbb538ba4c173 100644 --- a/tests/components/roku/fixtures/rokutv-device-info.xml +++ b/tests/components/roku/fixtures/rokutv-device-info.xml @@ -59,6 +59,7 @@ true true true + true true true https://www.onntvsupport.com/ diff --git a/tests/components/roku/test_binary_sensor.py b/tests/components/roku/test_binary_sensor.py new file mode 100644 index 0000000000000..d04e2db623b5c --- /dev/null +++ b/tests/components/roku/test_binary_sensor.py @@ -0,0 +1,169 @@ +"""Tests for the sensors provided by the Roku integration.""" +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.components.roku import UPNP_SERIAL, setup_integration +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_roku_binary_sensors( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the Roku binary sensors.""" + await setup_integration(hass, aioclient_mock) + + 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" + + +async def test_rokutv_binary_sensors( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the Roku binary sensors.""" + await setup_integration( + hass, + aioclient_mock, + device="rokutv", + app="tvinput-dtv", + host="192.168.1.161", + unique_id="YN00H5555555", + ) + + 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" diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 9874c23bec549..5f3e5ebe9567a 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -13,6 +13,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, @@ -24,6 +25,7 @@ MEDIA_TYPE_APPS, MEDIA_TYPE_CHANNEL, MEDIA_TYPE_CHANNELS, + MEDIA_TYPE_URL, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, SUPPORT_BROWSE_MEDIA, @@ -38,11 +40,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, @@ -85,12 +96,30 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - await setup_integration(hass, aioclient_mock) entity_registry = er.async_get(hass) - main = entity_registry.async_get(MAIN_ENTITY_ID) + device_registry = dr.async_get(hass) - assert hass.states.get(MAIN_ENTITY_ID) - assert main - assert main.original_device_class is MediaPlayerDeviceClass.RECEIVER - assert main.unique_id == UPNP_SERIAL + 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 == UPNP_SERIAL + + 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.hw_version == "4200X" + assert device_entry.sw_version == "7.5.0" async def test_idle_setup( @@ -100,6 +129,7 @@ async def test_idle_setup( await setup_integration(hass, aioclient_mock, power=False) state = hass.states.get(MAIN_ENTITY_ID) + assert state assert state.state == STATE_STANDBY @@ -117,12 +147,30 @@ async def test_tv_setup( ) 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 is MediaPlayerDeviceClass.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 == TV_SERIAL + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, TV_SERIAL)} + 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 == TV_MANUFACTURER + assert device_entry.model == TV_MODEL + assert device_entry.name == '58" Onn Roku TV' + assert device_entry.entry_type is None + assert device_entry.hw_version == "7820X" + assert device_entry.sw_version == TV_SW_VERSION async def test_availability( @@ -412,7 +460,74 @@ async def test_services( blocking=True, ) - launch_mock.assert_called_once_with("11") + launch_mock.assert_called_once_with("11", {}) + + 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: "291097", + ATTR_MEDIA_EXTRA: { + ATTR_MEDIA_TYPE: "movie", + ATTR_CONTENT_ID: "8e06a8b7-d667-4e31-939d-f40a6dd78a88", + }, + }, + blocking=True, + ) + + launch_mock.assert_called_once_with( + "291097", + { + "contentID": "8e06a8b7-d667-4e31-939d-f40a6dd78a88", + "MediaType": "movie", + }, + ) + + with patch("homeassistant.components.roku.coordinator.Roku.play_video") as pv_mock: + 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, + ) + + pv_mock.assert_called_once_with( + "https://awesome.tld/media.mp4", + { + "videoName": "Sent from HA", + "videoFormat": "mp4", + }, + ) + + with patch("homeassistant.components.roku.coordinator.Roku.play_video") as pv_mock: + 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, + ) + + pv_mock.assert_called_once_with( + "https://awesome.tld/api/hls/api_token/master_playlist.m3u8", + { + "MediaType": "hls", + }, + ) with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( diff --git a/tests/components/roku/test_sensor.py b/tests/components/roku/test_sensor.py new file mode 100644 index 0000000000000..670cf69a8dc22 --- /dev/null +++ b/tests/components/roku/test_sensor.py @@ -0,0 +1,115 @@ +"""Tests for the sensors provided by the Roku integration.""" +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.components.roku import UPNP_SERIAL, setup_integration +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_roku_sensors( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the Roku sensors.""" + await setup_integration(hass, aioclient_mock) + + 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" + + +async def test_rokutv_sensors( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the Roku TV sensors.""" + await setup_integration( + hass, + aioclient_mock, + device="rokutv", + app="tvinput-dtv", + host="192.168.1.161", + unique_id="YN00H5555555", + ) + + 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" diff --git a/tests/components/rtsp_to_webrtc/__init__.py b/tests/components/rtsp_to_webrtc/__init__.py new file mode 100644 index 0000000000000..ee4206e357d8f --- /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/test_config_flow.py b/tests/components/rtsp_to_webrtc/test_config_flow.py new file mode 100644 index 0000000000000..ad0cd0006ba76 --- /dev/null +++ b/tests/components/rtsp_to_webrtc/test_config_flow.py @@ -0,0 +1,215 @@ +"""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") == "form" + assert result.get("step_id") == "hassio_confirm" + assert result.get("errors") == {"base": "server_failure"} 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 0000000000000..0a385ed7b9272 --- /dev/null +++ b/tests/components/rtsp_to_webrtc/test_init.py @@ -0,0 +1,215 @@ +"""Tests for RTSPtoWebRTC inititalization.""" + +from __future__ import annotations + +import base64 +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Any +from unittest.mock import patch + +import aiohttp +import pytest +import rtsp_to_webrtc + +from homeassistant.components import camera +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 homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +STREAM_SOURCE = "rtsp://example.com" +# 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..." + +SERVER_URL = "http://127.0.0.1:8083" + +CONFIG_ENTRY_DATA = {"server_url": SERVER_URL} + + +@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 + + +async def async_setup_rtsp_to_webrtc(hass: HomeAssistant) -> None: + """Set up the component.""" + return await async_setup_component(hass, DOMAIN, {}) + + +async def test_setup_success(hass: HomeAssistant) -> None: + """Test successful setup and unload.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + with patch("rtsp_to_webrtc.client.Client.heartbeat"): + assert await async_setup_rtsp_to_webrtc(hass) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_invalid_config_entry(hass: HomeAssistant) -> None: + """Test a config entry with missing required fields.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + + assert await async_setup_rtsp_to_webrtc(hass) + + 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) -> None: + """Test server responds with a failure on startup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + with patch( + "rtsp_to_webrtc.client.Client.heartbeat", + side_effect=rtsp_to_webrtc.exceptions.ResponseError(), + ): + assert await async_setup_rtsp_to_webrtc(hass) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_setup_communication_failure(hass: HomeAssistant) -> None: + """Test unable to talk to server on startup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + with patch( + "rtsp_to_webrtc.client.Client.heartbeat", + side_effect=rtsp_to_webrtc.exceptions.ClientError(), + ): + assert await async_setup_rtsp_to_webrtc(hass) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_offer_for_stream_source( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + mock_camera: Any, +) -> None: + """Test successful response from RTSPtoWebRTC server.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + with patch("rtsp_to_webrtc.client.Client.heartbeat"): + assert await async_setup_rtsp_to_webrtc(hass) + await hass.async_block_till_done() + + 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 + + +async def test_offer_failure( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + mock_camera: Any, +) -> None: + """Test a transient failure talking to RTSPtoWebRTC server.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA) + config_entry.add_to_hass(hass) + + with patch("rtsp_to_webrtc.client.Client.heartbeat"): + assert await async_setup_rtsp_to_webrtc(hass) + await hass.async_block_till_done() + + 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 eff80a0387a1f..5c50f84506400 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 9238200727396..2c64bd3d0a8d0 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/scene/test_init.py b/tests/components/scene/test_init.py index 4c5b832ac14e1..41b16261cd1ac 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 1c02a35792bed..10e73a809394f 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 d1c6cfb0b9f22..7e673832121bf 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/senseme/__init__.py b/tests/components/senseme/__init__.py new file mode 100644 index 0000000000000..2286b1ad890e4 --- /dev/null +++ b/tests/components/senseme/__init__.py @@ -0,0 +1,117 @@ +"""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" + +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.room_name = "Main" +device.room_type = "Main" +device.fw_version = "1" +device.fan_autocomfort = "on" +device.fan_smartmode = "on" +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": "20:F8:5E:92:5A:75", + "address": MOCK_ADDRESS, + "base_model": "FAN,HAIKU,HSERIES", + "has_light": False, + "has_sensor": True, + "is_fan": True, + "is_light": False, +} + + +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, +} + +MOCK_DEVICE = device +MOCK_DEVICE_ALTERNATE_IP = device_alternate_ip +MOCK_DEVICE2 = device2 + + +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() diff --git a/tests/components/senseme/test_config_flow.py b/tests/components/senseme/test_config_flow.py new file mode 100644 index 0000000000000..71ce385e5438e --- /dev/null +++ b/tests/components/senseme/test_config_flow.py @@ -0,0 +1,282 @@ +"""Test the SenseME config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +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_UUID, + _patch_discovery, +) + +from tests.common import MockConfigEntry + + +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" diff --git a/tests/components/sensibo/test_config_flow.py b/tests/components/sensibo/test_config_flow.py index b277ed80e962b..cf3716f09e404 100644 --- a/tests/components/sensibo/test_config_flow.py +++ b/tests/components/sensibo/test_config_flow.py @@ -9,7 +9,7 @@ import pytest from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -47,7 +47,6 @@ async def test_form(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_NAME: "Sensibo@Home", CONF_API_KEY: "1234567890", }, ) @@ -55,7 +54,6 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["type"] == RESULT_TYPE_CREATE_ENTRY assert result2["data"] == { - "name": "Sensibo@Home", "api_key": "1234567890", } @@ -82,9 +80,8 @@ async def test_import_flow_success(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] == RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "Sensibo@Home" + assert result2["title"] == "Sensibo" assert result2["data"] == { - "name": "Sensibo@Home", "api_key": "1234567890", } assert len(mock_setup_entry.mock_calls) == 1 @@ -96,7 +93,6 @@ async def test_import_flow_already_exist(hass: HomeAssistant) -> None: MockConfigEntry( domain=DOMAIN, data={ - CONF_NAME: "Sensibo@Home", CONF_API_KEY: "1234567890", }, unique_id="1234567890", @@ -147,7 +143,6 @@ async def test_flow_fails(hass: HomeAssistant, error_message) -> None: result4 = await hass.config_entries.flow.async_configure( result4["flow_id"], user_input={ - CONF_NAME: "Sensibo@Home", CONF_API_KEY: "1234567890", }, ) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 919736fb59e56..b49d8894932b3 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -81,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"}}) @@ -94,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.""" diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index faadb7140e288..155060222c807 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", [ @@ -1101,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 @@ -1124,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 @@ -1204,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 @@ -1217,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 ) @@ -1523,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() @@ -1553,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/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index 4e22822150bbb..d8d75f827f72c 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,11 +368,13 @@ 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): diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index b36359ed31ace..5080c37910886 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 32fff324b5580..aee171eb46e91 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -169,6 +169,42 @@ async def test_get_triggers_button(hass): assert_lists_same(triggers, expected_triggers) +async def test_get_triggers_non_initialized_devices(hass): + """Test we get the empty triggers for non-initialized devices.""" + await async_setup_component(hass, "shelly", {}) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={"sleep_period": 43200, "model": "SHDW-2", "host": "1.2.3.4"}, + unique_id="12345678", + ) + config_entry.add_to_hass(hass) + + device = Mock( + blocks=None, + settings=None, + shelly=None, + update=AsyncMock(), + initialized=False, + ) + + hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} + hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id] = {} + coap_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][ + BLOCK + ] = BlockDeviceWrapper(hass, config_entry, device) + + coap_wrapper.async_setup() + + expected_triggers = [] + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, coap_wrapper.device_id + ) + + assert_lists_same(triggers, expected_triggers) + + async def test_get_triggers_for_invalid_device_id(hass, device_reg, coap_wrapper): """Test error raised for invalid shelly device_id.""" assert coap_wrapper diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 4210772420c85..0c6c8a7ee67f8 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 80d9b38e28bdc..b953d8692a8e5 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 9194bc15d6f3f..8cf22b3634e31 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 129af154924a0..58fafe930c7a6 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 2a7b5ed7084a9..e3e80d80e525b 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_config_flow.py b/tests/components/smartthings/test_config_flow.py index 7f8950f18b58b..420c07d2a04fc 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/smarttub/test_init.py b/tests/components/smarttub/test_init.py index f316a66c5d185..32b15e06c8aa1 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 3f189b5231198..60879e8af750e 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/sonarr/test_init.py b/tests/components/sonarr/test_init.py index 6dfce4ee2fec0..39d9b7fc24ea2 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/sonos/conftest.py b/tests/components/sonos/conftest.py index e6b48ff9a26e9..a35eee1c1b3cc 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -101,6 +101,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 @@ -222,11 +223,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_number.py b/tests/components/sonos/test_number.py new file mode 100644 index 0000000000000..91c00e053908f --- /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_sensor.py b/tests/components/sonos/test_sensor.py index 633bf7509620a..8fb757891496a 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -45,7 +45,7 @@ async def test_battery_attributes(hass, async_autosetup_sonos, soco): ) -async def test_battery_on_S1(hass, async_setup_sonos, 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 = {} @@ -60,7 +60,7 @@ async def test_battery_on_S1(hass, async_setup_sonos, soco, battery_event): 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"] @@ -74,7 +74,7 @@ async def test_battery_on_S1(hass, async_setup_sonos, soco, battery_event): async def test_device_payload_without_battery( - hass, async_setup_sonos, 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 @@ -85,16 +85,16 @@ async def test_device_payload_without_battery( 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, async_setup_sonos, 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 @@ -105,18 +105,35 @@ async def test_device_payload_without_battery_and_ignored_keys( 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, async_autosetup_sonos, soco): - """Test sonos device with battery state.""" + """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 0000000000000..cb53fb43ed252 --- /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/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 11f59444c2c5d..629ec464e58b0 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/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 05420a859c65f..e2e6c7dfd5d21 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -1,6 +1,10 @@ """The test for the statistics sensor platform.""" +from __future__ import annotations + +from collections.abc import Sequence from datetime import datetime, timedelta import statistics +from typing import Any from unittest.mock import patch from homeassistant import config as hass_config @@ -542,7 +546,7 @@ async def test_state_characteristics(hass: HomeAssistant): def mock_now(): return mock_data["return_time"] - characteristics = ( + characteristics: Sequence[dict[str, Any]] = ( { "source_sensor_domain": "sensor", "name": "average_linear", @@ -615,16 +619,16 @@ def mock_now(): "source_sensor_domain": "sensor", "name": "datetime_newest", "value_0": STATE_UNKNOWN, - "value_1": start_datetime + timedelta(minutes=9), - "value_9": start_datetime + timedelta(minutes=9), + "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), - "value_9": start_datetime + timedelta(minutes=1), + "value_1": (start_datetime + timedelta(minutes=9)).isoformat(), + "value_9": (start_datetime + timedelta(minutes=1)).isoformat(), "unit": None, }, { @@ -805,7 +809,11 @@ def mock_now(): state = hass.states.get( f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" ) - assert state is not None + 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']}' " @@ -826,7 +834,11 @@ def mock_now(): state = hass.states.get( f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" ) - assert state is not None + 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']}' " @@ -844,7 +856,11 @@ def mock_now(): state = hass.states.get( f"sensor.test_{characteristic['source_sensor_domain']}_{characteristic['name']}" ) - assert state is not None + assert state is not None, ( + f"no state object for characteristic " + f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " + f"(buffer empty)" + ) assert state.state == str(characteristic["value_0"]), ( f"value mismatch for characteristic " f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " @@ -987,7 +1003,7 @@ def mock_purge(self, *args): # 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" + state.state, "%Y-%m-%dT%H:%M:%S%z" ) + timedelta(hours=1) diff --git a/tests/components/steamist/__init__.py b/tests/components/steamist/__init__.py new file mode 100644 index 0000000000000..4868bb6a95658 --- /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 0000000000000..7876272368afe --- /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 0000000000000..a40917cfc3c07 --- /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 0000000000000..4d83a0eb80d18 --- /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 0000000000000..47a9cbf6708b6 --- /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/stream/test_hls.py b/tests/components/stream/test_hls.py index 67c7415f509bc..34c87349811bc 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -262,7 +262,6 @@ def update_callback() -> None: cur_time = 0 def time_side_effect(): - print("stream.available=%s", stream.available) nonlocal cur_time if cur_time >= 80: stream.keepalive = False # Thread should exit and be joinable. diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 6e35cc65b6f8b..b54c8dc347244 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -716,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 diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 6697e9e1949e0..832a58d6f2cae 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -51,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, @@ -159,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", + ) + ) + }, + }, + }, ] }, ) @@ -168,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_light.py b/tests/components/switch/test_light.py index e260ce83dbf52..62fef242e9fe5 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/system_log/test_init.py b/tests/components/system_log/test_init.py index d10ee9bbb5164..8b9284a4b327d 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/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 4ed4d59c12416..344ae27fcaad0 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -909,11 +909,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) @@ -976,7 +976,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 0000000000000..aa671bebd89a6 --- /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/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py index 6eaa52ac10344..3081323d9b63f 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 MockConfigEntry, 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", } @@ -53,6 +57,8 @@ async def test_create_entry(hass): async def test_flow_entry_already_exists(hass): """Test user input for config_entry that already exists.""" + await async_init_recorder_component(hass) + first_entry = MockConfigEntry( domain="tibber", data={CONF_ACCESS_TOKEN: "valid"}, diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py new file mode 100644 index 0000000000000..0b9078b225a69 --- /dev/null +++ b/tests/components/tibber/test_statistics.py @@ -0,0 +1,103 @@ +"""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 tests.common import async_init_recorder_component +from tests.components.recorder.common import async_wait_recording_done_without_instance + +_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, + }, +] + + +async def test_async_setup_entry(hass): + """Test setup Tibber.""" + await async_init_recorder_component(hass) + + def _get_homes(): + tibber_home = AsyncMock() + tibber_home.name = "Name" + tibber_home.home_id = "home_id" + tibber_home.currency = "NOK" + tibber_home.get_historic_data.return_value = _CONSUMPTION_DATA_1 + return [tibber_home] + + tibber_connection = AsyncMock() + tibber_connection.name = "tibber" + tibber_connection.fetch_consumption_data_active_homes.return_value = None + tibber_connection.get_homes = _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/tplink/test_light.py b/tests/components/tplink/test_light.py index 1017ad38eae65..c3b82045ef09e 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/tradfri/common.py b/tests/components/tradfri/common.py new file mode 100644 index 0000000000000..5e28bdcd55c4e --- /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 0000000000000..663e7209619cb --- /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 0000000000000..13b7e59e10348 --- /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 85e7f8dc03707..7de2c4dcb37fb 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 0000000000000..1e9ae71828534 --- /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 0000000000000..11903dc9a42c4 --- /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 3dbdf801f893e..67d5b95f5d8c5 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/twinkly/__init__.py b/tests/components/twinkly/__init__.py index 96f9f450b8a7b..d5440ddb74a7d 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 5c4d3bfb098d8..e29bd1c3fc185 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -4,10 +4,10 @@ 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, ) @@ -20,7 +20,9 @@ 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} ) @@ -29,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} ) @@ -51,23 +55,25 @@ 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_ENTRY_HOST: "dummy", - CONF_ENTRY_ID: client.id, - CONF_ENTRY_NAME: client.id, - CONF_ENTRY_MODEL: TEST_MODEL, + 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("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_DHCP}, @@ -86,7 +92,9 @@ async def test_dhcp_can_confirm(hass): async def test_dhcp_success(hass): """Test DHCP discovery flow success.""" 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_DHCP}, @@ -106,10 +114,10 @@ async def test_dhcp_success(hass): assert result["type"] == "create_entry" assert result["title"] == client.id assert result["data"] == { - CONF_ENTRY_HOST: "1.2.3.4", - 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, } @@ -120,16 +128,18 @@ async def test_dhcp_already_exists(hass): entry = MockConfigEntry( domain=TWINKLY_DOMAIN, data={ - CONF_ENTRY_HOST: "1.2.3.4", - 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, }, unique_id=client.id, ) entry.add_to_hass(hass) - 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_DHCP}, diff --git a/tests/components/twinkly/test_init.py b/tests/components/twinkly/test_init.py index 3f55d2ffdf013..573bf5fbc86ab 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 fcbbdb035c7a6..7072d7c2eecbc 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 42e9db6b958b2..e21c458386fdd 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_controller.py b/tests/components/unifi/test_controller.py index 738cb28e1e3dc..8a41ada9b6234 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 4014062ee27f8..d7d29db1be981 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -38,7 +38,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", @@ -157,7 +159,9 @@ async def test_tracked_wireless_clients(hass, aioclient_mock, mock_unifi_websock 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", @@ -234,7 +238,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 +327,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", @@ -399,7 +370,7 @@ async def test_remove_clients(hass, aioclient_mock, mock_unifi_websocket): async def test_remove_client_but_keep_device_entry( - hass, aioclient_mock, mock_unifi_websocket + 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 = { @@ -424,7 +395,7 @@ async def test_remove_client_but_keep_device_entry( "unique_id", device_id=device_entry.id, ) - assert len(device_entry.config_entries) == 2 + assert len(device_entry.config_entries) == 3 mock_unifi_websocket( data={ @@ -438,10 +409,12 @@ async def test_remove_client_but_keep_device_entry( 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 + assert len(device_entry.config_entries) == 2 -async def test_controller_state_change(hass, aioclient_mock, mock_unifi_websocket): +async def test_controller_state_change( + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry +): """Verify entities state reflect on controller becoming unavailable.""" client = { "essid": "ssid", @@ -495,7 +468,7 @@ async def test_controller_state_change(hass, aioclient_mock, mock_unifi_websocke async def test_controller_state_change_client_to_listen_on_all_state_changes( - hass, aioclient_mock, mock_unifi_websocket + hass, aioclient_mock, mock_unifi_websocket, mock_device_registry ): """Verify entities state reflect on controller becoming unavailable.""" client = { @@ -579,7 +552,7 @@ async def test_controller_state_change_client_to_listen_on_all_state_changes( 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 +618,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 +684,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 +737,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. @@ -896,7 +871,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. @@ -979,7 +954,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", @@ -1061,7 +1038,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 +1092,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 +1152,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 +1201,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_services.py b/tests/components/unifi/test_services.py index 8fe41d7a856f4..b483e789f96e8 100644 --- a/tests/components/unifi/test_services.py +++ b/tests/components/unifi/test_services.py @@ -197,6 +197,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 +242,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) diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 38231ef9609f8..73226a7f6476f 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -5,7 +5,6 @@ from aiounifi.controller import MESSAGE_CLIENT_REMOVED, 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.unifi.const import ( CONF_BLOCK_CLIENT, @@ -289,7 +288,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": [], } @@ -600,6 +634,82 @@ 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_new_client_discovered_on_block_control( hass, aioclient_mock, mock_unifi_websocket @@ -784,8 +894,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 0000000000000..5fd1b7cc90919 --- /dev/null +++ b/tests/components/unifiprotect/__init__.py @@ -0,0 +1,44 @@ +"""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" + + +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="x.ui.direct", +) + + +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 0000000000000..cf29a97eabbb5 --- /dev/null +++ b/tests/components/unifiprotect/conftest.py @@ -0,0 +1,260 @@ +"""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 Camera, Light, WSSubscriptionMessage +from pyunifiprotect.data.base import ProtectAdoptableDeviceModel +from pyunifiprotect.data.devices import Sensor, Viewer +from pyunifiprotect.data.nvr import NVR, Liveview + +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] + + def reset_objects(self) -> None: + """Reset all devices on bootstrap for tests.""" + self.cameras = {} + self.lights = {} + self.sensors = {} + self.viewers = {} + self.liveviews = {} + self.events = {} + + +@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={}, + ) + + +@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 Camera device.""" + + 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 Camera 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 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 0000000000000..7cc660f428b25 --- /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_light.json b/tests/components/unifiprotect/fixtures/sample_light.json new file mode 100644 index 0000000000000..599c26f4f0c94 --- /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 0000000000000..70e641285bb51 --- /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 0000000000000..f896283604563 --- /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 0000000000000..ef9e8253b91d9 --- /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 0000000000000..001abd86417ff --- /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 0000000000000..88f19e59d7da4 --- /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 0000000000000..0064781c6ced3 --- /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 0000000000000..362481bfb035e --- /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 0000000000000..557eb3d5e791f --- /dev/null +++ b/tests/components/unifiprotect/test_config_flow.py @@ -0,0 +1,520 @@ +"""Test the UniFi Protect config flow.""" +from __future__ import annotations + +from dataclasses import asdict +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, + 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": "x.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_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] == "x.ui.direct" + + +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 diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py new file mode 100644 index 0000000000000..77bf900d87ea3 --- /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 0000000000000..8f4dc4f8fcf8a --- /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_media_player.py b/tests/components/unifiprotect/test_media_player.py new file mode 100644 index 0000000000000..4d83da3fabb92 --- /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 0000000000000..d3dfc7b340560 --- /dev/null +++ b/tests/components/unifiprotect/test_number.py @@ -0,0 +1,251 @@ +"""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, Light + +from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.components.unifiprotect.number import ( + CAMERA_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 + + +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) diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py new file mode 100644 index 0000000000000..a328e92835d37 --- /dev/null +++ b/tests/components/unifiprotect/test_select.py @@ -0,0 +1,686 @@ +"""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, + ) + + +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 0000000000000..b586e5fbbfa4a --- /dev/null +++ b/tests/components/unifiprotect/test_sensor.py @@ -0,0 +1,571 @@ +"""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, +) + + +@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" diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py new file mode 100644 index 0000000000000..82e4434ad089a --- /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 0000000000000..87ffc5683c0a2 --- /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/upnp/conftest.py b/tests/components/upnp/conftest.py index d2b1a849ba59d..479cd9000504c 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 46f0021a07b78..0a8095cb10f52 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 0771e51f890cc..097ef1eb1c636 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 7729068a2ed52..39a63893e3366 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 7d6b498ab24ed..ba962d333d474 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -1,8 +1,8 @@ """Tests for UPnP/IGD sensor.""" -from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import patch +from homeassistant.components.upnp import UpnpDataUpdateCoordinator from homeassistant.components.upnp.const import ( BYTES_RECEIVED, BYTES_SENT, @@ -18,15 +18,13 @@ 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 +40,21 @@ 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: "", + } + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() b_received_state = hass.states.get("sensor.mock_name_b_received") @@ -79,7 +75,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 +90,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, + now = coordinator.data[TIMESTAMP] + with patch( + "homeassistant.components.upnp.device.utcnow", + return_value=now + UPDATE_INTERVAL, + ): + mock_device: MockIgdDevice = coordinator.device._igd_device + mock_device.traffic_data = { 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()), } - ) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) - await hass.async_block_till_done() + async_fire_time_changed(hass, now + UPDATE_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 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" diff --git a/tests/components/vallox/__init__.py b/tests/components/vallox/__init__.py new file mode 100644 index 0000000000000..60fbbde6beb9f --- /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 0000000000000..ee6b05f1f8b51 --- /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 01a40af175163..960eedcbd011b 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/vera/common.py b/tests/components/vera/common.py index 1ce55ac9e8f50..6212b68fd422a 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 a2086f9f5e000..6f6e62e00a24e 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 index 489d1d435bf5e..17d72d6de72d5 100644 --- a/tests/components/version/common.py +++ b/tests/components/version/common.py @@ -14,7 +14,6 @@ ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.util import dt from tests.common import MockConfigEntry, async_fire_time_changed @@ -55,7 +54,6 @@ async def mock_get_version_update( async def setup_version_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the Version integration.""" - await async_setup_component(hass, "persistent_notification", {}) mock_entry = MockConfigEntry(**MOCK_VERSION_CONFIG_ENTRY_DATA) mock_entry.add_to_hass(hass) diff --git a/tests/components/version/test_config_flow.py b/tests/components/version/test_config_flow.py index f45ff1764f2b1..757afeac93d7c 100644 --- a/tests/components/version/test_config_flow.py +++ b/tests/components/version/test_config_flow.py @@ -3,7 +3,7 @@ from pyhaversion.consts import HaVersionChannel, HaVersionSource -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.version.const import ( CONF_BETA, CONF_BOARD, @@ -34,9 +34,10 @@ ) -async def test_reload(hass: HomeAssistant): - """Test the Version sensor with different sources.""" +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", @@ -48,12 +49,10 @@ async def test_reload(hass: HomeAssistant): entry = hass.config_entries.async_get_entry(config_entry.entry_id) assert entry.state == config_entries.ConfigEntryState.LOADED - assert hass.states.get("sensor.local_installation").state == MOCK_VERSION async def test_basic_form(hass: HomeAssistant) -> None: - """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) + """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}, @@ -82,7 +81,6 @@ async def test_basic_form(hass: HomeAssistant) -> None: async def test_advanced_form_pypi(hass: HomeAssistant) -> None: """Show advanced form when pypi is selected.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, @@ -124,7 +122,6 @@ async def test_advanced_form_pypi(hass: HomeAssistant) -> None: async def test_advanced_form_container(hass: HomeAssistant) -> None: """Show advanced form when container source is selected.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, @@ -166,7 +163,6 @@ async def test_advanced_form_container(hass: HomeAssistant) -> None: async def test_advanced_form_supervisor(hass: HomeAssistant) -> None: """Show advanced form when docker source is selected.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index 6a37fd58b3ae0..1dead6ab40bc5 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -36,7 +36,6 @@ async def async_setup_sensor_wrapper( hass: HomeAssistant, config: dict[str, Any] ) -> ConfigEntry: """Set up the Version sensor platform.""" - await async_setup_component(hass, "persistent_notification", {}) with patch( "pyhaversion.HaVersion.get_version", return_value=(MOCK_VERSION, MOCK_VERSION_DATA), @@ -47,7 +46,6 @@ async def async_setup_sensor_wrapper( await hass.async_block_till_done() config_entries = hass.config_entries.async_entries(DOMAIN) - print(config_entries) config_entry = config_entries[-1] assert config_entry.source == "import" return config_entry diff --git a/tests/components/vicare/__init__.py b/tests/components/vicare/__init__.py index f67e50be1d667..ae9df782886ad 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 c816f53507765..0bedb0d73b800 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 ffe79cb4ecf45..ad67d309cdd88 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_media_player.py b/tests/components/vizio/test_media_player.py index 7a030ade53fc7..d3ef4019c5725 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/vultr/conftest.py b/tests/components/vultr/conftest.py new file mode 100644 index 0000000000000..76c48c2574b49 --- /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 0000000000000..06bbf2a74835e --- /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 1b84ddff29129..80cd198e371ac 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 040eac1a67455..3805c68d95b5c 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 ac7008d066be3..a0b93a59124e5 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 ab3698b48f4d0..8997d1c0b9d49 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 1396ae80f1ef4..cac287a87a1d0 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 21d1f0acbc569..5effb103d7f71 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/waze_travel_time/test_config_flow.py b/tests/components/waze_travel_time/test_config_flow.py index f0f8a0f3bde59..015850ba1b817 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/webhook/test_init.py b/tests/components/webhook/test_init.py index f00c20af74aad..c7ed1a2398598 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 adef8e9b86abb..96c83b33c41da 100644 --- a/tests/components/webostv/__init__.py +++ b/tests/components/webostv/__init__.py @@ -1 +1,40 @@ """Tests for the WebOS TV integration.""" +from unittest.mock import patch + +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.setup import async_setup_component + +from tests.common import MockConfigEntry + +TV_NAME = "fake" +ENTITY_ID = f"{MP_DOMAIN}.{TV_NAME}" +MOCK_CLIENT_KEYS = {"1.2.3.4": "some-secret"} + + +async def setup_webostv(hass, unique_id="some-unique-id"): + """Initialize webostv and media_player for tests.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.2.3.4", + CONF_CLIENT_SECRET: "0123456789", + }, + 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: "1.2.3.4"}}, + ) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py new file mode 100644 index 0000000000000..434915804194b --- /dev/null +++ b/tests/components/webostv/conftest.py @@ -0,0 +1,29 @@ +"""Common fixtures and objects for the LG webOS integration tests.""" +from unittest.mock import patch + +import pytest + +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": "some-fake-uuid"} + client.software_info = {"device_id": "00:01:02:03:04:05"} + client.system_info = {"modelName": "TVFAKE"} + client.client_key = "0123456789" + client.apps = {0: {"title": "Applicaiton01"}} + client.inputs = {0: {"label": "Input01"}, 1: {"label": "Input02"}} + + yield client diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py new file mode 100644 index 0000000000000..a46af19fc04d2 --- /dev/null +++ b/tests/components/webostv/test_config_flow.py @@ -0,0 +1,308 @@ +"""Test the WebOS Tv config flow.""" +import dataclasses +from unittest.mock import Mock, patch + +from aiowebostv import WebOsTvPairError + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN +from homeassistant.config_entries import SOURCE_SSDP +from homeassistant.const import ( + CONF_CLIENT_SECRET, + 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 + +MOCK_YAML_CONFIG = { + CONF_HOST: "1.2.3.4", + CONF_NAME: "fake", + CONF_ICON: "mdi:test", + CONF_CLIENT_SECRET: "some-secret", + CONF_UNIQUE_ID: "fake-uuid", +} + +MOCK_DISCOVERY_INFO = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://1.2.3.4", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "LG Webostv", + ssdp.ATTR_UPNP_UDN: "uuid:some-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"] == "fake" + 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" + + +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"] == "fake" + + +async def test_options_flow(hass, client): + """Test options config flow.""" + 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: ["Input01", "Input02"]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"][CONF_SOURCES] == ["Input01", "Input02"] + + client.connect = Mock(side_effect=ConnectionRefusedError()) + result3 = await hass.config_entries.options.async_init(entry.entry_id) + + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["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] == "1.2.3.4" + + 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: "fake", + } + + 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 0000000000000..07106e424773b --- /dev/null +++ b/tests/components/webostv/test_device_trigger.py @@ -0,0 +1,174 @@ +"""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 ENTITY_ID, setup_webostv + +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, "fake-uuid") + + 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, "fake-uuid") + + 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, "fake-uuid") + + # 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_media_player.py b/tests/components/webostv/test_media_player.py index 716c496d88a87..cbae84ac66da2 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -1,12 +1,5 @@ """The tests for the LG webOS media player platform.""" -import json -import os -from unittest.mock import patch - -import pytest -from sqlitedict import SqliteDict - -from homeassistant.components import media_player +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, ATTR_MEDIA_VOLUME_MUTED, @@ -18,61 +11,22 @@ DOMAIN, SERVICE_BUTTON, SERVICE_COMMAND, - WEBOSTV_CONFIG_FILE, -) -from homeassistant.const import ( - ATTR_COMMAND, - ATTR_ENTITY_ID, - CONF_HOST, - CONF_NAME, - SERVICE_VOLUME_MUTE, ) -from homeassistant.setup import async_setup_component - -NAME = "fake" -ENTITY_ID = f"{media_player.DOMAIN}.{NAME}" - - -@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 - - -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}}, - ) - await hass.async_block_till_done() - +from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID, SERVICE_VOLUME_MUTE -@pytest.fixture -def cleanup_config(hass): - """Test cleanup, remove the config file.""" - yield - os.remove(hass.config.path(WEBOSTV_CONFIG_FILE)) +from . import ENTITY_ID, setup_webostv async def test_mute(hass, client): """Test simple service call.""" - await setup_webostv(hass) data = { ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True, } - await hass.services.async_call(media_player.DOMAIN, SERVICE_VOLUME_MUTE, data) + + assert await hass.services.async_call(MP_DOMAIN, SERVICE_VOLUME_MUTE, data, True) await hass.async_block_till_done() client.set_mute.assert_called_once() @@ -80,14 +34,13 @@ async def test_mute(hass, client): async def test_select_source_with_empty_source_list(hass, client): """Ensure we don't call client methods when we don't have sources.""" - await setup_webostv(hass) data = { ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "nonexistent", } - await hass.services.async_call(media_player.DOMAIN, SERVICE_SELECT_SOURCE, data) + await hass.services.async_call(MP_DOMAIN, SERVICE_SELECT_SOURCE, data) await hass.async_block_till_done() client.launch_app.assert_not_called() @@ -96,7 +49,6 @@ async def test_select_source_with_empty_source_list(hass, client): async def test_button(hass, client): """Test generic button functionality.""" - await setup_webostv(hass) data = { @@ -139,37 +91,3 @@ async def test_command_with_optional_arg(hass, client): 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) - - # Run the component setup - 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 - - -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() - - # Run the component setup - await setup_webostv(hass) - - # 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 diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py new file mode 100644 index 0000000000000..af4778d0fdc9c --- /dev/null +++ b/tests/components/webostv/test_trigger.py @@ -0,0 +1,177 @@ +"""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 ENTITY_ID, setup_webostv + +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, "fake-uuid") + + 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, "fake-uuid") + + 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, "fake-uuid") + + 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, "fake-uuid") + + 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 1099519a2a0dc..58c9b414d5a16 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 1d6bf5f2f6be8..0a9ae710bc591 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 041c0e76533f9..c991eeed0d165 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/entity_test_helpers.py b/tests/components/wemo/entity_test_helpers.py index c3d671d1fcac8..61c7cd5bf2e35 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, ) @@ -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 26e4981203dd4..481a634868866 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_device_trigger.py b/tests/components/wemo/test_device_trigger.py index 0d9c537b24d5a..e29864c7a640d 100644 --- a/tests/components/wemo/test_device_trigger.py +++ b/tests/components/wemo/test_device_trigger.py @@ -66,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, diff --git a/tests/components/wemo/test_fan.py b/tests/components/wemo/test_fan.py index a00d5523678d3..8795b7cdc94c8 100644 --- a/tests/components/wemo/test_fan.py +++ b/tests/components/wemo/test_fan.py @@ -3,13 +3,14 @@ import pytest from pywemo.exceptions import ActionException +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN 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, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component from . import entity_test_helpers @@ -84,10 +85,15 @@ async def test_available_after_update( pywemo_device.set_state.side_effect = ActionException pywemo_device.get_state.return_value = 1 await entity_test_helpers.test_avaliable_after_update( - hass, pywemo_registry, pywemo_device, wemo_entity, Platform.FAN + hass, pywemo_registry, pywemo_device, wemo_entity, FAN_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, 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( diff --git a/tests/components/wemo/test_light_bridge.py b/tests/components/wemo/test_light_bridge.py index 1d316d93c8f2d..3184335b173ba 100644 --- a/tests/components/wemo/test_light_bridge.py +++ b/tests/components/wemo/test_light_bridge.py @@ -8,8 +8,8 @@ DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.light import ATTR_COLOR_TEMP -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +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 from . import entity_test_helpers @@ -76,10 +76,15 @@ async def test_available_after_update( pywemo_bridge_light.turn_on.side_effect = pywemo.exceptions.ActionException pywemo_bridge_light.state["onoff"] = 1 await entity_test_helpers.test_avaliable_after_update( - hass, pywemo_registry, pywemo_device, wemo_entity, Platform.LIGHT + hass, pywemo_registry, pywemo_device, wemo_entity, LIGHT_DOMAIN ) +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 ): diff --git a/tests/components/wemo/test_light_dimmer.py b/tests/components/wemo/test_light_dimmer.py index 0460994aa863c..56054fa77e9e7 100644 --- a/tests/components/wemo/test_light_dimmer.py +++ b/tests/components/wemo/test_light_dimmer.py @@ -7,7 +7,8 @@ DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +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 @@ -40,10 +41,42 @@ async def test_available_after_update( pywemo_device.on.side_effect = ActionException pywemo_device.get_state.return_value = 1 await entity_test_helpers.test_avaliable_after_update( - hass, pywemo_registry, pywemo_device, wemo_entity, Platform.LIGHT + hass, pywemo_registry, pywemo_device, wemo_entity, LIGHT_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, 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 eb322d469cdd6..305aad6102cdc 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 963c662b124b1..9c1dc804645ae 100644 --- a/tests/components/wemo/test_switch.py +++ b/tests/components/wemo/test_switch.py @@ -7,7 +7,8 @@ DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component from . import entity_test_helpers @@ -82,5 +83,10 @@ async def test_available_after_update( pywemo_device.on.side_effect = ActionException pywemo_device.get_state.return_value = 1 await entity_test_helpers.test_avaliable_after_update( - hass, pywemo_registry, pywemo_device, wemo_entity, Platform.SWITCH + 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/whois/__init__.py b/tests/components/whois/__init__.py new file mode 100644 index 0000000000000..753779d0f40ea --- /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 0000000000000..ca9b123f49108 --- /dev/null +++ b/tests/components/whois/conftest.py @@ -0,0 +1,34 @@ +"""Fixtures for Whois integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.whois.const import DOMAIN +from homeassistant.const import CONF_DOMAIN + +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_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 diff --git a/tests/components/whois/test_config_flow.py b/tests/components/whois/test_config_flow.py new file mode 100644 index 0000000000000..71cc195c40729 --- /dev/null +++ b/tests/components/whois/test_config_flow.py @@ -0,0 +1,80 @@ +"""Tests for the Whois config flow.""" + +from unittest.mock import AsyncMock + +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, +) -> 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 + + +async def test_already_configured( + hass: HomeAssistant, + 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}, + 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, +) -> 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/wled/conftest.py b/tests/components/wled/conftest.py index 708bbf46834cd..f89d92aaa16fc 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_config_flow.py b/tests/components/wled/test_config_flow.py index 770d6abd2f822..9ca62a010ade1 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,34 +113,7 @@ async def test_zeroconf_connection_error( hostname="example.local.", name="mock_name", port=None, - properties={}, - type="mock_type", - ), - ) - - assert result.get("type") == RESULT_TYPE_ABORT - assert result.get("reason") == "cannot_connect" - - -async def test_zeroconf_confirm_connection_error( - hass: HomeAssistant, mock_wled_config_flow: MagicMock -) -> None: - """Test we abort zeroconf flow on WLED connection error.""" - mock_wled_config_flow.update.side_effect = WLEDConnectionError - - 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={}, + properties={CONF_MAC: "aabbccddeeff"}, type="mock_type", ), ) @@ -151,10 +124,11 @@ async def test_zeroconf_confirm_connection_error( async def test_user_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_USER}, @@ -165,12 +139,13 @@ async def test_user_device_exists_abort( assert result.get("reason") == "already_configured" -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 +165,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}, diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 47190604238f7..a7d7929c84e3d 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_init.py b/tests/components/wled/test_init.py index 0182126238963..3186cf1e7e427 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 diff --git a/tests/components/yale_smart_alarm/test_config_flow.py b/tests/components/yale_smart_alarm/test_config_flow.py index a2a0e41ba40a9..a7c454851bf6c 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/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 0482f38415bd9..24b6ec97ec64c 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -541,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) @@ -549,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): @@ -650,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) @@ -658,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): @@ -686,6 +686,40 @@ 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." diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index f4ec40fcb1503..9005fd49d8f17 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -619,3 +619,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_device_action.py b/tests/components/zha/test_device_action.py index 38232e033cc04..9bc52a784f628 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -10,6 +10,7 @@ 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 @@ -59,6 +60,30 @@ async def test_get_actions(hass, device_ias): 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_discover.py b/tests/components/zha/test_discover.py index fd489d7c5d504..9953b6e9d1570 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -461,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_select.py b/tests/components/zha/test_select.py new file mode 100644 index 0000000000000..fb21c900838e6 --- /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_siren.py b/tests/components/zha/test_siren.py index 1414879c0f0f6..17e12491f8402 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -77,7 +77,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] == 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 @@ -125,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 diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 90ee54197ea69..9dd5c8f1de776 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -637,6 +637,11 @@ "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"): { @@ -659,6 +664,31 @@ 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", + }, }, }, { @@ -777,6 +807,11 @@ "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: { ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { @@ -804,6 +839,31 @@ 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", + }, }, }, { @@ -867,12 +927,36 @@ DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_ENTITIES: [ "button.heiman_warningdevice_77665544_identify", - "siren.heiman_warningdevice_77665544_ias_wd", "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: { + ("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", diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 12eaccec612fb..4f21f616ae131 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -479,6 +479,18 @@ def fortrezz_ssa3_siren_state_fixture(): return json.loads(load_fixture("zwave_js/fortrezz_ssa3_siren_state.json")) +@pytest.fixture(name="zp3111_not_ready_state", scope="session") +def zp3111_not_ready_state_fixture(): + """Load the zp3111 4-in-1 sensor not-ready node state fixture data.""" + return json.loads(load_fixture("zwave_js/zp3111-5_not_ready_state.json")) + + +@pytest.fixture(name="zp3111_state", scope="session") +def zp3111_state_fixture(): + """Load the zp3111 4-in-1 sensor node state fixture data.""" + return json.loads(load_fixture("zwave_js/zp3111-5_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state, log_config_state): """Mock a client.""" @@ -919,3 +931,19 @@ def fortrezz_ssa3_siren_fixture(client, fortrezz_ssa3_siren_state): def firmware_file_fixture(): """Return mock firmware file stream.""" return io.BytesIO(bytes(10)) + + +@pytest.fixture(name="zp3111_not_ready") +def zp3111_not_ready_fixture(client, zp3111_not_ready_state): + """Mock a zp3111 4-in-1 sensor node in a not-ready state.""" + node = Node(client, copy.deepcopy(zp3111_not_ready_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="zp3111") +def zp3111_fixture(client, zp3111_state): + """Mock a zp3111 4-in-1 sensor node.""" + node = Node(client, copy.deepcopy(zp3111_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/climate_radio_thermostat_ct100_plus_state.json b/tests/components/zwave_js/fixtures/climate_radio_thermostat_ct100_plus_state.json index 34df415301eb5..cd5a6bd4abe6e 100644 --- a/tests/components/zwave_js/fixtures/climate_radio_thermostat_ct100_plus_state.json +++ b/tests/components/zwave_js/fixtures/climate_radio_thermostat_ct100_plus_state.json @@ -57,7 +57,128 @@ }, { "nodeId": 13, "index": 2 } ], - "commandClasses": [], + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 5, + "isSecure": false + }, + { + "id": 64, + "name": "Thermostat Mode", + "version": 2, + "isSecure": false + }, + { + "id": 66, + "name": "Thermostat Operating State", + "version": 2, + "isSecure": false + }, + { + "id": 67, + "name": "Thermostat Setpoint", + "version": 2, + "isSecure": false + }, + { + "id": 68, + "name": "Thermostat Fan Mode", + "version": 1, + "isSecure": false + }, + { + "id": 69, + "name": "Thermostat Fan State", + "version": 1, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 3, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": false + }, + { + "id": 129, + "name": "Clock", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 135, + "name": "Indicator", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + } + ], "values": [ { "commandClassName": "Manufacturer Specific", diff --git a/tests/components/zwave_js/fixtures/zp3111-5_not_ready_state.json b/tests/components/zwave_js/fixtures/zp3111-5_not_ready_state.json new file mode 100644 index 0000000000000..f892eb5570eee --- /dev/null +++ b/tests/components/zwave_js/fixtures/zp3111-5_not_ready_state.json @@ -0,0 +1,68 @@ +{ + "nodeId": 22, + "index": 0, + "status": 1, + "ready": false, + "isListening": false, + "isRouting": true, + "isSecure": "unknown", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 22, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + } + } + ], + "values": [], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [ + 40000, + 100000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [], + "interviewStage": "ProtocolInfo", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + } +} diff --git a/tests/components/zwave_js/fixtures/zp3111-5_state.json b/tests/components/zwave_js/fixtures/zp3111-5_state.json new file mode 100644 index 0000000000000..8de7dd2b713e4 --- /dev/null +++ b/tests/components/zwave_js/fixtures/zp3111-5_state.json @@ -0,0 +1,706 @@ +{ + "nodeId": 22, + "index": 0, + "installerIcon": 3079, + "userIcon": 3079, + "status": 2, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": false, + "manufacturerId": 265, + "productId": 8449, + "productType": 8225, + "firmwareVersion": "5.1", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/cache/db/devices/0x0109/zp3111-5.json", + "isEmbedded": true, + "manufacturer": "Vision Security", + "manufacturerId": 265, + "label": "ZP3111-5", + "description": "4-in-1 Sensor", + "devices": [ + { + "productType": 8225, + "productId": 8449 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "To add the ZP3111 to the Z-Wave network (inclusion), place the Z-Wave primary controller into inclusion mode. Press the Program Switch of ZP3111 for sending the NIF. After sending NIF, Z-Wave will send the auto inclusion, otherwise, ZP3111 will go to sleep after 20 seconds.", + "exclusion": "To remove the ZP3111 from the Z-Wave network (exclusion), place the Z-Wave primary controller into “exclusion” mode, and following its instruction to delete the ZP3111 to the controller. Press the Program Switch of ZP3111 once to be excluded.", + "reset": "Remove cover to trigged tamper switch, LED flash once & send out Alarm Report. Press Program Switch 10 times within 10 seconds, ZP3111 will send the “Device Reset Locally Notification” command and reset to the factory default. (Remark: This is to be used only in the case of primary controller being inoperable or otherwise unavailable.)", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/2479/ZP3111-5_R2_20170316.pdf" + } + }, + "label": "ZP3111-5", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 22, + "index": 0, + "installerIcon": 3079, + "userIcon": 3079, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "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": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.5" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "5.1", + "10.1" + ] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 265 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 8225 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 8449 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level" + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Home Security", + "propertyKey": "Cover status", + "propertyName": "Home Security", + "propertyKeyName": "Cover status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Cover status", + "ccSpecific": { + "notificationType": 7 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "Tampering, product cover removed" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Home Security", + "propertyKey": "Motion sensor status", + "propertyName": "Home Security", + "propertyKeyName": "Motion sensor status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Motion sensor status", + "ccSpecific": { + "notificationType": 7 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "8": "Motion detection" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "°C" + }, + "value": 21.98 + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Illuminance", + "propertyName": "Illuminance", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Illuminance", + "ccSpecific": { + "sensorType": 3, + "scale": 0 + }, + "unit": "%" + }, + "value": 7.31 + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Humidity", + "propertyName": "Humidity", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Humidity", + "ccSpecific": { + "sensorType": 5, + "scale": 0 + }, + "unit": "%" + }, + "value": 51.98 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Temperature Scale", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Scale", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Celsius", + "1": "Fahrenheit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Temperature offset", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature offset", + "default": 1, + "min": 0, + "max": 50, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Humidity", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Configure Relative Humidity", + "label": "Humidity", + "default": 10, + "min": 1, + "max": 50, + "unit": "percent", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Light Sensor", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Sensor", + "default": 10, + "min": 1, + "max": 50, + "unit": "percent", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Trigger Interval", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Set the trigger interval for motion sensor re-activation.", + "label": "Trigger Interval", + "default": 180, + "min": 1, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Motion Sensor Sensitivity", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Adjust sensitivity of the motion sensor.", + "label": "Motion Sensor Sensitivity", + "default": 4, + "min": 1, + "max": 7, + "states": { + "1": "highest", + "2": "higher", + "3": "high", + "4": "normal", + "5": "low", + "6": "lower", + "7": "lowest" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "LED indicator mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED indicator mode", + "default": 3, + "min": 1, + "max": 3, + "states": { + "1": "Off", + "2": "Pulsing Temperature, Flashing Motion", + "3": "Flashing Temperature and Motion" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 2, + "metadata": { + "type": "number", + "default": 3600, + "readable": false, + "writeable": true, + "label": "Wake Up interval", + "min": 600, + "max": 604800, + "steps": 600 + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller" + }, + "value": 1 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [ + 40000, + 100000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 7, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 132, + "name": "Wake Up", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 2, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0109:0x2021:0x2101:5.1", + "statistics": { + "commandsTX": 39, + "commandsRX": 38, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0 + }, + "highestSecurityClass": -1 +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 4ca733786dc82..2546541336571 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2799,20 +2799,8 @@ 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.""" + """Test version info API request.""" entry = integration ws_client = await hass_ws_client(hass) @@ -2906,21 +2894,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 +2914,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 64c27f276e714..292bbc2da37bf 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( @@ -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 d6d3376d0e603..0958e259ab098 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 c222287b5c00d..5377d420268c1 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -1,4 +1,6 @@ """The tests for Z-Wave JS device actions.""" +from unittest.mock import patch + import pytest import voluptuous_serialize from zwave_js_server.client import Client @@ -10,12 +12,13 @@ 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 +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry from homeassistant.setup import async_setup_component -from tests.common import async_get_device_automations, async_mock_service +from tests.common import async_get_device_automations async def test_get_actions( @@ -92,8 +95,19 @@ async def test_get_actions_meter( assert len(filtered_actions) > 0 -async def test_action(hass: HomeAssistant) -> None: - """Test for turn_on and turn_off actions.""" +async def test_actions( + hass: HomeAssistant, + client: Client, + climate_radio_thermostat_ct100_plus: Node, + integration: ConfigEntry, +) -> None: + """Test actions.""" + node = climate_radio_thermostat_ct100_plus + device_id = get_device_id(client, node) + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({device_id}) + assert device + assert await async_setup_component( hass, automation.DOMAIN, @@ -102,115 +116,209 @@ async def test_action(hass: HomeAssistant) -> None: { "trigger": { "platform": "event", - "event_type": "test_event_clear_lock_usercode", + "event_type": "test_event_refresh_value", }, "action": { "domain": DOMAIN, - "type": "clear_lock_usercode", - "device_id": "fake", - "entity_id": "lock.touchscreen_deadbolt", - "code_slot": 1, + "type": "refresh_value", + "device_id": device.id, + "entity_id": "climate.z_wave_thermostat", }, }, { "trigger": { "platform": "event", - "event_type": "test_event_set_lock_usercode", + "event_type": "test_event_ping", }, "action": { "domain": DOMAIN, - "type": "set_lock_usercode", - "device_id": "fake", - "entity_id": "lock.touchscreen_deadbolt", - "code_slot": 1, - "usercode": "1234", + "type": "ping", + "device_id": device.id, }, }, { "trigger": { "platform": "event", - "event_type": "test_event_refresh_value", + "event_type": "test_event_set_value", }, "action": { "domain": DOMAIN, - "type": "refresh_value", - "device_id": "fake", - "entity_id": "lock.touchscreen_deadbolt", + "type": "set_value", + "device_id": device.id, + "command_class": 112, + "property": 1, + "value": 1, }, }, { "trigger": { "platform": "event", - "event_type": "test_event_ping", + "event_type": "test_event_set_config_parameter", }, "action": { "domain": DOMAIN, - "type": "ping", - "device_id": "fake", + "type": "set_config_parameter", + "device_id": device.id, + "parameter": 1, + "bitmask": None, + "subtype": "2-112-0-3 (Beeper)", + "value": 1, }, }, + ] + }, + ) + + with patch("zwave_js_server.model.node.Node.async_poll_value") as mock_call: + hass.bus.async_fire("test_event_refresh_value") + await hass.async_block_till_done() + mock_call.assert_called_once() + args = mock_call.call_args_list[0][0] + assert len(args) == 1 + assert args[0].value_id == "13-64-1-mode" + + with patch("zwave_js_server.model.node.Node.async_ping") as mock_call: + hass.bus.async_fire("test_event_ping") + await hass.async_block_till_done() + mock_call.assert_called_once() + args = mock_call.call_args_list[0][0] + assert len(args) == 0 + + with patch("zwave_js_server.model.node.Node.async_set_value") as mock_call: + hass.bus.async_fire("test_event_set_value") + await hass.async_block_till_done() + mock_call.assert_called_once() + args = mock_call.call_args_list[0][0] + assert len(args) == 2 + assert args[0] == "13-112-0-1" + assert args[1] == 1 + + with patch( + "homeassistant.components.zwave_js.services.async_set_config_parameter" + ) as mock_call: + hass.bus.async_fire("test_event_set_config_parameter") + await hass.async_block_till_done() + mock_call.assert_called_once() + args = mock_call.call_args_list[0][0] + assert len(args) == 3 + assert args[0].node_id == 13 + assert args[1] == 1 + assert args[2] == 1 + + +async def test_lock_actions( + hass: HomeAssistant, + client: Client, + lock_schlage_be469: Node, + integration: ConfigEntry, +) -> None: + """Test actions for locks.""" + node = lock_schlage_be469 + device_id = get_device_id(client, node) + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({device_id}) + assert device + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ { "trigger": { "platform": "event", - "event_type": "test_event_set_value", + "event_type": "test_event_clear_lock_usercode", }, "action": { "domain": DOMAIN, - "type": "set_value", - "device_id": "fake", - "command_class": 112, - "property": "test", - "value": 1, + "type": "clear_lock_usercode", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + "code_slot": 1, }, }, { "trigger": { "platform": "event", - "event_type": "test_event_set_config_parameter", + "event_type": "test_event_set_lock_usercode", }, "action": { "domain": DOMAIN, - "type": "set_config_parameter", - "device_id": "fake", - "parameter": 3, - "bitmask": None, - "subtype": "2-112-0-3 (Beeper)", - "value": 255, + "type": "set_lock_usercode", + "device_id": device.id, + "entity_id": "lock.touchscreen_deadbolt", + "code_slot": 1, + "usercode": "1234", }, }, ] }, ) - clear_lock_usercode = async_mock_service(hass, "zwave_js", "clear_lock_usercode") - hass.bus.async_fire("test_event_clear_lock_usercode") - await hass.async_block_till_done() - assert len(clear_lock_usercode) == 1 - - set_lock_usercode = async_mock_service(hass, "zwave_js", "set_lock_usercode") - hass.bus.async_fire("test_event_set_lock_usercode") - await hass.async_block_till_done() - assert len(set_lock_usercode) == 1 - - refresh_value = async_mock_service(hass, "zwave_js", "refresh_value") - hass.bus.async_fire("test_event_refresh_value") - await hass.async_block_till_done() - assert len(refresh_value) == 1 - - ping = async_mock_service(hass, "zwave_js", "ping") - hass.bus.async_fire("test_event_ping") - await hass.async_block_till_done() - assert len(ping) == 1 + with patch("homeassistant.components.zwave_js.lock.clear_usercode") as mock_call: + hass.bus.async_fire("test_event_clear_lock_usercode") + await hass.async_block_till_done() + mock_call.assert_called_once() + args = mock_call.call_args_list[0][0] + assert len(args) == 2 + assert args[0].node_id == node.node_id + assert args[1] == 1 + + with patch("homeassistant.components.zwave_js.lock.set_usercode") as mock_call: + hass.bus.async_fire("test_event_set_lock_usercode") + await hass.async_block_till_done() + mock_call.assert_called_once() + args = mock_call.call_args_list[0][0] + assert len(args) == 3 + assert args[0].node_id == node.node_id + assert args[1] == 1 + assert args[2] == "1234" + + +async def test_reset_meter_action( + hass: HomeAssistant, + client: Client, + aeon_smart_switch_6: Node, + integration: ConfigEntry, +) -> None: + """Test reset_meter action.""" + node = aeon_smart_switch_6 + device_id = get_device_id(client, node) + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_device({device_id}) + assert device - set_value = async_mock_service(hass, "zwave_js", "set_value") - hass.bus.async_fire("test_event_set_value") - await hass.async_block_till_done() - assert len(set_value) == 1 + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_reset_meter", + }, + "action": { + "domain": DOMAIN, + "type": "reset_meter", + "device_id": device.id, + "entity_id": "sensor.smart_switch_6_electric_consumed_kwh", + }, + }, + ] + }, + ) - set_config_parameter = async_mock_service(hass, "zwave_js", "set_config_parameter") - hass.bus.async_fire("test_event_set_config_parameter") - await hass.async_block_till_done() - assert len(set_config_parameter) == 1 + with patch( + "zwave_js_server.model.endpoint.Endpoint.async_invoke_cc_api" + ) as mock_call: + hass.bus.async_fire("test_event_reset_meter") + await hass.async_block_till_done() + mock_call.assert_called_once() + args = mock_call.call_args_list[0][0] + assert len(args) == 2 + assert args[0] == CommandClass.METER + assert args[1] == "reset" async def test_get_action_capabilities( @@ -266,7 +374,28 @@ async def test_get_action_capabilities( ) assert capabilities and "extra_fields" in capabilities - cc_options = [(cc.value, cc.name) for cc in CommandClass] + cc_options = [ + (133, "Association"), + (89, "Association Group Information"), + (128, "Battery"), + (129, "Clock"), + (112, "Configuration"), + (90, "Device Reset Locally"), + (122, "Firmware Update Meta Data"), + (135, "Indicator"), + (114, "Manufacturer Specific"), + (96, "Multi Channel"), + (142, "Multi Channel Association"), + (49, "Multilevel Sensor"), + (115, "Powerlevel"), + (68, "Thermostat Fan Mode"), + (69, "Thermostat Fan State"), + (64, "Thermostat Mode"), + (66, "Thermostat Operating State"), + (67, "Thermostat Setpoint"), + (134, "Version"), + (94, "Z-Wave Plus Info"), + ] assert voluptuous_serialize.convert( capabilities["extra_fields"], custom_serializer=cv.custom_serializer @@ -460,3 +589,25 @@ async def test_failure_scenarios( ) == {} ) + + +async def test_unavailable_entity_actions( + hass: HomeAssistant, + client: Client, + lock_schlage_be469: Node, + integration: ConfigEntry, +) -> None: + """Test unavailable entities are not included in actions list.""" + entity_id_unavailable = "binary_sensor.touchscreen_deadbolt_home_security_intrusion" + hass.states.async_set(entity_id_unavailable, STATE_UNAVAILABLE, force_update=True) + await hass.async_block_till_done() + node = lock_schlage_be469 + 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, 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_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py new file mode 100644 index 0000000000000..5abce53fcbbdf --- /dev/null +++ b/tests/components/zwave_js/test_diagnostics.py @@ -0,0 +1,16 @@ +"""Test the Z-Wave JS diagnostics.""" +from unittest.mock import patch + +from homeassistant.components.zwave_js.diagnostics import ( + async_get_config_entry_diagnostics, +) + + +async def test_config_entry_diagnostics(hass, integration): + """Test the config entry level diagnostics data dump.""" + with patch( + "homeassistant.components.zwave_js.diagnostics.dump_msgs", + return_value=[{"hello": "world"}, {"second": "msg"}], + ): + result = await async_get_config_entry_diagnostics(hass, integration) + assert result == [{"hello": "world"}, {"second": "msg"}] diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index d9a1695f60f30..7e39b7845337e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -12,7 +12,11 @@ from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY @@ -159,7 +163,7 @@ async def test_new_entity_on_value_added(hass, multisensor_6, client, integratio async def test_on_node_added_ready(hass, multisensor_6_state, client, integration): - """Test we handle a ready node added event.""" + """Test we handle a node added event with a ready node.""" dev_reg = dr.async_get(hass) node = Node(client, deepcopy(multisensor_6_state)) event = {"node": node} @@ -182,38 +186,34 @@ async def test_on_node_added_ready(hass, multisensor_6_state, client, integratio assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) -async def test_on_node_added_not_ready(hass, multisensor_6_state, client, integration): - """Test we handle a non ready node added event.""" +async def test_on_node_added_not_ready( + hass, zp3111_not_ready_state, client, integration +): + """Test we handle a node added event with a non-ready node.""" dev_reg = dr.async_get(hass) - node_data = deepcopy(multisensor_6_state) # Copy to allow modification in tests. - node = Node(client, node_data) - node.data["ready"] = False - event = {"node": node} - air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" + device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" - state = hass.states.get(AIR_TEMPERATURE_SENSOR) + assert len(hass.states.async_all()) == 0 + assert not dev_reg.devices - assert not state # entity and device not yet added - assert not dev_reg.async_get_device( - identifiers={(DOMAIN, air_temperature_device_id)} + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(zp3111_not_ready_state), + }, ) - - client.driver.controller.emit("node added", event) + client.driver.receive_event(event) await hass.async_block_till_done() - state = hass.states.get(AIR_TEMPERATURE_SENSOR) - - assert not state # entity not yet added but device added in registry - assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + # the only entity is the node status sensor + assert len(hass.states.async_all()) == 1 - node.data["ready"] = True - node.emit("ready", event) - await hass.async_block_till_done() - - state = hass.states.get(AIR_TEMPERATURE_SENSOR) - - assert state # entity added - assert state.state != STATE_UNAVAILABLE + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + # no extended device identifier yet + assert len(device.identifiers) == 1 async def test_existing_node_ready(hass, client, multisensor_6, integration): @@ -221,50 +221,163 @@ async def test_existing_node_ready(hass, client, multisensor_6, integration): dev_reg = dr.async_get(hass) node = multisensor_6 air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" + air_temperature_device_id_ext = ( + f"{air_temperature_device_id}-{node.manufacturer_id}:" + f"{node.product_type}:{node.product_id}" + ) state = hass.states.get(AIR_TEMPERATURE_SENSOR) assert state # entity and device added assert state.state != STATE_UNAVAILABLE - assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + device = dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + assert device + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, air_temperature_device_id_ext)} + ) -async def test_null_name(hass, client, null_name_check, integration): - """Test that node without a name gets a generic node name.""" - node = null_name_check - assert hass.states.get(f"switch.node_{node.node_id}") +async def test_existing_node_not_ready(hass, zp3111_not_ready, client, integration): + """Test we handle a non-ready node that exists during integration setup.""" + dev_reg = dr.async_get(hass) + node = zp3111_not_ready + device_id = f"{client.driver.controller.home_id}-{node.node_id}" + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device.name == f"Node {node.node_id}" + assert not device.manufacturer + assert not device.model + assert not device.sw_version + + # the only entity is the node status sensor + assert len(hass.states.async_all()) == 1 + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + # no extended device identifier yet + assert len(device.identifiers) == 1 -async def test_existing_node_not_ready(hass, client, multisensor_6): - """Test we handle a non ready node that exists during integration setup.""" + +async def test_existing_node_not_replaced_when_not_ready( + hass, zp3111, zp3111_not_ready_state, zp3111_state, client, integration +): + """Test when a node added event with a non-ready node is received. + + The existing node should not be replaced, and no customization should be lost. + """ dev_reg = dr.async_get(hass) - node = multisensor_6 - node.data = deepcopy(node.data) # Copy to allow modification in tests. - node.data["ready"] = False - event = {"node": node} - air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) + er_reg = er.async_get(hass) + kitchen_area = ar.async_get(hass).async_create("Kitchen") - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + device_id = f"{client.driver.controller.home_id}-{zp3111.node_id}" + device_id_ext = ( + f"{device_id}-{zp3111.manufacturer_id}:" + f"{zp3111.product_type}:{zp3111.product_id}" + ) - state = hass.states.get(AIR_TEMPERATURE_SENSOR) + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device.name == "4-in-1 Sensor" + assert not device.name_by_user + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.sw_version == "5.1" + assert not device.area_id + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + + motion_entity = "binary_sensor.4_in_1_sensor_home_security_motion_detection" + state = hass.states.get(motion_entity) + assert state + assert state.name == "4-in-1 Sensor: Home Security - Motion detection" + + dev_reg.async_update_device( + device.id, name_by_user="Custom Device Name", area_id=kitchen_area.id + ) - assert not state # entity not yet added - assert dev_reg.async_get_device( # device should be added - identifiers={(DOMAIN, air_temperature_device_id)} + custom_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert custom_device + assert custom_device.name == "4-in-1 Sensor" + assert custom_device.name_by_user == "Custom Device Name" + assert custom_device.manufacturer == "Vision Security" + assert custom_device.model == "ZP3111-5" + assert device.sw_version == "5.1" + assert custom_device.area_id == kitchen_area.id + assert custom_device == dev_reg.async_get_device( + identifiers={(DOMAIN, device_id_ext)} ) - node.data["ready"] = True - node.emit("ready", event) + custom_entity = "binary_sensor.custom_motion_sensor" + er_reg.async_update_entity( + motion_entity, new_entity_id=custom_entity, name="Custom Entity Name" + ) await hass.async_block_till_done() + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + assert not hass.states.get(motion_entity) - state = hass.states.get(AIR_TEMPERATURE_SENSOR) + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(zp3111_not_ready_state), + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() - assert state # entity and device added + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.id == custom_device.id + assert device.identifiers == custom_device.identifiers + assert device.name == f"Node {zp3111.node_id}" + assert device.name_by_user == "Custom Device Name" + assert not device.manufacturer + assert not device.model + assert not device.sw_version + assert device.area_id == kitchen_area.id + + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": zp3111_state["nodeId"], + "nodeState": deepcopy(zp3111_state), + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.id == custom_device.id + assert device.identifiers == custom_device.identifiers + assert device.name == "4-in-1 Sensor" + assert device.name_by_user == "Custom Device Name" + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.area_id == kitchen_area.id + assert device.sw_version == "5.1" + + state = hass.states.get(custom_entity) + assert state assert state.state != STATE_UNAVAILABLE - assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + assert state.name == "Custom Entity Name" + + +async def test_null_name(hass, client, null_name_check, integration): + """Test that node without a name gets a generic node name.""" + node = null_name_check + assert hass.states.get(f"switch.node_{node.node_id}") async def test_start_addon( @@ -740,63 +853,460 @@ async def test_node_removed(hass, multisensor_6_state, client, integration): assert not dev_reg.async_get(old_device.id) -async def test_replace_same_node(hass, multisensor_6_state, client, integration): +async def test_replace_same_node( + hass, multisensor_6, multisensor_6_state, client, integration +): """Test when a node is replaced with itself that the device remains.""" dev_reg = dr.async_get(hass) - node = Node(client, deepcopy(multisensor_6_state)) - device_id = f"{client.driver.controller.home_id}-{node.node_id}" - event = {"node": node} + node_id = multisensor_6.node_id + multisensor_6_state = deepcopy(multisensor_6_state) - client.driver.controller.emit("node added", event) + device_id = f"{client.driver.controller.home_id}-{node_id}" + multisensor_6_device_id = ( + f"{device_id}-{multisensor_6.manufacturer_id}:" + f"{multisensor_6.product_type}:{multisensor_6.product_id}" + ) + + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + dev_id = device.id + + assert hass.states.get(AIR_TEMPERATURE_SENSOR) + + # A replace node event has the extra field "replaced" set to True + # to distinguish it from an exclusion + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": True, + "node": multisensor_6_state, + }, + ) + client.driver.receive_event(event) await hass.async_block_till_done() - old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) - assert old_device.id - event = {"node": node, "replaced": True} + # Device should still be there after the node was removed + device = dev_reg.async_get(dev_id) + assert device - client.driver.controller.emit("node removed", event) + # When the node is replaced, a non-ready node added event is emitted + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": { + "nodeId": node_id, + "index": 0, + "status": 4, + "ready": False, + "isSecure": "unknown", + "interviewAttempts": 1, + "endpoints": [{"nodeId": node_id, "index": 0, "deviceClass": None}], + "values": [], + "deviceClass": None, + "commandClasses": [], + "interviewStage": "None", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + }, + }, + }, + ) + + # Device is still not removed + client.driver.receive_event(event) await hass.async_block_till_done() - # Assert device has remained - assert dev_reg.async_get(old_device.id) - event = {"node": node} + device = dev_reg.async_get(dev_id) + assert device - client.driver.controller.emit("node added", event) + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": node_id, + "nodeState": multisensor_6_state, + }, + ) + client.driver.receive_event(event) await hass.async_block_till_done() - # Assert device has remained - assert dev_reg.async_get(old_device.id) + + # Device is the same + device = dev_reg.async_get(dev_id) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + + assert hass.states.get(AIR_TEMPERATURE_SENSOR) async def test_replace_different_node( - hass, multisensor_6_state, hank_binary_switch_state, client, integration + hass, + multisensor_6, + multisensor_6_state, + hank_binary_switch_state, + client, + integration, ): """Test when a node is replaced with a different node.""" - hank_binary_switch_state = deepcopy(hank_binary_switch_state) - multisensor_6_state = deepcopy(multisensor_6_state) - hank_binary_switch_state["nodeId"] = multisensor_6_state["nodeId"] dev_reg = dr.async_get(hass) - old_node = Node(client, multisensor_6_state) - device_id = f"{client.driver.controller.home_id}-{old_node.node_id}" - new_node = Node(client, hank_binary_switch_state) - event = {"node": old_node} + node_id = multisensor_6.node_id + hank_binary_switch_state = deepcopy(hank_binary_switch_state) + hank_binary_switch_state["nodeId"] = node_id + + device_id = f"{client.driver.controller.home_id}-{node_id}" + multisensor_6_device_id = ( + f"{device_id}-{multisensor_6.manufacturer_id}:" + f"{multisensor_6.product_type}:{multisensor_6.product_id}" + ) + hank_device_id = ( + f"{device_id}-{hank_binary_switch_state['manufacturerId']}:" + f"{hank_binary_switch_state['productType']}:" + f"{hank_binary_switch_state['productId']}" + ) - client.driver.controller.emit("node added", event) - await hass.async_block_till_done() device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device + assert device == dev_reg.async_get_device( + identifiers={(DOMAIN, multisensor_6_device_id)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + dev_id = device.id - event = {"node": old_node, "replaced": True} + assert hass.states.get(AIR_TEMPERATURE_SENSOR) - client.driver.controller.emit("node removed", event) + # A replace node event has the extra field "replaced" set to True + # to distinguish it from an exclusion + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": True, + "node": multisensor_6_state, + }, + ) + client.driver.receive_event(event) await hass.async_block_till_done() + # Device should still be there after the node was removed + device = dev_reg.async_get(dev_id) assert device - event = {"node": new_node} + # When the node is replaced, a non-ready node added event is emitted + event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": { + "nodeId": multisensor_6.node_id, + "index": 0, + "status": 4, + "ready": False, + "isSecure": "unknown", + "interviewAttempts": 1, + "endpoints": [ + {"nodeId": multisensor_6.node_id, "index": 0, "deviceClass": None} + ], + "values": [], + "deviceClass": None, + "commandClasses": [], + "interviewStage": "None", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + }, + }, + }, + ) - client.driver.controller.emit("node added", event) + # Device is still not removed + client.driver.receive_event(event) await hass.async_block_till_done() - device = dev_reg.async_get(device.id) - # assert device is new + + device = dev_reg.async_get(dev_id) assert device + + event = Event( + type="ready", + data={ + "source": "node", + "event": "ready", + "nodeId": node_id, + "nodeState": hank_binary_switch_state, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + # Old device and entities were removed, but the ID is re-used + device = dev_reg.async_get(dev_id) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id)}) + assert not dev_reg.async_get_device(identifiers={(DOMAIN, multisensor_6_device_id)}) assert device.manufacturer == "HANK Electronics Ltd." + assert device.model == "HKZW-SO01" + + assert not hass.states.get(AIR_TEMPERATURE_SENSOR) + assert hass.states.get("switch.smart_plug_with_two_usb_ports") + + +async def test_node_model_change(hass, zp3111, client, integration): + """Test when a node's model is changed due to an updated device config file. + + The device and entities should not be removed. + """ + dev_reg = dr.async_get(hass) + er_reg = er.async_get(hass) + + device_id = f"{client.driver.controller.home_id}-{zp3111.node_id}" + device_id_ext = ( + f"{device_id}-{zp3111.manufacturer_id}:" + f"{zp3111.product_type}:{zp3111.product_id}" + ) + + # Verify device and entities have default names/ids + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.name == "4-in-1 Sensor" + assert not device.name_by_user + + dev_id = device.id + + motion_entity = "binary_sensor.4_in_1_sensor_home_security_motion_detection" + state = hass.states.get(motion_entity) + assert state + assert state.name == "4-in-1 Sensor: Home Security - Motion detection" + + # Customize device and entity names/ids + dev_reg.async_update_device(device.id, name_by_user="Custom Device Name") + device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device + assert device.id == dev_id + assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device.manufacturer == "Vision Security" + assert device.model == "ZP3111-5" + assert device.name == "4-in-1 Sensor" + assert device.name_by_user == "Custom Device Name" + + custom_entity = "binary_sensor.custom_motion_sensor" + er_reg.async_update_entity( + motion_entity, new_entity_id=custom_entity, name="Custom Entity Name" + ) + await hass.async_block_till_done() + assert not hass.states.get(motion_entity) + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + + # Unload the integration + assert await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + assert integration.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + # Simulate changes to the node labels + zp3111.device_config.data["description"] = "New Device Name" + zp3111.device_config.data["label"] = "New Device Model" + zp3111.device_config.data["manufacturer"] = "New Device Manufacturer" + + # Reload integration, it will re-add the nodes + integration.add_to_hass(hass) + await hass.config_entries.async_setup(integration.entry_id) + await hass.async_block_till_done() + + # Device name changes, but the customization is the same + device = dev_reg.async_get(dev_id) + assert device + assert device.id == dev_id + assert device.manufacturer == "New Device Manufacturer" + assert device.model == "New Device Model" + assert device.name == "New Device Name" + assert device.name_by_user == "Custom Device Name" + + assert not hass.states.get(motion_entity) + state = hass.states.get(custom_entity) + assert state + assert state.name == "Custom Entity Name" + + +async def test_disabled_node_status_entity_on_node_replaced( + hass, zp3111_state, zp3111, client, integration +): + """Test that when a node replacement event is received the node status sensor is removed.""" + node_status_entity = "sensor.4_in_1_sensor_node_status" + state = hass.states.get(node_status_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "replaced": True, + "node": zp3111_state, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(node_status_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_disabled_entity_on_value_removed(hass, zp3111, client, integration): + """Test that when entity primary values are removed the entity is removed.""" + er_reg = er.async_get(hass) + + # re-enable this default-disabled entity + sensor_cover_entity = "sensor.4_in_1_sensor_home_security_cover_status" + er_reg.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None) + await hass.async_block_till_done() + + # must reload the integration when enabling an entity + await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + assert integration.state is ConfigEntryState.NOT_LOADED + integration.add_to_hass(hass) + await hass.config_entries.async_setup(integration.entry_id) + await hass.async_block_till_done() + assert integration.state is ConfigEntryState.LOADED + + state = hass.states.get(sensor_cover_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + # check for expected entities + binary_cover_entity = ( + "binary_sensor.4_in_1_sensor_home_security_tampering_product_cover_removed" + ) + state = hass.states.get(binary_cover_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + battery_level_entity = "sensor.4_in_1_sensor_battery_level" + state = hass.states.get(battery_level_entity) + assert state + assert state.state != STATE_UNAVAILABLE + + unavailable_entities = { + state.entity_id + for state in hass.states.async_all() + if state.state == STATE_UNAVAILABLE + } + + # This value ID removal does not remove any entity + event = Event( + type="value removed", + data={ + "source": "node", + "event": "value removed", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Wake Up", + "commandClass": 132, + "endpoint": 0, + "property": "wakeUpInterval", + "prevValue": 3600, + "propertyName": "wakeUpInterval", + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + assert all(state != STATE_UNAVAILABLE for state in hass.states.async_all()) + + # This value ID removal only affects the battery level entity + event = Event( + type="value removed", + data={ + "source": "node", + "event": "value removed", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Battery", + "commandClass": 128, + "endpoint": 0, + "property": "level", + "prevValue": 100, + "propertyName": "level", + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(battery_level_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + # This value ID removal affects its multiple notification sensors + event = Event( + type="value removed", + data={ + "source": "node", + "event": "value removed", + "nodeId": zp3111.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Home Security", + "propertyKey": "Cover status", + "prevValue": 0, + "propertyName": "Home Security", + "propertyKeyName": "Cover status", + }, + }, + ) + client.driver.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(binary_cover_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get(sensor_cover_entity) + assert state + assert state.state == STATE_UNAVAILABLE + + # existing entities and the entities with removed values should be unavailable + new_unavailable_entities = { + state.entity_id + for state in hass.states.async_all() + if state.state == STATE_UNAVAILABLE + } + assert ( + unavailable_entities + | {battery_level_entity, binary_cover_entity, sensor_cover_entity} + == new_unavailable_entities + ) diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index 82f84372ee00f..e5b415d1341e4 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 74b7d33183480..2d120411513ca 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): @@ -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/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index e9a1506e71d85..a0949bad03ce0 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -782,6 +782,7 @@ async def test_loading_saving_data(hass, registry, area_registry): identifiers={("hue", "abc")}, manufacturer="manufacturer", model="light", + entry_type=device_registry.DeviceEntryType.SERVICE, ) assert orig_light4.id == orig_light3.id @@ -821,6 +822,15 @@ async def test_loading_saving_data(hass, registry, area_registry): assert orig_light == new_light assert orig_light4 == new_light4 + # Ensure enums converted + for (old, new) in ( + (orig_via, new_via), + (orig_light, new_light), + (orig_light4, new_light4), + ): + assert old.disabled_by is new.disabled_by + assert old.entry_type is new.entry_type + # Ensure a save/load cycle does not keep suggested area new_kitchen_light = registry2.async_get_device({("hue", "999")}) assert orig_kitchen_light.suggested_area == "Kitchen" diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index d0d8580d69de2..47255ea6b1fc4 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -2,6 +2,7 @@ from unittest.mock import patch 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 @@ -129,7 +130,9 @@ def test_circular_import(self): def component_setup(hass, config): """Set up mock component.""" - discovery.load_platform(hass, "switch", "test_circular", "disc", config) + discovery.load_platform( + hass, Platform.SWITCH, "test_circular", {"key": "value"}, config + ) component_calls.append(1) return True diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 568d82ebd4b00..f299177a08ecf 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -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 @@ -227,6 +231,7 @@ async def test_loading_saving_data(hass, registry): 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" @@ -570,6 +575,31 @@ 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( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 16f7ad4825a13..54507d9b3bda2 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_start.py b/tests/helpers/test_start.py index 35838f1ceaa93..55f98cf60ebe6 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 0478c17e29974..53c1b8a4677dd 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 471a5b2c48667..42834b3c14963 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -314,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): @@ -835,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.""" @@ -842,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.""" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1ad64e58bd778..87d93c1a1ac0b 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 cd77b80e3ae04..41e9bc5003803 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index abc5472200d3e..cad92a5d92d88 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -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 @@ -2739,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 diff --git a/tests/testing_config/custom_components/test/device_tracker.py b/tests/testing_config/custom_components/test/device_tracker.py index e4853d156cec4..d5f34f48ec8bf 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/util/test_aiohttp.py b/tests/util/test_aiohttp.py index e7b5ef73c32b7..cd156705ddf23 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 cae47835cd83c..d272da8fe96ba 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -77,7 +77,7 @@ async def test_check_loop_async(): 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=[ @@ -100,7 +100,40 @@ async def test_check_loop_async_integration(caplog): ): hasync.check_loop() 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 + ) + + +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(strict=False) + 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 ) @@ -129,24 +162,25 @@ async def test_check_loop_async_custom(caplog): ): hasync.check_loop() 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 + 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(strict=True) + func.assert_called_once_with(1, test=2) async def test_gather_with_concurrency(): diff --git a/tests/util/test_json.py b/tests/util/test_json.py index 752e93b39cd2d..d885186871987 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 d25e68597279d..3dff36744eebd 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/tox.ini b/tox.ini index 0532d67b247c3..af2f996195615 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