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,