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"]
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

View File

@ -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)

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
# 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

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.components.http import HomeAssistantView
from .const import HUE_SERIAL_NUMBER, HUE_UUID
_LOGGER = logging.getLogger(__name__)
@ -42,8 +44,8 @@ class DescriptionXmlView(HomeAssistantView):
<modelName>Philips hue bridge 2015</modelName>
<modelNumber>BSB002</modelNumber>
<modelURL>http://www.meethue.com</modelURL>
<serialNumber>001788FFFE23BFC2</serialNumber>
<UDN>uuid:2f402f80-da50-11e1-9b23-001788255acc</UDN>
<serialNumber>{HUE_SERIAL_NUMBER}</serialNumber>
<UDN>uuid:{HUE_UUID}</UDN>
</device>
</root>
"""
@ -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."""

View File

@ -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",

View File

@ -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,

View File

@ -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

View File

@ -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"]
}

View File

@ -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]:

View File

@ -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."""

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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:

View File

@ -10,6 +10,7 @@ class DeviceInfo:
manufacturer: str = None
model: str = None
fw_version: str = None
serial_number: 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."""
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):

View File

@ -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",

View File

@ -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."""

View File

@ -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"]
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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)

View File

@ -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."""

View File

@ -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,