Merge pull request #36155 from home-assistant/rc

This commit is contained in:
Franck Nijhof 2020-05-26 13:30:45 +02:00 committed by GitHub
commit 4054b1744f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 403 additions and 114 deletions

View File

@ -38,7 +38,7 @@ from .const import (
PLATFORMS = ["air_quality", "sensor"] PLATFORMS = ["air_quality", "sensor"]
DEFAULT_ATTRIBUTION = "Data provided by AirVisual" 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} DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True}
GEOGRAPHY_COORDINATES_SCHEMA = vol.Schema( 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 This will shift based on the number of active consumers, thus keeping the user
under the monthly API limit. under the monthly API limit.
""" """
num_consumers = len( num_consumers = len(async_get_cloud_coordinators_by_api_key(hass, api_key))
{
config_entry
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.data.get(CONF_API_KEY) == api_key
}
)
# Assuming 10,000 calls per month and a "smallest possible month" of 28 days; note # 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.: # 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)) 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) return timedelta(minutes=minutes_between_api_calls)
@callback @callback
def async_reset_coordinator_update_intervals(hass, update_interval): def async_get_cloud_coordinators_by_api_key(hass, api_key):
"""Update any existing data coordinators with a new update interval.""" """Get all DataUpdateCoordinator objects related to a particular API key."""
if not hass.data[DOMAIN][DATA_COORDINATOR]: coordinators = []
return 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 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) 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(): async def async_update_data():
"""Get new data from the API.""" """Get new data from the API."""
if CONF_CITY in config_entry.data: if CONF_CITY in config_entry.data:
@ -219,14 +231,19 @@ async def async_setup_entry(hass, config_entry):
coordinator = DataUpdateCoordinator( coordinator = DataUpdateCoordinator(
hass, hass,
LOGGER, LOGGER,
name="geography data", name=async_get_geography_id(config_entry.data),
update_interval=update_interval, # 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, update_method=async_update_data,
) )
# Ensure any other, existing config entries that use this API key are updated hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator
# with the new scan interval: async_sync_geo_coordinator_update_intervals(
async_reset_coordinator_update_intervals(hass, update_interval) hass, config_entry.data[CONF_API_KEY]
)
# Only geography-based entries have options: # Only geography-based entries have options:
config_entry.add_update_listener(async_update_options) config_entry.add_update_listener(async_update_options)
@ -251,13 +268,13 @@ async def async_setup_entry(hass, config_entry):
hass, hass,
LOGGER, LOGGER,
name="Node/Pro data", name="Node/Pro data",
update_interval=DEFAULT_NODE_PRO_SCAN_INTERVAL, update_interval=DEFAULT_NODE_PRO_UPDATE_INTERVAL,
update_method=async_update_data, 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: for component in PLATFORMS:
hass.async_create_task( hass.async_create_task(
@ -317,6 +334,12 @@ async def async_unload_entry(hass, config_entry):
) )
if unload_ok: if unload_ok:
hass.data[DOMAIN][DATA_COORDINATOR].pop(config_entry.entry_id) 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 return unload_ok

View File

@ -8,7 +8,7 @@ from .const import (
CONF_INTEGRATION_TYPE, CONF_INTEGRATION_TYPE,
DATA_COORDINATOR, DATA_COORDINATOR,
DOMAIN, DOMAIN,
INTEGRATION_TYPE_GEOGRAPHY, INTEGRATION_TYPE_NODE_PRO,
) )
ATTR_HUMIDITY = "humidity" ATTR_HUMIDITY = "humidity"
@ -18,12 +18,12 @@ ATTR_VOC = "voc"
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up AirVisual air quality entities based on a config entry.""" """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: # 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 return
coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id]
async_add_entities([AirVisualNodeProSensor(coordinator)], True) async_add_entities([AirVisualNodeProSensor(coordinator)], True)

View File

@ -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 # Whitelist the iOS and Android callbacks so that people can link apps
# without being connected to the internet. # without being connected to the internet.
if redirect_uri == "homeassistant://auth-callback" and client_id in ( if redirect_uri == "homeassistant://auth-callback" and client_id in (
"https://www.home-assistant.io/android", "https://home-assistant.io/android",
"https://www.home-assistant.io/iOS", "https://home-assistant.io/iOS",
): ):
return True return True

View File

@ -0,0 +1,4 @@
"""Constants for emulated_hue."""
HUE_SERIAL_NUMBER = "001788FFFE23BFC2"
HUE_UUID = "2f402f80-da50-11e1-9b23-001788255acc"

View File

@ -9,6 +9,8 @@ from aiohttp import web
from homeassistant import core from homeassistant import core
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from .const import HUE_SERIAL_NUMBER, HUE_UUID
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -42,8 +44,8 @@ class DescriptionXmlView(HomeAssistantView):
<modelName>Philips hue bridge 2015</modelName> <modelName>Philips hue bridge 2015</modelName>
<modelNumber>BSB002</modelNumber> <modelNumber>BSB002</modelNumber>
<modelURL>http://www.meethue.com</modelURL> <modelURL>http://www.meethue.com</modelURL>
<serialNumber>001788FFFE23BFC2</serialNumber> <serialNumber>{HUE_SERIAL_NUMBER}</serialNumber>
<UDN>uuid:2f402f80-da50-11e1-9b23-001788255acc</UDN> <UDN>uuid:{HUE_UUID}</UDN>
</device> </device>
</root> </root>
""" """
@ -70,21 +72,8 @@ class UPNPResponderThread(threading.Thread):
self.host_ip_addr = host_ip_addr self.host_ip_addr = host_ip_addr
self.listen_port = listen_port self.listen_port = listen_port
self.upnp_bind_multicast = upnp_bind_multicast self.upnp_bind_multicast = upnp_bind_multicast
self.advertise_ip = advertise_ip
# Note that the double newline at the end of self.advertise_port = advertise_port
# 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")
def run(self): def run(self):
"""Run the server.""" """Run the server."""
@ -136,10 +125,13 @@ USN: uuid:2f402f80-da50-11e1-9b23-00178829d301::upnp:rootdevice
continue continue
if "M-SEARCH" in data.decode("utf-8", errors="ignore"): 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 # 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() resp_socket.close()
def stop(self): def stop(self):
@ -148,6 +140,31 @@ USN: uuid:2f402f80-da50-11e1-9b23-00178829d301::upnp:rootdevice
self._interrupted = True self._interrupted = True
self.join() 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): def clean_socket_close(sock):
"""Close a socket connection and logs its closure.""" """Close a socket connection and logs its closure."""

View File

@ -2,7 +2,7 @@
"domain": "frontend", "domain": "frontend",
"name": "Home Assistant Frontend", "name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"requirements": ["home-assistant-frontend==20200519.4"], "requirements": ["home-assistant-frontend==20200519.5"],
"dependencies": [ "dependencies": [
"api", "api",
"auth", "auth",

View File

@ -2,6 +2,7 @@
import asyncio import asyncio
import ipaddress import ipaddress
import logging import logging
import os
from aiohttp import web from aiohttp import web
import voluptuous as vol import voluptuous as vol
@ -49,6 +50,7 @@ from .const import (
ATTR_SOFTWARE_VERSION, ATTR_SOFTWARE_VERSION,
ATTR_VALUE, ATTR_VALUE,
BRIDGE_NAME, BRIDGE_NAME,
BRIDGE_SERIAL_NUMBER,
CONF_ADVERTISE_IP, CONF_ADVERTISE_IP,
CONF_AUTO_START, CONF_AUTO_START,
CONF_ENTITY_CONFIG, CONF_ENTITY_CONFIG,
@ -434,6 +436,13 @@ class HomeKit:
interface_choice=self._interface_choice, 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) self.bridge = HomeBridge(self.hass, self.driver, self._name)
if self._safe_mode: if self._safe_mode:
_LOGGER.debug("Safe_mode selected for %s", self._name) _LOGGER.debug("Safe_mode selected for %s", self._name)
@ -540,16 +549,45 @@ class HomeKit:
@callback @callback
def _async_register_bridge(self, dev_reg): def _async_register_bridge(self, dev_reg):
"""Register the bridge as a device so homekit_controller and exclude it from discovery.""" """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( dev_reg.async_get_or_create(
config_entry_id=self._entry_id, config_entry_id=self._entry_id,
connections={ identifiers={identifier},
(device_registry.CONNECTION_NETWORK_MAC, self.driver.state.mac) connections={connection},
},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
name=self._name, name=self._name,
model="Home Assistant HomeKit Bridge", 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): def _start(self, bridged_states):
from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel
type_cameras, type_cameras,

View File

@ -28,6 +28,7 @@ from .const import (
DEVICE_INFO, DEVICE_INFO,
DEVICE_MODEL, DEVICE_MODEL,
DOMAIN, DOMAIN,
LEGACY_DEVICE_MODEL,
PV_API, PV_API,
PV_ROOM_DATA, PV_ROOM_DATA,
PV_SHADE_DATA, PV_SHADE_DATA,
@ -118,7 +119,7 @@ class PowerViewShade(ShadeEntity, CoverEntity):
def supported_features(self): def supported_features(self):
"""Flag supported features.""" """Flag supported features."""
supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION 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 supported_features |= SUPPORT_STOP
return supported_features return supported_features

View File

@ -4,5 +4,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/iaqualink/", "documentation": "https://www.home-assistant.io/integrations/iaqualink/",
"codeowners": ["@flz"], "codeowners": ["@flz"],
"requirements": ["iaqualink==0.3.1"] "requirements": ["iaqualink==0.3.3"]
} }

View File

@ -34,16 +34,25 @@ class HassAqualinkSensor(AqualinkEntity):
return self.dev.label return self.dev.label
@property @property
def unit_of_measurement(self) -> str: def unit_of_measurement(self) -> Optional[str]:
"""Return the measurement unit for the sensor.""" """Return the measurement unit for the sensor."""
if self.dev.system.temp_unit == "F": if self.dev.name.endswith("_temp"):
return TEMP_FAHRENHEIT if self.dev.system.temp_unit == "F":
return TEMP_CELSIUS return TEMP_FAHRENHEIT
return TEMP_CELSIUS
return None
@property @property
def state(self) -> str: def state(self) -> Optional[str]:
"""Return the state of the sensor.""" """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 @property
def device_class(self) -> Optional[str]: def device_class(self) -> Optional[str]:

View File

@ -103,6 +103,8 @@ class EmailReader:
if message_data is None: if message_data is None:
return None return None
if message_data[0] is None:
return None
raw_email = message_data[0][1] raw_email = message_data[0][1]
email_message = email.message_from_bytes(raw_email) email_message = email.message_from_bytes(raw_email)
return email_message return email_message
@ -126,13 +128,22 @@ class EmailReader:
self._last_id = int(message_uid) self._last_id = int(message_uid)
return self._fetch_message(message_uid) return self._fetch_message(message_uid)
return self._fetch_message(str(self._last_id))
except imaplib.IMAP4.error: except imaplib.IMAP4.error:
_LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server)
try: try:
self.connect() 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: except imaplib.IMAP4.error:
_LOGGER.error("Failed to reconnect") _LOGGER.error("Failed to reconnect")
return None
class EmailContentSensor(Entity): class EmailContentSensor(Entity):
"""Representation of an EMail sensor.""" """Representation of an EMail sensor."""

View File

@ -2,6 +2,7 @@
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .device import ONVIFDevice from .device import ONVIFDevice
from .models import Profile from .models import Profile
@ -11,8 +12,8 @@ class ONVIFBaseEntity(Entity):
def __init__(self, device: ONVIFDevice, profile: Profile = None) -> None: def __init__(self, device: ONVIFDevice, profile: Profile = None) -> None:
"""Initialize the ONVIF entity.""" """Initialize the ONVIF entity."""
self.device = device self.device: ONVIFDevice = device
self.profile = profile self.profile: Profile = profile
@property @property
def available(self): def available(self):
@ -22,10 +23,25 @@ class ONVIFBaseEntity(Entity):
@property @property
def device_info(self): def device_info(self):
"""Return a device description for device registry.""" """Return a device description for device registry."""
return { device_info = {
"connections": {(CONNECTION_NETWORK_MAC, self.device.info.mac)},
"manufacturer": self.device.info.manufacturer, "manufacturer": self.device.info.manufacturer,
"model": self.device.info.model, "model": self.device.info.model,
"name": self.device.name, "name": self.device.name,
"sw_version": self.device.info.fw_version, "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

View File

@ -96,8 +96,8 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera):
def unique_id(self) -> str: def unique_id(self) -> str:
"""Return a unique ID.""" """Return a unique ID."""
if self.profile.index: if self.profile.index:
return f"{self.device.info.mac}_{self.profile.index}" return f"{self.device.info.mac or self.device.info.serial_number}_{self.profile.index}"
return self.device.info.mac return self.device.info.mac or self.device.info.serial_number
@property @property
def entity_registry_enabled_default(self) -> bool: def entity_registry_enabled_default(self) -> bool:

View File

@ -169,10 +169,16 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self.onvif_config[CONF_PASSWORD] = user_input[CONF_PASSWORD] self.onvif_config[CONF_PASSWORD] = user_input[CONF_PASSWORD]
return await self.async_step_profiles() 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( return self.async_show_form(
step_id="auth", step_id="auth",
data_schema=vol.Schema( 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() await device.update_xaddrs()
try: try:
device_mgmt = device.create_devicemgmt_service()
# Get the MAC address to use as the unique ID for the config flow # Get the MAC address to use as the unique ID for the config flow
if not self.device_id: if not self.device_id:
devicemgmt = device.create_devicemgmt_service() network_interfaces = await device_mgmt.GetNetworkInterfaces()
network_interfaces = await devicemgmt.GetNetworkInterfaces()
for interface in network_interfaces: for interface in network_interfaces:
if interface.Enabled: if interface.Enabled:
self.device_id = interface.Info.HwAddress 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") return self.async_abort(reason="no_mac")
await self.async_set_unique_id(self.device_id, raise_on_progress=False) await self.async_set_unique_id(self.device_id, raise_on_progress=False)

View File

@ -148,12 +148,12 @@ class ONVIFDevice:
async def async_check_date_and_time(self) -> None: async def async_check_date_and_time(self) -> None:
"""Warns if device and system date not synced.""" """Warns if device and system date not synced."""
LOGGER.debug("Setting up the ONVIF device management service") 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") LOGGER.debug("Retrieving current device date/time")
try: try:
system_date = dt_util.utcnow() system_date = dt_util.utcnow()
device_time = await devicemgmt.GetSystemDateAndTime() device_time = await device_mgmt.GetSystemDateAndTime()
if not device_time: if not device_time:
LOGGER.debug( LOGGER.debug(
"""Couldn't get device '%s' date/time. """Couldn't get device '%s' date/time.
@ -212,13 +212,22 @@ class ONVIFDevice:
async def async_get_device_info(self) -> DeviceInfo: async def async_get_device_info(self) -> DeviceInfo:
"""Obtain information about this device.""" """Obtain information about this device."""
devicemgmt = self.device.create_devicemgmt_service() device_mgmt = self.device.create_devicemgmt_service()
device_info = await devicemgmt.GetDeviceInformation() 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( return DeviceInfo(
device_info.Manufacturer, device_info.Manufacturer,
device_info.Model, device_info.Model,
device_info.FirmwareVersion, device_info.FirmwareVersion,
self.config_entry.unique_id, device_info.SerialNumber,
mac,
) )
async def async_get_capabilities(self): async def async_get_capabilities(self):
@ -228,7 +237,7 @@ class ONVIFDevice:
media_service = self.device.create_media_service() media_service = self.device.create_media_service()
media_capabilities = await media_service.GetServiceCapabilities() media_capabilities = await media_service.GetServiceCapabilities()
snapshot = media_capabilities and media_capabilities.SnapshotUri snapshot = media_capabilities and media_capabilities.SnapshotUri
except (ONVIFError, Fault): except (ONVIFError, Fault, ServerDisconnectedError):
pass pass
pullpoint = False pullpoint = False
@ -415,7 +424,7 @@ class ONVIFDevice:
"PTZ preset '%s' does not exist on device '%s'. Available Presets: %s", "PTZ preset '%s' does not exist on device '%s'. Available Presets: %s",
preset_val, preset_val,
self.name, self.name,
profile.ptz.presets.join(", "), ", ".join(profile.ptz.presets),
) )
return return

View File

@ -62,7 +62,8 @@ class EventManager:
@callback @callback
def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None: def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None:
"""Remove data update.""" """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: if not self._listeners and self._unsub_refresh:
self._unsub_refresh() self._unsub_refresh()
@ -93,6 +94,8 @@ class EventManager:
async def async_stop(self) -> None: async def async_stop(self) -> None:
"""Unsubscribe from events.""" """Unsubscribe from events."""
self._listeners = []
if not self._subscription: if not self._subscription:
return return
@ -144,6 +147,10 @@ class EventManager:
async def async_parse_messages(self, messages) -> None: async def async_parse_messages(self, messages) -> None:
"""Parse notification message.""" """Parse notification message."""
for msg in messages: for msg in messages:
# Guard against empty message
if not msg.Topic:
continue
topic = msg.Topic._value_1 topic = msg.Topic._value_1
parser = PARSERS.get(topic) parser = PARSERS.get(topic)
if not parser: if not parser:

View File

@ -10,6 +10,7 @@ class DeviceInfo:
manufacturer: str = None manufacturer: str = None
model: str = None model: str = None
fw_version: str = None fw_version: str = None
serial_number: str = None
mac: str = None mac: str = None

View File

@ -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.""" """Set up the OpenGarage covers."""
covers = [] covers = []
devices = config.get(CONF_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)) covers.append(OpenGarageCover(device_config.get(CONF_NAME), open_garage))
add_entities(covers, True) async_add_entities(covers, True)
class OpenGarageCover(CoverEntity): class OpenGarageCover(CoverEntity):

View File

@ -2,7 +2,7 @@
"domain": "roku", "domain": "roku",
"name": "Roku", "name": "Roku",
"documentation": "https://www.home-assistant.io/integrations/roku", "documentation": "https://www.home-assistant.io/integrations/roku",
"requirements": ["rokuecp==0.4.1"], "requirements": ["rokuecp==0.4.2"],
"ssdp": [ "ssdp": [
{ {
"st": "roku:ecp", "st": "roku:ecp",

View File

@ -171,15 +171,18 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
async def async_media_pause(self) -> None: async def async_media_pause(self) -> None:
"""Send pause command.""" """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: async def async_media_play(self) -> None:
"""Send play command.""" """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: async def async_media_play_pause(self) -> None:
"""Send play/pause command.""" """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: async def async_media_previous_track(self) -> None:
"""Send previous track command.""" """Send previous track command."""

View File

@ -2,6 +2,6 @@
"domain": "velux", "domain": "velux",
"name": "Velux", "name": "Velux",
"documentation": "https://www.home-assistant.io/integrations/velux", "documentation": "https://www.home-assistant.io/integrations/velux",
"requirements": ["pyvlx==0.2.14"], "requirements": ["pyvlx==0.2.16"],
"codeowners": ["@Julius2342"] "codeowners": ["@Julius2342"]
} }

View File

@ -1,7 +1,7 @@
"""Constants used by Home Assistant components.""" """Constants used by Home Assistant components."""
MAJOR_VERSION = 0 MAJOR_VERSION = 0
MINOR_VERSION = 110 MINOR_VERSION = 110
PATCH_VERSION = "2" PATCH_VERSION = "3"
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER = (3, 7, 0) REQUIRED_PYTHON_VER = (3, 7, 0)

View File

@ -12,7 +12,7 @@ cryptography==2.9.2
defusedxml==0.6.0 defusedxml==0.6.0
distro==1.5.0 distro==1.5.0
hass-nabucasa==0.34.3 hass-nabucasa==0.34.3
home-assistant-frontend==20200519.4 home-assistant-frontend==20200519.5
importlib-metadata==1.6.0 importlib-metadata==1.6.0
jinja2>=2.11.1 jinja2>=2.11.1
netdisco==2.6.0 netdisco==2.6.0

View File

@ -731,7 +731,7 @@ hole==0.5.1
holidays==0.10.2 holidays==0.10.2
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20200519.4 home-assistant-frontend==20200519.5
# homeassistant.components.zwave # homeassistant.components.zwave
homeassistant-pyozw==0.1.10 homeassistant-pyozw==0.1.10
@ -764,7 +764,7 @@ hydrawiser==0.1.1
iammeter==0.1.7 iammeter==0.1.7
# homeassistant.components.iaqualink # homeassistant.components.iaqualink
iaqualink==0.3.1 iaqualink==0.3.3
# homeassistant.components.watson_tts # homeassistant.components.watson_tts
ibm-watson==4.0.1 ibm-watson==4.0.1
@ -1802,7 +1802,7 @@ pyvesync==1.1.0
pyvizio==0.1.47 pyvizio==0.1.47
# homeassistant.components.velux # homeassistant.components.velux
pyvlx==0.2.14 pyvlx==0.2.16
# homeassistant.components.html5 # homeassistant.components.html5
pywebpush==1.9.2 pywebpush==1.9.2
@ -1871,7 +1871,7 @@ rjpl==0.3.5
rocketchat-API==0.6.1 rocketchat-API==0.6.1
# homeassistant.components.roku # homeassistant.components.roku
rokuecp==0.4.1 rokuecp==0.4.2
# homeassistant.components.roomba # homeassistant.components.roomba
roombapy==1.6.1 roombapy==1.6.1

View File

@ -312,7 +312,7 @@ hole==0.5.1
holidays==0.10.2 holidays==0.10.2
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20200519.4 home-assistant-frontend==20200519.5
# homeassistant.components.zwave # homeassistant.components.zwave
homeassistant-pyozw==0.1.10 homeassistant-pyozw==0.1.10
@ -331,7 +331,7 @@ httplib2==0.10.3
huawei-lte-api==1.4.12 huawei-lte-api==1.4.12
# homeassistant.components.iaqualink # homeassistant.components.iaqualink
iaqualink==0.3.1 iaqualink==0.3.3
# homeassistant.components.influxdb # homeassistant.components.influxdb
influxdb==5.2.3 influxdb==5.2.3
@ -762,7 +762,7 @@ rflink==0.0.52
ring_doorbell==0.6.0 ring_doorbell==0.6.0
# homeassistant.components.roku # homeassistant.components.roku
rokuecp==0.4.1 rokuecp==0.4.2
# homeassistant.components.roomba # homeassistant.components.roomba
roombapy==1.6.1 roombapy==1.6.1

View File

@ -166,8 +166,7 @@ async def test_find_link_tag_max_size(hass, mock_session):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"client_id", "client_id", ["https://home-assistant.io/android", "https://home-assistant.io/iOS"],
["https://www.home-assistant.io/android", "https://www.home-assistant.io/iOS"],
) )
async def test_verify_redirect_uri_android_ios(client_id): async def test_verify_redirect_uri_android_ios(client_id):
"""Test that we verify redirect uri correctly for Android/iOS.""" """Test that we verify redirect uri correctly for Android/iOS."""

View File

@ -48,6 +48,66 @@ class TestEmulatedHue(unittest.TestCase):
"""Stop the class.""" """Stop the class."""
cls.hass.stop() 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): def test_description_xml(self):
"""Test the description.""" """Test the description."""
result = requests.get(BRIDGE_URL_BASE.format("/description.xml"), timeout=5) result = requests.get(BRIDGE_URL_BASE.format("/description.xml"), timeout=5)

View File

@ -19,6 +19,7 @@ from homeassistant.components.homekit.accessories import HomeBridge
from homeassistant.components.homekit.const import ( from homeassistant.components.homekit.const import (
AID_STORAGE, AID_STORAGE,
BRIDGE_NAME, BRIDGE_NAME,
BRIDGE_SERIAL_NUMBER,
CONF_AUTO_START, CONF_AUTO_START,
CONF_ENTRY_INDEX, CONF_ENTRY_INDEX,
CONF_SAFE_MODE, CONF_SAFE_MODE,
@ -458,7 +459,7 @@ async def test_homekit_entity_filter(hass):
assert mock_get_acc.called is False 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.""" """Test HomeKit start method."""
entry = await async_init_integration(hass) 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.driver = hk_driver
homekit._filter = Mock(return_value=True) 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") hass.states.async_set("light.demo", "on")
state = hass.states.async_all()[0] 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() await hass.async_block_till_done()
assert not hk_driver_start.called 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): async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, debounce_patcher):
"""Test HomeKit start method.""" """Test HomeKit start method."""

View File

@ -1,13 +1,11 @@
"""Test ONVIF config flow.""" """Test ONVIF config flow."""
from asyncio import Future
from asynctest import MagicMock, patch
from onvif.exceptions import ONVIFError from onvif.exceptions import ONVIFError
from zeep.exceptions import Fault from zeep.exceptions import Fault
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries, data_entry_flow
from homeassistant.components.onvif import config_flow from homeassistant.components.onvif import config_flow
from tests.async_mock import AsyncMock, MagicMock, patch
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
URN = "urn:uuid:123456789" URN = "urn:uuid:123456789"
@ -17,6 +15,7 @@ PORT = 80
USERNAME = "admin" USERNAME = "admin"
PASSWORD = "12345" PASSWORD = "12345"
MAC = "aa:bb:cc:dd:ee" MAC = "aa:bb:cc:dd:ee"
SERIAL_NUMBER = "ABCDEFGHIJK"
DISCOVERY = [ DISCOVERY = [
{ {
@ -37,18 +36,25 @@ DISCOVERY = [
def setup_mock_onvif_camera( 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.""" """Prepare mock onvif.ONVIFCamera."""
devicemgmt = MagicMock() 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 = MagicMock()
interface.Enabled = True interface.Enabled = True
interface.Info.HwAddress = MAC interface.Info.HwAddress = MAC
devicemgmt.GetNetworkInterfaces.return_value = Future() devicemgmt.GetNetworkInterfaces = AsyncMock(
devicemgmt.GetNetworkInterfaces.return_value.set_result( return_value=[interface] if with_interfaces else []
[interface] if with_interfaces else []
) )
media_service = MagicMock() media_service = MagicMock()
@ -58,11 +64,9 @@ def setup_mock_onvif_camera(
profile2 = MagicMock() profile2 = MagicMock()
profile2.VideoEncoderConfiguration.Encoding = "H264" if two_profiles else "MJPEG" profile2.VideoEncoderConfiguration.Encoding = "H264" if two_profiles else "MJPEG"
media_service.GetProfiles.return_value = Future() media_service.GetProfiles = AsyncMock(return_value=[profile1, profile2])
media_service.GetProfiles.return_value.set_result([profile1, profile2])
mock_onvif_camera.update_xaddrs.return_value = Future() mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True)
mock_onvif_camera.update_xaddrs.return_value.set_result(True)
mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt) mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt)
mock_onvif_camera.create_media_service = MagicMock(return_value=media_service) 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): def setup_mock_device(mock_device):
"""Prepare mock ONVIFDevice.""" """Prepare mock ONVIFDevice."""
mock_device.async_setup.return_value = Future() mock_device.async_setup = AsyncMock(return_value=True)
mock_device.async_setup.return_value.set_result(True)
def mock_constructor(hass, config): def mock_constructor(hass, config):
"""Fake the controller constructor.""" """Fake the controller constructor."""
@ -390,11 +393,48 @@ async def test_flow_manual_entry(hass):
async def test_flow_import_no_mac(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( with patch(
"homeassistant.components.onvif.config_flow.get_device" "homeassistant.components.onvif.config_flow.get_device"
) as mock_onvif_camera: ) 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( result = await hass.config_entries.flow.async_init(
config_flow.DOMAIN, config_flow.DOMAIN,