diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index e5d8b03f316..96b3770974a 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -38,7 +38,7 @@ from .const import ( PLATFORMS = ["air_quality", "sensor"] DEFAULT_ATTRIBUTION = "Data provided by AirVisual" -DEFAULT_NODE_PRO_SCAN_INTERVAL = timedelta(minutes=1) +DEFAULT_NODE_PRO_UPDATE_INTERVAL = timedelta(minutes=1) DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True} GEOGRAPHY_COORDINATES_SCHEMA = vol.Schema( @@ -95,27 +95,43 @@ def async_get_cloud_api_update_interval(hass, api_key): This will shift based on the number of active consumers, thus keeping the user under the monthly API limit. """ - num_consumers = len( - { - config_entry - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.data.get(CONF_API_KEY) == api_key - } - ) + num_consumers = len(async_get_cloud_coordinators_by_api_key(hass, api_key)) # Assuming 10,000 calls per month and a "smallest possible month" of 28 days; note # that we give a buffer of 1500 API calls for any drift, restarts, etc.: minutes_between_api_calls = ceil(1 / (8500 / 28 / 24 / 60 / num_consumers)) + + LOGGER.debug( + "Leveling API key usage (%s): %s consumers, %s minutes between updates", + api_key, + num_consumers, + minutes_between_api_calls, + ) + return timedelta(minutes=minutes_between_api_calls) @callback -def async_reset_coordinator_update_intervals(hass, update_interval): - """Update any existing data coordinators with a new update interval.""" - if not hass.data[DOMAIN][DATA_COORDINATOR]: - return +def async_get_cloud_coordinators_by_api_key(hass, api_key): + """Get all DataUpdateCoordinator objects related to a particular API key.""" + coordinators = [] + for entry_id, coordinator in hass.data[DOMAIN][DATA_COORDINATOR].items(): + config_entry = hass.config_entries.async_get_entry(entry_id) + if config_entry.data.get(CONF_API_KEY) == api_key: + coordinators.append(coordinator) + return coordinators - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR].values(): + +@callback +def async_sync_geo_coordinator_update_intervals(hass, api_key): + """Sync the update interval for geography-based data coordinators (by API key).""" + update_interval = async_get_cloud_api_update_interval(hass, api_key) + for coordinator in async_get_cloud_coordinators_by_api_key(hass, api_key): + LOGGER.debug( + "Updating interval for coordinator: %s, %s", + coordinator.name, + update_interval, + ) coordinator.update_interval = update_interval @@ -194,10 +210,6 @@ async def async_setup_entry(hass, config_entry): client = Client(api_key=config_entry.data[CONF_API_KEY], session=websession) - update_interval = async_get_cloud_api_update_interval( - hass, config_entry.data[CONF_API_KEY] - ) - async def async_update_data(): """Get new data from the API.""" if CONF_CITY in config_entry.data: @@ -219,14 +231,19 @@ async def async_setup_entry(hass, config_entry): coordinator = DataUpdateCoordinator( hass, LOGGER, - name="geography data", - update_interval=update_interval, + name=async_get_geography_id(config_entry.data), + # We give a placeholder update interval in order to create the coordinator; + # then, below, we use the coordinator's presence (along with any other + # coordinators using the same API key) to calculate an actual, leveled + # update interval: + update_interval=timedelta(minutes=5), update_method=async_update_data, ) - # Ensure any other, existing config entries that use this API key are updated - # with the new scan interval: - async_reset_coordinator_update_intervals(hass, update_interval) + hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator + async_sync_geo_coordinator_update_intervals( + hass, config_entry.data[CONF_API_KEY] + ) # Only geography-based entries have options: config_entry.add_update_listener(async_update_options) @@ -251,13 +268,13 @@ async def async_setup_entry(hass, config_entry): hass, LOGGER, name="Node/Pro data", - update_interval=DEFAULT_NODE_PRO_SCAN_INTERVAL, + update_interval=DEFAULT_NODE_PRO_UPDATE_INTERVAL, update_method=async_update_data, ) - await coordinator.async_refresh() + hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator - hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator + await coordinator.async_refresh() for component in PLATFORMS: hass.async_create_task( @@ -317,6 +334,12 @@ async def async_unload_entry(hass, config_entry): ) if unload_ok: hass.data[DOMAIN][DATA_COORDINATOR].pop(config_entry.entry_id) + if config_entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_GEOGRAPHY: + # Re-calculate the update interval period for any remaining consumes of this + # API key: + async_sync_geo_coordinator_update_intervals( + hass, config_entry.data[CONF_API_KEY] + ) return unload_ok diff --git a/homeassistant/components/airvisual/air_quality.py b/homeassistant/components/airvisual/air_quality.py index bd1c10a9d84..bb2d64a23db 100644 --- a/homeassistant/components/airvisual/air_quality.py +++ b/homeassistant/components/airvisual/air_quality.py @@ -8,7 +8,7 @@ from .const import ( CONF_INTEGRATION_TYPE, DATA_COORDINATOR, DOMAIN, - INTEGRATION_TYPE_GEOGRAPHY, + INTEGRATION_TYPE_NODE_PRO, ) ATTR_HUMIDITY = "humidity" @@ -18,12 +18,12 @@ ATTR_VOC = "voc" async def async_setup_entry(hass, config_entry, async_add_entities): """Set up AirVisual air quality entities based on a config entry.""" - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] - # Geography-based AirVisual integrations don't utilize this platform: - if config_entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_GEOGRAPHY: + if config_entry.data[CONF_INTEGRATION_TYPE] != INTEGRATION_TYPE_NODE_PRO: return + coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] + async_add_entities([AirVisualNodeProSensor(coordinator)], True) diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index cd8e797876f..0d942bd358d 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -33,8 +33,8 @@ async def verify_redirect_uri(hass, client_id, redirect_uri): # Whitelist the iOS and Android callbacks so that people can link apps # without being connected to the internet. if redirect_uri == "homeassistant://auth-callback" and client_id in ( - "https://www.home-assistant.io/android", - "https://www.home-assistant.io/iOS", + "https://home-assistant.io/android", + "https://home-assistant.io/iOS", ): return True diff --git a/homeassistant/components/emulated_hue/const.py b/homeassistant/components/emulated_hue/const.py new file mode 100644 index 00000000000..bfd58c5a0e1 --- /dev/null +++ b/homeassistant/components/emulated_hue/const.py @@ -0,0 +1,4 @@ +"""Constants for emulated_hue.""" + +HUE_SERIAL_NUMBER = "001788FFFE23BFC2" +HUE_UUID = "2f402f80-da50-11e1-9b23-001788255acc" diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index f0fe392f865..14e3cf11ca2 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -9,6 +9,8 @@ from aiohttp import web from homeassistant import core from homeassistant.components.http import HomeAssistantView +from .const import HUE_SERIAL_NUMBER, HUE_UUID + _LOGGER = logging.getLogger(__name__) @@ -42,8 +44,8 @@ class DescriptionXmlView(HomeAssistantView): Philips hue bridge 2015 BSB002 http://www.meethue.com -001788FFFE23BFC2 -uuid:2f402f80-da50-11e1-9b23-001788255acc +{HUE_SERIAL_NUMBER} +uuid:{HUE_UUID} """ @@ -70,21 +72,8 @@ class UPNPResponderThread(threading.Thread): self.host_ip_addr = host_ip_addr self.listen_port = listen_port self.upnp_bind_multicast = upnp_bind_multicast - - # Note that the double newline at the end of - # this string is required per the SSDP spec - resp_template = f"""HTTP/1.1 200 OK -CACHE-CONTROL: max-age=60 -EXT: -LOCATION: http://{advertise_ip}:{advertise_port}/description.xml -SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/1.16.0 -hue-bridgeid: 001788FFFE23BFC2 -ST: upnp:rootdevice -USN: uuid:2f402f80-da50-11e1-9b23-00178829d301::upnp:rootdevice - -""" - - self.upnp_response = resp_template.replace("\n", "\r\n").encode("utf-8") + self.advertise_ip = advertise_ip + self.advertise_port = advertise_port def run(self): """Run the server.""" @@ -136,10 +125,13 @@ USN: uuid:2f402f80-da50-11e1-9b23-00178829d301::upnp:rootdevice continue if "M-SEARCH" in data.decode("utf-8", errors="ignore"): + _LOGGER.debug("UPNP Responder M-SEARCH method received: %s", data) # SSDP M-SEARCH method received, respond to it with our info - resp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + response = self._handle_request(data) - resp_socket.sendto(self.upnp_response, addr) + resp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + resp_socket.sendto(response, addr) + _LOGGER.debug("UPNP Responder responding with: %s", response) resp_socket.close() def stop(self): @@ -148,6 +140,31 @@ USN: uuid:2f402f80-da50-11e1-9b23-00178829d301::upnp:rootdevice self._interrupted = True self.join() + def _handle_request(self, data): + if "upnp:rootdevice" in data.decode("utf-8", errors="ignore"): + return self._prepare_response( + "upnp:rootdevice", f"uuid:{HUE_UUID}::upnp:rootdevice" + ) + + return self._prepare_response( + "urn:schemas-upnp-org:device:basic:1", f"uuid:{HUE_UUID}" + ) + + def _prepare_response(self, search_target, unique_service_name): + # Note that the double newline at the end of + # this string is required per the SSDP spec + response = f"""HTTP/1.1 200 OK +CACHE-CONTROL: max-age=60 +EXT: +LOCATION: http://{self.advertise_ip}:{self.advertise_port}/description.xml +SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/1.16.0 +hue-bridgeid: {HUE_SERIAL_NUMBER} +ST: {search_target} +USN: {unique_service_name} + +""" + return response.replace("\n", "\r\n").encode("utf-8") + def clean_socket_close(sock): """Close a socket connection and logs its closure.""" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 89f83d8fa65..14ae15dd87b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20200519.4"], + "requirements": ["home-assistant-frontend==20200519.5"], "dependencies": [ "api", "auth", diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index adbf79128e3..428f8e30abf 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -2,6 +2,7 @@ import asyncio import ipaddress import logging +import os from aiohttp import web import voluptuous as vol @@ -49,6 +50,7 @@ from .const import ( ATTR_SOFTWARE_VERSION, ATTR_VALUE, BRIDGE_NAME, + BRIDGE_SERIAL_NUMBER, CONF_ADVERTISE_IP, CONF_AUTO_START, CONF_ENTITY_CONFIG, @@ -434,6 +436,13 @@ class HomeKit: interface_choice=self._interface_choice, ) + # If we do not load the mac address will be wrong + # as pyhap uses a random one until state is restored + if os.path.exists(persist_file): + self.driver.load() + else: + self.driver.persist() + self.bridge = HomeBridge(self.hass, self.driver, self._name) if self._safe_mode: _LOGGER.debug("Safe_mode selected for %s", self._name) @@ -540,16 +549,45 @@ class HomeKit: @callback def _async_register_bridge(self, dev_reg): """Register the bridge as a device so homekit_controller and exclude it from discovery.""" + formatted_mac = device_registry.format_mac(self.driver.state.mac) + # Connections and identifiers are both used here. + # + # connections exists so homekit_controller can know the + # virtual mac address of the bridge and know to not offer + # it via discovery. + # + # identifiers is used as well since the virtual mac may change + # because it will not survive manual pairing resets (deleting state file) + # which we have trained users to do over the past few years + # because this was the way you had to fix homekit when pairing + # failed. + # + connection = (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) + identifier = (DOMAIN, self._entry_id, BRIDGE_SERIAL_NUMBER) + self._async_purge_old_bridges(dev_reg, identifier, connection) dev_reg.async_get_or_create( config_entry_id=self._entry_id, - connections={ - (device_registry.CONNECTION_NETWORK_MAC, self.driver.state.mac) - }, + identifiers={identifier}, + connections={connection}, manufacturer=MANUFACTURER, name=self._name, model="Home Assistant HomeKit Bridge", ) + @callback + def _async_purge_old_bridges(self, dev_reg, identifier, connection): + """Purge bridges that exist from failed pairing or manual resets.""" + devices_to_purge = [] + for entry in dev_reg.devices.values(): + if self._entry_id in entry.config_entries and ( + identifier not in entry.identifiers + or connection not in entry.connections + ): + devices_to_purge.append(entry.id) + + for device_id in devices_to_purge: + dev_reg.async_remove_device(device_id) + def _start(self, bridged_states): from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel type_cameras, diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 8135b4a8c77..ea56d56352a 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -28,6 +28,7 @@ from .const import ( DEVICE_INFO, DEVICE_MODEL, DOMAIN, + LEGACY_DEVICE_MODEL, PV_API, PV_ROOM_DATA, PV_SHADE_DATA, @@ -118,7 +119,7 @@ class PowerViewShade(ShadeEntity, CoverEntity): def supported_features(self): """Flag supported features.""" supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION - if self._device_info[DEVICE_MODEL] != "1": + if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL: supported_features |= SUPPORT_STOP return supported_features diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index d9d16038d19..68b4554f73a 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -4,5 +4,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iaqualink/", "codeowners": ["@flz"], - "requirements": ["iaqualink==0.3.1"] + "requirements": ["iaqualink==0.3.3"] } diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index 81021d0b447..80f18dc8191 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -34,16 +34,25 @@ class HassAqualinkSensor(AqualinkEntity): return self.dev.label @property - def unit_of_measurement(self) -> str: + def unit_of_measurement(self) -> Optional[str]: """Return the measurement unit for the sensor.""" - if self.dev.system.temp_unit == "F": - return TEMP_FAHRENHEIT - return TEMP_CELSIUS + if self.dev.name.endswith("_temp"): + if self.dev.system.temp_unit == "F": + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + return None @property - def state(self) -> str: + def state(self) -> Optional[str]: """Return the state of the sensor.""" - return int(self.dev.state) if self.dev.state != "" else None + if self.dev.state == "": + return None + + try: + state = int(self.dev.state) + except ValueError: + state = float(self.dev.state) + return state @property def device_class(self) -> Optional[str]: diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index ea93c2bb975..21b535450a1 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -103,6 +103,8 @@ class EmailReader: if message_data is None: return None + if message_data[0] is None: + return None raw_email = message_data[0][1] email_message = email.message_from_bytes(raw_email) return email_message @@ -126,13 +128,22 @@ class EmailReader: self._last_id = int(message_uid) return self._fetch_message(message_uid) + return self._fetch_message(str(self._last_id)) + except imaplib.IMAP4.error: _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) try: self.connect() + _LOGGER.info( + "Reconnect to %s succeeded, trying last message", self._server + ) + if self._last_id is not None: + return self._fetch_message(str(self._last_id)) except imaplib.IMAP4.error: _LOGGER.error("Failed to reconnect") + return None + class EmailContentSensor(Entity): """Representation of an EMail sensor.""" diff --git a/homeassistant/components/onvif/base.py b/homeassistant/components/onvif/base.py index 43a846cac37..cca84c4562a 100644 --- a/homeassistant/components/onvif/base.py +++ b/homeassistant/components/onvif/base.py @@ -2,6 +2,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import Entity +from .const import DOMAIN from .device import ONVIFDevice from .models import Profile @@ -11,8 +12,8 @@ class ONVIFBaseEntity(Entity): def __init__(self, device: ONVIFDevice, profile: Profile = None) -> None: """Initialize the ONVIF entity.""" - self.device = device - self.profile = profile + self.device: ONVIFDevice = device + self.profile: Profile = profile @property def available(self): @@ -22,10 +23,25 @@ class ONVIFBaseEntity(Entity): @property def device_info(self): """Return a device description for device registry.""" - return { - "connections": {(CONNECTION_NETWORK_MAC, self.device.info.mac)}, + device_info = { "manufacturer": self.device.info.manufacturer, "model": self.device.info.model, "name": self.device.name, "sw_version": self.device.info.fw_version, } + + # MAC address is not always available, and given the number + # of non-conformant ONVIF devices we have historically supported, + # we can not guarantee serial number either. Due to this, we have + # adopted an either/or approach in the config entry setup, and can + # guarantee that one or the other will be populated. + # See: https://github.com/home-assistant/core/issues/35883 + if self.device.info.serial_number: + device_info["identifiers"] = {(DOMAIN, self.device.info.serial_number)} + + if self.device.info.mac: + device_info["connections"] = { + (CONNECTION_NETWORK_MAC, self.device.info.mac) + } + + return device_info diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 570b99bfe3a..7f97e0fbea4 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -96,8 +96,8 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): 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 + return f"{self.device.info.mac or self.device.info.serial_number}_{self.profile.index}" + return self.device.info.mac or self.device.info.serial_number @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 1dba697380d..29784b25429 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -169,10 +169,16 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.onvif_config[CONF_PASSWORD] = user_input[CONF_PASSWORD] return await self.async_step_profiles() + # Password is optional and default empty due to some cameras not + # allowing you to change ONVIF user settings. + # See https://github.com/home-assistant/core/issues/35904 return self.async_show_form( step_id="auth", data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD, default=""): str, + } ), ) @@ -195,15 +201,21 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await device.update_xaddrs() try: + device_mgmt = device.create_devicemgmt_service() + # Get the MAC address to use as the unique ID for the config flow if not self.device_id: - devicemgmt = device.create_devicemgmt_service() - network_interfaces = await devicemgmt.GetNetworkInterfaces() + network_interfaces = await device_mgmt.GetNetworkInterfaces() for interface in network_interfaces: if interface.Enabled: self.device_id = interface.Info.HwAddress - if self.device_id is None: + # If no network interfaces are exposed, fallback to serial number + if not self.device_id: + device_info = await device_mgmt.GetDeviceInformation() + self.device_id = device_info.SerialNumber + + if not self.device_id: return self.async_abort(reason="no_mac") await self.async_set_unique_id(self.device_id, raise_on_progress=False) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 938c960080f..0e9d3ddca98 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -148,12 +148,12 @@ class ONVIFDevice: 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() + device_mgmt = self.device.create_devicemgmt_service() LOGGER.debug("Retrieving current device date/time") try: system_date = dt_util.utcnow() - device_time = await devicemgmt.GetSystemDateAndTime() + device_time = await device_mgmt.GetSystemDateAndTime() if not device_time: LOGGER.debug( """Couldn't get device '%s' date/time. @@ -212,13 +212,22 @@ class ONVIFDevice: async def async_get_device_info(self) -> DeviceInfo: """Obtain information about this device.""" - devicemgmt = self.device.create_devicemgmt_service() - device_info = await devicemgmt.GetDeviceInformation() + device_mgmt = self.device.create_devicemgmt_service() + device_info = await device_mgmt.GetDeviceInformation() + + # Grab the last MAC address for backwards compatibility + mac = None + network_interfaces = await device_mgmt.GetNetworkInterfaces() + for interface in network_interfaces: + if interface.Enabled: + mac = interface.Info.HwAddress + return DeviceInfo( device_info.Manufacturer, device_info.Model, device_info.FirmwareVersion, - self.config_entry.unique_id, + device_info.SerialNumber, + mac, ) async def async_get_capabilities(self): @@ -228,7 +237,7 @@ class ONVIFDevice: media_service = self.device.create_media_service() media_capabilities = await media_service.GetServiceCapabilities() snapshot = media_capabilities and media_capabilities.SnapshotUri - except (ONVIFError, Fault): + except (ONVIFError, Fault, ServerDisconnectedError): pass pullpoint = False @@ -415,7 +424,7 @@ class ONVIFDevice: "PTZ preset '%s' does not exist on device '%s'. Available Presets: %s", preset_val, self.name, - profile.ptz.presets.join(", "), + ", ".join(profile.ptz.presets), ) return diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 183ad0ab532..3888db4fa8e 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -62,7 +62,8 @@ class EventManager: @callback def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None: """Remove data update.""" - self._listeners.remove(update_callback) + if update_callback in self._listeners: + self._listeners.remove(update_callback) if not self._listeners and self._unsub_refresh: self._unsub_refresh() @@ -93,6 +94,8 @@ class EventManager: async def async_stop(self) -> None: """Unsubscribe from events.""" + self._listeners = [] + if not self._subscription: return @@ -144,6 +147,10 @@ class EventManager: async def async_parse_messages(self, messages) -> None: """Parse notification message.""" for msg in messages: + # Guard against empty message + if not msg.Topic: + continue + topic = msg.Topic._value_1 parser = PARSERS.get(topic) if not parser: diff --git a/homeassistant/components/onvif/models.py b/homeassistant/components/onvif/models.py index 686d9fecbda..2a129d3bc44 100644 --- a/homeassistant/components/onvif/models.py +++ b/homeassistant/components/onvif/models.py @@ -10,6 +10,7 @@ class DeviceInfo: manufacturer: str = None model: str = None fw_version: str = None + serial_number: str = None mac: str = None diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 239697a229c..70b4d8c98ee 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -55,7 +55,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the OpenGarage covers.""" covers = [] devices = config.get(CONF_COVERS) @@ -75,7 +75,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): covers.append(OpenGarageCover(device_config.get(CONF_NAME), open_garage)) - add_entities(covers, True) + async_add_entities(covers, True) class OpenGarageCover(CoverEntity): diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 57c64f4c64a..276fe2332f5 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -2,7 +2,7 @@ "domain": "roku", "name": "Roku", "documentation": "https://www.home-assistant.io/integrations/roku", - "requirements": ["rokuecp==0.4.1"], + "requirements": ["rokuecp==0.4.2"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 8c92eff3687..0deeb44dbc2 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -171,15 +171,18 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): async def async_media_pause(self) -> None: """Send pause command.""" - await self.coordinator.roku.remote("play") + if self.state != STATE_STANDBY: + await self.coordinator.roku.remote("play") async def async_media_play(self) -> None: """Send play command.""" - await self.coordinator.roku.remote("play") + if self.state != STATE_STANDBY: + await self.coordinator.roku.remote("play") async def async_media_play_pause(self) -> None: """Send play/pause command.""" - await self.coordinator.roku.remote("play") + if self.state != STATE_STANDBY: + await self.coordinator.roku.remote("play") async def async_media_previous_track(self) -> None: """Send previous track command.""" diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index e6747060da6..73306bca7b5 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -2,6 +2,6 @@ "domain": "velux", "name": "Velux", "documentation": "https://www.home-assistant.io/integrations/velux", - "requirements": ["pyvlx==0.2.14"], + "requirements": ["pyvlx==0.2.16"], "codeowners": ["@Julius2342"] } diff --git a/homeassistant/const.py b/homeassistant/const.py index 8993225f283..da3d94d85b9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 110 -PATCH_VERSION = "2" +PATCH_VERSION = "3" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e8c3550e996..52981f0a1a1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.9.2 defusedxml==0.6.0 distro==1.5.0 hass-nabucasa==0.34.3 -home-assistant-frontend==20200519.4 +home-assistant-frontend==20200519.5 importlib-metadata==1.6.0 jinja2>=2.11.1 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3a7bc962114..c1c0323a906 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -731,7 +731,7 @@ hole==0.5.1 holidays==0.10.2 # homeassistant.components.frontend -home-assistant-frontend==20200519.4 +home-assistant-frontend==20200519.5 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -764,7 +764,7 @@ hydrawiser==0.1.1 iammeter==0.1.7 # homeassistant.components.iaqualink -iaqualink==0.3.1 +iaqualink==0.3.3 # homeassistant.components.watson_tts ibm-watson==4.0.1 @@ -1802,7 +1802,7 @@ pyvesync==1.1.0 pyvizio==0.1.47 # homeassistant.components.velux -pyvlx==0.2.14 +pyvlx==0.2.16 # homeassistant.components.html5 pywebpush==1.9.2 @@ -1871,7 +1871,7 @@ rjpl==0.3.5 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.4.1 +rokuecp==0.4.2 # homeassistant.components.roomba roombapy==1.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59a9b17b4a2..8f75c4d8694 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -312,7 +312,7 @@ hole==0.5.1 holidays==0.10.2 # homeassistant.components.frontend -home-assistant-frontend==20200519.4 +home-assistant-frontend==20200519.5 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -331,7 +331,7 @@ httplib2==0.10.3 huawei-lte-api==1.4.12 # homeassistant.components.iaqualink -iaqualink==0.3.1 +iaqualink==0.3.3 # homeassistant.components.influxdb influxdb==5.2.3 @@ -762,7 +762,7 @@ rflink==0.0.52 ring_doorbell==0.6.0 # homeassistant.components.roku -rokuecp==0.4.1 +rokuecp==0.4.2 # homeassistant.components.roomba roombapy==1.6.1 diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py index 3d1ba068c85..8a02502e16c 100644 --- a/tests/components/auth/test_indieauth.py +++ b/tests/components/auth/test_indieauth.py @@ -166,8 +166,7 @@ async def test_find_link_tag_max_size(hass, mock_session): @pytest.mark.parametrize( - "client_id", - ["https://www.home-assistant.io/android", "https://www.home-assistant.io/iOS"], + "client_id", ["https://home-assistant.io/android", "https://home-assistant.io/iOS"], ) async def test_verify_redirect_uri_android_ios(client_id): """Test that we verify redirect uri correctly for Android/iOS.""" diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index 32859ca00c1..a6040e8db68 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -48,6 +48,66 @@ class TestEmulatedHue(unittest.TestCase): """Stop the class.""" cls.hass.stop() + def test_upnp_discovery_basic(self): + """Tests the UPnP basic discovery response.""" + with patch("threading.Thread.__init__"): + upnp_responder_thread = emulated_hue.UPNPResponderThread( + "0.0.0.0", 80, True, "192.0.2.42", 8080 + ) + + """Original request emitted by the Hue Bridge v1 app.""" + request = """M-SEARCH * HTTP/1.1 +HOST:239.255.255.250:1900 +ST:ssdp:all +Man:"ssdp:discover" +MX:3 + +""" + encoded_request = request.replace("\n", "\r\n").encode("utf-8") + + response = upnp_responder_thread._handle_request(encoded_request) + expected_response = """HTTP/1.1 200 OK +CACHE-CONTROL: max-age=60 +EXT: +LOCATION: http://192.0.2.42:8080/description.xml +SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/1.16.0 +hue-bridgeid: 001788FFFE23BFC2 +ST: urn:schemas-upnp-org:device:basic:1 +USN: uuid:2f402f80-da50-11e1-9b23-001788255acc + +""" + assert expected_response.replace("\n", "\r\n").encode("utf-8") == response + + def test_upnp_discovery_rootdevice(self): + """Tests the UPnP rootdevice discovery response.""" + with patch("threading.Thread.__init__"): + upnp_responder_thread = emulated_hue.UPNPResponderThread( + "0.0.0.0", 80, True, "192.0.2.42", 8080 + ) + + """Original request emitted by Busch-Jaeger free@home SysAP.""" + request = """M-SEARCH * HTTP/1.1 +HOST: 239.255.255.250:1900 +MAN: "ssdp:discover" +MX: 40 +ST: upnp:rootdevice + +""" + encoded_request = request.replace("\n", "\r\n").encode("utf-8") + + response = upnp_responder_thread._handle_request(encoded_request) + expected_response = """HTTP/1.1 200 OK +CACHE-CONTROL: max-age=60 +EXT: +LOCATION: http://192.0.2.42:8080/description.xml +SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/1.16.0 +hue-bridgeid: 001788FFFE23BFC2 +ST: upnp:rootdevice +USN: uuid:2f402f80-da50-11e1-9b23-001788255acc::upnp:rootdevice + +""" + assert expected_response.replace("\n", "\r\n").encode("utf-8") == response + def test_description_xml(self): """Test the description.""" result = requests.get(BRIDGE_URL_BASE.format("/description.xml"), timeout=5) diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index c0e2ea90fba..b016997b7c9 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -19,6 +19,7 @@ from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( AID_STORAGE, BRIDGE_NAME, + BRIDGE_SERIAL_NUMBER, CONF_AUTO_START, CONF_ENTRY_INDEX, CONF_SAFE_MODE, @@ -458,7 +459,7 @@ async def test_homekit_entity_filter(hass): assert mock_get_acc.called is False -async def test_homekit_start(hass, hk_driver, debounce_patcher): +async def test_homekit_start(hass, hk_driver, device_reg, debounce_patcher): """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -480,6 +481,15 @@ async def test_homekit_start(hass, hk_driver, debounce_patcher): homekit.driver = hk_driver homekit._filter = Mock(return_value=True) + connection = (device_registry.CONNECTION_NETWORK_MAC, "AA:BB:CC:DD:EE:FF") + bridge_with_wrong_mac = device_reg.async_get_or_create( + config_entry_id=entry.entry_id, + connections={connection}, + manufacturer="Any", + name="Any", + model="Home Assistant HomeKit Bridge", + ) + hass.states.async_set("light.demo", "on") state = hass.states.async_all()[0] @@ -505,6 +515,35 @@ async def test_homekit_start(hass, hk_driver, debounce_patcher): await hass.async_block_till_done() assert not hk_driver_start.called + assert device_reg.async_get(bridge_with_wrong_mac.id) is None + + device = device_reg.async_get_device( + {(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)}, {} + ) + assert device + formatted_mac = device_registry.format_mac(homekit.driver.state.mac) + assert (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + + # Start again to make sure the registry entry is kept + homekit.status = STATUS_READY + with patch(f"{PATH_HOMEKIT}.HomeKit.add_bridge_accessory") as mock_add_acc, patch( + f"{PATH_HOMEKIT}.show_setup_message" + ) as mock_setup_msg, patch( + "pyhap.accessory_driver.AccessoryDriver.add_accessory" + ) as hk_driver_add_acc, patch( + "pyhap.accessory_driver.AccessoryDriver.start" + ) as hk_driver_start: + await homekit.async_start() + + device = device_reg.async_get_device( + {(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)}, {} + ) + assert device + formatted_mac = device_registry.format_mac(homekit.driver.state.mac) + assert (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + + assert len(device_reg.devices) == 1 + async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, debounce_patcher): """Test HomeKit start method.""" diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index c709c5e6f67..5c41349bbbe 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -1,13 +1,11 @@ """Test ONVIF config flow.""" -from asyncio import Future - -from asynctest import MagicMock, patch from onvif.exceptions import ONVIFError from zeep.exceptions import Fault from homeassistant import config_entries, data_entry_flow from homeassistant.components.onvif import config_flow +from tests.async_mock import AsyncMock, MagicMock, patch from tests.common import MockConfigEntry URN = "urn:uuid:123456789" @@ -17,6 +15,7 @@ PORT = 80 USERNAME = "admin" PASSWORD = "12345" MAC = "aa:bb:cc:dd:ee" +SERIAL_NUMBER = "ABCDEFGHIJK" DISCOVERY = [ { @@ -37,18 +36,25 @@ DISCOVERY = [ def setup_mock_onvif_camera( - mock_onvif_camera, with_h264=True, two_profiles=False, with_interfaces=True + mock_onvif_camera, + with_h264=True, + two_profiles=False, + with_interfaces=True, + with_serial=True, ): """Prepare mock onvif.ONVIFCamera.""" devicemgmt = MagicMock() + device_info = MagicMock() + device_info.SerialNumber = SERIAL_NUMBER if with_serial else None + devicemgmt.GetDeviceInformation = AsyncMock(return_value=device_info) + interface = MagicMock() interface.Enabled = True interface.Info.HwAddress = MAC - devicemgmt.GetNetworkInterfaces.return_value = Future() - devicemgmt.GetNetworkInterfaces.return_value.set_result( - [interface] if with_interfaces else [] + devicemgmt.GetNetworkInterfaces = AsyncMock( + return_value=[interface] if with_interfaces else [] ) media_service = MagicMock() @@ -58,11 +64,9 @@ def setup_mock_onvif_camera( profile2 = MagicMock() profile2.VideoEncoderConfiguration.Encoding = "H264" if two_profiles else "MJPEG" - media_service.GetProfiles.return_value = Future() - media_service.GetProfiles.return_value.set_result([profile1, profile2]) + media_service.GetProfiles = AsyncMock(return_value=[profile1, profile2]) - mock_onvif_camera.update_xaddrs.return_value = Future() - mock_onvif_camera.update_xaddrs.return_value.set_result(True) + mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True) mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt) mock_onvif_camera.create_media_service = MagicMock(return_value=media_service) @@ -116,8 +120,7 @@ def setup_mock_discovery( 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) + mock_device.async_setup = AsyncMock(return_value=True) def mock_constructor(hass, config): """Fake the controller constructor.""" @@ -390,11 +393,48 @@ async def test_flow_manual_entry(hass): async def test_flow_import_no_mac(hass): - """Test that config flow fails when no MAC available.""" + """Test that config flow uses Serial Number when no MAC available.""" + with patch( + "homeassistant.components.onvif.config_flow.get_device" + ) as mock_onvif_camera, patch( + "homeassistant.components.onvif.ONVIFDevice" + ) as mock_device: + setup_mock_onvif_camera(mock_onvif_camera, with_interfaces=False) + setup_mock_device(mock_device) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + }, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"{NAME} - {SERIAL_NUMBER}" + assert result["data"] == { + config_flow.CONF_NAME: NAME, + config_flow.CONF_HOST: HOST, + config_flow.CONF_PORT: PORT, + config_flow.CONF_USERNAME: USERNAME, + config_flow.CONF_PASSWORD: PASSWORD, + } + + +async def test_flow_import_no_mac_or_serial(hass): + """Test that config flow fails when no MAC or Serial Number available.""" with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera: - setup_mock_onvif_camera(mock_onvif_camera, with_interfaces=False) + setup_mock_onvif_camera( + mock_onvif_camera, with_interfaces=False, with_serial=False + ) result = await hass.config_entries.flow.async_init( config_flow.DOMAIN,