Skip to content

Commit

Permalink
Add SSDP integration (home-assistant#24090)
Browse files Browse the repository at this point in the history
* Add SSDP integration

* Fix tests

* Sort all the things

* Add netdisco to test requirements
  • Loading branch information
balloob authored May 27, 2019
1 parent 97b6711 commit 9debbfb
Show file tree
Hide file tree
Showing 22 changed files with 436 additions and 28 deletions.
1 change: 1 addition & 0 deletions homeassistant/components/default_config/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"mobile_app",
"person",
"script",
"ssdp",
"sun",
"system_health",
"updater",
Expand Down
2 changes: 0 additions & 2 deletions homeassistant/components/discovery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
SERVICE_HASSIO = 'hassio'
SERVICE_HOMEKIT = 'homekit'
SERVICE_HEOS = 'heos'
SERVICE_HUE = 'philips_hue'
SERVICE_IGD = 'igd'
SERVICE_IKEA_TRADFRI = 'ikea_tradfri'
SERVICE_KONNECTED = 'konnected'
Expand All @@ -54,7 +53,6 @@
SERVICE_DECONZ: 'deconz',
'google_cast': 'cast',
SERVICE_HEOS: 'heos',
SERVICE_HUE: 'hue',
SERVICE_TELLDUSLIVE: 'tellduslive',
SERVICE_IKEA_TRADFRI: 'tradfri',
'sonos': 'sonos',
Expand Down
11 changes: 8 additions & 3 deletions homeassistant/components/hue/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,22 @@ async def async_step_link(self, user_input=None):
errors=errors,
)

async def async_step_discovery(self, discovery_info):
async def async_step_ssdp(self, discovery_info):
"""Handle a discovered Hue bridge.
This flow is triggered by the discovery component. It will check if the
This flow is triggered by the SSDP component. It will check if the
host is already configured and delegate to the import step if not.
"""
# Filter out emulated Hue
if "HASS Bridge" in discovery_info.get('name', ''):
return self.async_abort(reason='already_configured')

host = discovery_info.get('host')
# pylint: disable=unsupported-assignment-operation
host = self.context['host'] = discovery_info.get('host')

if any(host == flow['context']['host']
for flow in self._async_in_progress()):
return self.async_abort(reason='already_in_progress')

if host in configured_hosts(self.hass):
return self.async_abort(reason='already_configured')
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/components/hue/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
"requirements": [
"aiohue==1.9.1"
],
"ssdp": {
"manufacturer": [
"Royal Philips Electronics"
]
},
"dependencies": [],
"codeowners": [
"@balloob"
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/hue/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"all_configured": "All Philips Hue bridges are already configured",
"unknown": "Unknown error occurred",
"cannot_connect": "Unable to connect to the bridge",
"already_configured": "Bridge is already configured"
"already_configured": "Bridge is already configured",
"already_in_progress": "Config flow for bridge is already in progress."
}
}
}
170 changes: 170 additions & 0 deletions homeassistant/components/ssdp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""The SSDP integration."""
import asyncio
from datetime import timedelta
import logging
from urllib.parse import urlparse
from xml.etree import ElementTree

import aiohttp
from netdisco import ssdp, util

from homeassistant.helpers.event import async_track_time_interval
from homeassistant.generated.ssdp import SSDP

DOMAIN = 'ssdp'
SCAN_INTERVAL = timedelta(seconds=60)

ATTR_HOST = 'host'
ATTR_PORT = 'port'
ATTR_SSDP_DESCRIPTION = 'ssdp_description'
ATTR_ST = 'ssdp_st'
ATTR_NAME = 'name'
ATTR_MODEL_NAME = 'model_name'
ATTR_MODEL_NUMBER = 'model_number'
ATTR_SERIAL = 'serial_number'
ATTR_MANUFACTURER = 'manufacturer'
ATTR_UDN = 'udn'
ATTR_UPNP_DEVICE_TYPE = 'upnp_device_type'

_LOGGER = logging.getLogger(__name__)


async def async_setup(hass, config):
"""Set up the SSDP integration."""
async def initialize():
scanner = Scanner(hass)
await scanner.async_scan(None)
async_track_time_interval(hass, scanner.async_scan, SCAN_INTERVAL)

hass.loop.create_task(initialize())

return True


class Scanner:
"""Class to manage SSDP scanning."""

def __init__(self, hass):
"""Initialize class."""
self.hass = hass
self.seen = set()
self._description_cache = {}

async def async_scan(self, _):
"""Scan for new entries."""
_LOGGER.debug("Scanning")
# Run 3 times as packets can get lost
for _ in range(3):
entries = await self.hass.async_add_executor_job(ssdp.scan)
await self._process_entries(entries)

# We clear the cache after each run. We track discovered entries
# so will never need a description twice.
self._description_cache.clear()

async def _process_entries(self, entries):
"""Process SSDP entries."""
tasks = []

for entry in entries:
key = (entry.st, entry.location)

if key in self.seen:
continue

self.seen.add(key)

tasks.append(self._process_entry(entry))

if not tasks:
return

to_load = [result for result in await asyncio.gather(*tasks)
if result is not None]

if not to_load:
return

for entry, info, domains in to_load:

for domain in domains:
_LOGGER.debug("Discovered %s at %s", domain, entry.location)
await self.hass.config_entries.flow.async_init(
domain, context={'source': DOMAIN}, data=info
)

async def _process_entry(self, entry):
"""Process a single entry."""
domains = set(SSDP["st"].get(entry.st, []))

xml_location = entry.location

if not xml_location:
if domains:
return (entry, info_from_entry(entry, None), domains)
return None

# Multiple entries usally share same location. Make sure
# we fetch it only once.
info_req = self._description_cache.get(xml_location)

if info_req is None:
info_req = self._description_cache[xml_location] = \
self.hass.async_create_task(
self._fetch_description(xml_location))

info = await info_req

domains.update(SSDP["manufacturer"].get(info.get('manufacturer'), []))
domains.update(SSDP["device_type"].get(info.get('deviceType'), []))

if domains:
return (entry, info_from_entry(entry, info), domains)

return None

async def _fetch_description(self, xml_location):
"""Fetch an XML description."""
session = self.hass.helpers.aiohttp_client.async_get_clientsession()
try:
resp = await session.get(xml_location, timeout=5)
xml = await resp.text()

# Samsung Smart TV sometimes returns an empty document the
# first time. Retry once.
if not xml:
resp = await session.get(xml_location, timeout=5)
xml = await resp.text()
except aiohttp.ClientError as err:
_LOGGER.debug("Error fetching %s: %s", xml_location, err)
return None

try:
tree = ElementTree.fromstring(xml)
except ElementTree.ParseError as err:
_LOGGER.debug("Error parsing %s: %s", xml_location, err)
return None

return util.etree_to_dict(tree).get('root', {}).get('device', {})


def info_from_entry(entry, device_info):
"""Get most important info from an entry."""
url = urlparse(entry.location)
info = {
ATTR_HOST: url.hostname,
ATTR_PORT: url.port,
ATTR_SSDP_DESCRIPTION: entry.location,
ATTR_ST: entry.st,
}

if device_info:
info[ATTR_NAME] = device_info.get('friendlyName')
info[ATTR_MODEL_NAME] = device_info.get('modelName')
info[ATTR_MODEL_NUMBER] = device_info.get('modelNumber')
info[ATTR_SERIAL] = device_info.get('serialNumber')
info[ATTR_MANUFACTURER] = device_info.get('manufacturer')
info[ATTR_UDN] = device_info.get('UDN')
info[ATTR_UPNP_DEVICE_TYPE] = device_info.get('deviceType')

return info
12 changes: 12 additions & 0 deletions homeassistant/components/ssdp/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"domain": "ssdp",
"name": "SSDP",
"documentation": "https://www.home-assistant.io/components/ssdp",
"requirements": [
"netdisco==2.6.0"
],
"dependencies": [
],
"codeowners": [
]
}
6 changes: 2 additions & 4 deletions homeassistant/components/zeroconf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,8 @@ async def new_service(service_type, name):
_LOGGER.debug("Discovered new device %s %s", name, info)

for domain in zeroconf_manifest.SERVICE_TYPES[service_type]:
hass.async_create_task(
hass.config_entries.flow.async_init(
domain, context={'source': DOMAIN}, data=info
)
await hass.config_entries.flow.async_init(
domain, context={'source': DOMAIN}, data=info
)

def service_update(_, service_type, name, state_change):
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ async def async_step_discovery(info):

DISCOVERY_NOTIFICATION_ID = 'config_entry_discovery'
DISCOVERY_SOURCES = (
'ssdp',
'zeroconf',
SOURCE_DISCOVERY,
SOURCE_IMPORT,
)
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/data_entry_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ async def async_init(self, handler: Hashable, *,
context: Optional[Dict] = None,
data: Any = None) -> Any:
"""Start a configuration flow."""
if context is None:
context = {}
flow = await self._async_create_flow(
handler, context=context, data=data)
flow.hass = self.hass
Expand Down
15 changes: 15 additions & 0 deletions homeassistant/generated/ssdp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Automatically generated by hassfest.
To update, run python3 -m hassfest
"""


SSDP = {
"device_type": {},
"manufacturer": {
"Royal Philips Electronics": [
"hue"
]
},
"st": {}
}
1 change: 1 addition & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@ nessclient==0.9.15
netdata==0.1.2

# homeassistant.components.discovery
# homeassistant.components.ssdp
netdisco==2.6.0

# homeassistant.components.neurio_energy
Expand Down
4 changes: 4 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ mbddns==0.1.2
# homeassistant.components.mfi
mficlient==0.3.0

# homeassistant.components.discovery
# homeassistant.components.ssdp
netdisco==2.6.0

# homeassistant.components.iqvia
# homeassistant.components.opencv
# homeassistant.components.tensorflow
Expand Down
1 change: 1 addition & 0 deletions script/gen_requirements_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
'luftdaten',
'mbddns',
'mficlient',
'netdisco',
'numpy',
'oauth2client',
'paho-mqtt',
Expand Down
18 changes: 13 additions & 5 deletions script/hassfest/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,23 @@

from .model import Integration, Config
from . import (
dependencies, manifest, codeowners, services, config_flow, zeroconf)
codeowners,
config_flow,
dependencies,
manifest,
services,
ssdp,
zeroconf,
)

PLUGINS = [
manifest,
dependencies,
codeowners,
services,
config_flow,
zeroconf
dependencies,
manifest,
services,
ssdp,
zeroconf,
]


Expand Down
5 changes: 5 additions & 0 deletions script/hassfest/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
vol.Required('name'): str,
vol.Optional('config_flow'): bool,
vol.Optional('zeroconf'): [str],
vol.Optional('ssdp'): vol.Schema({
vol.Optional('st'): [str],
vol.Optional('manufacturer'): [str],
vol.Optional('device_type'): [str],
}),
vol.Required('documentation'): str,
vol.Required('requirements'): [str],
vol.Required('dependencies'): [str],
Expand Down
Loading

0 comments on commit 9debbfb

Please sign in to comment.