diff --git a/.coveragerc b/.coveragerc index 251fe05c01446..95bd72253c7af 100644 --- a/.coveragerc +++ b/.coveragerc @@ -509,7 +509,9 @@ omit = homeassistant/components/onewire/sensor.py homeassistant/components/onkyo/media_player.py homeassistant/components/onvif/__init__.py + homeassistant/components/onvif/base.py homeassistant/components/onvif/camera.py + homeassistant/components/onvif/device.py homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py homeassistant/components/openexchangerates/sensor.py @@ -677,7 +679,8 @@ omit = homeassistant/components/somfy/* homeassistant/components/somfy_mylink/* homeassistant/components/sonarr/sensor.py - homeassistant/components/songpal/* + homeassistant/components/songpal/__init__.py + homeassistant/components/songpal/media_player.py homeassistant/components/sonos/* homeassistant/components/sony_projector/switch.py homeassistant/components/spc/* @@ -860,7 +863,6 @@ omit = homeassistant/components/zengge/light.py homeassistant/components/zeroconf/* homeassistant/components/zestimate/sensor.py - homeassistant/components/zha/__init__.py homeassistant/components/zha/api.py homeassistant/components/zha/core/channels/* homeassistant/components/zha/core/const.py diff --git a/CODEOWNERS b/CODEOWNERS index 1f53b1292b067..e518f19c9f945 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -362,7 +362,7 @@ homeassistant/components/solax/* @squishykid homeassistant/components/soma/* @ratsept homeassistant/components/somfy/* @tetienne homeassistant/components/sonarr/* @ctalkington -homeassistant/components/songpal/* @rytilahti +homeassistant/components/songpal/* @rytilahti @shenxn homeassistant/components/sonos/* @amelchio homeassistant/components/spaceapi/* @fabaff homeassistant/components/speedtestdotnet/* @rohankapoorcom diff --git a/homeassistant/components/alert/reproduce_state.py b/homeassistant/components/alert/reproduce_state.py new file mode 100644 index 0000000000000..7645b642d5950 --- /dev/null +++ b/homeassistant/components/alert/reproduce_state.py @@ -0,0 +1,76 @@ +"""Reproduce an Alert state.""" +import asyncio +import logging +from typing import Any, Dict, Iterable, Optional + +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +VALID_STATES = {STATE_ON, STATE_OFF} + + +async def _async_reproduce_state( + hass: HomeAssistantType, + state: State, + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce a single state.""" + cur_state = hass.states.get(state.entity_id) + + if cur_state is None: + _LOGGER.warning("Unable to find entity %s", state.entity_id) + return + + if state.state not in VALID_STATES: + _LOGGER.warning( + "Invalid state specified for %s: %s", state.entity_id, state.state + ) + return + + # Return if we are already at the right state. + if cur_state.state == state.state: + return + + service_data = {ATTR_ENTITY_ID: state.entity_id} + + if state.state == STATE_ON: + service = SERVICE_TURN_ON + + elif state.state == STATE_OFF: + service = SERVICE_TURN_OFF + + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, + states: Iterable[State], + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce Alert states.""" + # Reproduce states in parallel. + await asyncio.gather( + *( + _async_reproduce_state( + hass, state, context=context, reproduce_options=reproduce_options + ) + for state in states + ) + ) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index ad4d30358c2fe..5c461075f8535 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -5,19 +5,18 @@ from aiohttp import ClientConnectionError from async_timeout import timeout -from pydaikin.appliance import Appliance +from pydaikin.daikin_base import Appliance import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_HOSTS +from homeassistant.const import CONF_HOST, CONF_HOSTS, CONF_PASSWORD from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import Throttle from . import config_flow # noqa: F401 -from .const import TIMEOUT +from .const import CONF_KEY, CONF_UUID, TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -62,7 +61,13 @@ async def async_setup(hass, config): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Establish connection with Daikin.""" conf = entry.data - daikin_api = await daikin_api_setup(hass, conf[CONF_HOST]) + daikin_api = await daikin_api_setup( + hass, + conf[CONF_HOST], + conf.get(CONF_KEY), + conf.get(CONF_UUID), + conf.get(CONF_PASSWORD), + ) if not daikin_api: return False hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: daikin_api}) @@ -87,14 +92,15 @@ async def async_unload_entry(hass, config_entry): return True -async def daikin_api_setup(hass, host): +async def daikin_api_setup(hass, host, key, uuid, password): """Create a Daikin instance only once.""" session = hass.helpers.aiohttp_client.async_get_clientsession() try: with timeout(TIMEOUT): - device = Appliance(host, session) - await device.init() + device = await Appliance.factory( + host, session, key=key, uuid=uuid, password=password + ) except asyncio.TimeoutError: _LOGGER.debug("Connection to %s timed out", host) raise ConfigEntryNotReady @@ -116,8 +122,8 @@ class DaikinApi: def __init__(self, device): """Initialize the Daikin Handle.""" self.device = device - self.name = device.values["name"] - self.ip_address = device.ip + self.name = device.values.get("name", "Daikin AC") + self.ip_address = device.device_ip self._available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) @@ -135,20 +141,14 @@ def available(self) -> bool: """Return True if entity is available.""" return self._available - @property - def mac(self): - """Return mac-address of device.""" - return self.device.values.get(CONNECTION_NETWORK_MAC) - @property def device_info(self): """Return a device description for device registry.""" info = self.device.values return { - "connections": {(CONNECTION_NETWORK_MAC, self.mac)}, - "identifieres": self.mac, + "identifieres": self.device.mac, "manufacturer": "Daikin", "model": info.get("model"), "name": info.get("name"), - "sw_version": info.get("ver").replace("_", "."), + "sw_version": info.get("ver", "").replace("_", "."), } diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index ebf909dcbdac8..60a126c182b2f 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -1,7 +1,6 @@ """Support for the Daikin HVAC.""" import logging -from pydaikin import appliance import voluptuous as vol from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity @@ -96,12 +95,7 @@ def __init__(self, api): self._list = { ATTR_HVAC_MODE: list(HA_STATE_TO_DAIKIN), ATTR_FAN_MODE: self._api.device.fan_rate, - ATTR_SWING_MODE: list( - map( - str.title, - appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE]), - ) - ), + ATTR_SWING_MODE: self._api.device.swing_modes, } self._supported_features = SUPPORT_TARGET_TEMPERATURE @@ -156,7 +150,7 @@ def name(self): @property def unique_id(self): """Return a unique ID.""" - return self._api.mac + return self._api.device.mac @property def temperature_unit(self): diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 35f21ef3e0d70..600c27b161971 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -1,16 +1,17 @@ """Config flow for the Daikin platform.""" import asyncio import logging +from uuid import uuid4 from aiohttp import ClientError from async_timeout import timeout -from pydaikin.appliance import Appliance +from pydaikin.daikin_base import Appliance import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD -from .const import KEY_IP, KEY_MAC, TIMEOUT +from .const import CONF_KEY, CONF_UUID, KEY_IP, KEY_MAC, TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -22,24 +23,46 @@ class FlowHandler(config_entries.ConfigFlow): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - async def _create_entry(self, host, mac): + def _create_entry(self, host, mac, key=None, uuid=None, password=None): """Register new entry.""" # Check if mac already is registered for entry in self._async_current_entries(): if entry.data[KEY_MAC] == mac: return self.async_abort(reason="already_configured") - return self.async_create_entry(title=host, data={CONF_HOST: host, KEY_MAC: mac}) + return self.async_create_entry( + title=host, + data={ + CONF_HOST: host, + KEY_MAC: mac, + CONF_KEY: key, + CONF_UUID: uuid, + CONF_PASSWORD: password, + }, + ) - async def _create_device(self, host): + async def _create_device(self, host, key=None, password=None): """Create device.""" + # BRP07Cxx devices needs uuid together with key + if key: + uuid = str(uuid4()) + else: + uuid = None + key = None + + if not password: + password = None + try: - device = Appliance( - host, self.hass.helpers.aiohttp_client.async_get_clientsession() - ) with timeout(TIMEOUT): - await device.init() + device = await Appliance.factory( + host, + self.hass.helpers.aiohttp_client.async_get_clientsession(), + key=key, + uuid=uuid, + password=password, + ) except asyncio.TimeoutError: return self.async_abort(reason="device_timeout") except ClientError: @@ -49,16 +72,27 @@ async def _create_device(self, host): _LOGGER.exception("Unexpected error creating device") return self.async_abort(reason="device_fail") - mac = device.values.get("mac") - return await self._create_entry(host, mac) + mac = device.mac + return self._create_entry(host, mac, key, uuid, password) async def async_step_user(self, user_input=None): """User initiated config flow.""" if user_input is None: return self.async_show_form( - step_id="user", data_schema=vol.Schema({vol.Required(CONF_HOST): str}) + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_KEY): str, + vol.Optional(CONF_PASSWORD): str, + } + ), ) - return await self._create_device(user_input[CONF_HOST]) + return await self._create_device( + user_input[CONF_HOST], + user_input.get(CONF_KEY), + user_input.get(CONF_PASSWORD), + ) async def async_step_import(self, user_input): """Import a config entry.""" @@ -70,4 +104,4 @@ async def async_step_import(self, user_input): async def async_step_discovery(self, user_input): """Initialize step from discovery.""" _LOGGER.info("Discovered device: %s", user_input) - return await self._create_entry(user_input[KEY_IP], user_input[KEY_MAC]) + return self._create_entry(user_input[KEY_IP], user_input[KEY_MAC]) diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py index 15ae5321bf364..452331ff734b4 100644 --- a/homeassistant/components/daikin/const.py +++ b/homeassistant/components/daikin/const.py @@ -23,6 +23,9 @@ }, } +CONF_KEY = "key" +CONF_UUID = "uuid" + KEY_MAC = "mac" KEY_IP = "ip" diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index c501fa7c1202c..ec993ed9b1331 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -3,7 +3,7 @@ "name": "Daikin AC", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/daikin", - "requirements": ["pydaikin==1.6.3"], + "requirements": ["pydaikin==2.0.0"], "codeowners": ["@fredrike"], "quality_scale": "platinum" } diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index d0d8e4b0fdafe..c389566396d4f 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -40,7 +40,7 @@ def __init__(self, api, monitored_state) -> None: @property def unique_id(self): """Return a unique ID.""" - return f"{self._api.mac}-{self._device_attribute}" + return f"{self._api.device.mac}-{self._device_attribute}" @property def icon(self): diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index 1e82d285eee0d..8d2862123d4bf 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -4,7 +4,11 @@ "user": { "title": "Configure Daikin AC", "description": "Enter IP address of your Daikin AC.", - "data": { "host": "Host" } + "data": { + "host": "Host", + "key": "Authentication key (only used by BRP072C/Zena devices)", + "password": "Device password (only used by SKYFi devices)" + } } }, "abort": { diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index b7131c29bdd4d..0dae8848d3943 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -43,7 +43,7 @@ def __init__(self, daikin_api, zone_id): @property def unique_id(self): """Return a unique ID.""" - return f"{self._api.mac}-zone{self._zone_id}" + return f"{self._api.device.mac}-zone{self._zone_id}" @property def icon(self): diff --git a/homeassistant/components/daikin/translations/en.json b/homeassistant/components/daikin/translations/en.json index f66f360d096ef..14bb34d5a2afe 100644 --- a/homeassistant/components/daikin/translations/en.json +++ b/homeassistant/components/daikin/translations/en.json @@ -8,7 +8,9 @@ "step": { "user": { "data": { - "host": "Host" + "host": "Host", + "key": "Authentication key (only used by BRP072C/Zena devices)", + "password": "Device password (only used by SKYFi devices)" }, "description": "Enter IP address of your Daikin AC.", "title": "Configure Daikin AC" diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 87c0e41533b12..afcf8cc341db9 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -71,7 +71,6 @@ "openhome": ("media_player", "openhome"), "bose_soundtouch": ("media_player", "soundtouch"), "bluesound": ("media_player", "bluesound"), - "songpal": ("media_player", "songpal"), "kodi": ("media_player", "kodi"), "volumio": ("media_player", "volumio"), "lg_smart_device": ("media_player", "lg_soundbar"), @@ -91,6 +90,7 @@ "ikea_tradfri", "philips_hue", "sonos", + "songpal", SERVICE_WEMO, ] diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index a97d2a1d02beb..ea93c2bb97551 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -230,6 +230,8 @@ def update(self): email_message = self._email_reader.read_next() if email_message is None: + self._message = None + self._state_attributes = {} return if self.sender_allowed(email_message): diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index fa676221ea3dd..90a31890d162d 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -125,11 +125,11 @@ async def async_schedule_future_update(self): """ _LOGGER.debug("Scheduling next update for Islamic prayer times") - now = dt_util.as_local(dt_util.now()) + now = dt_util.utcnow() midnight_dt = self.prayer_times_info["Midnight"] - if now > dt_util.as_local(midnight_dt): + if now > dt_util.as_utc(midnight_dt): next_update_at = midnight_dt + timedelta(days=1, minutes=1) _LOGGER.debug( "Midnight is after day the changes so schedule update for after Midnight the next day" diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index 92a0a491d8d12..e0c2cf16f68aa 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -4,6 +4,7 @@ from homeassistant.const import DEVICE_CLASS_TIMESTAMP from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util from .const import DATA_UPDATED, DOMAIN, PRAYER_TIMES_ICON, SENSOR_TYPES @@ -48,7 +49,11 @@ def icon(self): @property def state(self): """Return the state of the sensor.""" - return self.client.prayer_times_info.get(self.sensor_type).isoformat() + return ( + self.client.prayer_times_info.get(self.sensor_type) + .astimezone(dt_util.UTC) + .isoformat() + ) @property def should_poll(self): diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 34a396758cb56..0a7ea08543ab7 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -188,6 +188,7 @@ def setup(self): bytesize=self._config_bytesize, parity=self._config_parity, timeout=self._config_timeout, + retry_on_empty=True, ) elif self._config_type == "rtuovertcp": self._client = ModbusTcpClient( diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 97a5d00a30f43..0d5a32c45e075 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -273,10 +273,12 @@ def update(self): def _read_register(self) -> Optional[int]: try: if self._register_type == CALL_TYPE_REGISTER_INPUT: - result = self._hub.read_input_registers(self._slave, self._register, 1) + result = self._hub.read_input_registers( + self._slave, self._verify_register, 1 + ) else: result = self._hub.read_holding_registers( - self._slave, self._register, 1 + self._slave, self._verify_register, 1 ) except ConnectionException: self._available = False diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 77ab68d8e7031..258a939a07eab 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -302,7 +302,9 @@ async def _update_bridge_id(self): bridge_device = device_registry.async_get_device( {DOMAIN: bridge["hardware_id"]}, set() ) - this_device = device_registry.async_get_device({DOMAIN: sensor["hardware_id"]}) + this_device = device_registry.async_get_device( + {DOMAIN: sensor["hardware_id"]}, set() + ) device_registry.async_update_device( this_device.id, via_device_id=bridge_device.id diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index eed5a20e3cc68..200254a08065a 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -13,6 +13,7 @@ CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_per_platform from .const import ( @@ -25,6 +26,7 @@ DOMAIN, RTSP_TRANS_PROTOCOLS, ) +from .device import ONVIFDevice CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) @@ -61,9 +63,22 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up ONVIF from a config entry.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + if not entry.options: await async_populate_options(hass, entry) + device = ONVIFDevice(hass, entry) + + if not await device.async_setup(): + return False + + if not device.available: + raise ConfigEntryNotReady() + + hass.data[DOMAIN][entry.unique_id] = device + for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) diff --git a/homeassistant/components/onvif/base.py b/homeassistant/components/onvif/base.py new file mode 100644 index 0000000000000..72c3d969d222a --- /dev/null +++ b/homeassistant/components/onvif/base.py @@ -0,0 +1,31 @@ +"""Base classes for ONVIF entities.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import Entity + +from .device import ONVIFDevice +from .models import Profile + + +class ONVIFBaseEntity(Entity): + """Base class common to all ONVIF entities.""" + + def __init__(self, device: ONVIFDevice, profile: Profile) -> None: + """Initialize the ONVIF entity.""" + self.device = device + self.profile = profile + + @property + def available(self): + """Return True if device is available.""" + return self.device.available + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self.device.info.mac)}, + "manufacturer": self.device.info.manufacturer, + "model": self.device.info.model, + "name": self.device.name, + "sw_version": self.device.info.fw_version, + } diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 34f33e302b8e5..4d39c95c3cd17 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,37 +1,18 @@ """Support for ONVIF Cameras with FFmpeg as decoder.""" import asyncio -import datetime as dt -import os -from typing import Optional -from aiohttp.client_exceptions import ClientConnectionError, ServerDisconnectedError from haffmpeg.camera import CameraMjpeg from haffmpeg.tools import IMAGE_JPEG, ImageFrame -import onvif -from onvif import ONVIFCamera, exceptions import requests from requests.auth import HTTPDigestAuth import voluptuous as vol -from zeep.asyncio import AsyncTransport -from zeep.exceptions import Fault from homeassistant.components.camera import SUPPORT_STREAM, Camera from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, DATA_FFMPEG -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, -) -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.aiohttp_client import ( - async_aiohttp_proxy_stream, - async_get_clientsession, -) -import homeassistant.util.dt as dt_util +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from .base import ONVIFBaseEntity from .const import ( ABSOLUTE_MOVE, ATTR_CONTINUOUS_DURATION, @@ -42,7 +23,6 @@ ATTR_SPEED, ATTR_TILT, ATTR_ZOOM, - CONF_PROFILE, CONF_RTSP_TRANSPORT, CONTINUOUS_MOVE, DIR_DOWN, @@ -50,14 +30,10 @@ DIR_RIGHT, DIR_UP, DOMAIN, - ENTITIES, GOTOPRESET_MOVE, LOGGER, - PAN_FACTOR, RELATIVE_MOVE, SERVICE_PTZ, - TILT_FACTOR, - ZOOM_FACTOR, ZOOM_IN, ZOOM_OUT, ) @@ -85,414 +61,98 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "async_perform_ptz", ) - base_config = { - CONF_NAME: config_entry.data[CONF_NAME], - CONF_HOST: config_entry.data[CONF_HOST], - CONF_PORT: config_entry.data[CONF_PORT], - CONF_USERNAME: config_entry.data[CONF_USERNAME], - CONF_PASSWORD: config_entry.data[CONF_PASSWORD], - CONF_EXTRA_ARGUMENTS: config_entry.options[CONF_EXTRA_ARGUMENTS], - CONF_RTSP_TRANSPORT: config_entry.options[CONF_RTSP_TRANSPORT], - } - - entities = [] - for profile in config_entry.data[CONF_PROFILE]: - config = {**base_config, CONF_PROFILE: profile} - camera = ONVIFHassCamera(hass, config) - await camera.async_initialize() - entities.append(camera) + device = hass.data[DOMAIN][config_entry.unique_id] + async_add_entities( + [ONVIFCameraEntity(device, profile) for profile in device.profiles] + ) - async_add_entities(entities) return True -class ONVIFHassCamera(Camera): - """An implementation of an ONVIF camera.""" - - def __init__(self, hass, config): - """Initialize an ONVIF camera.""" - super().__init__() - - LOGGER.debug("Importing dependencies") - - LOGGER.debug("Setting up the ONVIF camera component") - - self._username = config.get(CONF_USERNAME) - self._password = config.get(CONF_PASSWORD) - self._host = config.get(CONF_HOST) - self._port = config.get(CONF_PORT) - self._name = config.get(CONF_NAME) - self._ffmpeg_arguments = config.get(CONF_EXTRA_ARGUMENTS) - self._profile_index = config.get(CONF_PROFILE) - self._profile_token = None - self._profile_name = None - self._ptz_service = None - self._input = None - self._snapshot = None - self.stream_options[CONF_RTSP_TRANSPORT] = config.get(CONF_RTSP_TRANSPORT) - self._manufacturer = None - self._model = None - self._firmware_version = None - self._mac = None - - LOGGER.debug( - "Setting up the ONVIF camera device @ '%s:%s'", self._host, self._port - ) - - session = async_get_clientsession(hass) - transport = AsyncTransport(None, session=session) - self._camera = ONVIFCamera( - self._host, - self._port, - self._username, - self._password, - f"{os.path.dirname(onvif.__file__)}/wsdl/", - transport=transport, - ) - - async def async_initialize(self): - """ - Initialize the camera. - - Initializes the camera by obtaining the input uri and connecting to - the camera. Also retrieves the ONVIF profiles. - """ - try: - LOGGER.debug("Updating service addresses") - await self._camera.update_xaddrs() - - await self.async_obtain_device_info() - await self.async_obtain_mac_address() - await self.async_check_date_and_time() - await self.async_obtain_profile_token() - await self.async_obtain_input_uri() - await self.async_obtain_snapshot_uri() - self.setup_ptz() - except ClientConnectionError as err: - LOGGER.warning( - "Couldn't connect to camera '%s', but will retry later. Error: %s", - self._name, - err, - ) - raise PlatformNotReady - except Fault as err: - LOGGER.error( - "Couldn't connect to camera '%s', please verify " - "that the credentials are correct. Error: %s", - self._name, - err, - ) - - async def async_obtain_device_info(self): - """Obtain the MAC address of the camera to use as the unique ID.""" - devicemgmt = self._camera.create_devicemgmt_service() - device_info = await devicemgmt.GetDeviceInformation() - self._manufacturer = device_info.Manufacturer - self._model = device_info.Model - self._firmware_version = device_info.FirmwareVersion - - async def async_obtain_mac_address(self): - """Obtain the MAC address of the camera to use as the unique ID.""" - devicemgmt = self._camera.create_devicemgmt_service() - network_interfaces = await devicemgmt.GetNetworkInterfaces() - for interface in network_interfaces: - if interface.Enabled: - self._mac = interface.Info.HwAddress - - async def async_check_date_and_time(self): - """Warns if camera and system date not synced.""" - LOGGER.debug("Setting up the ONVIF device management service") - devicemgmt = self._camera.create_devicemgmt_service() - - LOGGER.debug("Retrieving current camera date/time") - try: - system_date = dt_util.utcnow() - device_time = await devicemgmt.GetSystemDateAndTime() - if not device_time: - LOGGER.debug( - """Couldn't get camera '%s' date/time. - GetSystemDateAndTime() return null/empty""", - self._name, - ) - return - - if device_time.UTCDateTime: - tzone = dt_util.UTC - cdate = device_time.UTCDateTime - else: - tzone = ( - dt_util.get_time_zone(device_time.TimeZone) - or dt_util.DEFAULT_TIME_ZONE - ) - cdate = device_time.LocalDateTime - - if cdate is None: - LOGGER.warning("Could not retrieve date/time on this camera") - else: - cam_date = dt.datetime( - cdate.Date.Year, - cdate.Date.Month, - cdate.Date.Day, - cdate.Time.Hour, - cdate.Time.Minute, - cdate.Time.Second, - 0, - tzone, - ) - - cam_date_utc = cam_date.astimezone(dt_util.UTC) - - LOGGER.debug("TimeZone for date/time: %s", tzone) - - LOGGER.debug("Camera date/time: %s", cam_date) - - LOGGER.debug("Camera date/time in UTC: %s", cam_date_utc) - - LOGGER.debug("System date/time: %s", system_date) - - dt_diff = cam_date - system_date - dt_diff_seconds = dt_diff.total_seconds() - - if dt_diff_seconds > 5: - LOGGER.warning( - "The date/time on the camera (UTC) is '%s', " - "which is different from the system '%s', " - "this could lead to authentication issues", - cam_date_utc, - system_date, - ) - except ServerDisconnectedError as err: - LOGGER.warning( - "Couldn't get camera '%s' date/time. Error: %s", self._name, err - ) - - async def async_obtain_profile_token(self): - """Obtain profile token to use with requests.""" - try: - media_service = self._camera.get_service("media") - - profiles = await media_service.GetProfiles() - - LOGGER.debug("Retrieved '%d' profiles", len(profiles)) - - if self._profile_index >= len(profiles): - LOGGER.warning( - "ONVIF Camera '%s' doesn't provide profile %d." - " Using the last profile.", - self._name, - self._profile_index, - ) - self._profile_index = -1 - - LOGGER.debug("Using profile index '%d'", self._profile_index) +class ONVIFCameraEntity(ONVIFBaseEntity, Camera): + """Representation of an ONVIF camera.""" - self._profile_token = profiles[self._profile_index].token - self._profile_name = profiles[self._profile_index].Name - except exceptions.ONVIFError as err: - LOGGER.error( - "Couldn't retrieve profile token of camera '%s'. Error: %s", - self._name, - err, - ) - - async def async_obtain_input_uri(self): - """Set the input uri for the camera.""" - LOGGER.debug( - "Connecting with ONVIF Camera: %s on port %s", self._host, self._port - ) - - try: - LOGGER.debug("Retrieving stream uri") - - # Fix Onvif setup error on Goke GK7102 based IP camera - # where we need to recreate media_service #26781 - media_service = self._camera.create_media_service() - - req = media_service.create_type("GetStreamUri") - req.ProfileToken = self._profile_token - req.StreamSetup = { - "Stream": "RTP-Unicast", - "Transport": {"Protocol": "RTSP"}, - } - - stream_uri = await media_service.GetStreamUri(req) - uri_no_auth = stream_uri.Uri - uri_for_log = uri_no_auth.replace("rtsp://", "rtsp://:@", 1) - self._input = uri_no_auth.replace( - "rtsp://", f"rtsp://{self._username}:{self._password}@", 1 - ) - - LOGGER.debug( - "ONVIF Camera Using the following URL for %s: %s", - self._name, - uri_for_log, - ) - except exceptions.ONVIFError as err: - LOGGER.error("Couldn't setup camera '%s'. Error: %s", self._name, err) - - async def async_obtain_snapshot_uri(self): - """Set the snapshot uri for the camera.""" - LOGGER.debug( - "Connecting with ONVIF Camera: %s on port %s", self._host, self._port + def __init__(self, device, profile): + """Initialize ONVIF camera entity.""" + ONVIFBaseEntity.__init__(self, device, profile) + Camera.__init__(self) + self.stream_options[CONF_RTSP_TRANSPORT] = device.config_entry.options.get( + CONF_RTSP_TRANSPORT ) + self._stream_uri = None + self._snapshot_uri = None - try: - LOGGER.debug("Retrieving snapshot uri") + @property + def supported_features(self) -> int: + """Return supported features.""" + return SUPPORT_STREAM - # Fix Onvif setup error on Goke GK7102 based IP camera - # where we need to recreate media_service #26781 - media_service = self._camera.create_media_service() + @property + def name(self) -> str: + """Return the name of this camera.""" + return f"{self.device.name} - {self.profile.name}" - req = media_service.create_type("GetSnapshotUri") - req.ProfileToken = self._profile_token + @property + def unique_id(self) -> str: + """Return a unique ID.""" + if self.profile.index: + return f"{self.device.info.mac}_{self.profile.index}" + return self.device.info.mac - try: - snapshot_uri = await media_service.GetSnapshotUri(req) - self._snapshot = snapshot_uri.Uri - except ServerDisconnectedError as err: - LOGGER.debug("Camera does not support GetSnapshotUri: %s", err) + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self.device.max_resolution == self.profile.video.resolution.width - LOGGER.debug( - "ONVIF Camera Using the following URL for %s snapshot: %s", - self._name, - self._snapshot, + async def stream_source(self): + """Return the stream source.""" + if self._stream_uri is None: + uri_no_auth = await self.device.async_get_stream_uri(self.profile) + self._stream_uri = uri_no_auth.replace( + "rtsp://", f"rtsp://{self.device.username}:{self.device.password}@", 1 ) - except exceptions.ONVIFError as err: - LOGGER.error("Couldn't setup camera '%s'. Error: %s", self._name, err) - - def setup_ptz(self): - """Set up PTZ if available.""" - LOGGER.debug("Setting up the ONVIF PTZ service") - if self._camera.get_service("ptz", create=False) is None: - LOGGER.debug("PTZ is not available") - else: - self._ptz_service = self._camera.create_ptz_service() - LOGGER.debug("Completed set up of the ONVIF camera component") - - async def async_perform_ptz( - self, - distance, - speed, - move_mode, - continuous_duration, - preset, - pan=None, - tilt=None, - zoom=None, - ): - """Perform a PTZ action on the camera.""" - if self._ptz_service is None: - LOGGER.warning("PTZ actions are not supported on camera '%s'", self._name) - return - - pan_val = distance * PAN_FACTOR.get(pan, 0) - tilt_val = distance * TILT_FACTOR.get(tilt, 0) - zoom_val = distance * ZOOM_FACTOR.get(zoom, 0) - speed_val = speed - preset_val = preset - LOGGER.debug( - "Calling %s PTZ | Pan = %4.2f | Tilt = %4.2f | Zoom = %4.2f | Speed = %4.2f | Preset = %s", - move_mode, - pan_val, - tilt_val, - zoom_val, - speed_val, - preset_val, - ) - try: - req = self._ptz_service.create_type(move_mode) - req.ProfileToken = self._profile_token - if move_mode == CONTINUOUS_MOVE: - req.Velocity = { - "PanTilt": {"x": pan_val, "y": tilt_val}, - "Zoom": {"x": zoom_val}, - } - - await self._ptz_service.ContinuousMove(req) - await asyncio.sleep(continuous_duration) - req = self._ptz_service.create_type("Stop") - req.ProfileToken = self._profile_token - await self._ptz_service.Stop({"ProfileToken": req.ProfileToken}) - elif move_mode == RELATIVE_MOVE: - req.Translation = { - "PanTilt": {"x": pan_val, "y": tilt_val}, - "Zoom": {"x": zoom_val}, - } - req.Speed = { - "PanTilt": {"x": speed_val, "y": speed_val}, - "Zoom": {"x": speed_val}, - } - await self._ptz_service.RelativeMove(req) - elif move_mode == ABSOLUTE_MOVE: - req.Position = { - "PanTilt": {"x": pan_val, "y": tilt_val}, - "Zoom": {"x": zoom_val}, - } - req.Speed = { - "PanTilt": {"x": speed_val, "y": speed_val}, - "Zoom": {"x": speed_val}, - } - await self._ptz_service.AbsoluteMove(req) - elif move_mode == GOTOPRESET_MOVE: - req.PresetToken = preset_val - req.Speed = { - "PanTilt": {"x": speed_val, "y": speed_val}, - "Zoom": {"x": speed_val}, - } - await self._ptz_service.GotoPreset(req) - except exceptions.ONVIFError as err: - if "Bad Request" in err.reason: - self._ptz_service = None - LOGGER.debug("Camera '%s' doesn't support PTZ.", self._name) - else: - LOGGER.error("Error trying to perform PTZ action: %s", err) - - async def async_added_to_hass(self): - """Handle entity addition to hass.""" - LOGGER.debug("Camera '%s' added to hass", self._name) - - if DOMAIN not in self.hass.data: - self.hass.data[DOMAIN] = {} - self.hass.data[DOMAIN][ENTITIES] = [] - self.hass.data[DOMAIN][ENTITIES].append(self) + return self._stream_uri async def async_camera_image(self): """Return a still image response from the camera.""" - LOGGER.debug("Retrieving image from camera '%s'", self._name) image = None - if self._snapshot is not None: + if self.device.capabilities.snapshot: + if self._snapshot_uri is None: + self._snapshot_uri = await self.device.async_get_snapshot_uri( + self.profile + ) + auth = None - if self._username and self._password: - auth = HTTPDigestAuth(self._username, self._password) + if self.device.username and self.device.password: + auth = HTTPDigestAuth(self.device.username, self.device.password) def fetch(): """Read image from a URL.""" try: - response = requests.get(self._snapshot, timeout=5, auth=auth) + response = requests.get(self._snapshot_uri, timeout=5, auth=auth) if response.status_code < 300: return response.content except requests.exceptions.RequestException as error: LOGGER.error( "Fetch snapshot image failed from %s, falling back to FFmpeg; %s", - self._name, + self.device.name, error, ) return None - image = await self.hass.async_add_job(fetch) + image = await self.hass.async_add_executor_job(fetch) if image is None: - # Don't keep trying the snapshot URL - self._snapshot = None - ffmpeg = ImageFrame(self.hass.data[DATA_FFMPEG].binary, loop=self.hass.loop) image = await asyncio.shield( ffmpeg.get_image( - self._input, + self._stream_uri, output_format=IMAGE_JPEG, - extra_cmd=self._ffmpeg_arguments, + extra_cmd=self.device.config_entry.options.get( + CONF_EXTRA_ARGUMENTS + ), ) ) @@ -500,12 +160,15 @@ def fetch(): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" - LOGGER.debug("Handling mjpeg stream from camera '%s'", self._name) + LOGGER.debug("Handling mjpeg stream from camera '%s'", self.device.name) ffmpeg_manager = self.hass.data[DATA_FFMPEG] stream = CameraMjpeg(ffmpeg_manager.binary, loop=self.hass.loop) - await stream.open_camera(self._input, extra_cmd=self._ffmpeg_arguments) + await stream.open_camera( + self._stream_uri, + extra_cmd=self.device.config_entry.options.get(CONF_EXTRA_ARGUMENTS), + ) try: stream_reader = await stream.get_reader() @@ -518,36 +181,26 @@ async def handle_async_mjpeg_stream(self, request): finally: await stream.close() - @property - def supported_features(self): - """Return supported features.""" - if self._input: - return SUPPORT_STREAM - return 0 - - async def stream_source(self): - """Return the stream source.""" - return self._input - - @property - def name(self): - """Return the name of this camera.""" - return f"{self._name} - {self._profile_name}" - - @property - def unique_id(self) -> Optional[str]: - """Return a unique ID.""" - if self._profile_index: - return f"{self._mac}_{self._profile_index}" - return self._mac - - @property - def device_info(self): - """Return a device description for device registry.""" - return { - "identifiers": {(DOMAIN, self._mac)}, - "name": self._name, - "manufacturer": self._manufacturer, - "model": self._model, - "sw_version": self._firmware_version, - } + async def async_perform_ptz( + self, + distance, + speed, + move_mode, + continuous_duration, + preset, + pan=None, + tilt=None, + zoom=None, + ) -> None: + """Perform a PTZ action on the camera.""" + await self.device.async_perform_ptz( + self.profile, + distance, + speed, + move_mode, + continuous_duration, + preset, + pan, + tilt, + zoom, + ) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index c3fe3b6d4b771..ceb861fc7dde0 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -1,16 +1,13 @@ """Config flow for ONVIF.""" -import os from pprint import pformat from typing import List from urllib.parse import urlparse -import onvif -from onvif import ONVIFCamera, exceptions +from onvif.exceptions import ONVIFError import voluptuous as vol from wsdiscovery.discovery import ThreadedWSDiscovery as WSDiscovery from wsdiscovery.scope import Scope from wsdiscovery.service import Service -from zeep.asyncio import AsyncTransport from zeep.exceptions import Fault from homeassistant import config_entries @@ -23,12 +20,10 @@ CONF_USERNAME, ) from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession # pylint: disable=unused-import from .const import ( CONF_DEVICE_ID, - CONF_PROFILE, CONF_RTSP_TRANSPORT, DEFAULT_ARGUMENTS, DEFAULT_PORT, @@ -36,6 +31,7 @@ LOGGER, RTSP_TRANS_PROTOCOLS, ) +from .device import get_device CONF_MANUAL_INPUT = "Manually configure ONVIF device" @@ -219,23 +215,21 @@ async def async_step_profiles(self, user_input=None): } ) - if not self.onvif_config.get(CONF_PROFILE): - self.onvif_config[CONF_PROFILE] = [] - media_service = device.create_media_service() - profiles = await media_service.GetProfiles() - LOGGER.debug("Media Profiles %s", pformat(profiles)) - for key, profile in enumerate(profiles): - if profile.VideoEncoderConfiguration.Encoding != "H264": - continue - self.onvif_config[CONF_PROFILE].append(key) - - if not self.onvif_config[CONF_PROFILE]: + # Verify there is an H264 profile + media_service = device.create_media_service() + profiles = await media_service.GetProfiles() + h264 = any( + profile.VideoEncoderConfiguration.Encoding == "H264" + for profile in profiles + ) + + if not h264: return self.async_abort(reason="no_h264") title = f"{self.onvif_config[CONF_NAME]} - {self.device_id}" return self.async_create_entry(title=title, data=self.onvif_config) - except exceptions.ONVIFError as err: + except ONVIFError as err: LOGGER.error( "Couldn't setup ONVIF device '%s'. Error: %s", self.onvif_config[CONF_NAME], @@ -292,17 +286,3 @@ async def async_step_onvif_devices(self, user_input=None): } ), ) - - -def get_device(hass, host, port, username, password) -> ONVIFCamera: - """Get ONVIFCamera instance.""" - session = async_get_clientsession(hass) - transport = AsyncTransport(None, session=session) - return ONVIFCamera( - host, - port, - username, - password, - f"{os.path.dirname(onvif.__file__)}/wsdl/", - transport=transport, - ) diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index c2eb2604a26e2..ddc1cc22801fd 100644 --- a/homeassistant/components/onvif/const.py +++ b/homeassistant/components/onvif/const.py @@ -4,18 +4,14 @@ LOGGER = logging.getLogger(__package__) DOMAIN = "onvif" -ONVIF_DATA = "onvif" -ENTITIES = "entities" DEFAULT_NAME = "ONVIF Camera" DEFAULT_PORT = 5000 DEFAULT_USERNAME = "admin" DEFAULT_PASSWORD = "888888" DEFAULT_ARGUMENTS = "-pred 1" -DEFAULT_PROFILE = 0 CONF_DEVICE_ID = "deviceid" -CONF_PROFILE = "profile" CONF_RTSP_TRANSPORT = "rtsp_transport" RTSP_TRANS_PROTOCOLS = ["tcp", "udp", "udp_multicast", "http"] @@ -44,4 +40,3 @@ GOTOPRESET_MOVE = "GotoPreset" SERVICE_PTZ = "ptz" -ENTITIES = "entities" diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py new file mode 100644 index 0000000000000..49f1a58932565 --- /dev/null +++ b/homeassistant/components/onvif/device.py @@ -0,0 +1,399 @@ +"""ONVIF device abstraction.""" +import asyncio +import datetime as dt +import os +from typing import List + +from aiohttp.client_exceptions import ClientConnectionError, ServerDisconnectedError +import onvif +from onvif import ONVIFCamera +from onvif.exceptions import ONVIFError +from zeep.asyncio import AsyncTransport +from zeep.exceptions import Fault + +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.util.dt as dt_util + +from .const import ( + ABSOLUTE_MOVE, + CONTINUOUS_MOVE, + GOTOPRESET_MOVE, + LOGGER, + PAN_FACTOR, + RELATIVE_MOVE, + TILT_FACTOR, + ZOOM_FACTOR, +) +from .models import PTZ, Capabilities, DeviceInfo, Profile, Resolution, Video + + +class ONVIFDevice: + """Manages an ONVIF device.""" + + def __init__(self, hass, config_entry=None): + """Initialize the device.""" + self.hass = hass + self.config_entry = config_entry + self.available = True + + self.device = None + + self.info = DeviceInfo() + self.capabilities = Capabilities() + self.profiles = [] + self.max_resolution = 0 + + @property + def name(self) -> str: + """Return the name of this device.""" + return self.config_entry.data[CONF_NAME] + + @property + def host(self) -> str: + """Return the host of this device.""" + return self.config_entry.data[CONF_HOST] + + @property + def port(self) -> int: + """Return the port of this device.""" + return self.config_entry.data[CONF_PORT] + + @property + def username(self) -> int: + """Return the username of this device.""" + return self.config_entry.data[CONF_USERNAME] + + @property + def password(self) -> int: + """Return the password of this device.""" + return self.config_entry.data[CONF_PASSWORD] + + async def async_setup(self) -> bool: + """Set up the device.""" + self.device = get_device( + self.hass, + host=self.config_entry.data[CONF_HOST], + port=self.config_entry.data[CONF_PORT], + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + ) + + # Get all device info + try: + await self.device.update_xaddrs() + await self.async_check_date_and_time() + self.info = await self.async_get_device_info() + self.capabilities = await self.async_get_capabilities() + self.profiles = await self.async_get_profiles() + + if self.capabilities.ptz: + self.device.create_ptz_service() + + # Determine max resolution from profiles + self.max_resolution = max( + profile.video.resolution.width + for profile in self.profiles + if profile.video.encoding == "H264" + ) + except ClientConnectionError as err: + LOGGER.warning( + "Couldn't connect to camera '%s', but will retry later. Error: %s", + self.name, + err, + ) + self.available = False + except Fault as err: + LOGGER.error( + "Couldn't connect to camera '%s', please verify " + "that the credentials are correct. Error: %s", + self.name, + err, + ) + return False + + return True + + async def async_check_date_and_time(self) -> None: + """Warns if device and system date not synced.""" + LOGGER.debug("Setting up the ONVIF device management service") + devicemgmt = self.device.create_devicemgmt_service() + + LOGGER.debug("Retrieving current device date/time") + try: + system_date = dt_util.utcnow() + device_time = await devicemgmt.GetSystemDateAndTime() + if not device_time: + LOGGER.debug( + """Couldn't get device '%s' date/time. + GetSystemDateAndTime() return null/empty""", + self.name, + ) + return + + if device_time.UTCDateTime: + tzone = dt_util.UTC + cdate = device_time.UTCDateTime + else: + tzone = ( + dt_util.get_time_zone(device_time.TimeZone) + or dt_util.DEFAULT_TIME_ZONE + ) + cdate = device_time.LocalDateTime + + if cdate is None: + LOGGER.warning("Could not retrieve date/time on this camera") + else: + cam_date = dt.datetime( + cdate.Date.Year, + cdate.Date.Month, + cdate.Date.Day, + cdate.Time.Hour, + cdate.Time.Minute, + cdate.Time.Second, + 0, + tzone, + ) + + cam_date_utc = cam_date.astimezone(dt_util.UTC) + + LOGGER.debug( + "Device date/time: %s | System date/time: %s", + cam_date_utc, + system_date, + ) + + dt_diff = cam_date - system_date + dt_diff_seconds = dt_diff.total_seconds() + + if dt_diff_seconds > 5: + LOGGER.warning( + "The date/time on the device (UTC) is '%s', " + "which is different from the system '%s', " + "this could lead to authentication issues", + cam_date_utc, + system_date, + ) + except ServerDisconnectedError as err: + LOGGER.warning( + "Couldn't get device '%s' date/time. Error: %s", self.name, err + ) + + async def async_get_device_info(self) -> DeviceInfo: + """Obtain information about this device.""" + devicemgmt = self.device.create_devicemgmt_service() + device_info = await devicemgmt.GetDeviceInformation() + return DeviceInfo( + device_info.Manufacturer, + device_info.Model, + device_info.FirmwareVersion, + self.config_entry.unique_id, + ) + + async def async_get_capabilities(self): + """Obtain information about the available services on the device.""" + media_service = self.device.create_media_service() + capabilities = await media_service.GetServiceCapabilities() + ptz = False + try: + self.device.get_definition("ptz") + ptz = True + except ONVIFError: + pass + return Capabilities(capabilities.SnapshotUri, ptz) + + async def async_get_profiles(self) -> List[Profile]: + """Obtain media profiles for this device.""" + media_service = self.device.create_media_service() + result = await media_service.GetProfiles() + profiles = [] + for key, onvif_profile in enumerate(result): + # Only add H264 profiles + if onvif_profile.VideoEncoderConfiguration.Encoding != "H264": + continue + + profile = Profile( + key, + onvif_profile.token, + onvif_profile.Name, + Video( + onvif_profile.VideoEncoderConfiguration.Encoding, + Resolution( + onvif_profile.VideoEncoderConfiguration.Resolution.Width, + onvif_profile.VideoEncoderConfiguration.Resolution.Height, + ), + ), + ) + + # Configure PTZ options + if onvif_profile.PTZConfiguration: + profile.ptz = PTZ( + onvif_profile.PTZConfiguration.DefaultContinuousPanTiltVelocitySpace + is not None, + onvif_profile.PTZConfiguration.DefaultRelativePanTiltTranslationSpace + is not None, + onvif_profile.PTZConfiguration.DefaultAbsolutePantTiltPositionSpace + is not None, + ) + + ptz_service = self.device.get_service("ptz") + presets = await ptz_service.GetPresets(profile.token) + profile.ptz.presets = [preset.token for preset in presets] + + profiles.append(profile) + + return profiles + + async def async_get_snapshot_uri(self, profile: Profile) -> str: + """Get the snapshot URI for a specified profile.""" + if not self.capabilities.snapshot: + return None + + media_service = self.device.create_media_service() + req = media_service.create_type("GetSnapshotUri") + req.ProfileToken = profile.token + result = await media_service.GetSnapshotUri(req) + return result.Uri + + async def async_get_stream_uri(self, profile: Profile) -> str: + """Get the stream URI for a specified profile.""" + media_service = self.device.create_media_service() + req = media_service.create_type("GetStreamUri") + req.ProfileToken = profile.token + req.StreamSetup = { + "Stream": "RTP-Unicast", + "Transport": {"Protocol": "RTSP"}, + } + result = await media_service.GetStreamUri(req) + return result.Uri + + async def async_perform_ptz( + self, + profile: Profile, + distance, + speed, + move_mode, + continuous_duration, + preset, + pan=None, + tilt=None, + zoom=None, + ): + """Perform a PTZ action on the camera.""" + if not self.capabilities.ptz: + LOGGER.warning("PTZ actions are not supported on device '%s'", self.name) + return + + ptz_service = self.device.get_service("ptz") + + pan_val = distance * PAN_FACTOR.get(pan, 0) + tilt_val = distance * TILT_FACTOR.get(tilt, 0) + zoom_val = distance * ZOOM_FACTOR.get(zoom, 0) + speed_val = speed + preset_val = preset + LOGGER.debug( + "Calling %s PTZ | Pan = %4.2f | Tilt = %4.2f | Zoom = %4.2f | Speed = %4.2f | Preset = %s", + move_mode, + pan_val, + tilt_val, + zoom_val, + speed_val, + preset_val, + ) + try: + req = ptz_service.create_type(move_mode) + req.ProfileToken = profile.token + if move_mode == CONTINUOUS_MOVE: + # Guard against unsupported operation + if not profile.ptz.continuous: + LOGGER.warning( + "ContinuousMove not supported on device '%s'", self.name + ) + return + + req.Velocity = { + "PanTilt": {"x": pan_val, "y": tilt_val}, + "Zoom": {"x": zoom_val}, + } + + await ptz_service.ContinuousMove(req) + await asyncio.sleep(continuous_duration) + req = ptz_service.create_type("Stop") + req.ProfileToken = profile.token + await ptz_service.Stop({"ProfileToken": req.ProfileToken}) + elif move_mode == RELATIVE_MOVE: + # Guard against unsupported operation + if not profile.ptz.relative: + LOGGER.warning( + "ContinuousMove not supported on device '%s'", self.name + ) + return + + req.Translation = { + "PanTilt": {"x": pan_val, "y": tilt_val}, + "Zoom": {"x": zoom_val}, + } + req.Speed = { + "PanTilt": {"x": speed_val, "y": speed_val}, + "Zoom": {"x": speed_val}, + } + await ptz_service.RelativeMove(req) + elif move_mode == ABSOLUTE_MOVE: + # Guard against unsupported operation + if not profile.ptz.absolute: + LOGGER.warning( + "ContinuousMove not supported on device '%s'", self.name + ) + return + + req.Position = { + "PanTilt": {"x": pan_val, "y": tilt_val}, + "Zoom": {"x": zoom_val}, + } + req.Speed = { + "PanTilt": {"x": speed_val, "y": speed_val}, + "Zoom": {"x": speed_val}, + } + await ptz_service.AbsoluteMove(req) + elif move_mode == GOTOPRESET_MOVE: + # Guard against unsupported operation + if preset_val not in profile.ptz.presets: + LOGGER.warning( + "PTZ preset '%s' does not exist on device '%s'. Available Presets: %s", + preset_val, + self.name, + profile.ptz.presets.join(", "), + ) + return + + req.PresetToken = preset_val + req.Speed = { + "PanTilt": {"x": speed_val, "y": speed_val}, + "Zoom": {"x": speed_val}, + } + await ptz_service.GotoPreset(req) + except ONVIFError as err: + if "Bad Request" in err.reason: + LOGGER.warning("Device '%s' doesn't support PTZ.", self.name) + else: + LOGGER.error("Error trying to perform PTZ action: %s", err) + + +def get_device(hass, host, port, username, password) -> ONVIFCamera: + """Get ONVIFCamera instance.""" + session = async_get_clientsession(hass) + transport = AsyncTransport(None, session=session) + return ONVIFCamera( + host, + port, + username, + password, + f"{os.path.dirname(onvif.__file__)}/wsdl/", + transport=transport, + ) diff --git a/homeassistant/components/onvif/models.py b/homeassistant/components/onvif/models.py new file mode 100644 index 0000000000000..68ae5c6bc90cb --- /dev/null +++ b/homeassistant/components/onvif/models.py @@ -0,0 +1,58 @@ +"""ONVIF models.""" +from dataclasses import dataclass +from typing import List + + +@dataclass +class DeviceInfo: + """Represent device information.""" + + manufacturer: str = None + model: str = None + fw_version: str = None + mac: str = None + + +@dataclass +class Resolution: + """Represent video resolution.""" + + width: int + height: int + + +@dataclass +class Video: + """Represent video encoding settings.""" + + encoding: str + resolution: Resolution + + +@dataclass +class PTZ: + """Represents PTZ configuration on a profile.""" + + continuous: bool + relative: bool + absolute: bool + presets: List[str] = None + + +@dataclass +class Profile: + """Represent a ONVIF Profile.""" + + index: int + token: str + name: str + video: Video + ptz: PTZ = None + + +@dataclass +class Capabilities: + """Represents Service capabilities.""" + + snapshot: bool = False + ptz: bool = False diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index e62be0244fe9f..66f02881b78ad 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -3,6 +3,7 @@ import ssl from urllib.parse import urlparse +from plexapi.exceptions import Unauthorized import plexapi.myplex import plexapi.playqueue import plexapi.server @@ -138,21 +139,24 @@ def _update_plexdirect_hostname(): else: _connect_with_token() - self._accounts = [ - account.name - for account in self._plex_server.systemAccounts() - if account.name - ] - _LOGGER.debug("Linked accounts: %s", self.accounts) - - owner_account = [ - account.name - for account in self._plex_server.systemAccounts() - if account.accountID == 1 - ] - if owner_account: - self._owner_username = owner_account[0] - _LOGGER.debug("Server owner found: '%s'", self._owner_username) + try: + system_accounts = self._plex_server.systemAccounts() + except Unauthorized: + _LOGGER.warning( + "Plex account has limited permissions, shared account filtering will not be available." + ) + else: + self._accounts = [ + account.name for account in system_accounts if account.name + ] + _LOGGER.debug("Linked accounts: %s", self.accounts) + + owner_account = [ + account.name for account in system_accounts if account.accountID == 1 + ] + if owner_account: + self._owner_username = owner_account[0] + _LOGGER.debug("Server owner found: '%s'", self._owner_username) self._version = self._plex_server.version diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 4897ef2844b39..b8d46a4aec500 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -17,7 +17,7 @@ ) -async def async_setup(hass, config): +def setup(hass, config): """Configure Gammu state machine.""" conf = config[DOMAIN] device = conf.get(CONF_DEVICE) diff --git a/homeassistant/components/songpal/__init__.py b/homeassistant/components/songpal/__init__.py index 7b181d375a54a..4a4332cb0a517 100644 --- a/homeassistant/components/songpal/__init__.py +++ b/homeassistant/components/songpal/__init__.py @@ -1 +1,50 @@ """The songpal component.""" +from collections import OrderedDict +import logging + +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType + +from .const import CONF_ENDPOINT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SONGPAL_CONFIG_SCHEMA = vol.Schema( + {vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_ENDPOINT): cv.string} +) + +CONFIG_SCHEMA = vol.Schema( + {vol.Optional(DOMAIN): vol.All(cv.ensure_list, [SONGPAL_CONFIG_SCHEMA])}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: OrderedDict) -> bool: + """Set up songpal environment.""" + conf = config.get(DOMAIN) + if conf is None: + return True + for config_entry in conf: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config_entry, + ), + ) + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up songpal media player.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "media_player") + ) + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload songpal media player.""" + return await hass.config_entries.async_forward_entry_unload(entry, "media_player") diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py new file mode 100644 index 0000000000000..206cfce575db4 --- /dev/null +++ b/homeassistant/components/songpal/config_flow.py @@ -0,0 +1,153 @@ +"""Config flow to configure songpal component.""" +import logging +from typing import Optional +from urllib.parse import urlparse + +from songpal import Device, SongpalException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import CONF_HOST, CONF_NAME + +from .const import CONF_ENDPOINT, DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class SongpalConfig: + """Device Configuration.""" + + def __init__(self, name, host, endpoint): + """Initialize Configuration.""" + self.name = name + self.host = host + self.endpoint = endpoint + + +class SongpalConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Songpal configuration flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the flow.""" + self.conf: Optional[SongpalConfig] = None + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_ENDPOINT): str}), + ) + + # Validate input + endpoint = user_input[CONF_ENDPOINT] + parsed_url = urlparse(endpoint) + + # Try to connect and get device name + try: + device = Device(endpoint) + await device.get_supported_methods() + interface_info = await device.get_interface_information() + name = interface_info.modelName + except SongpalException as ex: + _LOGGER.debug("Connection failed: %s", ex) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_ENDPOINT, default=user_input.get(CONF_ENDPOINT, "") + ): str, + } + ), + errors={"base": "connection"}, + ) + + self.conf = SongpalConfig(name, parsed_url.hostname, endpoint) + + return await self.async_step_init(user_input) + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + # Check if already configured + if self._endpoint_already_configured(): + return self.async_abort(reason="already_configured") + + if user_input is None: + return self.async_show_form( + step_id="init", + description_placeholders={ + CONF_NAME: self.conf.name, + CONF_HOST: self.conf.host, + }, + ) + + await self.async_set_unique_id(self.conf.endpoint) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self.conf.name, + data={CONF_NAME: self.conf.name, CONF_ENDPOINT: self.conf.endpoint}, + ) + + async def async_step_ssdp(self, discovery_info): + """Handle a discovered Songpal device.""" + await self.async_set_unique_id(discovery_info[ssdp.ATTR_UPNP_UDN]) + self._abort_if_unique_id_configured() + + _LOGGER.debug("Discovered: %s", discovery_info) + + friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] + parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + scalarweb_info = discovery_info["X_ScalarWebAPI_DeviceInfo"] + endpoint = scalarweb_info["X_ScalarWebAPI_BaseURL"] + service_types = scalarweb_info["X_ScalarWebAPI_ServiceList"][ + "X_ScalarWebAPI_ServiceType" + ] + + # Ignore Bravia TVs + if "videoScreen" in service_types: + return self.async_abort(reason="not_songpal_device") + + # pylint: disable=no-member + self.context["title_placeholders"] = { + CONF_NAME: friendly_name, + CONF_HOST: parsed_url.hostname, + } + + self.conf = SongpalConfig(friendly_name, parsed_url.hostname, endpoint) + + return await self.async_step_init() + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + name = user_input.get(CONF_NAME) + endpoint = user_input.get(CONF_ENDPOINT) + parsed_url = urlparse(endpoint) + + # Try to connect to test the endpoint + try: + device = Device(endpoint) + await device.get_supported_methods() + # Get name + if name is None: + interface_info = await device.get_interface_information() + name = interface_info.modelName + except SongpalException as ex: + _LOGGER.error("Import from yaml configuration failed: %s", ex) + return self.async_abort(reason="connection") + + self.conf = SongpalConfig(name, parsed_url.hostname, endpoint) + + return await self.async_step_init(user_input) + + def _endpoint_already_configured(self): + """See if we already have an endpoint matching user input configured.""" + existing_endpoints = [ + entry.data[CONF_ENDPOINT] for entry in self._async_current_entries() + ] + return self.conf.endpoint in existing_endpoints diff --git a/homeassistant/components/songpal/const.py b/homeassistant/components/songpal/const.py index 6a19e316a9f38..f12b77800a9d9 100644 --- a/homeassistant/components/songpal/const.py +++ b/homeassistant/components/songpal/const.py @@ -1,3 +1,5 @@ """Constants for the Songpal component.""" DOMAIN = "songpal" SET_SOUND_SETTING = "set_sound_setting" + +CONF_ENDPOINT = "endpoint" diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json index 583f0dff6efe6..162ec7c21470f 100644 --- a/homeassistant/components/songpal/manifest.json +++ b/homeassistant/components/songpal/manifest.json @@ -1,7 +1,14 @@ { "domain": "songpal", "name": "Sony Songpal", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/songpal", - "requirements": ["python-songpal==0.11.2"], - "codeowners": ["@rytilahti"] + "requirements": ["python-songpal==0.12"], + "codeowners": ["@rytilahti", "@shenxn"], + "ssdp": [ + { + "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", + "manufacturer": "Sony Corporation" + } + ] } diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 55d8f0133a905..5894faa5e2e53 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -13,7 +13,7 @@ ) import voluptuous as vol -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, @@ -22,6 +22,7 @@ SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_NAME, @@ -30,19 +31,16 @@ STATE_ON, ) from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import HomeAssistantType -from .const import DOMAIN, SET_SOUND_SETTING +from .const import CONF_ENDPOINT, DOMAIN, SET_SOUND_SETTING _LOGGER = logging.getLogger(__name__) -CONF_ENDPOINT = "endpoint" - PARAM_NAME = "name" PARAM_VALUE = "value" -PLATFORM = "songpal" - SUPPORT_SONGPAL = ( SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP @@ -52,10 +50,6 @@ | SUPPORT_TURN_OFF ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_ENDPOINT): cv.string} -) - SET_SOUND_SCHEMA = vol.Schema( { vol.Optional(ATTR_ENTITY_ID): cv.entity_id, @@ -65,33 +59,37 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Songpal platform.""" - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} +async def async_setup_platform( + hass: HomeAssistantType, config: dict, async_add_entities, discovery_info=None +) -> None: + """Set up from legacy configuration file. Obsolete.""" + _LOGGER.error( + "Configuring Songpal through media_player platform is no longer supported. Convert to songpal platform or UI configuration." + ) + - if discovery_info is not None: - name = discovery_info["name"] - endpoint = discovery_info["properties"]["endpoint"] - _LOGGER.debug("Got autodiscovered %s - endpoint: %s", name, endpoint) +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up songpal media player.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} - device = SongpalDevice(name, endpoint) - else: - name = config.get(CONF_NAME) - endpoint = config.get(CONF_ENDPOINT) - device = SongpalDevice(name, endpoint, poll=False) + name = config_entry.data[CONF_NAME] + endpoint = config_entry.data[CONF_ENDPOINT] - if endpoint in hass.data[PLATFORM]: + if endpoint in hass.data[DOMAIN]: _LOGGER.debug("The endpoint exists already, skipping setup.") return + device = SongpalDevice(name, endpoint) try: await device.initialize() except SongpalException as ex: _LOGGER.error("Unable to get methods from songpal: %s", ex) raise PlatformNotReady - hass.data[PLATFORM][endpoint] = device + hass.data[DOMAIN][endpoint] = device async_add_entities([device], True) @@ -102,7 +100,7 @@ async def async_service_handler(service): key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID } - for device in hass.data[PLATFORM].values(): + for device in hass.data[DOMAIN].values(): if device.entity_id == entity_id or entity_id is None: _LOGGER.debug( "Calling %s (entity: %s) with params %s", service, entity_id, params @@ -127,6 +125,7 @@ def __init__(self, name, endpoint, poll=False): self._poll = poll self.dev = Device(self._endpoint) self._sysinfo = None + self._model = None self._state = False self._available = False @@ -150,6 +149,13 @@ async def initialize(self): """Initialize the device.""" await self.dev.get_supported_methods() self._sysinfo = await self.dev.get_system_info() + interface_info = await self.dev.get_interface_information() + self._model = interface_info.modelName + + async def async_will_remove_from_hass(self): + """Run when entity will be removed from hass.""" + self.hass.data[DOMAIN].pop(self._endpoint) + await self.dev.stop_listen_notifications() async def async_activate_websocket(self): """Activate websocket for listening if wanted.""" @@ -221,6 +227,18 @@ def unique_id(self): """Return a unique ID.""" return self._sysinfo.macAddr + @property + def device_info(self): + """Return the device info.""" + return { + "connections": {(dr.CONNECTION_NETWORK_MAC, self._sysinfo.macAddr)}, + "identifiers": {(DOMAIN, self.unique_id)}, + "manufacturer": "Sony Corporation", + "name": self.name, + "sw_version": self._sysinfo.version, + "model": self._model, + } + @property def available(self): """Return availability of the device.""" diff --git a/homeassistant/components/songpal/strings.json b/homeassistant/components/songpal/strings.json new file mode 100644 index 0000000000000..7948e99af292e --- /dev/null +++ b/homeassistant/components/songpal/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "flow_title": "Sony Songpal {name} ({host})", + "step": { + "user": { + "data": { + "endpoint": "Endpoint" + }, + "title": "Sony Songpal" + }, + "init": { + "description": "Do you want to set up {name} ({host})?", + "title": "Sony Songpal" + } + }, + "error": { + "connection": "Connection error: please check your endpoint" + }, + "abort": { + "already_configured": "Device already configured", + "not_songpal_device": "Not a Songpal device" + } + } +} diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 9431cb7b1c97b..39ce1547a2ced 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -14,6 +14,7 @@ CONF_MAC, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, ) @@ -22,7 +23,14 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import HomeAssistantType -from .const import CONF_VOLUMES, DEFAULT_SSL, DOMAIN +from .const import ( + CONF_VOLUMES, + DEFAULT_SCAN_INTERVAL, + DEFAULT_SSL, + DOMAIN, + SYNO_API, + UNDO_UPDATE_LISTENER, +) CONFIG_SCHEMA = vol.Schema( { @@ -41,8 +49,6 @@ extra=vol.ALLOW_EXTRA, ) -SCAN_INTERVAL = timedelta(minutes=15) - async def async_setup(hass, config): """Set up Synology DSM sensors from legacy config file.""" @@ -63,20 +69,17 @@ async def async_setup(hass, config): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Set up Synology DSM sensors.""" - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - unit = hass.config.units.temperature_unit - use_ssl = entry.data[CONF_SSL] - device_token = entry.data.get("device_token") - - api = SynoApi(hass, host, port, username, password, unit, use_ssl, device_token) + api = SynoApi(hass, entry) await api.async_setup() + undo_listener = entry.add_update_listener(_async_update_listener) + hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.unique_id] = api + hass.data[DOMAIN][entry.unique_id] = { + SYNO_API: api, + UNDO_UPDATE_LISTENER: undo_listener, + } # For SSDP compat if not entry.data.get(CONF_MAC): @@ -94,34 +97,31 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): """Unload Synology DSM sensors.""" - api = hass.data[DOMAIN][entry.unique_id] - await api.async_unload() - return await hass.config_entries.async_forward_entry_unload(entry, "sensor") + unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "sensor") + + if unload_ok: + entry_data = hass.data[DOMAIN][entry.unique_id] + entry_data[UNDO_UPDATE_LISTENER]() + await entry_data[SYNO_API].async_unload() + hass.data[DOMAIN].pop(entry.unique_id) + + return unload_ok + + +async def _async_update_listener(hass: HomeAssistantType, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) class SynoApi: """Class to interface with Synology DSM API.""" - def __init__( - self, - hass: HomeAssistantType, - host: str, - port: int, - username: str, - password: str, - temp_unit: str, - use_ssl: bool, - device_token: str, - ): + def __init__(self, hass: HomeAssistantType, entry: ConfigEntry): """Initialize the API wrapper class.""" self._hass = hass - self._host = host - self._port = port - self._username = username - self._password = password - self._use_ssl = use_ssl - self._device_token = device_token - self.temp_unit = temp_unit + self._entry = entry + + self.temp_unit = hass.config.units.temperature_unit self.dsm: SynologyDSM = None self.information: SynoDSMInformation = None @@ -138,19 +138,25 @@ def signal_sensor_update(self) -> str: async def async_setup(self): """Start interacting with the NAS.""" self.dsm = SynologyDSM( - self._host, - self._port, - self._username, - self._password, - self._use_ssl, - device_token=self._device_token, + self._entry.data[CONF_HOST], + self._entry.data[CONF_PORT], + self._entry.data[CONF_USERNAME], + self._entry.data[CONF_PASSWORD], + self._entry.data[CONF_SSL], + device_token=self._entry.data.get("device_token"), ) await self._hass.async_add_executor_job(self._fetch_device_configuration) await self.update() self._unsub_dispatcher = async_track_time_interval( - self._hass, self.update, SCAN_INTERVAL + self._hass, + self.update, + timedelta( + minutes=self._entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + ), ) def _fetch_device_configuration(self): diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index c3d15aff2fd6f..0ddd22d3e1be1 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -21,11 +21,20 @@ CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, ) - -from .const import CONF_VOLUMES, DEFAULT_PORT, DEFAULT_PORT_SSL, DEFAULT_SSL +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_VOLUMES, + DEFAULT_PORT, + DEFAULT_PORT_SSL, + DEFAULT_SCAN_INTERVAL, + DEFAULT_SSL, +) from .const import DOMAIN # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -61,6 +70,12 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return SynologyDSMOptionsFlowHandler(config_entry) + def __init__(self): """Initialize the synology_dsm config flow.""" self.saved_user_input = {} @@ -212,6 +227,31 @@ def _mac_already_configured(self, mac): return mac in existing_macs +class SynologyDSMOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): cv.positive_int + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + def _login_and_fetch_syno_info(api, otp_code): """Login to the NAS and fetch basic data.""" # These do i/o diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index b3c9f66c8da27..e0a166e908bee 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -9,10 +9,17 @@ DOMAIN = "synology_dsm" BASE_NAME = "Synology" +# Entry keys +SYNO_API = "syno_api" +UNDO_UPDATE_LISTENER = "undo_update_listener" + +# Configuration CONF_VOLUMES = "volumes" DEFAULT_SSL = True DEFAULT_PORT = 5000 DEFAULT_PORT_SSL = 5001 +# Options +DEFAULT_SCAN_INTERVAL = 15 # min UTILISATION_SENSORS = { "cpu_other_load": ["CPU Load (Other)", UNIT_PERCENTAGE, "mdi:chip"], diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 87c1ba128c62f..e776f5e97e38e 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -22,6 +22,7 @@ DOMAIN, STORAGE_DISK_SENSORS, STORAGE_VOL_SENSORS, + SYNO_API, TEMP_SENSORS_KEYS, UTILISATION_SENSORS, ) @@ -34,7 +35,7 @@ async def async_setup_entry( ) -> None: """Set up the Synology NAS Sensor.""" - api = hass.data[DOMAIN][entry.unique_id] + api = hass.data[DOMAIN][entry.unique_id][SYNO_API] sensors = [ SynoNasUtilSensor(api, sensor_type, UTILISATION_SENSORS[sensor_type]) diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index c58b0d819eaf0..7c81b1dae280b 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -37,5 +37,14 @@ "unknown": "Unknown error: please check logs to get more details" }, "abort": { "already_configured": "Host already configured" } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } } } diff --git a/homeassistant/components/synology_dsm/translations/en.json b/homeassistant/components/synology_dsm/translations/en.json index 60a17d703de9c..3a52b0dc7d47f 100644 --- a/homeassistant/components/synology_dsm/translations/en.json +++ b/homeassistant/components/synology_dsm/translations/en.json @@ -20,7 +20,6 @@ }, "link": { "data": { - "api_version": "DSM version", "password": "Password", "port": "Port (Optional)", "ssl": "Use SSL/TLS to connect to your NAS", @@ -31,7 +30,6 @@ }, "user": { "data": { - "api_version": "DSM version", "host": "Host", "password": "Password", "port": "Port (Optional)", @@ -41,5 +39,14 @@ "title": "Synology DSM" } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Minutes between scans" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 8c836f7713137..a4382c59ee37b 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -229,12 +229,20 @@ async def async_step_device_tracker(self, user_input=None): self.options.update(user_input) return await self.async_step_client_control() - ssids = list(self.controller.api.wlans) + [ - f"{wlan.name}{wlan.name_combine_suffix}" - for wlan in self.controller.api.wlans.values() - if not wlan.name_combine_enabled - ] - ssid_filter = {ssid: ssid for ssid in sorted(ssids)} + ssids = ( + set(self.controller.api.wlans) + | { + f"{wlan.name}{wlan.name_combine_suffix}" + for wlan in self.controller.api.wlans.values() + if not wlan.name_combine_enabled + } + | { + wlan["name"] + for ap in self.controller.api.devices.values() + for wlan in ap.wlan_overrides + } + ) + ssid_filter = {ssid: ssid for ssid in sorted(list(ssids))} return self.async_show_form( step_id="device_tracker", diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 161c862f6b463..7be0031da43f5 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -1,6 +1,9 @@ """Track devices using UniFi controllers.""" +from datetime import timedelta import logging +from aiounifi.api import SOURCE_DATA + from homeassistant.components.device_tracker import DOMAIN from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER @@ -243,6 +246,9 @@ def __init__(self, device, controller): self.device = device super().__init__(controller) + self._is_connected = self.device.state == 1 + self.cancel_scheduled_update = None + @property def mac(self): """Return MAC of device.""" @@ -260,20 +266,34 @@ async def async_will_remove_from_hass(self) -> None: @callback def async_update_callback(self): - """Update the sensor's state.""" + """Update the devices' state.""" + + @callback + def _no_heartbeat(now): + """No heart beat by device.""" + self._is_connected = False + self.cancel_scheduled_update = None + self.async_write_ha_state() + + if self.device.last_updated == SOURCE_DATA: + self._is_connected = True + + if self.cancel_scheduled_update: + self.cancel_scheduled_update() + + self.cancel_scheduled_update = async_track_point_in_utc_time( + self.hass, + _no_heartbeat, + dt_util.utcnow() + timedelta(seconds=self.device.next_interval + 10), + ) + LOGGER.debug("Updating device %s (%s)", self.entity_id, self.device.mac) self.async_write_ha_state() @property def is_connected(self): """Return true if the device is connected to the network.""" - if self.device.state == 1 and ( - dt_util.utcnow() - dt_util.utc_from_timestamp(float(self.device.last_seen)) - < self.controller.option_detection_time - ): - return True - - return False + return self._is_connected @property def source_type(self): @@ -333,3 +353,8 @@ async def options_updated(self) -> None: """Config entry options are updated, remove entity if option is disabled.""" if not self.controller.option_track_devices: await self.async_remove() + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 8c05d19531680..c814971521c59 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,7 +3,7 @@ "name": "Ubiquiti UniFi", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", - "requirements": ["aiounifi==20"], + "requirements": ["aiounifi==21"], "codeowners": ["@kane610"], "quality_scale": "platinum" } diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 6fd5d96a40c65..fc87abd4bc167 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -5,6 +5,7 @@ import voluptuous as vol from zeroconf import ( + InterfaceChoice, NonUniqueNameException, ServiceBrowser, ServiceInfo, @@ -20,6 +21,7 @@ __version__, ) from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -34,12 +36,29 @@ ZEROCONF_TYPE = "_home-assistant._tcp.local." HOMEKIT_TYPE = "_hap._tcp.local." -CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) +CONF_DEFAULT_INTERFACE = "default_interface" +DEFAULT_DEFAULT_INTERFACE = False + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional( + CONF_DEFAULT_INTERFACE, default=DEFAULT_DEFAULT_INTERFACE + ): cv.boolean + } + ) + }, + extra=vol.ALLOW_EXTRA, +) def setup(hass, config): """Set up Zeroconf and make Home Assistant discoverable.""" - zeroconf = Zeroconf() + if config.get(CONF_DEFAULT_INTERFACE): + zeroconf = Zeroconf(interfaces=InterfaceChoice.Default) + else: + zeroconf = Zeroconf() zeroconf_name = f"{hass.config.location_name}.{ZEROCONF_TYPE}" params = { diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 655ff4dc032c4..a3d4d1d83990c 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.26.0"], + "requirements": ["zeroconf==0.26.1"], "dependencies": ["api"], "codeowners": ["@robbiet480", "@Kane610"], "quality_scale": "internal" diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 9e59b63adb4ea..4f844613336ca 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -4,6 +4,7 @@ import logging import voluptuous as vol +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries, const as ha_const import homeassistant.helpers.config_validation as cv @@ -14,6 +15,7 @@ from . import api from .core import ZHAGateway from .core.const import ( + BAUD_RATES, COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, @@ -21,13 +23,12 @@ CONF_ENABLE_QUIRKS, CONF_RADIO_TYPE, CONF_USB_PATH, + CONF_ZIGPY, DATA_ZHA, DATA_ZHA_CONFIG, DATA_ZHA_DISPATCHERS, DATA_ZHA_GATEWAY, DATA_ZHA_PLATFORM_LOADED, - DEFAULT_BAUDRATE, - DEFAULT_RADIO_TYPE, DOMAIN, SIGNAL_ADD_ENTITIES, RadioType, @@ -35,23 +36,25 @@ from .core.discovery import GROUP_PROBE DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(ha_const.CONF_TYPE): cv.string}) - +ZHA_CONFIG_SCHEMA = { + vol.Optional(CONF_BAUDRATE): cv.positive_int, + vol.Optional(CONF_DATABASE): cv.string, + vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema( + {cv.string: DEVICE_CONFIG_SCHEMA_ENTRY} + ), + vol.Optional(CONF_ENABLE_QUIRKS, default=True): cv.boolean, + vol.Optional(CONF_ZIGPY): dict, +} CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( - { - vol.Optional(CONF_RADIO_TYPE, default=DEFAULT_RADIO_TYPE): cv.enum( - RadioType - ), - CONF_USB_PATH: cv.string, - vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): cv.positive_int, - vol.Optional(CONF_DATABASE): cv.string, - vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema( - {cv.string: DEVICE_CONFIG_SCHEMA_ENTRY} - ), - vol.Optional(CONF_ENABLE_QUIRKS, default=True): cv.boolean, - } - ) + vol.All( + cv.deprecated(CONF_USB_PATH, invalidation_version="0.112"), + cv.deprecated(CONF_BAUDRATE, invalidation_version="0.112"), + cv.deprecated(CONF_RADIO_TYPE, invalidation_version="0.112"), + ZHA_CONFIG_SCHEMA, + ), + ), }, extra=vol.ALLOW_EXTRA, ) @@ -67,23 +70,10 @@ async def async_setup(hass, config): """Set up ZHA from config.""" hass.data[DATA_ZHA] = {} - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf - - if not hass.config_entries.async_entries(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_USB_PATH: conf[CONF_USB_PATH], - CONF_RADIO_TYPE: conf.get(CONF_RADIO_TYPE).value, - }, - ) - ) + if DOMAIN in config: + conf = config[DOMAIN] + hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf + return True @@ -161,3 +151,26 @@ async def async_load_entities(hass: HomeAssistantType) -> None: if isinstance(res, Exception): _LOGGER.warning("Couldn't setup zha platform: %s", res) async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) + + +async def async_migrate_entry( + hass: HomeAssistantType, config_entry: config_entries.ConfigEntry +): + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + data = { + CONF_RADIO_TYPE: config_entry.data[CONF_RADIO_TYPE], + CONF_DEVICE: {CONF_DEVICE_PATH: config_entry.data[CONF_USB_PATH]}, + } + + baudrate = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}).get(CONF_BAUDRATE) + if data[CONF_RADIO_TYPE] != RadioType.deconz and baudrate in BAUD_RATES: + data[CONF_DEVICE][CONF_BAUDRATE] = baudrate + + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, data=data) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + return True diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 5ee0d0ee9bbf2..7f0ba0451848e 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1,80 +1,142 @@ """Config flow for ZHA.""" -import asyncio -from collections import OrderedDict import os +from typing import Any, Dict, Optional +import serial.tools.list_ports import voluptuous as vol +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries -from .core.const import ( +from .core.const import ( # pylint:disable=unused-import + CONF_BAUDRATE, + CONF_FLOWCONTROL, CONF_RADIO_TYPE, - CONF_USB_PATH, CONTROLLER, - DEFAULT_BAUDRATE, - DEFAULT_DATABASE_NAME, DOMAIN, - ZHA_GW_RADIO, RadioType, ) from .core.registries import RADIO_TYPES +CONF_MANUAL_PATH = "Enter Manually" +SUPPORTED_PORT_SETTINGS = ( + CONF_BAUDRATE, + CONF_FLOWCONTROL, +) + -@config_entries.HANDLERS.register(DOMAIN) -class ZhaFlowHandler(config_entries.ConfigFlow): +class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + def __init__(self): + """Initialize flow instance.""" + self._device_path = None + self._radio_type = None + async def async_step_user(self, user_input=None): """Handle a zha config flow start.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - errors = {} - - fields = OrderedDict() - fields[vol.Required(CONF_USB_PATH)] = str - fields[vol.Optional(CONF_RADIO_TYPE, default="ezsp")] = vol.In(RadioType.list()) + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + list_of_ports = [ + f"{p}, s/n: {p.serial_number or 'n/a'}" + + (f" - {p.manufacturer}" if p.manufacturer else "") + for p in ports + ] + list_of_ports.append(CONF_MANUAL_PATH) if user_input is not None: - database = os.path.join(self.hass.config.config_dir, DEFAULT_DATABASE_NAME) - test = await check_zigpy_connection( - user_input[CONF_USB_PATH], user_input[CONF_RADIO_TYPE], database + user_selection = user_input[CONF_DEVICE_PATH] + if user_selection == CONF_MANUAL_PATH: + return await self.async_step_pick_radio() + + port = ports[list_of_ports.index(user_selection)] + dev_path = await self.hass.async_add_executor_job( + get_serial_by_id, port.device ) - if test: + auto_detected_data = await detect_radios(dev_path) + if auto_detected_data is not None: + title = f"{port.description}, s/n: {port.serial_number or 'n/a'}" + title += f" - {port.manufacturer}" if port.manufacturer else "" + return self.async_create_entry(title=title, data=auto_detected_data,) + + # did not detect anything + self._device_path = dev_path + return await self.async_step_pick_radio() + + schema = vol.Schema({vol.Required(CONF_DEVICE_PATH): vol.In(list_of_ports)}) + return self.async_show_form(step_id="user", data_schema=schema) + + async def async_step_pick_radio(self, user_input=None): + """Select radio type.""" + + if user_input is not None: + self._radio_type = user_input[CONF_RADIO_TYPE] + return await self.async_step_port_config() + + schema = {vol.Required(CONF_RADIO_TYPE): vol.In(sorted(RadioType.list()))} + return self.async_show_form( + step_id="pick_radio", data_schema=vol.Schema(schema), + ) + + async def async_step_port_config(self, user_input=None): + """Enter port settings specific for this type of radio.""" + errors = {} + app_cls = RADIO_TYPES[self._radio_type][CONTROLLER] + + if user_input is not None: + self._device_path = user_input.get(CONF_DEVICE_PATH) + if await app_cls.probe(user_input): + serial_by_id = await self.hass.async_add_executor_job( + get_serial_by_id, user_input[CONF_DEVICE_PATH] + ) + user_input[CONF_DEVICE_PATH] = serial_by_id return self.async_create_entry( - title=user_input[CONF_USB_PATH], data=user_input + title=user_input[CONF_DEVICE_PATH], + data={CONF_DEVICE: user_input, CONF_RADIO_TYPE: self._radio_type}, ) errors["base"] = "cannot_connect" + schema = { + vol.Required( + CONF_DEVICE_PATH, default=self._device_path or vol.UNDEFINED + ): str + } + radio_schema = app_cls.SCHEMA_DEVICE.schema + if isinstance(radio_schema, vol.Schema): + radio_schema = radio_schema.schema + + for param, value in radio_schema.items(): + if param in SUPPORTED_PORT_SETTINGS: + schema[param] = value + return self.async_show_form( - step_id="user", data_schema=vol.Schema(fields), errors=errors + step_id="port_config", data_schema=vol.Schema(schema), errors=errors, ) - async def async_step_import(self, import_info): - """Handle a zha config import.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - return self.async_create_entry( - title=import_info[CONF_USB_PATH], data=import_info - ) +async def detect_radios(dev_path: str) -> Optional[Dict[str, Any]]: + """Probe all radio types on the device port.""" + for radio in RadioType.list(): + app_cls = RADIO_TYPES[radio][CONTROLLER] + dev_config = app_cls.SCHEMA_DEVICE({CONF_DEVICE_PATH: dev_path}) + if await app_cls.probe(dev_config): + return {CONF_RADIO_TYPE: radio, CONF_DEVICE: dev_config} + + return None + +def get_serial_by_id(dev_path: str) -> str: + """Return a /dev/serial/by-id match for given device if available.""" + by_id = "/dev/serial/by-id" + if not os.path.isdir(by_id): + return dev_path -async def check_zigpy_connection(usb_path, radio_type, database_path): - """Test zigpy radio connection.""" - try: - radio = RADIO_TYPES[radio_type][ZHA_GW_RADIO]() - controller_application = RADIO_TYPES[radio_type][CONTROLLER] - except KeyError: - return False - try: - await radio.connect(usb_path, DEFAULT_BAUDRATE) - controller = controller_application(radio, database_path) - await asyncio.wait_for(controller.startup(auto_form=True), timeout=30) - await controller.shutdown() - except Exception: # pylint: disable=broad-except - return False - return True + for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()): + if os.path.realpath(path) == dev_path: + return path + return dev_path diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index b181b848f0475..0d8d95a196986 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -2,6 +2,8 @@ import enum import logging +from zigpy.config import CONF_DEVICE_PATH # noqa: F401 # pylint: disable=unused-import + from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.cover import DOMAIN as COVER from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER @@ -92,8 +94,10 @@ CONF_DATABASE = "database_path" CONF_DEVICE_CONFIG = "device_config" CONF_ENABLE_QUIRKS = "enable_quirks" +CONF_FLOWCONTROL = "flow_control" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" +CONF_ZIGPY = "zigpy_config" CONTROLLER = "controller" DATA_DEVICE_CONFIG = "zha_device_config" @@ -145,11 +149,11 @@ class RadioType(enum.Enum): """Possible options for radio type.""" - deconz = "deconz" ezsp = "ezsp" + deconz = "deconz" ti_cc = "ti_cc" - xbee = "xbee" zigate = "zigate" + xbee = "xbee" @classmethod def list(cls): @@ -258,7 +262,6 @@ def list(cls): ZHA_GW_MSG_LOG_ENTRY = "log_entry" ZHA_GW_MSG_LOG_OUTPUT = "log_output" ZHA_GW_MSG_RAW_INIT = "raw_device_initialized" -ZHA_GW_RADIO = "radio" ZHA_GW_RADIO_DESCRIPTION = "radio_description" EFFECT_BLINK = 0x00 diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index b8efdf873b133..c340ab99473f5 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -10,6 +10,7 @@ from typing import List, Optional from serial import SerialException +from zigpy.config import CONF_DEVICE import zigpy.device as zigpy_dev from homeassistant.components.system_log import LogEntry, _figure_out_source @@ -33,10 +34,9 @@ ATTR_NWK, ATTR_SIGNATURE, ATTR_TYPE, - CONF_BAUDRATE, CONF_DATABASE, CONF_RADIO_TYPE, - CONF_USB_PATH, + CONF_ZIGPY, CONTROLLER, DATA_ZHA, DATA_ZHA_BRIDGE_ID, @@ -52,7 +52,6 @@ DEBUG_LEVEL_ORIGINAL, DEBUG_LEVELS, DEBUG_RELAY_LOGGERS, - DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DOMAIN, SIGNAL_ADD_ENTITIES, @@ -74,7 +73,6 @@ ZHA_GW_MSG_LOG_ENTRY, ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_RAW_INIT, - ZHA_GW_RADIO, ZHA_GW_RADIO_DESCRIPTION, ) from .device import DeviceStatus, ZHADevice @@ -125,43 +123,35 @@ async def async_initialize(self): self.ha_device_registry = await get_dev_reg(self._hass) self.ha_entity_registry = await get_ent_reg(self._hass) - usb_path = self._config_entry.data.get(CONF_USB_PATH) - baudrate = self._config.get(CONF_BAUDRATE, DEFAULT_BAUDRATE) - radio_type = self._config_entry.data.get(CONF_RADIO_TYPE) + radio_type = self._config_entry.data[CONF_RADIO_TYPE] - radio_details = RADIO_TYPES[radio_type] - radio = radio_details[ZHA_GW_RADIO]() - self.radio_description = radio_details[ZHA_GW_RADIO_DESCRIPTION] - try: - await radio.connect(usb_path, baudrate) - except (SerialException, OSError) as exception: - _LOGGER.error("Couldn't open serial port for ZHA: %s", str(exception)) - raise ConfigEntryNotReady - - if CONF_DATABASE in self._config: - database = self._config[CONF_DATABASE] - else: - database = os.path.join(self._hass.config.config_dir, DEFAULT_DATABASE_NAME) + app_controller_cls = RADIO_TYPES[radio_type][CONTROLLER] + self.radio_description = RADIO_TYPES[radio_type][ZHA_GW_RADIO_DESCRIPTION] - self.application_controller = radio_details[CONTROLLER](radio, database) - apply_application_controller_patch(self) - self.application_controller.add_listener(self) - self.application_controller.groups.add_listener(self) + app_config = self._config.get(CONF_ZIGPY, {}) + database = self._config.get( + CONF_DATABASE, + os.path.join(self._hass.config.config_dir, DEFAULT_DATABASE_NAME), + ) + app_config[CONF_DATABASE] = database + app_config[CONF_DEVICE] = self._config_entry.data[CONF_DEVICE] + app_config = app_controller_cls.SCHEMA(app_config) try: - res = await self.application_controller.startup(auto_form=True) - if res is False: - await self.application_controller.shutdown() - raise ConfigEntryNotReady - except asyncio.TimeoutError as exception: + self.application_controller = await app_controller_cls.new( + app_config, auto_form=True, start_radio=True + ) + except (asyncio.TimeoutError, SerialException, OSError) as exception: _LOGGER.error( "Couldn't start %s coordinator", - radio_details[ZHA_GW_RADIO_DESCRIPTION], + self.radio_description, exc_info=exception, ) - radio.close() raise ConfigEntryNotReady from exception + apply_application_controller_patch(self) + self.application_controller.add_listener(self) + self.application_controller.groups.add_listener(self) self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str( self.application_controller.ieee diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 29b7134324566..9ce3f9df30d87 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -3,18 +3,13 @@ from typing import Callable, Dict, List, Set, Tuple, Union import attr -import bellows.ezsp import bellows.zigbee.application import zigpy.profiles.zha import zigpy.profiles.zll import zigpy.zcl as zcl -import zigpy_cc.api import zigpy_cc.zigbee.application -import zigpy_deconz.api import zigpy_deconz.zigbee.application -import zigpy_xbee.api import zigpy_xbee.zigbee.application -import zigpy_zigate.api import zigpy_zigate.zigbee.application from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR @@ -28,7 +23,7 @@ # importing channels updates registries from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import -from .const import CONTROLLER, ZHA_GW_RADIO, ZHA_GW_RADIO_DESCRIPTION, RadioType +from .const import CONTROLLER, ZHA_GW_RADIO_DESCRIPTION, RadioType from .decorators import CALLABLE_T, DictRegistry, SetRegistry from .typing import ChannelType @@ -131,27 +126,22 @@ RADIO_TYPES = { RadioType.deconz.name: { - ZHA_GW_RADIO: zigpy_deconz.api.Deconz, CONTROLLER: zigpy_deconz.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "Deconz", }, RadioType.ezsp.name: { - ZHA_GW_RADIO: bellows.ezsp.EZSP, CONTROLLER: bellows.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "EZSP", }, RadioType.ti_cc.name: { - ZHA_GW_RADIO: zigpy_cc.api.API, CONTROLLER: zigpy_cc.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "TI CC", }, RadioType.xbee.name: { - ZHA_GW_RADIO: zigpy_xbee.api.XBee, CONTROLLER: zigpy_xbee.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "XBee", }, RadioType.zigate.name: { - ZHA_GW_RADIO: zigpy_zigate.api.ZiGate, CONTROLLER: zigpy_zigate.zigbee.application.ControllerApplication, ZHA_GW_RADIO_DESCRIPTION: "ZiGate", }, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index e49f4f1407aee..51db75600bdc9 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,13 +4,14 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows-homeassistant==0.15.2", + "bellows==0.16.1", + "pyserial==3.4", "zha-quirks==0.0.38", - "zigpy-cc==0.3.1", - "zigpy-deconz==0.8.1", - "zigpy-homeassistant==0.19.0", - "zigpy-xbee-homeassistant==0.11.0", - "zigpy-zigate==0.5.1" + "zigpy-cc==0.4.2", + "zigpy-deconz==0.9.1", + "zigpy==0.20.1", + "zigpy-xbee==0.12.1", + "zigpy-zigate==0.6.1" ], "codeowners": ["@dmulcahey", "@adminiuga"] } diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 6906b5b3e8cf8..b26cebbd40a61 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -3,9 +3,21 @@ "step": { "user": { "title": "ZHA", + "data": { "path": "Serial Device Path" }, + "description": "Select serial port for Zigbee radio" + }, + "pick_radio": { + "data": { "radio_type": "Radio Type" }, + "title": "Radio Type", + "description": "Pick a type of your Zigbee radio" + }, + "port_config": { + "title": "Settings", + "description": "Enter port specific settings", "data": { - "radio_type": "Radio Type", - "usb_path": "[%key:common::config_flow::data::usb_path%]" + "path": "Serial device path", + "baudrate": "port speed", + "flow_control": "data flow control" } } }, diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json index d8db817507dd3..6a1eb4bac8ec1 100644 --- a/homeassistant/components/zha/translations/en.json +++ b/homeassistant/components/zha/translations/en.json @@ -7,11 +7,27 @@ "cannot_connect": "Unable to connect to ZHA device." }, "step": { + "pick_radio": { + "data": { + "radio_type": "Radio Type" + }, + "description": "Pick a type of your Zigbee radio", + "title": "Radio Type" + }, + "port_config": { + "data": { + "baudrate": "port speed", + "flow_control": "data flow control", + "path": "Serial device path" + }, + "description": "Enter port specific settings", + "title": "Settings" + }, "user": { "data": { - "radio_type": "Radio Type", - "usb_path": "USB Device Path" + "path": "Serial Device Path" }, + "description": "Select serial port for Zigbee radio", "title": "ZHA" } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 015e240d766e2..9189af4cdd31d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -120,6 +120,7 @@ "solarlog", "soma", "somfy", + "songpal", "sonos", "spotify", "starline", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index f46ba1611a8d1..52134888c0c94 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -70,6 +70,12 @@ "st": "urn:samsung.com:device:RemoteControlReceiver:1" } ], + "songpal": [ + { + "manufacturer": "Sony Corporation", + "st": "urn:schemas-sony-com:service:ScalarWebAPI:1" + } + ], "sonos": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index eb3092354f496..2c2d3437cd539 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ruamel.yaml==0.15.100 sqlalchemy==1.3.16 voluptuous-serialize==2.3.0 voluptuous==0.11.7 -zeroconf==0.26.0 +zeroconf==0.26.1 pycryptodome>=3.6.6 diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index abadb61316895..18bd1a4e3ca3f 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -124,27 +124,29 @@ def name(self, name: str) -> None: self.handler.set_name(name) # type: ignore +def log_exception(format_err: Callable[..., Any], *args: Any) -> None: + """Log an exception with additional context.""" + module = inspect.getmodule(inspect.stack()[1][0]) + if module is not None: + module_name = module.__name__ + else: + # If Python is unable to access the sources files, the call stack frame + # will be missing information, so let's guard. + # https://github.com/home-assistant/home-assistant/issues/24982 + module_name = __name__ + + # Do not print the wrapper in the traceback + frames = len(inspect.trace()) - 1 + exc_msg = traceback.format_exc(-frames) + friendly_msg = format_err(*args) + logging.getLogger(module_name).error("%s\n%s", friendly_msg, exc_msg) + + def catch_log_exception( func: Callable[..., Any], format_err: Callable[..., Any], *args: Any ) -> Callable[[], None]: """Decorate a callback to catch and log exceptions.""" - def log_exception(*args: Any) -> None: - module = inspect.getmodule(inspect.stack()[1][0]) - if module is not None: - module_name = module.__name__ - else: - # If Python is unable to access the sources files, the call stack frame - # will be missing information, so let's guard. - # https://github.com/home-assistant/home-assistant/issues/24982 - module_name = __name__ - - # Do not print the wrapper in the traceback - frames = len(inspect.trace()) - 1 - exc_msg = traceback.format_exc(-frames) - friendly_msg = format_err(*args) - logging.getLogger(module_name).error("%s\n%s", friendly_msg, exc_msg) - # Check for partials to properly determine if coroutine function check_func = func while isinstance(check_func, partial): @@ -159,7 +161,7 @@ async def async_wrapper(*args: Any) -> None: try: await func(*args) except Exception: # pylint: disable=broad-except - log_exception(*args) + log_exception(format_err, *args) wrapper_func = async_wrapper else: @@ -170,7 +172,7 @@ def wrapper(*args: Any) -> None: try: func(*args) except Exception: # pylint: disable=broad-except - log_exception(*args) + log_exception(format_err, *args) wrapper_func = wrapper return wrapper_func @@ -186,20 +188,7 @@ async def coro_wrapper(*args: Any) -> Any: try: return await target except Exception: # pylint: disable=broad-except - module = inspect.getmodule(inspect.stack()[1][0]) - if module is not None: - module_name = module.__name__ - else: - # If Python is unable to access the sources files, the frame - # will be missing information, so let's guard. - # https://github.com/home-assistant/home-assistant/issues/24982 - module_name = __name__ - - # Do not print the wrapper in the traceback - frames = len(inspect.trace()) - 1 - exc_msg = traceback.format_exc(-frames) - friendly_msg = format_err(*args) - logging.getLogger(module_name).error("%s\n%s", friendly_msg, exc_msg) + log_exception(format_err, *args) return None return coro_wrapper() diff --git a/requirements_all.txt b/requirements_all.txt index c3b5be78d8e96..4670a3243b665 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -215,7 +215,7 @@ aiopylgtv==0.3.3 aioswitcher==1.1.0 # homeassistant.components.unifi -aiounifi==20 +aiounifi==21 # homeassistant.components.wwlln aiowwlln==2.0.2 @@ -327,7 +327,7 @@ beautifulsoup4==4.9.0 beewi_smartclim==0.0.7 # homeassistant.components.zha -bellows-homeassistant==0.15.2 +bellows==0.16.1 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.5 @@ -1248,7 +1248,7 @@ pycsspeechtts==1.0.3 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==1.6.3 +pydaikin==2.0.0 # homeassistant.components.danfoss_air pydanfossair==0.1.0 @@ -1557,6 +1557,7 @@ pysensibo==1.0.3 pyserial-asyncio==0.4 # homeassistant.components.acer_projector +# homeassistant.components.zha pyserial==3.4 # homeassistant.components.sesame @@ -1698,7 +1699,7 @@ python-ripple-api==0.0.3 python-sochain-api==0.0.2 # homeassistant.components.songpal -python-songpal==0.11.2 +python-songpal==0.12 # homeassistant.components.synology_dsm python-synology==0.8.0 @@ -2205,7 +2206,7 @@ youtube_dl==2020.05.03 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.26.0 +zeroconf==0.26.1 # homeassistant.components.zha zha-quirks==0.0.38 @@ -2217,19 +2218,19 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-cc==0.3.1 +zigpy-cc==0.4.2 # homeassistant.components.zha -zigpy-deconz==0.8.1 +zigpy-deconz==0.9.1 # homeassistant.components.zha -zigpy-homeassistant==0.19.0 +zigpy-xbee==0.12.1 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.11.0 +zigpy-zigate==0.6.1 # homeassistant.components.zha -zigpy-zigate==0.5.1 +zigpy==0.20.1 # homeassistant.components.zoneminder zm-py==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7f3c6075da0a..7b83aa29a53b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -101,7 +101,7 @@ aiopylgtv==0.3.3 aioswitcher==1.1.0 # homeassistant.components.unifi -aiounifi==20 +aiounifi==21 # homeassistant.components.wwlln aiowwlln==2.0.2 @@ -141,7 +141,7 @@ axis==25 base36==0.1.1 # homeassistant.components.zha -bellows-homeassistant==0.15.2 +bellows==0.16.1 # homeassistant.components.blebox blebox_uniapi==1.3.2 @@ -518,7 +518,7 @@ pychromecast==5.0.0 pycoolmasternet==0.0.4 # homeassistant.components.daikin -pydaikin==1.6.3 +pydaikin==2.0.0 # homeassistant.components.deconz pydeconz==70 @@ -634,6 +634,10 @@ pyps4-2ndscreen==1.0.7 # homeassistant.components.qwikswitch pyqwikswitch==0.93 +# homeassistant.components.acer_projector +# homeassistant.components.zha +pyserial==3.4 + # homeassistant.components.signal_messenger pysignalclirestapi==0.3.4 @@ -673,6 +677,9 @@ python-nest==4.1.0 # homeassistant.components.zwave_mqtt python-openzwave-mqtt==1.0.1 +# homeassistant.components.songpal +python-songpal==0.12 + # homeassistant.components.synology_dsm python-synology==0.8.0 @@ -851,22 +858,22 @@ xmltodict==0.12.0 ya_ma==0.3.8 # homeassistant.components.zeroconf -zeroconf==0.26.0 +zeroconf==0.26.1 # homeassistant.components.zha zha-quirks==0.0.38 # homeassistant.components.zha -zigpy-cc==0.3.1 +zigpy-cc==0.4.2 # homeassistant.components.zha -zigpy-deconz==0.8.1 +zigpy-deconz==0.9.1 # homeassistant.components.zha -zigpy-homeassistant==0.19.0 +zigpy-xbee==0.12.1 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.11.0 +zigpy-zigate==0.6.1 # homeassistant.components.zha -zigpy-zigate==0.5.1 +zigpy==0.20.1 diff --git a/script/translations/migrate.py b/script/translations/migrate.py index 9b0fce4765ebc..c1b520f606f79 100644 --- a/script/translations/migrate.py +++ b/script/translations/migrate.py @@ -324,30 +324,98 @@ def find_frontend_states(): migrate_project_keys_translations(FRONTEND_PROJECT_ID, CORE_PROJECT_ID, to_migrate) +def clean_wled(): + """Clean WLED strings.""" + offending_translation = "Wykryto urzÄ…dzenie [%key:component::wled::title%]" + ignore_lang = ["pl"] + core_api = get_api(CORE_PROJECT_ID) + translations = core_api.keys_list( + { + "include_translations": 1, + "filter_keys": ",".join( + [ + "component::wled::config::flow_title", + "component::wled::config::step::user::description", + "component::wled::config::step::user::data::host", + "component::wled::config::step::zeroconf_confirm::description", + "component::wled::config::step::zeroconf_confirm::title", + "component::wled::config::error::connection_error", + "component::wled::config::abort::already_configured", + "component::wled::config::abort::connection_error", + ] + ), + } + ) + bad_data = {} + for translation in translations: + bad_key_data = [] + + for key_trans in translation["translations"]: + if ( + key_trans["translation"] == offending_translation + and key_trans["language_iso"] not in ignore_lang + ): + bad_key_data.append(key_trans["translation_id"]) + + if bad_key_data: + bad_data[translation["key_id"]] = bad_key_data + + pprint( + [ + { + "key_id": key_id, + "translations": [ + {"translation_id": trans_id, "translation": ""} + for trans_id in trans_ids + ], + } + for key_id, trans_ids in bad_data.items() + ] + ) + print(sum(len(val) for val in bad_data.values())) + + return + + core_api.keys_bulk_update( + [ + { + "key_id": key_id, + "translations": [ + {"translation_id": trans_id, "translation": ""} + for trans_id in trans_ids + ], + } + for key_id, trans_ids in bad_data.items() + ] + ) + + def run(): """Migrate translations.""" + clean_wled() + # Import new common keys - rename_keys( - CORE_PROJECT_ID, - { - "component::netatmo::config::step::pick_implementation::title": "common::config_flow::title::oauth2_pick_implementation", - "component::doorbird::config::step::user::data::username": "common::config_flow::data::username", - "component::doorbird::config::step::user::data::password": "common::config_flow::data::password", - "component::adguard::config::step::user::data::host": "common::config_flow::data::host", - "component::adguard::config::step::user::data::port": "common::config_flow::data::port", - "component::zha::config::step::user::data::usb_path": "common::config_flow::data::usb_path", - "component::smartthings::config::step::pat::data::access_token": "common::config_flow::data::access_token", - "component::airvisual::config::step::geography::data::api_key": "common::config_flow::data::api_key", - "component::doorbird::config::error::invalid_auth": "common::config_flow::error::invalid_auth", - "component::airvisual::config::error::invalid_api_key": "common::config_flow::error::invalid_api_key", - "component::tibber::config::error::invalid_access_token": "common::config_flow::error::invalid_access_token", - "component::doorbird::config::error::unknown": "common::config_flow::error::unknown", - "component::life360::config::abort::user_already_configured": "common::config_flow::abort::already_configured_account", - "component::xiaomi_miio::config::abort::already_configured": "common::config_flow::abort::already_configured_device", - "component::netatmo::config::abort::missing_configuration": "common::config_flow::abort::oauth2_missing_configuration", - "component::netatmo::config::abort::authorize_url_timeout": "common::config_flow::abort::oauth2_authorize_url_timeout", - }, - ) + # rename_keys( + # CORE_PROJECT_ID, + # { + # "component::netatmo::config::step::pick_implementation::title": "common::config_flow::title::oauth2_pick_implementation", + # "component::doorbird::config::step::user::data::username": "common::config_flow::data::username", + # "component::doorbird::config::step::user::data::password": "common::config_flow::data::password", + # "component::adguard::config::step::user::data::host": "common::config_flow::data::host", + # "component::adguard::config::step::user::data::port": "common::config_flow::data::port", + # "component::zha::config::step::user::data::usb_path": "common::config_flow::data::usb_path", + # "component::smartthings::config::step::pat::data::access_token": "common::config_flow::data::access_token", + # "component::airvisual::config::step::geography::data::api_key": "common::config_flow::data::api_key", + # "component::doorbird::config::error::invalid_auth": "common::config_flow::error::invalid_auth", + # "component::airvisual::config::error::invalid_api_key": "common::config_flow::error::invalid_api_key", + # "component::tibber::config::error::invalid_access_token": "common::config_flow::error::invalid_access_token", + # "component::doorbird::config::error::unknown": "common::config_flow::error::unknown", + # "component::life360::config::abort::user_already_configured": "common::config_flow::abort::already_configured_account", + # "component::xiaomi_miio::config::abort::already_configured": "common::config_flow::abort::already_configured_device", + # "component::netatmo::config::abort::missing_configuration": "common::config_flow::abort::oauth2_missing_configuration", + # "component::netatmo::config::abort::authorize_url_timeout": "common::config_flow::abort::oauth2_authorize_url_timeout", + # }, + # ) # find_frontend_states() diff --git a/tests/components/alert/test_reproduce_state.py b/tests/components/alert/test_reproduce_state.py new file mode 100644 index 0000000000000..2470106558c95 --- /dev/null +++ b/tests/components/alert/test_reproduce_state.py @@ -0,0 +1,50 @@ +"""Test reproduce state for Alert.""" +from homeassistant.core import State + +from tests.common import async_mock_service + + +async def test_reproducing_states(hass, caplog): + """Test reproducing Alert states.""" + hass.states.async_set("alert.entity_off", "off", {}) + hass.states.async_set("alert.entity_on", "on", {}) + + turn_on_calls = async_mock_service(hass, "alert", "turn_on") + turn_off_calls = async_mock_service(hass, "alert", "turn_off") + + # These calls should do nothing as entities already in desired state + await hass.helpers.state.async_reproduce_state( + [State("alert.entity_off", "off"), State("alert.entity_on", "on")] + ) + + assert len(turn_on_calls) == 0 + assert len(turn_off_calls) == 0 + + # Test invalid state is handled + await hass.helpers.state.async_reproduce_state( + [State("alert.entity_off", "not_supported")] + ) + + assert "not_supported" in caplog.text + assert len(turn_on_calls) == 0 + assert len(turn_off_calls) == 0 + + # Make sure correct services are called + await hass.helpers.state.async_reproduce_state( + [ + State("alert.entity_on", "off"), + State("alert.entity_off", "on"), + # Should not raise + State("alert.non_existing", "on"), + ] + ) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "alert" + assert turn_on_calls[0].data == { + "entity_id": "alert.entity_off", + } + + assert len(turn_off_calls) == 1 + assert turn_off_calls[0].domain == "alert" + assert turn_off_calls[0].data == {"entity_id": "alert.entity_on"} diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 996aef3db4b35..db2de0bb268ac 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.daikin.const import KEY_IP, KEY_MAC from homeassistant.const import CONF_HOST -from tests.async_mock import patch +from tests.async_mock import PropertyMock, patch from tests.common import MockConfigEntry MAC = "AABBCCDDEEFF" @@ -27,13 +27,13 @@ def init_config_flow(hass): def mock_daikin(): """Mock pydaikin.""" - async def mock_daikin_init(): + async def mock_daikin_factory(*args, **kwargs): """Mock the init function in pydaikin.""" - pass + return Appliance with patch("homeassistant.components.daikin.config_flow.Appliance") as Appliance: - Appliance().values.get.return_value = "AABBCCDDEEFF" - Appliance().init = mock_daikin_init + type(Appliance).mac = PropertyMock(return_value="AABBCCDDEEFF") + Appliance.factory.side_effect = mock_daikin_factory yield Appliance @@ -95,7 +95,7 @@ async def test_discovery(hass, mock_daikin): async def test_device_abort(hass, mock_daikin, s_effect, reason): """Test device abort.""" flow = init_config_flow(hass) - mock_daikin.side_effect = s_effect + mock_daikin.factory.side_effect = s_effect result = await flow.async_step_user({CONF_HOST: HOST}) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index db25428d17acd..a8b68b987152a 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -42,4 +42,4 @@ "Midnight": datetime(2020, 1, 1, 00, 43, 0), } -NOW = datetime(2020, 1, 1, 00, 00, 0) +NOW = datetime(2020, 1, 1, 00, 00, 0).astimezone() diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 147d7e6ef1d0d..3ee6a59136af6 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -1,5 +1,6 @@ """The tests for the Islamic prayer times sensor platform.""" from homeassistant.components import islamic_prayer_times +import homeassistant.util.dt as dt_util from . import NOW, PRAYER_TIMES, PRAYER_TIMES_TIMESTAMPS @@ -25,5 +26,5 @@ async def test_islamic_prayer_times_sensors(hass): hass.states.get( f"sensor.{prayer}_{islamic_prayer_times.const.SENSOR_TYPES[prayer]}" ).state - == PRAYER_TIMES_TIMESTAMPS[prayer].isoformat() + == PRAYER_TIMES_TIMESTAMPS[prayer].astimezone(dt_util.UTC).isoformat() ) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 254449cc129e0..6677122cf1088 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -2,6 +2,8 @@ import copy import json +import pytest + from homeassistant.components import alarm_control_panel from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, @@ -551,6 +553,7 @@ async def test_discovery_update_alarm(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 7c07abb8d350a..31acf187ad5dd 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -3,6 +3,8 @@ from datetime import datetime, timedelta import json +import pytest + from homeassistant.components import binary_sensor, mqtt from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import ( @@ -184,6 +186,43 @@ async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): assert state.state == STATE_OFF +async def test_invalid_sensor_value_via_mqtt_message(hass, mqtt_mock, caplog): + """Test the setting of the value via MQTT.""" + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + { + binary_sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "payload_on": "ON", + "payload_off": "OFF", + } + }, + ) + + state = hass.states.get("binary_sensor.test") + + assert state.state == STATE_OFF + + async_fire_mqtt_message(hass, "test-topic", "0N") + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + assert "No matching payload found for entity" in caplog.text + caplog.clear() + assert "No matching payload found for entity" not in caplog.text + + async_fire_mqtt_message(hass, "test-topic", "ON") + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + + async_fire_mqtt_message(hass, "test-topic", "0FF") + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + assert "No matching payload found for entity" in caplog.text + + async def test_setting_sensor_value_via_mqtt_message_and_template(hass, mqtt_mock): """Test the setting of the value via MQTT.""" assert await async_setup_component( @@ -548,6 +587,7 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( assert state.state == STATE_UNAVAILABLE +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer",' ' "off_delay": -1 }' diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index cefeda0409786..5747d876b5723 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -1,6 +1,8 @@ """The tests for mqtt camera component.""" import json +import pytest + from homeassistant.components import camera, mqtt from homeassistant.components.mqtt.discovery import async_start from homeassistant.setup import async_setup_component @@ -155,6 +157,7 @@ async def test_discovery_update_camera(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 9ae13a426b99c..8c3bfebed20d6 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -868,6 +868,7 @@ async def test_discovery_update_climate(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer",' ' "power_command_topic": "test_topic#" }' diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 4bc3bc1a9378e..bfd478a712ebb 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -252,7 +252,6 @@ async def help_test_unique_id(hass, domain, config): """Test unique id option only creates one entity per unique_id.""" await async_mock_mqtt_component(hass) assert await async_setup_component(hass, domain, config,) - async_fire_mqtt_message(hass, "test-topic", "payload") assert len(hass.states.async_entity_ids(domain)) == 1 diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 6de462b902026..201bb17c7a827 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1,4 +1,6 @@ """The tests for the MQTT cover platform.""" +import pytest + from homeassistant.components import cover from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -1831,6 +1833,7 @@ async def test_discovery_update_cover(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }' diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 7274badbed94d..f77ccca57ef79 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -134,6 +134,7 @@ async def test_get_non_existing_triggers(hass, device_reg, entity_reg, mqtt_mock assert_lists_same(triggers, []) +@pytest.mark.no_fail_on_log_exception async def test_discover_bad_triggers(hass, device_reg, entity_reg, mqtt_mock): """Test bad discovery message.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 6ef1c0aab8607..d8b6ce00ee6d5 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1,4 +1,6 @@ """Test MQTT fans.""" +import pytest + from homeassistant.components import fan from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -681,6 +683,7 @@ async def test_discovery_update_fan(hass, mqtt_mock, caplog): await help_test_discovery_update(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 28cca6a856a66..9ec5e09f27651 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -821,6 +821,7 @@ async def test_setup_fails_without_config(hass): assert not await async_setup_component(hass, mqtt.DOMAIN, {}) +@pytest.mark.no_fail_on_log_exception async def test_message_callback_exception_gets_logged(hass, caplog): """Test exception raised by message handler.""" await async_mock_mqtt_component(hass) diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 9e774bfdf1ed0..b402c23e29964 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -2,6 +2,8 @@ from copy import deepcopy import json +import pytest + from homeassistant.components import vacuum from homeassistant.components.mqtt import CONF_COMMAND_TOPIC from homeassistant.components.mqtt.vacuum import schema_legacy as mqttvacuum @@ -612,6 +614,7 @@ async def test_discovery_update_vacuum(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }' diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 3019b6b6ebd0b..9f5e65bd987ca 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -153,6 +153,8 @@ payload_off: "off" """ +import pytest + from homeassistant.components import light, mqtt from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON @@ -1420,6 +1422,7 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index f640d287a1b8a..23c83dabde7e7 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -89,6 +89,8 @@ """ import json +import pytest + from homeassistant.components import light from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -1155,6 +1157,7 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' @@ -1214,5 +1217,5 @@ async def test_entity_id_update_discovery_update(hass, mqtt_mock): async def test_entity_debug_info_message(hass, mqtt_mock): """Test MQTT debug info.""" await help_test_entity_debug_info_message( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG, payload='{"state":"ON"}' ) diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 29adc555bc554..611ba07c3d615 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -26,6 +26,8 @@ If your light doesn't support RGB feature, omit `(red|green|blue)_template`. """ +import pytest + from homeassistant.components import light from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -901,6 +903,7 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' @@ -961,6 +964,15 @@ async def test_entity_id_update_discovery_update(hass, mqtt_mock): async def test_entity_debug_info_message(hass, mqtt_mock): """Test MQTT debug info.""" - await help_test_entity_debug_info_message( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG - ) + config = { + light.DOMAIN: { + "platform": "mqtt", + "schema": "template", + "name": "test", + "command_topic": "test-topic", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "state_template": '{{ value.split(",")[0] }}', + } + } + await help_test_entity_debug_info_message(hass, mqtt_mock, light.DOMAIN, config) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index a6e0110215295..80ecbde3c4d13 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -1,4 +1,6 @@ """The tests for the MQTT lock platform.""" +import pytest + from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, @@ -366,6 +368,7 @@ async def test_discovery_update_lock(hass, mqtt_mock, caplog): await help_test_discovery_update(hass, mqtt_mock, caplog, LOCK_DOMAIN, data1, data2) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 1acc7656a8bb5..58c98f02484db 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -2,6 +2,8 @@ from datetime import datetime, timedelta import json +import pytest + from homeassistant.components import mqtt from homeassistant.components.mqtt.discovery import async_start import homeassistant.components.sensor as sensor @@ -361,6 +363,7 @@ async def test_discovery_update_sensor(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer",' ' "state_topic": "test_topic#" }' diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 367b9ceda8a8d..15429e6bc57a1 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -2,6 +2,8 @@ from copy import deepcopy import json +import pytest + from homeassistant.components import vacuum from homeassistant.components.mqtt import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC from homeassistant.components.mqtt.vacuum import CONF_SCHEMA, schema_state as mqttvacuum @@ -298,6 +300,7 @@ async def test_no_fan_vacuum(hass, mqtt_mock): assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 +@pytest.mark.no_fail_on_log_exception async def test_status_invalid_json(hass, mqtt_mock): """Test to make sure nothing breaks if the vacuum sends bad JSON.""" config = deepcopy(DEFAULT_CONFIG) @@ -406,6 +409,7 @@ async def test_discovery_update_vacuum(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic#"}' @@ -460,5 +464,5 @@ async def test_entity_id_update_discovery_update(hass, mqtt_mock): async def test_entity_debug_info_message(hass, mqtt_mock): """Test MQTT debug info.""" await help_test_entity_debug_info_message( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2, payload="{}" ) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index c34d32a3c9deb..b51812d2fa39c 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -314,6 +314,7 @@ async def test_discovery_update_switch(hass, mqtt_mock, caplog): ) +@pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 685e1e3fc4df2..c709c5e6f6789 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -36,10 +36,10 @@ ] -def setup_mock_onvif_device( - mock_device, with_h264=True, two_profiles=False, with_interfaces=True +def setup_mock_onvif_camera( + mock_onvif_camera, with_h264=True, two_profiles=False, with_interfaces=True ): - """Prepare mock ONVIF device.""" + """Prepare mock onvif.ONVIFCamera.""" devicemgmt = MagicMock() interface = MagicMock() @@ -61,10 +61,10 @@ def setup_mock_onvif_device( media_service.GetProfiles.return_value = Future() media_service.GetProfiles.return_value.set_result([profile1, profile2]) - mock_device.update_xaddrs.return_value = Future() - mock_device.update_xaddrs.return_value.set_result(True) - mock_device.create_devicemgmt_service = MagicMock(return_value=devicemgmt) - mock_device.create_media_service = MagicMock(return_value=media_service) + mock_onvif_camera.update_xaddrs.return_value = Future() + mock_onvif_camera.update_xaddrs.return_value.set_result(True) + mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt) + mock_onvif_camera.create_media_service = MagicMock(return_value=media_service) def mock_constructor( host, @@ -78,9 +78,9 @@ def mock_constructor( transport=None, ): """Fake the controller constructor.""" - return mock_device + return mock_onvif_camera - mock_device.side_effect = mock_constructor + mock_onvif_camera.side_effect = mock_constructor def setup_mock_discovery( @@ -114,16 +114,16 @@ def setup_mock_discovery( mock_discovery.return_value = services -def setup_mock_camera(mock_camera): - """Prepare mock HASS camera.""" - mock_camera.async_initialize.return_value = Future() - mock_camera.async_initialize.return_value.set_result(True) +def setup_mock_device(mock_device): + """Prepare mock ONVIFDevice.""" + mock_device.async_setup.return_value = Future() + mock_device.async_setup.return_value.set_result(True) def mock_constructor(hass, config): """Fake the controller constructor.""" - return mock_camera + return mock_device - mock_camera.side_effect = mock_constructor + mock_device.side_effect = mock_constructor async def setup_onvif_integration( @@ -137,7 +137,6 @@ async def setup_onvif_integration( config_flow.CONF_PORT: PORT, config_flow.CONF_USERNAME: USERNAME, config_flow.CONF_PASSWORD: PASSWORD, - config_flow.CONF_PROFILE: [0], } config_entry = MockConfigEntry( @@ -153,15 +152,15 @@ async def setup_onvif_integration( with patch( "homeassistant.components.onvif.config_flow.get_device" - ) as mock_device, patch( + ) as mock_onvif_camera, patch( "homeassistant.components.onvif.config_flow.wsdiscovery" ) as mock_discovery, patch( - "homeassistant.components.onvif.camera.ONVIFHassCamera" - ) as mock_camera: - setup_mock_onvif_device(mock_device, two_profiles=True) + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True) # no discovery mock_discovery.return_value = [] - setup_mock_camera(mock_camera) + setup_mock_device(mock_device) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return config_entry @@ -179,14 +178,14 @@ async def test_flow_discovered_devices(hass): with patch( "homeassistant.components.onvif.config_flow.get_device" - ) as mock_device, patch( + ) as mock_onvif_camera, patch( "homeassistant.components.onvif.config_flow.wsdiscovery" ) as mock_discovery, patch( - "homeassistant.components.onvif.camera.ONVIFHassCamera" - ) as mock_camera: - setup_mock_onvif_device(mock_device) + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera) setup_mock_discovery(mock_discovery) - setup_mock_camera(mock_camera) + setup_mock_device(mock_device) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -221,7 +220,6 @@ async def test_flow_discovered_devices(hass): config_flow.CONF_PORT: PORT, config_flow.CONF_USERNAME: USERNAME, config_flow.CONF_PASSWORD: PASSWORD, - config_flow.CONF_PROFILE: [0], } @@ -238,14 +236,14 @@ async def test_flow_discovered_devices_ignore_configured_manual_input(hass): with patch( "homeassistant.components.onvif.config_flow.get_device" - ) as mock_device, patch( + ) as mock_onvif_camera, patch( "homeassistant.components.onvif.config_flow.wsdiscovery" ) as mock_discovery, patch( - "homeassistant.components.onvif.camera.ONVIFHassCamera" - ) as mock_camera: - setup_mock_onvif_device(mock_device) + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera) setup_mock_discovery(mock_discovery, with_mac=True) - setup_mock_camera(mock_camera) + setup_mock_device(mock_device) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -289,14 +287,14 @@ async def test_flow_discovery_ignore_existing_and_abort(hass): with patch( "homeassistant.components.onvif.config_flow.get_device" - ) as mock_device, patch( + ) as mock_onvif_camera, patch( "homeassistant.components.onvif.config_flow.wsdiscovery" ) as mock_discovery, patch( - "homeassistant.components.onvif.camera.ONVIFHassCamera" - ) as mock_camera: - setup_mock_onvif_device(mock_device) + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera) setup_mock_discovery(mock_discovery, with_name=True, with_mac=True) - setup_mock_camera(mock_camera) + setup_mock_device(mock_device) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -341,15 +339,15 @@ async def test_flow_manual_entry(hass): with patch( "homeassistant.components.onvif.config_flow.get_device" - ) as mock_device, patch( + ) as mock_onvif_camera, patch( "homeassistant.components.onvif.config_flow.wsdiscovery" ) as mock_discovery, patch( - "homeassistant.components.onvif.camera.ONVIFHassCamera" - ) as mock_camera: - setup_mock_onvif_device(mock_device, two_profiles=True) + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True) # no discovery mock_discovery.return_value = [] - setup_mock_camera(mock_camera) + setup_mock_device(mock_device) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={}, @@ -388,14 +386,15 @@ async def test_flow_manual_entry(hass): config_flow.CONF_PORT: PORT, config_flow.CONF_USERNAME: USERNAME, config_flow.CONF_PASSWORD: PASSWORD, - config_flow.CONF_PROFILE: [0, 1], } async def test_flow_import_no_mac(hass): """Test that config flow fails when no MAC available.""" - with patch("homeassistant.components.onvif.config_flow.get_device") as mock_device: - setup_mock_onvif_device(mock_device, with_interfaces=False) + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera: + setup_mock_onvif_camera(mock_onvif_camera, with_interfaces=False) result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, @@ -406,7 +405,6 @@ async def test_flow_import_no_mac(hass): config_flow.CONF_PORT: PORT, config_flow.CONF_USERNAME: USERNAME, config_flow.CONF_PASSWORD: PASSWORD, - config_flow.CONF_PROFILE: [0], }, ) @@ -416,8 +414,10 @@ async def test_flow_import_no_mac(hass): async def test_flow_import_no_h264(hass): """Test that config flow fails when no MAC available.""" - with patch("homeassistant.components.onvif.config_flow.get_device") as mock_device: - setup_mock_onvif_device(mock_device, with_h264=False) + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera: + setup_mock_onvif_camera(mock_onvif_camera, with_h264=False) result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, @@ -437,9 +437,11 @@ async def test_flow_import_no_h264(hass): async def test_flow_import_onvif_api_error(hass): """Test that config flow fails when ONVIF API fails.""" - with patch("homeassistant.components.onvif.config_flow.get_device") as mock_device: - setup_mock_onvif_device(mock_device) - mock_device.create_devicemgmt_service = MagicMock( + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera: + setup_mock_onvif_camera(mock_onvif_camera) + mock_onvif_camera.create_devicemgmt_service = MagicMock( side_effect=ONVIFError("Could not get device mgmt service") ) @@ -461,9 +463,11 @@ async def test_flow_import_onvif_api_error(hass): async def test_flow_import_onvif_auth_error(hass): """Test that config flow fails when ONVIF API fails.""" - with patch("homeassistant.components.onvif.config_flow.get_device") as mock_device: - setup_mock_onvif_device(mock_device) - mock_device.create_devicemgmt_service = MagicMock( + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera: + setup_mock_onvif_camera(mock_onvif_camera) + mock_onvif_camera.create_devicemgmt_service = MagicMock( side_effect=Fault("Auth Error") ) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 2c0faadcc3b21..d901f7e62f0cd 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -688,3 +688,36 @@ async def test_manual_config_with_token(hass): assert result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machineIdentifier assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server._baseurl assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + + +async def test_setup_with_limited_credentials(hass): + """Test setup with a user with limited permissions.""" + mock_plex_server = MockPlexServer() + + entry = MockConfigEntry( + domain=DOMAIN, + data=DEFAULT_DATA, + options=DEFAULT_OPTIONS, + unique_id=DEFAULT_DATA["server_id"], + ) + + with patch( + "plexapi.server.PlexServer", return_value=mock_plex_server + ), patch.object( + mock_plex_server, "systemAccounts", side_effect=plexapi.exceptions.Unauthorized + ) as mock_accounts, patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ) as mock_listen: + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert mock_listen.called + assert mock_accounts.called + + plex_server = hass.data[DOMAIN][SERVERS][mock_plex_server.machineIdentifier] + assert len(plex_server.accounts) == 0 + assert plex_server.owner is None + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED diff --git a/tests/components/songpal/__init__.py b/tests/components/songpal/__init__.py new file mode 100644 index 0000000000000..24729c1e8ccaf --- /dev/null +++ b/tests/components/songpal/__init__.py @@ -0,0 +1 @@ +"""Test the songpal integration.""" diff --git a/tests/components/songpal/test_config_flow.py b/tests/components/songpal/test_config_flow.py new file mode 100644 index 0000000000000..c83faf42699f6 --- /dev/null +++ b/tests/components/songpal/test_config_flow.py @@ -0,0 +1,249 @@ +"""Test the songpal config flow.""" +import copy + +from asynctest import MagicMock, patch +from songpal import SongpalException +from songpal.containers import InterfaceInfo + +from homeassistant.components import ssdp +from homeassistant.components.songpal.const import CONF_ENDPOINT, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + +UDN = "uuid:1234" +FRIENDLY_NAME = "friendly name" +HOST = "0.0.0.0" +ENDPOINT = f"http://{HOST}:10000/sony" +MODEL = "model" + +SSDP_DATA = { + ssdp.ATTR_UPNP_UDN: UDN, + ssdp.ATTR_UPNP_FRIENDLY_NAME: FRIENDLY_NAME, + ssdp.ATTR_SSDP_LOCATION: f"http://{HOST}:52323/dmr.xml", + "X_ScalarWebAPI_DeviceInfo": { + "X_ScalarWebAPI_BaseURL": ENDPOINT, + "X_ScalarWebAPI_ServiceList": { + "X_ScalarWebAPI_ServiceType": ["guide", "system", "audio", "avContent"], + }, + }, +} + +CONF_DATA = { + CONF_NAME: FRIENDLY_NAME, + CONF_ENDPOINT: ENDPOINT, +} + + +async def _async_return_value(): + pass + + +def _get_supported_methods(throw_exception): + def get_supported_methods(): + if throw_exception: + raise SongpalException("Unable to do POST request: ") + return _async_return_value() + + return get_supported_methods + + +async def _get_interface_information(): + return InterfaceInfo( + productName="product name", + modelName=MODEL, + productCategory="product category", + interfaceVersion="interface version", + serverName="server name", + ) + + +def _create_mocked_device(throw_exception=False): + mocked_device = MagicMock() + type(mocked_device).get_supported_methods = MagicMock( + side_effect=_get_supported_methods(throw_exception) + ) + type(mocked_device).get_interface_information = MagicMock( + side_effect=_get_interface_information + ) + return mocked_device + + +def _patch_config_flow_device(mocked_device): + return patch( + "homeassistant.components.songpal.config_flow.Device", + return_value=mocked_device, + ) + + +def _flow_next(hass, flow_id): + return next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == flow_id + ) + + +async def test_flow_ssdp(hass): + """Test working ssdp flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DATA, + ) + assert result["type"] == "form" + assert result["step_id"] == "init" + assert result["description_placeholders"] == { + CONF_NAME: FRIENDLY_NAME, + CONF_HOST: HOST, + } + flow = _flow_next(hass, result["flow_id"]) + assert flow["context"]["unique_id"] == UDN + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == FRIENDLY_NAME + assert result["data"] == CONF_DATA + + +async def test_flow_user(hass): + """Test working user initialized flow.""" + mocked_device = _create_mocked_device() + + with _patch_config_flow_device(mocked_device): + 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 result["errors"] is None + _flow_next(hass, result["flow_id"]) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENDPOINT: ENDPOINT}, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == MODEL + assert result["data"] == { + CONF_NAME: MODEL, + CONF_ENDPOINT: ENDPOINT, + } + + mocked_device.get_supported_methods.assert_called_once() + mocked_device.get_interface_information.assert_called_once() + + +async def test_flow_import(hass): + """Test working import flow.""" + mocked_device = _create_mocked_device() + + with _patch_config_flow_device(mocked_device): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == FRIENDLY_NAME + assert result["data"] == CONF_DATA + + mocked_device.get_supported_methods.assert_called_once() + mocked_device.get_interface_information.assert_not_called() + + +def _create_mock_config_entry(hass): + MockConfigEntry(domain=DOMAIN, unique_id="uuid:0000", data=CONF_DATA,).add_to_hass( + hass + ) + + +async def test_ssdp_bravia(hass): + """Test discovering a bravia TV.""" + ssdp_data = copy.deepcopy(SSDP_DATA) + ssdp_data["X_ScalarWebAPI_DeviceInfo"]["X_ScalarWebAPI_ServiceList"][ + "X_ScalarWebAPI_ServiceType" + ].append("videoScreen") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=ssdp_data, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "not_songpal_device" + + +async def test_sddp_exist(hass): + """Test discovering existed device.""" + _create_mock_config_entry(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DATA, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_exist(hass): + """Test user adding existed device.""" + mocked_device = _create_mocked_device() + _create_mock_config_entry(hass) + + with _patch_config_flow_device(mocked_device): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + mocked_device.get_supported_methods.assert_called_once() + mocked_device.get_interface_information.assert_called_once() + + +async def test_import_exist(hass): + """Test importing existed device.""" + mocked_device = _create_mocked_device() + _create_mock_config_entry(hass) + + with _patch_config_flow_device(mocked_device): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + mocked_device.get_supported_methods.assert_called_once() + mocked_device.get_interface_information.assert_not_called() + + +async def test_user_invalid(hass): + """Test using adding invalid config.""" + mocked_device = _create_mocked_device(True) + _create_mock_config_entry(hass) + + with _patch_config_flow_device(mocked_device): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection"} + + mocked_device.get_supported_methods.assert_called_once() + mocked_device.get_interface_information.assert_not_called() + + +async def test_import_invalid(hass): + """Test importing invalid config.""" + mocked_device = _create_mocked_device(True) + _create_mock_config_entry(hass) + + with _patch_config_flow_device(mocked_device): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=CONF_DATA + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "connection" + + mocked_device.get_supported_methods.assert_called_once() + mocked_device.get_interface_information.assert_not_called() diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index 67c3cab659e43..41bd42a98b341 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -4,8 +4,8 @@ from tests.async_mock import patch -@pytest.fixture(name="dsm_bypass_setup", autouse=True) -def dsm_bypass_setup_fixture(): +@pytest.fixture(name="bypass_setup", autouse=True) +def bypass_setup_fixture(): """Mock component setup.""" with patch( "homeassistant.components.synology_dsm.async_setup_entry", return_value=True diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 66fc943920efb..a339f01558461 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -17,6 +17,7 @@ CONF_VOLUMES, DEFAULT_PORT, DEFAULT_PORT_SSL, + DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN, ) @@ -27,6 +28,7 @@ CONF_MAC, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, ) @@ -393,3 +395,40 @@ async def test_form_ssdp(hass: HomeAssistantType, service: MagicMock): assert result["data"].get("device_token") is None assert result["data"].get(CONF_DISKS) is None assert result["data"].get(CONF_VOLUMES) is None + + +async def test_options_flow(hass: HomeAssistantType, service: MagicMock): + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS, + }, + unique_id=SERIAL, + ) + config_entry.add_to_hass(hass) + + assert config_entry.options == {} + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # Scan interval + # Default + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options[CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL + + # Manual + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: 2}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options[CONF_SCAN_INTERVAL] == 2 diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 16777e5d9a9c2..75cce3921569f 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -31,6 +31,29 @@ CLIENTS = [{"mac": "00:00:00:00:00:01"}] +DEVICES = [ + { + "board_rev": 21, + "device_id": "mock-id", + "ip": "10.0.1.1", + "last_seen": 0, + "mac": "00:00:00:00:01:01", + "model": "U7PG2", + "name": "access_point", + "state": 1, + "type": "uap", + "version": "4.0.80.10875", + "wlan_overrides": [ + { + "name": "SSID 3", + "radio": "na", + "radio_name": "wifi1", + "wlan_id": "012345678910111213141516", + }, + ], + } +] + WLANS = [ {"name": "SSID 1"}, {"name": "SSID 2", "name_combine_enabled": False, "name_combine_suffix": "_IOT"}, @@ -317,7 +340,7 @@ async def test_flow_fails_unknown_problem(hass, aioclient_mock): async def test_advanced_option_flow(hass): """Test advanced config flow options.""" controller = await setup_unifi_integration( - hass, clients_response=CLIENTS, wlans_response=WLANS + hass, clients_response=CLIENTS, devices_response=DEVICES, wlans_response=WLANS ) result = await hass.config_entries.options.async_init( @@ -333,7 +356,7 @@ async def test_advanced_option_flow(hass): CONF_TRACK_CLIENTS: False, CONF_TRACK_WIRED_CLIENTS: False, CONF_TRACK_DEVICES: False, - CONF_SSID_FILTER: ["SSID 1", "SSID 2_IOT"], + CONF_SSID_FILTER: ["SSID 1", "SSID 2_IOT", "SSID 3"], CONF_DETECTION_TIME: 100, }, ) @@ -358,7 +381,7 @@ async def test_advanced_option_flow(hass): CONF_TRACK_CLIENTS: False, CONF_TRACK_WIRED_CLIENTS: False, CONF_TRACK_DEVICES: False, - CONF_SSID_FILTER: ["SSID 1", "SSID 2_IOT"], + CONF_SSID_FILTER: ["SSID 1", "SSID 2_IOT", "SSID 3"], CONF_DETECTION_TIME: 100, CONF_IGNORE_WIRED_BUG: False, CONF_POE_CLIENTS: False, diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 9f37c71468dcf..ab23cd2222a9e 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -84,6 +84,7 @@ "mac": "00:00:00:00:01:01", "model": "US16P150", "name": "device_1", + "next_interval": 20, "overheating": True, "state": 1, "type": "usw", @@ -94,10 +95,11 @@ "board_rev": 3, "device_id": "mock-id", "has_fan": True, - "ip": "10.0.1.1", - "mac": "00:00:00:00:01:01", + "ip": "10.0.1.2", + "mac": "00:00:00:00:01:02", "model": "US16P150", - "name": "device_1", + "name": "device_2", + "next_interval": 20, "state": 0, "type": "usw", "version": "4.0.42.10433", @@ -206,7 +208,7 @@ async def test_tracked_wireless_clients(hass): # test wired bug -async def test_tracked_devices(hass): +async def test_tracked_clients(hass): """Test the update_items function with some clients.""" client_4_copy = copy(CLIENT_4) client_4_copy["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) @@ -216,9 +218,9 @@ async def test_tracked_devices(hass): options={CONF_SSID_FILTER: ["ssid"]}, clients_response=[CLIENT_1, CLIENT_2, CLIENT_3, CLIENT_5, client_4_copy], devices_response=[DEVICE_1, DEVICE_2], - known_wireless_clients=(CLIENT_4["mac"],), + known_wireless_clients=([CLIENT_4["mac"]]), ) - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 5 + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 6 client_1 = hass.states.get("device_tracker.client_1") assert client_1 is not None @@ -242,26 +244,54 @@ async def test_tracked_devices(hass): assert client_5 is not None assert client_5.state == "not_home" - device_1 = hass.states.get("device_tracker.device_1") - assert device_1 is not None - assert device_1.state == "not_home" - # State change signalling works client_1_copy = copy(CLIENT_1) client_1_copy["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) event = {"meta": {"message": MESSAGE_CLIENT}, "data": [client_1_copy]} controller.api.message_handler(event) + + client_1 = hass.states.get("device_tracker.client_1") + assert client_1.state == "home" + + +async def test_tracked_devices(hass): + """Test the update_items function with some devices.""" + controller = await setup_unifi_integration( + hass, devices_response=[DEVICE_1, DEVICE_2], + ) + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 + + device_1 = hass.states.get("device_tracker.device_1") + assert device_1 + assert device_1.state == "home" + + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 + assert device_2.state == "not_home" + + # State change signalling work device_1_copy = copy(DEVICE_1) - device_1_copy["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) + device_1_copy["next_interval"] = 20 event = {"meta": {"message": MESSAGE_DEVICE}, "data": [device_1_copy]} controller.api.message_handler(event) + device_2_copy = copy(DEVICE_2) + device_2_copy["next_interval"] = 50 + event = {"meta": {"message": MESSAGE_DEVICE}, "data": [device_2_copy]} + controller.api.message_handler(event) await hass.async_block_till_done() - client_1 = hass.states.get("device_tracker.client_1") - assert client_1.state == "home" - device_1 = hass.states.get("device_tracker.device_1") assert device_1.state == "home" + device_2 = hass.states.get("device_tracker.device_2") + assert device_2.state == "home" + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=40)) + await hass.async_block_till_done() + + device_1 = hass.states.get("device_tracker.device_1") + assert device_1.state == "not_home" + device_2 = hass.states.get("device_tracker.device_2") + assert device_2.state == "home" # Disabled device is unavailable device_1_copy = copy(DEVICE_1) @@ -330,7 +360,7 @@ async def test_controller_state_change(hass): assert client_1.state == "not_home" device_1 = hass.states.get("device_tracker.device_1") - assert device_1.state == "not_home" + assert device_1.state == "home" async def test_option_track_clients(hass): @@ -648,7 +678,7 @@ async def test_dont_track_clients(hass): device_1 = hass.states.get("device_tracker.device_1") assert device_1 is not None - assert device_1.state == "not_home" + assert device_1.state == "home" async def test_dont_track_devices(hass): diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index a8e22e74bc410..eaa4c5ee61a5a 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,8 +1,9 @@ """Test Zeroconf component setup process.""" import pytest -from zeroconf import ServiceInfo, ServiceStateChange +from zeroconf import InterfaceChoice, ServiceInfo, ServiceStateChange from homeassistant.components import zeroconf +from homeassistant.components.zeroconf import CONF_DEFAULT_INTERFACE from homeassistant.generated import zeroconf as zc_gen from homeassistant.setup import async_setup_component @@ -78,6 +79,19 @@ async def test_setup(hass, mock_zeroconf): assert len(mock_config_flow.mock_calls) == expected_flow_calls +async def test_setup_with_default_interface(hass, mock_zeroconf): + """Test default interface config.""" + with patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "ServiceBrowser", side_effect=service_update_mock + ): + mock_zeroconf.get_service_info.side_effect = get_service_info_mock + assert await async_setup_component( + hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_DEFAULT_INTERFACE: True}} + ) + + assert mock_zeroconf.called_with(interface_choice=InterfaceChoice.Default) + + async def test_homekit_match_partial_space(hass, mock_zeroconf): """Test configured options for a device are loaded via config entry.""" with patch.dict( diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index e737f99016351..f11f5ac936220 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -4,6 +4,7 @@ import pytest import zigpy from zigpy.application import ControllerApplication +import zigpy.config import zigpy.group import zigpy.types @@ -49,12 +50,11 @@ def zigpy_radio(): async def config_entry_fixture(hass): """Fixture representing a config entry.""" entry = MockConfigEntry( - version=1, + version=2, domain=zha_const.DOMAIN, data={ - zha_const.CONF_BAUDRATE: zha_const.DEFAULT_BAUDRATE, + zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB0"}, zha_const.CONF_RADIO_TYPE: "MockRadio", - zha_const.CONF_USB_PATH: "/dev/ttyUSB0", }, ) entry.add_to_hass(hass) @@ -65,10 +65,13 @@ async def config_entry_fixture(hass): def setup_zha(hass, config_entry, zigpy_app_controller, zigpy_radio): """Set up ZHA component.""" zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} + app_ctrl = mock.MagicMock() + app_ctrl.new = tests.async_mock.AsyncMock(return_value=zigpy_app_controller) + app_ctrl.SCHEMA = zigpy.config.CONFIG_SCHEMA + app_ctrl.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE radio_details = { - zha_const.ZHA_GW_RADIO: mock.MagicMock(return_value=zigpy_radio), - zha_const.CONTROLLER: mock.MagicMock(return_value=zigpy_app_controller), + zha_const.CONTROLLER: app_ctrl, zha_const.ZHA_GW_RADIO_DESCRIPTION: "mock radio", } diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 91d2ef75aa534..7ba566e33f557 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,125 +1,237 @@ """Tests for ZHA config flow.""" -from unittest import mock +import os + +import pytest +import serial.tools.list_ports +import zigpy.config + +from homeassistant import setup from homeassistant.components.zha import config_flow -from homeassistant.components.zha.core.const import CONTROLLER, DOMAIN, ZHA_GW_RADIO -import homeassistant.components.zha.core.registries +from homeassistant.components.zha.core.const import CONF_RADIO_TYPE, CONTROLLER, DOMAIN +from homeassistant.components.zha.core.registries import RADIO_TYPES +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_SOURCE +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM -import tests.async_mock +from tests.async_mock import AsyncMock, MagicMock, patch, sentinel from tests.common import MockConfigEntry -async def test_user_flow(hass): - """Test that config flow works.""" - flow = config_flow.ZhaFlowHandler() - flow.hass = hass +def com_port(): + """Mock of a serial port.""" + port = serial.tools.list_ports_common.ListPortInfo() + port.serial_number = "1234" + port.manufacturer = "Virtual serial port" + port.device = "/dev/ttyUSB1234" + port.description = "Some serial port" - with tests.async_mock.patch( - "homeassistant.components.zha.config_flow.check_zigpy_connection", - return_value=False, - ): - result = await flow.async_step_user( - user_input={"usb_path": "/dev/ttyUSB1", "radio_type": "ezsp"} - ) + return port - assert result["errors"] == {"base": "cannot_connect"} - with tests.async_mock.patch( - "homeassistant.components.zha.config_flow.check_zigpy_connection", - return_value=True, - ): - result = await flow.async_step_user( - user_input={"usb_path": "/dev/ttyUSB1", "radio_type": "ezsp"} - ) +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.zha.config_flow.detect_radios", + return_value={CONF_RADIO_TYPE: "test_radio"}, +) +async def test_user_flow(detect_mock, hass): + """Test user flow -- radio detected.""" - assert result["type"] == "create_entry" - assert result["title"] == "/dev/ttyUSB1" - assert result["data"] == {"usb_path": "/dev/ttyUSB1", "radio_type": "ezsp"} + port = com_port() + port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={zigpy.config.CONF_DEVICE_PATH: port_select}, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"].startswith(port.description) + assert result["data"] == {CONF_RADIO_TYPE: "test_radio"} + assert detect_mock.await_count == 1 + assert detect_mock.await_args[0][0] == port.device + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +@patch( + "homeassistant.components.zha.config_flow.detect_radios", return_value=None, +) +async def test_user_flow_not_detected(detect_mock, hass): + """Test user flow, radio not detected.""" + + port = com_port() + port_select = f"{port}, s/n: {port.serial_number} - {port.manufacturer}" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={zigpy.config.CONF_DEVICE_PATH: port_select}, + ) -async def test_user_flow_existing_config_entry(hass): - """Test if config entry already exists.""" - MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass) - flow = config_flow.ZhaFlowHandler() - flow.hass = hass + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "pick_radio" + assert detect_mock.await_count == 1 + assert detect_mock.await_args[0][0] == port.device - result = await flow.async_step_user() - assert result["type"] == "abort" +async def test_user_flow_show_form(hass): + """Test user step form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" -async def test_import_flow(hass): - """Test import from configuration.yaml .""" - flow = config_flow.ZhaFlowHandler() - flow.hass = hass +async def test_user_flow_manual(hass): + """Test user flow manual entry.""" - result = await flow.async_step_import( - {"usb_path": "/dev/ttyUSB1", "radio_type": "xbee"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={zigpy.config.CONF_DEVICE_PATH: config_flow.CONF_MANUAL_PATH}, ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "pick_radio" - assert result["type"] == "create_entry" - assert result["title"] == "/dev/ttyUSB1" - assert result["data"] == {"usb_path": "/dev/ttyUSB1", "radio_type": "xbee"} +async def test_pick_radio_flow(hass): + """Test radio picker.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: "pick_radio"}, data={CONF_RADIO_TYPE: "ezsp"} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "port_config" -async def test_import_flow_existing_config_entry(hass): - """Test import from configuration.yaml .""" - MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass) - flow = config_flow.ZhaFlowHandler() - flow.hass = hass - result = await flow.async_step_import( - {"usb_path": "/dev/ttyUSB1", "radio_type": "xbee"} +async def test_user_flow_existing_config_entry(hass): + """Test if config entry already exists.""" + MockConfigEntry(domain=DOMAIN, data={"usb_path": "/dev/ttyUSB1"}).add_to_hass(hass) + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) assert result["type"] == "abort" -async def test_check_zigpy_connection(): - """Test config flow validator.""" +async def test_probe_radios(hass): + """Test detect radios.""" + app_ctrl_cls = MagicMock() + app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE + app_ctrl_cls.probe = AsyncMock(side_effect=(True, False)) - mock_radio = tests.async_mock.MagicMock() - mock_radio.connect = tests.async_mock.AsyncMock() - radio_cls = tests.async_mock.MagicMock(return_value=mock_radio) + with patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): + res = await config_flow.detect_radios("/dev/null") + assert app_ctrl_cls.probe.await_count == 1 + assert res[CONF_RADIO_TYPE] == "ezsp" + assert zigpy.config.CONF_DEVICE in res + assert ( + res[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] == "/dev/null" + ) - bad_radio = tests.async_mock.MagicMock() - bad_radio.connect = tests.async_mock.AsyncMock(side_effect=Exception) - bad_radio_cls = tests.async_mock.MagicMock(return_value=bad_radio) + res = await config_flow.detect_radios("/dev/null") + assert res is None - mock_ctrl = tests.async_mock.MagicMock() - mock_ctrl.startup = tests.async_mock.AsyncMock() - mock_ctrl.shutdown = tests.async_mock.AsyncMock() - ctrl_cls = tests.async_mock.MagicMock(return_value=mock_ctrl) - new_radios = { - mock.sentinel.radio: {ZHA_GW_RADIO: radio_cls, CONTROLLER: ctrl_cls}, - mock.sentinel.bad_radio: {ZHA_GW_RADIO: bad_radio_cls, CONTROLLER: ctrl_cls}, - } - with mock.patch.dict( - homeassistant.components.zha.core.registries.RADIO_TYPES, new_radios, clear=True - ): - assert not await config_flow.check_zigpy_connection( - mock.sentinel.usb_path, mock.sentinel.unk_radio, mock.sentinel.zigbee_db +async def test_user_port_config_fail(hass): + """Test port config flow.""" + app_ctrl_cls = MagicMock() + app_ctrl_cls.SCHEMA_DEVICE = zigpy.config.SCHEMA_DEVICE + app_ctrl_cls.probe = AsyncMock(return_value=False) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: "pick_radio"}, data={CONF_RADIO_TYPE: "ezsp"} + ) + + with patch.dict(config_flow.RADIO_TYPES, {"ezsp": {CONTROLLER: app_ctrl_cls}}): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, ) - assert mock_radio.connect.call_count == 0 - assert bad_radio.connect.call_count == 0 - assert mock_ctrl.startup.call_count == 0 - assert mock_ctrl.shutdown.call_count == 0 - - # unsuccessful radio connect - assert not await config_flow.check_zigpy_connection( - mock.sentinel.usb_path, mock.sentinel.bad_radio, mock.sentinel.zigbee_db + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "port_config" + assert result["errors"]["base"] == "cannot_connect" + + +@pytest.mark.parametrize( + "radio_type, orig_ctrl_cls", + ((name, r[CONTROLLER]) for name, r in RADIO_TYPES.items()), +) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_user_port_config(hass, radio_type, orig_ctrl_cls): + """Test port config.""" + app_ctrl_cls = MagicMock() + app_ctrl_cls.SCHEMA_DEVICE = orig_ctrl_cls.SCHEMA_DEVICE + app_ctrl_cls.probe = AsyncMock(return_value=True) + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: "pick_radio"}, data={CONF_RADIO_TYPE: radio_type} + ) + + with patch.dict( + config_flow.RADIO_TYPES, + {radio_type: {CONTROLLER: app_ctrl_cls, "radio_description": "radio"}}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"}, ) - assert mock_radio.connect.call_count == 0 - assert bad_radio.connect.call_count == 1 - assert mock_ctrl.startup.call_count == 0 - assert mock_ctrl.shutdown.call_count == 0 - - # successful radio connect - assert await config_flow.check_zigpy_connection( - mock.sentinel.usb_path, mock.sentinel.radio, mock.sentinel.zigbee_db + + assert result["type"] == "create_entry" + assert result["title"].startswith("/dev/ttyUSB33") + assert ( + result["data"][zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH] + == "/dev/ttyUSB33" ) - assert mock_radio.connect.call_count == 1 - assert bad_radio.connect.call_count == 1 - assert mock_ctrl.startup.call_count == 1 - assert mock_ctrl.shutdown.call_count == 1 + assert result["data"][CONF_RADIO_TYPE] == radio_type + + +def test_get_serial_by_id_no_dir(): + """Test serial by id conversion if there's no /dev/serial/by-id.""" + p1 = patch("os.path.isdir", MagicMock(return_value=False)) + p2 = patch("os.scandir") + with p1 as is_dir_mock, p2 as scan_mock: + res = config_flow.get_serial_by_id(sentinel.path) + assert res is sentinel.path + assert is_dir_mock.call_count == 1 + assert scan_mock.call_count == 0 + + +def test_get_serial_by_id(): + """Test serial by id conversion.""" + p1 = patch("os.path.isdir", MagicMock(return_value=True)) + p2 = patch("os.scandir") + + def _realpath(path): + if path is sentinel.matched_link: + return sentinel.path + return sentinel.serial_link_path + + p3 = patch("os.path.realpath", side_effect=_realpath) + with p1 as is_dir_mock, p2 as scan_mock, p3: + res = config_flow.get_serial_by_id(sentinel.path) + assert res is sentinel.path + assert is_dir_mock.call_count == 1 + assert scan_mock.call_count == 1 + + entry1 = MagicMock(spec_set=os.DirEntry) + entry1.is_symlink.return_value = True + entry1.path = sentinel.some_path + + entry2 = MagicMock(spec_set=os.DirEntry) + entry2.is_symlink.return_value = False + entry2.path = sentinel.other_path + + entry3 = MagicMock(spec_set=os.DirEntry) + entry3.is_symlink.return_value = True + entry3.path = sentinel.matched_link + + scan_mock.return_value = [entry1, entry2, entry3] + res = config_flow.get_serial_by_id(sentinel.path) + assert res is sentinel.matched_link + assert is_dir_mock.call_count == 2 + assert scan_mock.call_count == 2 diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py new file mode 100644 index 0000000000000..963cea33bdd89 --- /dev/null +++ b/tests/components/zha/test_init.py @@ -0,0 +1,72 @@ +"""Tests for ZHA integration init.""" + +import pytest +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH + +from homeassistant.components.zha.core.const import ( + CONF_BAUDRATE, + CONF_RADIO_TYPE, + CONF_USB_PATH, + DOMAIN, +) +from homeassistant.setup import async_setup_component + +from tests.async_mock import AsyncMock, patch +from tests.common import MockConfigEntry + +DATA_RADIO_TYPE = "deconz" +DATA_PORT_PATH = "/dev/serial/by-id/FTDI_USB__-__Serial_Cable_12345678-if00-port0" + + +@pytest.fixture +def config_entry_v1(hass): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_RADIO_TYPE: DATA_RADIO_TYPE, CONF_USB_PATH: DATA_PORT_PATH}, + version=1, + ) + + +@pytest.mark.parametrize("config", ({}, {DOMAIN: {}})) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_from_v1_no_baudrate(hass, config_entry_v1, config): + """Test migration of config entry from v1.""" + config_entry_v1.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, config) + + assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert CONF_DEVICE in config_entry_v1.data + assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH + assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] + assert CONF_USB_PATH not in config_entry_v1.data + assert config_entry_v1.version == 2 + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_from_v1_with_baudrate(hass, config_entry_v1): + """Test migration of config entry from v1 with baudrate in config.""" + config_entry_v1.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_BAUDRATE: 115200}}) + + assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert CONF_DEVICE in config_entry_v1.data + assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH + assert CONF_USB_PATH not in config_entry_v1.data + assert CONF_BAUDRATE in config_entry_v1.data[CONF_DEVICE] + assert config_entry_v1.data[CONF_DEVICE][CONF_BAUDRATE] == 115200 + assert config_entry_v1.version == 2 + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_from_v1_wrong_baudrate(hass, config_entry_v1): + """Test migration of config entry from v1 with wrong baudrate.""" + config_entry_v1.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_BAUDRATE: 115222}}) + + assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert CONF_DEVICE in config_entry_v1.data + assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH + assert CONF_USB_PATH not in config_entry_v1.data + assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] + assert config_entry_v1.version == 2 diff --git a/tests/conftest.py b/tests/conftest.py index c7885c6125e8e..efaf1ff7dffb5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ from homeassistant.util import location from tests.async_mock import patch +from tests.ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS pytest.register_assert_rewrite("tests.common") @@ -36,6 +37,13 @@ logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) +def pytest_configure(config): + """Register marker for tests that log exceptions.""" + config.addinivalue_line( + "markers", "no_fail_on_log_exception: mark test to not fail on logged exception" + ) + + def check_real(func): """Force a function to require a keyword _test_real to be passed in.""" @@ -95,6 +103,11 @@ def exc_handle(loop, context): loop.run_until_complete(hass.async_stop(force=True)) for ex in exceptions: + if ( + request.module.__name__, + request.function.__name__, + ) in IGNORE_UNCAUGHT_EXCEPTIONS: + continue if isinstance(ex, ServiceNotFound): continue raise ex @@ -242,3 +255,15 @@ async def create_client(hass=hass, access_token=hass_access_token): return websocket return create_client + + +@pytest.fixture(autouse=True) +def fail_on_log_exception(request, monkeypatch): + """Fixture to fail if a callback wrapped by catch_log_exception or coroutine wrapped by async_create_catching_coro throws.""" + if "no_fail_on_log_exception" in request.keywords: + return + + def log_exception(format_err, *args): + raise + + monkeypatch.setattr("homeassistant.util.logging.log_exception", log_exception) diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index d2aa939043c6c..09c7942d08fa6 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -1,6 +1,8 @@ """Test dispatcher helpers.""" from functools import partial +import pytest + from homeassistant.core import callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -128,6 +130,7 @@ def test_funct(data1, data2, data3): assert calls == [3, 2, "bla"] +@pytest.mark.no_fail_on_log_exception async def test_callback_exception_gets_logged(hass, caplog): """Test exception raised by signal handler.""" diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py new file mode 100644 index 0000000000000..20d32202a1a98 --- /dev/null +++ b/tests/ignore_uncaught_exceptions.py @@ -0,0 +1,16 @@ +"""List of tests that have uncaught exceptions today. Will be shrunk over time.""" +IGNORE_UNCAUGHT_EXCEPTIONS = [ + ("test_homeassistant_bridge", "test_homeassistant_bridge_fan_setup",), + ( + "tests.components.owntracks.test_device_tracker", + "test_mobile_multiple_async_enter_exit", + ), + ( + "tests.components.smartthings.test_init", + "test_event_handler_dispatches_updated_devices", + ), + ( + "tests.components.unifi.test_controller", + "test_wireless_client_event_calls_update_wireless_devices", + ), +] diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index c2c9d4803f9ed..2d05157e26f7f 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -3,6 +3,8 @@ import logging import threading +import pytest + import homeassistant.util.logging as logging_util @@ -65,6 +67,7 @@ def add_log(): assert queue.empty() +@pytest.mark.no_fail_on_log_exception async def test_async_create_catching_coro(hass, caplog): """Test exception logging of wrapped coroutine."""