summaryrefslogtreecommitdiff
path: root/netdisco
diff options
context:
space:
mode:
Diffstat (limited to 'netdisco')
-rw-r--r--netdisco/__init__.py1
-rw-r--r--netdisco/__main__.py35
-rw-r--r--netdisco/const.py46
-rw-r--r--netdisco/daikin.py102
-rw-r--r--netdisco/discoverables/__init__.py151
-rw-r--r--netdisco/discoverables/apple_tv.py16
-rw-r--r--netdisco/discoverables/arduino.py9
-rw-r--r--netdisco/discoverables/asus_router.py13
-rw-r--r--netdisco/discoverables/axis.py41
-rw-r--r--netdisco/discoverables/belkin_wemo.py19
-rw-r--r--netdisco/discoverables/bluesound.py10
-rw-r--r--netdisco/discoverables/bose_soundtouch.py10
-rw-r--r--netdisco/discoverables/cambridgeaudio.py13
-rw-r--r--netdisco/discoverables/canon_printer.py13
-rw-r--r--netdisco/discoverables/daikin.py14
-rw-r--r--netdisco/discoverables/deconz.py13
-rw-r--r--netdisco/discoverables/denonavr.py23
-rw-r--r--netdisco/discoverables/directv.py13
-rw-r--r--netdisco/discoverables/dlna_dmr.py13
-rw-r--r--netdisco/discoverables/dlna_dms.py13
-rw-r--r--netdisco/discoverables/freebox.py10
-rw-r--r--netdisco/discoverables/fritzbox.py10
-rw-r--r--netdisco/discoverables/frontier_silicon.py12
-rw-r--r--netdisco/discoverables/google_cast.py10
-rw-r--r--netdisco/discoverables/harmony.py13
-rw-r--r--netdisco/discoverables/hass_ios.py9
-rw-r--r--netdisco/discoverables/home_assistant.py9
-rw-r--r--netdisco/discoverables/homekit.py18
-rw-r--r--netdisco/discoverables/hp_printer.py13
-rw-r--r--netdisco/discoverables/huawei_router.py13
-rw-r--r--netdisco/discoverables/igd.py14
-rw-r--r--netdisco/discoverables/ikea_tradfri.py10
-rw-r--r--netdisco/discoverables/kodi.py13
-rw-r--r--netdisco/discoverables/konnected.py10
-rw-r--r--netdisco/discoverables/lg_smart_device.py10
-rw-r--r--netdisco/discoverables/logitech_mediaserver.py14
-rw-r--r--netdisco/discoverables/lutron.py11
-rw-r--r--netdisco/discoverables/nanoleaf_aurora.py9
-rw-r--r--netdisco/discoverables/netgear_router.py13
-rw-r--r--netdisco/discoverables/octoprint.py12
-rw-r--r--netdisco/discoverables/openhome.py10
-rw-r--r--netdisco/discoverables/panasonic_viera.py10
-rw-r--r--netdisco/discoverables/philips_hue.py14
-rw-r--r--netdisco/discoverables/plex_mediaserver.py21
-rw-r--r--netdisco/discoverables/roku.py10
-rw-r--r--netdisco/discoverables/sabnzbd.py13
-rw-r--r--netdisco/discoverables/samsung_printer.py13
-rw-r--r--netdisco/discoverables/samsung_tv.py25
-rw-r--r--netdisco/discoverables/songpal.py50
-rw-r--r--netdisco/discoverables/sonos.py10
-rw-r--r--netdisco/discoverables/spotify_connect.py10
-rw-r--r--netdisco/discoverables/tellstick.py14
-rw-r--r--netdisco/discoverables/tivo_dvr.py14
-rw-r--r--netdisco/discoverables/volumio.py10
-rw-r--r--netdisco/discoverables/webos_tv.py15
-rw-r--r--netdisco/discoverables/wink.py14
-rw-r--r--netdisco/discoverables/xbox_smartglass.py14
-rw-r--r--netdisco/discoverables/xiaomi_gw.py33
-rw-r--r--netdisco/discoverables/yamaha.py42
-rw-r--r--netdisco/discoverables/yeelight.py27
-rw-r--r--netdisco/discoverables/ziggo_mediabox_xl.py12
-rw-r--r--netdisco/discovery.py148
-rw-r--r--netdisco/gdm.py110
-rw-r--r--netdisco/lms.py78
-rw-r--r--netdisco/mdns.py45
-rw-r--r--netdisco/service.py88
-rw-r--r--netdisco/smartglass.py156
-rw-r--r--netdisco/ssdp.py290
-rw-r--r--netdisco/tellstick.py72
-rw-r--r--netdisco/util.py29
70 files changed, 2198 insertions, 0 deletions
diff --git a/netdisco/__init__.py b/netdisco/__init__.py
new file mode 100644
index 0000000..7b4bb4c
--- /dev/null
+++ b/netdisco/__init__.py
@@ -0,0 +1 @@
+"""Module to scan the network using uPnP and mDNS for devices and services."""
diff --git a/netdisco/__main__.py b/netdisco/__main__.py
new file mode 100644
index 0000000..64037f0
--- /dev/null
+++ b/netdisco/__main__.py
@@ -0,0 +1,35 @@
+"""Command line tool to print discocvered devices or dump raw data."""
+from pprint import pprint
+import sys
+
+from netdisco.discovery import NetworkDiscovery
+
+
+def main():
+ """Handle command line execution."""
+ netdisco = NetworkDiscovery()
+
+ netdisco.scan()
+
+ print("Discovered devices:")
+ count = 0
+ for dev in netdisco.discover():
+ count += 1
+ print('{}:'.format(dev))
+ pprint(netdisco.get_info(dev))
+ print()
+ print("Discovered {} devices".format(count))
+
+ # Pass in command line argument dump to get the raw data
+ if sys.argv[-1] == 'dump':
+ print()
+ print()
+ print("Raw Data")
+ print()
+ netdisco.print_raw_data()
+
+ netdisco.stop()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/netdisco/const.py b/netdisco/const.py
new file mode 100644
index 0000000..e363c5e
--- /dev/null
+++ b/netdisco/const.py
@@ -0,0 +1,46 @@
+"""Constants of services that can be discovered."""
+
+BELKIN_WEMO = "belkin_wemo"
+DLNA_DMS = "DLNA_DMS"
+DLNA_DMR = "DLNA_DMR"
+GOOGLE_CAST = "google_cast"
+PHILIPS_HUE = "philips_hue"
+PMS = 'plex_mediaserver'
+LMS = 'logitech_mediaserver'
+ASUS_ROUTER = "asus_router"
+HUAWEI_ROUTER = "huawei_router"
+NETGEAR_ROUTER = "netgear_router"
+SONOS = "sonos"
+PANASONIC_VIERA = "panasonic_viera"
+SABNZBD = 'sabnzbd'
+KODI = 'kodi'
+HOME_ASSISTANT = "home_assistant"
+MYSTROM = 'mystrom'
+HASS_IOS = "hass_ios"
+BOSE_SOUNDTOUCH = 'bose_soundtouch'
+SAMSUNG_TV = "samsung_tv"
+FRONTIER_SILICON = "frontier_silicon"
+APPLE_TV = "apple_tv"
+HARMONY = "harmony"
+BLUESOUND = "bluesound"
+ZIGGO_MEDIABOX_XL = "ziggo_mediabox_xl"
+DECONZ = "deconz"
+TIVO_DVR = "tivo_dvr"
+FREEBOX = "freebox"
+XBOX_SMARTGLASS = "xbox_smartglass"
+
+ATTR_NAME = 'name'
+ATTR_HOST = 'host'
+ATTR_PORT = 'port'
+ATTR_HOSTNAME = 'hostname'
+ATTR_URLBASE = 'urlbase'
+ATTR_DEVICE_TYPE = 'device_type'
+ATTR_MODEL_NAME = 'model_name'
+ATTR_MODEL_NUMBER = 'model_number'
+ATTR_MANUFACTURER = 'manufacturer'
+ATTR_UDN = 'udn'
+ATTR_PROPERTIES = 'properties'
+ATTR_SSDP_DESCRIPTION = 'ssdp_description'
+ATTR_UPNP_DEVICE_TYPE = 'upnp_device_type'
+ATTR_SERIAL = 'serial'
+ATTR_MAC_ADDRESS = 'mac_address'
diff --git a/netdisco/daikin.py b/netdisco/daikin.py
new file mode 100644
index 0000000..a8e6e8f
--- /dev/null
+++ b/netdisco/daikin.py
@@ -0,0 +1,102 @@
+"""Daikin device discovery."""
+import socket
+
+from datetime import timedelta
+from urllib.parse import unquote
+
+DISCOVERY_MSG = b"DAIKIN_UDP/common/basic_info"
+
+UDP_SRC_PORT = 30000
+UDP_DST_PORT = 30050
+
+DISCOVERY_ADDRESS = '<broadcast>'
+DISCOVERY_TIMEOUT = timedelta(seconds=2)
+
+
+class Daikin:
+ """Base class to discover Daikin devices."""
+
+ def __init__(self):
+ """Initialize the Daikin discovery."""
+ self.entries = []
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Scan and return all found entries."""
+ self.scan()
+ return self.entries
+
+ def update(self):
+ """Scan network for Daikin devices."""
+ entries = []
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.settimeout(DISCOVERY_TIMEOUT.seconds)
+ sock.bind(("", UDP_SRC_PORT))
+
+ try:
+
+ sock.sendto(DISCOVERY_MSG, (DISCOVERY_ADDRESS, UDP_DST_PORT))
+
+ while True:
+ try:
+ data, (address, _) = sock.recvfrom(1024)
+
+ # pylint: disable=consider-using-dict-comprehension
+ entry = dict([e.split('=')
+ for e in data.decode("UTF-8").split(',')])
+
+ # expecting product, mac, activation code, version
+ if 'ret' not in entry or entry['ret'] != 'OK':
+ # non-OK return on response
+ continue
+
+ if 'mac' not in entry:
+ # no mac found for device"
+ continue
+
+ if 'type' not in entry or entry['type'] != 'aircon':
+ # no mac found for device"
+ continue
+
+ if 'name' in entry:
+ entry['name'] = unquote(entry['name'])
+
+ # in case the device was not configured to have an id
+ # then use the mac address
+ if 'id' in entry and entry['id'] == '':
+ entry['id'] = entry['mac']
+
+ entries.append({
+ 'id': entry['id'],
+ 'name': entry['name'],
+ 'ip': address,
+ 'mac': entry['mac'],
+ 'ver': entry['ver'],
+ })
+
+ except socket.timeout:
+ break
+
+ finally:
+ sock.close()
+
+ self.entries = entries
+
+
+def main():
+ """Test Daikin discovery."""
+ from pprint import pprint
+ daikin = Daikin()
+ pprint("Scanning for Daikin devices..")
+ daikin.update()
+ pprint(daikin.entries)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/discoverables/__init__.py b/netdisco/discoverables/__init__.py
new file mode 100644
index 0000000..dd39785
--- /dev/null
+++ b/netdisco/discoverables/__init__.py
@@ -0,0 +1,151 @@
+"""Provides helpful stuff for discoverables."""
+# pylint: disable=abstract-method
+import ipaddress
+from urllib.parse import urlparse
+
+from ..const import (
+ ATTR_NAME, ATTR_MODEL_NAME, ATTR_HOST, ATTR_PORT, ATTR_SSDP_DESCRIPTION,
+ ATTR_SERIAL, ATTR_MODEL_NUMBER, ATTR_HOSTNAME, ATTR_MAC_ADDRESS,
+ ATTR_PROPERTIES, ATTR_MANUFACTURER, ATTR_UDN, ATTR_UPNP_DEVICE_TYPE)
+
+
+class BaseDiscoverable:
+ """Base class for discoverable services or device types."""
+
+ def is_discovered(self):
+ """Return True if it is discovered."""
+ return len(self.get_entries()) > 0
+
+ def get_info(self):
+ """Return a list with the important info for each item.
+
+ Uses self.info_from_entry internally.
+ """
+ return [self.info_from_entry(entry) for entry in self.get_entries()]
+
+ # pylint: disable=no-self-use
+ def info_from_entry(self, entry):
+ """Return an object with important info from the entry."""
+ return entry
+
+ def get_entries(self):
+ """Return all the discovered entries."""
+ raise NotImplementedError()
+
+
+class SSDPDiscoverable(BaseDiscoverable):
+ """uPnP discoverable base class."""
+
+ def __init__(self, netdis):
+ """Initialize SSDPDiscoverable."""
+ self.netdis = netdis
+
+ def info_from_entry(self, entry):
+ """Get most important info."""
+ url = urlparse(entry.location)
+ info = {
+ ATTR_HOST: url.hostname,
+ ATTR_PORT: url.port,
+ ATTR_SSDP_DESCRIPTION: entry.location
+ }
+ device = entry.description.get('device')
+
+ if device:
+ info[ATTR_NAME] = device.get('friendlyName')
+ info[ATTR_MODEL_NAME] = device.get('modelName')
+ info[ATTR_MODEL_NUMBER] = device.get('modelNumber')
+ info[ATTR_SERIAL] = device.get('serialNumber')
+ info[ATTR_MANUFACTURER] = device.get('manufacturer')
+ info[ATTR_UDN] = device.get('UDN')
+ info[ATTR_UPNP_DEVICE_TYPE] = device.get('deviceType')
+
+ return info
+
+ # Helper functions
+
+ # pylint: disable=invalid-name
+ def find_by_st(self, st):
+ """Find entries by ST (the device identifier)."""
+ return self.netdis.ssdp.find_by_st(st)
+
+ def find_by_device_description(self, values):
+ """Find entries based on values from their description."""
+ return self.netdis.ssdp.find_by_device_description(values)
+
+
+class MDNSDiscoverable(BaseDiscoverable):
+ """mDNS Discoverable base class."""
+
+ def __init__(self, netdis, typ):
+ """Initialize MDNSDiscoverable."""
+ self.netdis = netdis
+ self.typ = typ
+ self.services = {}
+
+ netdis.mdns.register_service(self)
+
+ def reset(self):
+ """Reset found services."""
+ self.services.clear()
+
+ # pylint: disable=unused-argument
+ def remove_service(self, zconf, typ, name):
+ """Callback when a service is removed."""
+ self.services.pop(name, None)
+
+ def add_service(self, zconf, typ, name):
+ """Callback when a service is found."""
+ service = None
+ tries = 0
+ while service is None and tries < 3:
+ service = zconf.get_service_info(typ, name)
+ tries += 1
+
+ if service is not None:
+ self.services[name] = service
+
+ def get_entries(self):
+ """Return all found services."""
+ return self.services.values()
+
+ def info_from_entry(self, entry):
+ """Return most important info from mDNS entries."""
+ properties = {}
+
+ for key, value in entry.properties.items():
+ if isinstance(value, bytes):
+ value = value.decode('utf-8')
+ properties[key.decode('utf-8')] = value
+
+ info = {
+ ATTR_HOST: str(ipaddress.ip_address(entry.address)),
+ ATTR_PORT: entry.port,
+ ATTR_HOSTNAME: entry.server,
+ ATTR_PROPERTIES: properties,
+ }
+
+ if "mac" in properties:
+ info[ATTR_MAC_ADDRESS] = properties["mac"]
+
+ return info
+
+ def find_by_device_name(self, name):
+ """Find entries based on the beginning of their entry names."""
+ return [entry for entry in self.services.values()
+ if entry.name.startswith(name)]
+
+
+class GDMDiscoverable(BaseDiscoverable):
+ """GDM discoverable base class."""
+
+ def __init__(self, netdis):
+ """Initialize GDMDiscoverable."""
+ self.netdis = netdis
+
+ def find_by_content_type(self, value):
+ """Find entries based on values from their content_type."""
+ return self.netdis.gdm.find_by_content_type(value)
+
+ def find_by_data(self, values):
+ """Find entries based on values from any returned field."""
+ return self.netdis.gdm.find_by_data(values)
diff --git a/netdisco/discoverables/apple_tv.py b/netdisco/discoverables/apple_tv.py
new file mode 100644
index 0000000..02cb642
--- /dev/null
+++ b/netdisco/discoverables/apple_tv.py
@@ -0,0 +1,16 @@
+"""Discover Apple TV media players."""
+from . import MDNSDiscoverable
+from ..const import ATTR_NAME, ATTR_PROPERTIES
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for Apple TV devices."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_appletv-v2._tcp.local.')
+
+ def info_from_entry(self, entry):
+ """Returns most important info from mDNS entries."""
+ info = super().info_from_entry(entry)
+ info[ATTR_NAME] = info[ATTR_PROPERTIES]['Name'].replace('\xa0', ' ')
+ return info
diff --git a/netdisco/discoverables/arduino.py b/netdisco/discoverables/arduino.py
new file mode 100644
index 0000000..013b6cc
--- /dev/null
+++ b/netdisco/discoverables/arduino.py
@@ -0,0 +1,9 @@
+"""Discover Arduino devices."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Arduino devices."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_arduino._tcp.local.')
diff --git a/netdisco/discoverables/asus_router.py b/netdisco/discoverables/asus_router.py
new file mode 100644
index 0000000..20de3a8
--- /dev/null
+++ b/netdisco/discoverables/asus_router.py
@@ -0,0 +1,13 @@
+"""Discover ASUS routers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering ASUS routers."""
+
+ def get_entries(self):
+ """Get all the ASUS uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "ASUSTeK Computer Inc.",
+ "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
+ })
diff --git a/netdisco/discoverables/axis.py b/netdisco/discoverables/axis.py
new file mode 100644
index 0000000..c4278b1
--- /dev/null
+++ b/netdisco/discoverables/axis.py
@@ -0,0 +1,41 @@
+"""Discover Axis devices."""
+from . import MDNSDiscoverable
+
+from ..const import (
+ ATTR_HOST, ATTR_PORT, ATTR_HOSTNAME, ATTR_PROPERTIES)
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Axis devices."""
+
+ def info_from_entry(self, entry):
+ """Return most important info from mDNS entries."""
+ properties = {}
+
+ for key, value in entry.properties.items():
+ if isinstance(value, bytes):
+ value = value.decode('utf-8')
+ properties[key.decode('utf-8')] = value
+
+ return {
+ ATTR_HOST: self.ip_from_host(entry.server),
+ ATTR_PORT: entry.port,
+ ATTR_HOSTNAME: entry.server,
+ ATTR_PROPERTIES: properties,
+ }
+
+ def __init__(self, nd):
+ """Initialize the Axis discovery."""
+ super(Discoverable, self).__init__(nd, '_axis-video._tcp.local.')
+
+ def ip_from_host(self, host):
+ """Attempt to return the ip address from an mDNS host.
+
+ Return host if failed.
+ """
+ ips = self.netdis.mdns.zeroconf.cache.entries_with_name(host.lower())
+
+ try:
+ return repr(ips[0]) if ips else host
+ except TypeError:
+ return host
diff --git a/netdisco/discoverables/belkin_wemo.py b/netdisco/discoverables/belkin_wemo.py
new file mode 100644
index 0000000..8f3a26e
--- /dev/null
+++ b/netdisco/discoverables/belkin_wemo.py
@@ -0,0 +1,19 @@
+"""Discover Belkin Wemo devices."""
+from . import SSDPDiscoverable
+from ..const import ATTR_MAC_ADDRESS
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Belkin WeMo platform devices."""
+
+ def info_from_entry(self, entry):
+ """Return most important info from a uPnP entry."""
+ info = super().info_from_entry(entry)
+ device = entry.description['device']
+ info[ATTR_MAC_ADDRESS] = device.get('macAddress', '')
+ return info
+
+ def get_entries(self):
+ """Return all Belkin Wemo entries."""
+ return self.find_by_device_description(
+ {'manufacturer': 'Belkin International Inc.'})
diff --git a/netdisco/discoverables/bluesound.py b/netdisco/discoverables/bluesound.py
new file mode 100644
index 0000000..4603d83
--- /dev/null
+++ b/netdisco/discoverables/bluesound.py
@@ -0,0 +1,10 @@
+"""Discover devices that implement the Bluesound platform."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Bluesound service."""
+
+ def __init__(self, nd):
+ """Initialize the Bluesound discovery."""
+ super(Discoverable, self).__init__(nd, '_musc._tcp.local.')
diff --git a/netdisco/discoverables/bose_soundtouch.py b/netdisco/discoverables/bose_soundtouch.py
new file mode 100644
index 0000000..e9f819a
--- /dev/null
+++ b/netdisco/discoverables/bose_soundtouch.py
@@ -0,0 +1,10 @@
+"""Discover Bose SoundTouch devices."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Bose SoundTouch devices."""
+
+ def __init__(self, nd):
+ """Initialize the Bose SoundTouch discovery."""
+ super(Discoverable, self).__init__(nd, '_soundtouch._tcp.local.')
diff --git a/netdisco/discoverables/cambridgeaudio.py b/netdisco/discoverables/cambridgeaudio.py
new file mode 100644
index 0000000..3538e48
--- /dev/null
+++ b/netdisco/discoverables/cambridgeaudio.py
@@ -0,0 +1,13 @@
+""" Discover Cambridge Audio StreamMagic devices. """
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Cambridge Audio StreamMagic devices."""
+
+ def get_entries(self):
+ """Get all Cambridge Audio MediaRenderer uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Cambridge Audio",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1"
+ })
diff --git a/netdisco/discoverables/canon_printer.py b/netdisco/discoverables/canon_printer.py
new file mode 100644
index 0000000..b005f61
--- /dev/null
+++ b/netdisco/discoverables/canon_printer.py
@@ -0,0 +1,13 @@
+"""Discover Canon Printers"""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Support for the discovery of Canon Printers"""
+
+ def get_entries(self):
+ """Get all the Canon Printer uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "CANON INC.",
+ "deviceType": "urn:schemas-cipa-jp:device:DPSPrinter:1"
+ })
diff --git a/netdisco/discoverables/daikin.py b/netdisco/discoverables/daikin.py
new file mode 100644
index 0000000..b260fd4
--- /dev/null
+++ b/netdisco/discoverables/daikin.py
@@ -0,0 +1,14 @@
+"""Discover Daikin devices."""
+from . import BaseDiscoverable
+
+
+class Discoverable(BaseDiscoverable):
+ """Add support for discovering a Daikin device."""
+
+ def __init__(self, netdis):
+ """Initialize the Daikin discovery."""
+ self._netdis = netdis
+
+ def get_entries(self):
+ """Get all the Daikin details."""
+ return self._netdis.daikin.entries
diff --git a/netdisco/discoverables/deconz.py b/netdisco/discoverables/deconz.py
new file mode 100644
index 0000000..0863cda
--- /dev/null
+++ b/netdisco/discoverables/deconz.py
@@ -0,0 +1,13 @@
+"""Discover deCONZ gateways."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering deCONZ Wireless Light Control gateways."""
+
+ def get_entries(self):
+ """Get all the deCONZ uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturerURL": "http://www.dresden-elektronik.de",
+ "modelDescription": "dresden elektronik Wireless Light Control"
+ })
diff --git a/netdisco/discoverables/denonavr.py b/netdisco/discoverables/denonavr.py
new file mode 100644
index 0000000..d830aa6
--- /dev/null
+++ b/netdisco/discoverables/denonavr.py
@@ -0,0 +1,23 @@
+"""Discover Denon AVR devices."""
+from urllib.parse import urlparse
+
+from . import SSDPDiscoverable
+from ..const import ATTR_HOST
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Denon AVR devices."""
+
+ def get_entries(self):
+ """Get all Denon AVR uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Denon",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1"
+ })
+
+ def info_from_entry(self, entry):
+ """Get most important info, which is name, model and host."""
+ info = super().info_from_entry(entry)
+ info[ATTR_HOST] = urlparse(
+ entry.description['device']['presentationURL']).hostname
+ return info
diff --git a/netdisco/discoverables/directv.py b/netdisco/discoverables/directv.py
new file mode 100644
index 0000000..7babd9c
--- /dev/null
+++ b/netdisco/discoverables/directv.py
@@ -0,0 +1,13 @@
+"""Discover DirecTV Receivers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering DirecTV Receivers."""
+
+ def get_entries(self):
+ """Get all the DirecTV uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "DIRECTV",
+ "deviceType": "urn:schemas-upnp-org:device:MediaServer:1"
+ })
diff --git a/netdisco/discoverables/dlna_dmr.py b/netdisco/discoverables/dlna_dmr.py
new file mode 100644
index 0000000..bbc7fe6
--- /dev/null
+++ b/netdisco/discoverables/dlna_dmr.py
@@ -0,0 +1,13 @@
+"""Discover DLNA services."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering DLNA services."""
+
+ def get_entries(self):
+ """Get all the DLNA service uPnP entries."""
+ return \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaRenderer:1") + \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaRenderer:2") + \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaRenderer:3")
diff --git a/netdisco/discoverables/dlna_dms.py b/netdisco/discoverables/dlna_dms.py
new file mode 100644
index 0000000..eac38ec
--- /dev/null
+++ b/netdisco/discoverables/dlna_dms.py
@@ -0,0 +1,13 @@
+"""Discover DLNA services."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering DLNA services."""
+
+ def get_entries(self):
+ """Get all the DLNA service uPnP entries."""
+ return self.find_by_st("urn:schemas-upnp-org:device:MediaServer:1") + \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaServer:2") + \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaServer:3") + \
+ self.find_by_st("urn:schemas-upnp-org:device:MediaServer:4")
diff --git a/netdisco/discoverables/freebox.py b/netdisco/discoverables/freebox.py
new file mode 100644
index 0000000..11bc5f8
--- /dev/null
+++ b/netdisco/discoverables/freebox.py
@@ -0,0 +1,10 @@
+"""Discover Freebox routers."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Freebox routers."""
+
+ def __init__(self, nd):
+ """Initialize the Freebox discovery."""
+ super(Discoverable, self).__init__(nd, '_fbx-api._tcp.local.')
diff --git a/netdisco/discoverables/fritzbox.py b/netdisco/discoverables/fritzbox.py
new file mode 100644
index 0000000..4b729f5
--- /dev/null
+++ b/netdisco/discoverables/fritzbox.py
@@ -0,0 +1,10 @@
+"""Discover AVM FRITZ devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering AVM FRITZ devices."""
+
+ def get_entries(self):
+ """Get all AVM FRITZ entries."""
+ return self.find_by_st("urn:schemas-upnp-org:device:fritzbox:1")
diff --git a/netdisco/discoverables/frontier_silicon.py b/netdisco/discoverables/frontier_silicon.py
new file mode 100644
index 0000000..6cb5fe5
--- /dev/null
+++ b/netdisco/discoverables/frontier_silicon.py
@@ -0,0 +1,12 @@
+"""Discover frontier silicon devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering frontier silicon devices."""
+
+ def get_entries(self):
+ """Get all the frontier silicon uPnP entries."""
+ return [entry for entry in self.netdis.ssdp.all()
+ if entry.st and 'fsapi' in entry.st and
+ 'urn:schemas-frontier-silicon-com' in entry.st]
diff --git a/netdisco/discoverables/google_cast.py b/netdisco/discoverables/google_cast.py
new file mode 100644
index 0000000..fb7d373
--- /dev/null
+++ b/netdisco/discoverables/google_cast.py
@@ -0,0 +1,10 @@
+"""Discover devices that implement the Google Cast platform."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Google Cast platform devices."""
+
+ def __init__(self, nd):
+ """Initialize the Cast discovery."""
+ super(Discoverable, self).__init__(nd, '_googlecast._tcp.local.')
diff --git a/netdisco/discoverables/harmony.py b/netdisco/discoverables/harmony.py
new file mode 100644
index 0000000..19d97eb
--- /dev/null
+++ b/netdisco/discoverables/harmony.py
@@ -0,0 +1,13 @@
+"""Discover Harmony Hub remotes."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Harmony Hub remotes"""
+
+ def get_entries(self):
+ """Get all the Harmony uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Logitech",
+ "deviceType": "urn:myharmony-com:device:harmony:1"
+ })
diff --git a/netdisco/discoverables/hass_ios.py b/netdisco/discoverables/hass_ios.py
new file mode 100644
index 0000000..84c1eaa
--- /dev/null
+++ b/netdisco/discoverables/hass_ios.py
@@ -0,0 +1,9 @@
+"""Discover Home Assistant iOS app."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering the Home Assistant iOS app."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_hass-ios._tcp.local.')
diff --git a/netdisco/discoverables/home_assistant.py b/netdisco/discoverables/home_assistant.py
new file mode 100644
index 0000000..2b7828e
--- /dev/null
+++ b/netdisco/discoverables/home_assistant.py
@@ -0,0 +1,9 @@
+"""Discover Home Assistant servers."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Home Assistant instances."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_home-assistant._tcp.local.')
diff --git a/netdisco/discoverables/homekit.py b/netdisco/discoverables/homekit.py
new file mode 100644
index 0000000..690cb63
--- /dev/null
+++ b/netdisco/discoverables/homekit.py
@@ -0,0 +1,18 @@
+"""Discover Homekit devices."""
+from . import MDNSDiscoverable
+
+from ..const import ATTR_NAME
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering HomeKit devices."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_hap._tcp.local.')
+
+ def info_from_entry(self, entry):
+ info = super(Discoverable, self).info_from_entry(entry)
+ name = entry.name
+ name = name.replace('._hap._tcp.local.', '')
+ info[ATTR_NAME] = name
+ return info
diff --git a/netdisco/discoverables/hp_printer.py b/netdisco/discoverables/hp_printer.py
new file mode 100644
index 0000000..b4c6131
--- /dev/null
+++ b/netdisco/discoverables/hp_printer.py
@@ -0,0 +1,13 @@
+"""Discover HP Printers"""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Support for the discovery of HP Printers"""
+
+ def __init__(self, nd):
+ """Initialize the HP Printer discovery"""
+ super(Discoverable, self).__init__(nd, '_printer._tcp.local.')
+
+ def get_entries(self):
+ return self.find_by_device_name('HP ')
diff --git a/netdisco/discoverables/huawei_router.py b/netdisco/discoverables/huawei_router.py
new file mode 100644
index 0000000..7f1bb3d
--- /dev/null
+++ b/netdisco/discoverables/huawei_router.py
@@ -0,0 +1,13 @@
+"""Discover Huawei routers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Huawei routers."""
+
+ def get_entries(self):
+ """Get all the Huawei uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Huawei Technologies Co., Ltd.",
+ "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
+ })
diff --git a/netdisco/discoverables/igd.py b/netdisco/discoverables/igd.py
new file mode 100644
index 0000000..ba92c90
--- /dev/null
+++ b/netdisco/discoverables/igd.py
@@ -0,0 +1,14 @@
+"""Discover IGD services."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering IGD services."""
+
+ def get_entries(self):
+ """Get all the IGD service uPnP entries."""
+ return \
+ self.find_by_st(
+ "urn:schemas-upnp-org:device:InternetGatewayDevice:1") + \
+ self.find_by_st(
+ "urn:schemas-upnp-org:device:InternetGatewayDevice:2")
diff --git a/netdisco/discoverables/ikea_tradfri.py b/netdisco/discoverables/ikea_tradfri.py
new file mode 100644
index 0000000..9fa7c57
--- /dev/null
+++ b/netdisco/discoverables/ikea_tradfri.py
@@ -0,0 +1,10 @@
+"""Discover devices that implement the Ikea Tradfri platform."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Ikea Tradfri devices."""
+
+ def __init__(self, nd):
+ """Initialize the Cast discovery."""
+ super(Discoverable, self).__init__(nd, '_coap._udp.local.')
diff --git a/netdisco/discoverables/kodi.py b/netdisco/discoverables/kodi.py
new file mode 100644
index 0000000..4de74dd
--- /dev/null
+++ b/netdisco/discoverables/kodi.py
@@ -0,0 +1,13 @@
+"""Discover Kodi servers."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Kodi."""
+
+ def __init__(self, nd):
+ """Initialize the Kodi discovery."""
+ super(Discoverable, self).__init__(nd, '_http._tcp.local.')
+
+ def get_entries(self):
+ return self.find_by_device_name('Kodi ')
diff --git a/netdisco/discoverables/konnected.py b/netdisco/discoverables/konnected.py
new file mode 100644
index 0000000..7057b17
--- /dev/null
+++ b/netdisco/discoverables/konnected.py
@@ -0,0 +1,10 @@
+"""Discover Konnected Security devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Konnected Security devices."""
+
+ def get_entries(self):
+ """Return all Konnected entries."""
+ return self.find_by_st('urn:schemas-konnected-io:device:Security:1')
diff --git a/netdisco/discoverables/lg_smart_device.py b/netdisco/discoverables/lg_smart_device.py
new file mode 100644
index 0000000..49a6fe9
--- /dev/null
+++ b/netdisco/discoverables/lg_smart_device.py
@@ -0,0 +1,10 @@
+"""Discover LG smart devices."""
+from . import MDNSDiscoverable
+
+
+# pylint: disable=too-few-public-methods
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering LG smart devices."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_lg-smart-device._tcp.local.')
diff --git a/netdisco/discoverables/logitech_mediaserver.py b/netdisco/discoverables/logitech_mediaserver.py
new file mode 100644
index 0000000..d03b8fd
--- /dev/null
+++ b/netdisco/discoverables/logitech_mediaserver.py
@@ -0,0 +1,14 @@
+"""Discover Logitech Media Server."""
+from . import BaseDiscoverable
+
+
+class Discoverable(BaseDiscoverable):
+ """Add support for discovering Logitech Media Server."""
+
+ def __init__(self, netdis):
+ """Initialize Logitech Media Server discovery."""
+ self.netdis = netdis
+
+ def get_entries(self):
+ """Get all the Logitech Media Server details."""
+ return self.netdis.lms.entries
diff --git a/netdisco/discoverables/lutron.py b/netdisco/discoverables/lutron.py
new file mode 100644
index 0000000..b0c51d0
--- /dev/null
+++ b/netdisco/discoverables/lutron.py
@@ -0,0 +1,11 @@
+"""Discover Lutron Caseta Smart Bridge and Smart Bridge Pro devices."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Lutron Caseta Smart Bridge
+ and Smart Bridge Pro devices."""
+
+ def __init__(self, nd):
+ """Initialize the Lutron Smart Bridge discovery."""
+ super(Discoverable, self).__init__(nd, '_lutron._tcp.local.')
diff --git a/netdisco/discoverables/nanoleaf_aurora.py b/netdisco/discoverables/nanoleaf_aurora.py
new file mode 100644
index 0000000..135d785
--- /dev/null
+++ b/netdisco/discoverables/nanoleaf_aurora.py
@@ -0,0 +1,9 @@
+"""Discover Nanoleaf Aurora devices."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Nanoleaf Aurora devices."""
+
+ def __init__(self, nd):
+ super(Discoverable, self).__init__(nd, '_nanoleafapi._tcp.local.')
diff --git a/netdisco/discoverables/netgear_router.py b/netdisco/discoverables/netgear_router.py
new file mode 100644
index 0000000..d4409bd
--- /dev/null
+++ b/netdisco/discoverables/netgear_router.py
@@ -0,0 +1,13 @@
+"""Discover Netgear routers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Netgear routers."""
+
+ def get_entries(self):
+ """Get all the Netgear uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "NETGEAR, Inc.",
+ "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
+ })
diff --git a/netdisco/discoverables/octoprint.py b/netdisco/discoverables/octoprint.py
new file mode 100644
index 0000000..f6d10e0
--- /dev/null
+++ b/netdisco/discoverables/octoprint.py
@@ -0,0 +1,12 @@
+"""Discover OctoPrint Servers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering OctoPrint servers."""
+
+ def get_entries(self):
+ """Get all the OctoPrint uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "The OctoPrint Project"
+ })
diff --git a/netdisco/discoverables/openhome.py b/netdisco/discoverables/openhome.py
new file mode 100644
index 0000000..3cc4314
--- /dev/null
+++ b/netdisco/discoverables/openhome.py
@@ -0,0 +1,10 @@
+"""Discover Openhome devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Openhome compliant devices."""
+
+ def get_entries(self):
+ """Get all the Openhome compliant device uPnP entries."""
+ return self.find_by_st("urn:av-openhome-org:service:Product:2")
diff --git a/netdisco/discoverables/panasonic_viera.py b/netdisco/discoverables/panasonic_viera.py
new file mode 100644
index 0000000..3f90271
--- /dev/null
+++ b/netdisco/discoverables/panasonic_viera.py
@@ -0,0 +1,10 @@
+"""Discover Panasonic Viera TV devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Viera TV devices."""
+
+ def get_entries(self):
+ """Get all the Viera TV device uPnP entries."""
+ return self.find_by_st("urn:panasonic-com:service:p00NetworkControl:1")
diff --git a/netdisco/discoverables/philips_hue.py b/netdisco/discoverables/philips_hue.py
new file mode 100644
index 0000000..582fbc1
--- /dev/null
+++ b/netdisco/discoverables/philips_hue.py
@@ -0,0 +1,14 @@
+"""Discover Philips Hue bridges."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Philips Hue bridges."""
+
+ def get_entries(self):
+ """Get all the Hue bridge uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Royal Philips Electronics",
+ "manufacturerURL": "http://www.philips.com",
+ "modelNumber": ["929000226503", "BSB002"]
+ })
diff --git a/netdisco/discoverables/plex_mediaserver.py b/netdisco/discoverables/plex_mediaserver.py
new file mode 100644
index 0000000..9eb427a
--- /dev/null
+++ b/netdisco/discoverables/plex_mediaserver.py
@@ -0,0 +1,21 @@
+"""Discover PlexMediaServer."""
+from . import GDMDiscoverable
+from ..const import ATTR_NAME, ATTR_HOST, ATTR_PORT, ATTR_URLBASE
+
+
+class Discoverable(GDMDiscoverable):
+ """Add support for discovering Plex Media Server."""
+
+ def info_from_entry(self, entry):
+ """Return most important info from a GDM entry."""
+ return {
+ ATTR_NAME: entry['data']['Name'],
+ ATTR_HOST: entry['from'][0],
+ ATTR_PORT: entry['data']['Port'],
+ ATTR_URLBASE: 'https://%s:%s' % (entry['from'][0],
+ entry['data']['Port'])
+ }
+
+ def get_entries(self):
+ """Return all PMS entries."""
+ return self.find_by_data({'Content-Type': 'plex/media-server'})
diff --git a/netdisco/discoverables/roku.py b/netdisco/discoverables/roku.py
new file mode 100644
index 0000000..2cf7c4e
--- /dev/null
+++ b/netdisco/discoverables/roku.py
@@ -0,0 +1,10 @@
+"""Discover Roku players."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Roku media players."""
+
+ def get_entries(self):
+ """Get all the Roku entries."""
+ return self.find_by_st("roku:ecp")
diff --git a/netdisco/discoverables/sabnzbd.py b/netdisco/discoverables/sabnzbd.py
new file mode 100644
index 0000000..b2cb9e7
--- /dev/null
+++ b/netdisco/discoverables/sabnzbd.py
@@ -0,0 +1,13 @@
+"""Discover SABnzbd servers."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering SABnzbd."""
+
+ def __init__(self, nd):
+ """Initialize the SABnzbd discovery."""
+ super(Discoverable, self).__init__(nd, '_http._tcp.local.')
+
+ def get_entries(self):
+ return self.find_by_device_name('SABnzbd on')
diff --git a/netdisco/discoverables/samsung_printer.py b/netdisco/discoverables/samsung_printer.py
new file mode 100644
index 0000000..04eb2c1
--- /dev/null
+++ b/netdisco/discoverables/samsung_printer.py
@@ -0,0 +1,13 @@
+"""Discover Samsung Printers"""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Support for the discovery of Samsung Printers"""
+
+ def get_entries(self):
+ """Get all the Samsung Printer uPnP entries."""
+ return self.find_by_device_description({
+ "manufacturer": "Samsung Electronics",
+ "deviceType": "urn:schemas-upnp-org:device:Printer:1"
+ })
diff --git a/netdisco/discoverables/samsung_tv.py b/netdisco/discoverables/samsung_tv.py
new file mode 100644
index 0000000..4ad31bb
--- /dev/null
+++ b/netdisco/discoverables/samsung_tv.py
@@ -0,0 +1,25 @@
+"""Discover Samsung Smart TV services."""
+from . import SSDPDiscoverable
+from ..const import ATTR_NAME
+
+# For some models, Samsung forces a [TV] prefix to the user-specified name.
+FORCED_NAME_PREFIX = '[TV]'
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Samsung Smart TV services."""
+
+ def get_entries(self):
+ """Get all the Samsung RemoteControlReceiver entries."""
+ return self.find_by_st(
+ "urn:samsung.com:device:RemoteControlReceiver:1")
+
+ def info_from_entry(self, entry):
+ """Get most important info, by default the description location."""
+ info = super().info_from_entry(entry)
+
+ # Strip the forced prefix, if present
+ if info[ATTR_NAME].startswith(FORCED_NAME_PREFIX):
+ info[ATTR_NAME] = info[ATTR_NAME][len(FORCED_NAME_PREFIX):].strip()
+
+ return info
diff --git a/netdisco/discoverables/songpal.py b/netdisco/discoverables/songpal.py
new file mode 100644
index 0000000..94d09d6
--- /dev/null
+++ b/netdisco/discoverables/songpal.py
@@ -0,0 +1,50 @@
+"""Discover Songpal devices."""
+import logging
+from . import SSDPDiscoverable
+from . import ATTR_PROPERTIES
+
+
+class Discoverable(SSDPDiscoverable):
+ """Support for Songpal devices.
+ Supported devices: http://vssupport.sony.net/en_ww/device.html."""
+
+ def get_entries(self):
+ """Get all the Songpal devices."""
+ devs = self.find_by_st(
+ "urn:schemas-sony-com:service:ScalarWebAPI:1")
+
+ # At least some Bravia televisions use this API for communication.
+ # Based on some examples they always seem to lack modelNumber,
+ # so we use it here to keep them undiscovered for now.
+ non_bravias = []
+ for dev in devs:
+ if 'device' in dev.description:
+ device = dev.description['device']
+ if 'modelNumber' in device:
+ non_bravias.append(dev)
+
+ return non_bravias
+
+ def info_from_entry(self, entry):
+ """Get information for a device.."""
+ info = super().info_from_entry(entry)
+
+ cached_descs = entry.DESCRIPTION_CACHE[entry.location]
+
+ device_info_element = "X_ScalarWebAPI_DeviceInfo"
+ baseurl_element = "X_ScalarWebAPI_BaseURL"
+ device_element = "device"
+ if device_element in cached_descs and \
+ device_info_element in cached_descs[device_element]:
+ scalarweb = cached_descs[device_element][device_info_element]
+
+ properties = {"scalarwebapi": scalarweb}
+ if baseurl_element in scalarweb:
+ properties["endpoint"] = scalarweb[baseurl_element]
+ else:
+ logging.warning("Unable to find %s", baseurl_element)
+ info[ATTR_PROPERTIES] = properties
+ else:
+ logging.warning("Unable to find ScalarWeb element from desc.")
+
+ return info
diff --git a/netdisco/discoverables/sonos.py b/netdisco/discoverables/sonos.py
new file mode 100644
index 0000000..29c96c0
--- /dev/null
+++ b/netdisco/discoverables/sonos.py
@@ -0,0 +1,10 @@
+"""Discover Sonos devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Sonos devices."""
+
+ def get_entries(self):
+ """Get all the Sonos device uPnP entries."""
+ return self.find_by_st("urn:schemas-upnp-org:device:ZonePlayer:1")
diff --git a/netdisco/discoverables/spotify_connect.py b/netdisco/discoverables/spotify_connect.py
new file mode 100644
index 0000000..6bdb062
--- /dev/null
+++ b/netdisco/discoverables/spotify_connect.py
@@ -0,0 +1,10 @@
+"""Discover devices that implement the Spotify Connect platform."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Spotify Connect service."""
+
+ def __init__(self, nd):
+ """Initialize the Cast discovery."""
+ super(Discoverable, self).__init__(nd, '_spotify-connect._tcp.local.')
diff --git a/netdisco/discoverables/tellstick.py b/netdisco/discoverables/tellstick.py
new file mode 100644
index 0000000..727aa76
--- /dev/null
+++ b/netdisco/discoverables/tellstick.py
@@ -0,0 +1,14 @@
+"""Discover Tellstick devices."""
+from . import BaseDiscoverable
+
+
+class Discoverable(BaseDiscoverable):
+ """Add support for discovering a Tellstick device."""
+
+ def __init__(self, netdis):
+ """Initialize the Tellstick discovery."""
+ self._netdis = netdis
+
+ def get_entries(self):
+ """Get all the Tellstick details."""
+ return self._netdis.tellstick.entries
diff --git a/netdisco/discoverables/tivo_dvr.py b/netdisco/discoverables/tivo_dvr.py
new file mode 100644
index 0000000..454cfe9
--- /dev/null
+++ b/netdisco/discoverables/tivo_dvr.py
@@ -0,0 +1,14 @@
+"""Discover TiVo DVR devices providing the TCP Remote Protocol."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering TiVo Remote Protocol service."""
+
+ def __init__(self, nd):
+ """Initialize the discovery.
+
+ Yields a dictionary with hostname, host and port along with a
+ properties sub-dictionary with some device specific ids.
+ """
+ super(Discoverable, self).__init__(nd, '_tivo-remote._tcp.local.')
diff --git a/netdisco/discoverables/volumio.py b/netdisco/discoverables/volumio.py
new file mode 100644
index 0000000..a08b902
--- /dev/null
+++ b/netdisco/discoverables/volumio.py
@@ -0,0 +1,10 @@
+"""Discover Volumio servers."""
+from . import MDNSDiscoverable
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Volumio."""
+
+ def __init__(self, nd):
+ """Initialize the Volumio discovery."""
+ super(Discoverable, self).__init__(nd, '_Volumio._tcp.local.')
diff --git a/netdisco/discoverables/webos_tv.py b/netdisco/discoverables/webos_tv.py
new file mode 100644
index 0000000..4328c60
--- /dev/null
+++ b/netdisco/discoverables/webos_tv.py
@@ -0,0 +1,15 @@
+"""Discover LG WebOS TV devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering LG WebOS TV devices."""
+
+ def get_entries(self):
+ """Get all the LG WebOS TV device uPnP entries."""
+ return self.find_by_device_description(
+ {
+ "deviceType": "urn:schemas-upnp-org:device:Basic:1",
+ "modelName": "LG Smart TV"
+ }
+ )
diff --git a/netdisco/discoverables/wink.py b/netdisco/discoverables/wink.py
new file mode 100644
index 0000000..9e6833f
--- /dev/null
+++ b/netdisco/discoverables/wink.py
@@ -0,0 +1,14 @@
+"""Discover Wink hub devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Wink hub devices."""
+
+ def get_entries(self):
+ """Return all Wink entries."""
+ results = []
+ results.extend(self.find_by_st('urn:wink-com:device:hub2:2'))
+ results.extend(self.find_by_st('urn:wink-com:device:hub:2'))
+ results.extend(self.find_by_st('urn:wink-com:device:relay:2'))
+ return results
diff --git a/netdisco/discoverables/xbox_smartglass.py b/netdisco/discoverables/xbox_smartglass.py
new file mode 100644
index 0000000..191ee7b
--- /dev/null
+++ b/netdisco/discoverables/xbox_smartglass.py
@@ -0,0 +1,14 @@
+"""Discover Xbox SmartGlass devices."""
+from . import BaseDiscoverable
+
+
+class Discoverable(BaseDiscoverable):
+ """Add support for discovering a Xbox SmartGlass device."""
+
+ def __init__(self, netdis):
+ """Initialize the Xbox SmartGlass discovery."""
+ self._netdis = netdis
+
+ def get_entries(self):
+ """Get all the Xbox SmartGlass details."""
+ return self._netdis.xbox_smartglass.entries
diff --git a/netdisco/discoverables/xiaomi_gw.py b/netdisco/discoverables/xiaomi_gw.py
new file mode 100644
index 0000000..5c9ab0d
--- /dev/null
+++ b/netdisco/discoverables/xiaomi_gw.py
@@ -0,0 +1,33 @@
+"""Discover Xiaomi Mi Home (aka Lumi) Gateways."""
+from . import MDNSDiscoverable
+from ..const import ATTR_MAC_ADDRESS, ATTR_PROPERTIES
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Xiaomi Gateway"""
+
+ def __init__(self, nd):
+ """Initialize the discovery."""
+ super(Discoverable, self).__init__(nd, '_miio._udp.local.')
+
+ def info_from_entry(self, entry):
+ """Return most important info from mDNS entries."""
+ info = super().info_from_entry(entry)
+
+ # Workaround of misparsing of mDNS properties. It's unclear
+ # whether it's bug in zeroconf module or in the Gateway, but
+ # returned properties look like:
+ # {b'poch': b'0:mac=286c07aaaaaa\x00'} instead of expected:
+ # {b'epoch': b'0', b'mac': '286c07aaaaaa'}
+ if "poch" in info[ATTR_PROPERTIES]:
+ misparsed = info[ATTR_PROPERTIES]["poch"]
+ misparsed = misparsed.rstrip("\0")
+ for val in misparsed.split(":"):
+ if val.startswith("mac="):
+ info[ATTR_MAC_ADDRESS] = val[len("mac="):]
+
+ return info
+
+ def get_entries(self):
+ """Return Xiaomi Gateway devices."""
+ return self.find_by_device_name('lumi-gateway-')
diff --git a/netdisco/discoverables/yamaha.py b/netdisco/discoverables/yamaha.py
new file mode 100644
index 0000000..7361818
--- /dev/null
+++ b/netdisco/discoverables/yamaha.py
@@ -0,0 +1,42 @@
+"""Discover Yamaha Receivers."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Yamaha Receivers."""
+
+ INCOMPATIBLE_MODELS = set('N301')
+
+ REMOTE_CONTROL_SPEC_TYPE =\
+ 'urn:schemas-yamaha-com:service:X_YamahaRemoteControl:1'
+
+ def info_from_entry(self, entry):
+ """Return the most important info from a uPnP entry."""
+ info = super().info_from_entry(entry)
+
+ yam = entry.description['X_device']
+ services = yam['X_serviceList']['X_service']
+ if isinstance(services, list):
+ service = next(
+ (s for s in services
+ if s['X_specType'] == self.REMOTE_CONTROL_SPEC_TYPE),
+ services[0])
+ else:
+ service = services
+ # do a slice of the second element so we don't have double /
+ info['control_url'] = yam['X_URLBase'] + service['X_controlURL'][1:]
+ info['description_url'] = (yam['X_URLBase'] +
+ service['X_unitDescURL'][1:])
+
+ return info
+
+ def get_entries(self):
+ """Get all the Yamaha uPnP entries."""
+ devices = self.find_by_device_description({
+ "manufacturer": "Yamaha Corporation",
+ "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1"
+ })
+
+ return [device for device in devices if
+ device.description['device'].get('modelNumber', '') not in
+ self.INCOMPATIBLE_MODELS]
diff --git a/netdisco/discoverables/yeelight.py b/netdisco/discoverables/yeelight.py
new file mode 100644
index 0000000..9a42c1b
--- /dev/null
+++ b/netdisco/discoverables/yeelight.py
@@ -0,0 +1,27 @@
+"""Discover Yeelight bulbs, based on Kodi discoverable."""
+from . import MDNSDiscoverable
+from ..const import ATTR_DEVICE_TYPE
+
+DEVICE_NAME_PREFIX = 'yeelink-light-'
+
+
+class Discoverable(MDNSDiscoverable):
+ """Add support for discovering Yeelight."""
+
+ def __init__(self, nd):
+ """Initialize the Yeelight discovery."""
+ super(Discoverable, self).__init__(nd, '_miio._udp.local.')
+
+ def info_from_entry(self, entry):
+ """Return most important info from mDNS entries."""
+ info = super().info_from_entry(entry)
+
+ # Example name: yeelink-light-ceiling4_mibt72799069._miio._udp.local.
+ info[ATTR_DEVICE_TYPE] = \
+ entry.name.replace(DEVICE_NAME_PREFIX, '').split('_', 1)[0]
+
+ return info
+
+ def get_entries(self):
+ """ Return yeelight devices. """
+ return self.find_by_device_name(DEVICE_NAME_PREFIX)
diff --git a/netdisco/discoverables/ziggo_mediabox_xl.py b/netdisco/discoverables/ziggo_mediabox_xl.py
new file mode 100644
index 0000000..57fcd50
--- /dev/null
+++ b/netdisco/discoverables/ziggo_mediabox_xl.py
@@ -0,0 +1,12 @@
+"""Discover Ziggo Mediabox XL devices."""
+from . import SSDPDiscoverable
+
+
+class Discoverable(SSDPDiscoverable):
+ """Add support for discovering Ziggo Mediabox XL devices."""
+
+ def get_entries(self):
+ """Return all Ziggo (UPC) Mediabox XL entries."""
+ return self.find_by_device_description(
+ {'modelDescription': 'UPC Hzn Gateway',
+ 'deviceType': 'urn:schemas-upnp-org:device:RemoteUIServer:2'})
diff --git a/netdisco/discovery.py b/netdisco/discovery.py
new file mode 100644
index 0000000..27bce19
--- /dev/null
+++ b/netdisco/discovery.py
@@ -0,0 +1,148 @@
+"""Combine all the different protocols into a simple interface."""
+import logging
+import os
+import importlib
+
+from .ssdp import SSDP
+from .mdns import MDNS
+from .gdm import GDM
+from .lms import LMS
+from .tellstick import Tellstick
+from .daikin import Daikin
+from .smartglass import XboxSmartGlass
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class NetworkDiscovery:
+ """Scan the network for devices.
+
+ mDNS scans in a background thread.
+ SSDP scans in the foreground.
+ GDM scans in the foreground.
+ LMS scans in the foreground.
+ Tellstick scans in the foreground
+ Xbox One scans in the foreground
+
+ start: is ready to scan
+ scan: scan the network
+ discover: parse scanned data
+ get_in
+ """
+
+ # pylint: disable=too-many-instance-attributes
+ def __init__(self):
+ """Initialize the discovery."""
+
+ self.mdns = None
+ self.ssdp = None
+ self.gdm = None
+ self.lms = None
+ self.tellstick = None
+ self.daikin = None
+ self.xbox_smartglass = None
+
+ self.is_discovering = False
+ self.discoverables = None
+
+ def scan(self):
+ """Start and tells scanners to scan."""
+ self.is_discovering = True
+
+ self.mdns = MDNS()
+
+ # Needs to be after MDNS init
+ self._load_device_support()
+
+ self.mdns.start()
+
+ self.ssdp = SSDP()
+ self.ssdp.scan()
+
+ self.gdm = GDM()
+ self.gdm.scan()
+
+ self.lms = LMS()
+ self.lms.scan()
+
+ self.tellstick = Tellstick()
+ self.tellstick.scan()
+
+ self.daikin = Daikin()
+ self.daikin.scan()
+
+ self.xbox_smartglass = XboxSmartGlass()
+ self.xbox_smartglass.scan()
+
+ def stop(self):
+ """Turn discovery off."""
+ if not self.is_discovering:
+ return
+
+ self.mdns.stop()
+
+ # Not removing SSDP because it tracks state
+ self.mdns = None
+ self.gdm = None
+ self.lms = None
+ self.tellstick = None
+ self.daikin = None
+ self.xbox_smartglass = None
+ self.discoverables = None
+ self.is_discovering = False
+
+ def discover(self):
+ """Return a list of discovered devices and services."""
+ if not self.is_discovering:
+ raise RuntimeError("Needs to be called after start, before stop")
+
+ return [dis for dis, checker in self.discoverables.items()
+ if checker.is_discovered()]
+
+ def get_info(self, dis):
+ """Get a list with the most important info about discovered type."""
+ return self.discoverables[dis].get_info()
+
+ def get_entries(self, dis):
+ """Get a list with all info about a discovered type."""
+ return self.discoverables[dis].get_entries()
+
+ def _load_device_support(self):
+ """Load the devices and services that can be discovered."""
+ self.discoverables = {}
+
+ discoverables_format = __name__.rsplit('.', 1)[0] + '.discoverables.{}'
+
+ for module_name in os.listdir(os.path.join(os.path.dirname(__file__),
+ 'discoverables')):
+ if module_name[-3:] != '.py' or module_name == '__init__.py':
+ continue
+
+ module_name = module_name[:-3]
+
+ module = importlib.import_module(
+ discoverables_format.format(module_name))
+
+ self.discoverables[module_name] = module.Discoverable(self)
+
+ def print_raw_data(self):
+ """Helper method to show what is discovered in your network."""
+ from pprint import pprint
+
+ print("Zeroconf")
+ pprint(self.mdns.entries)
+ print("")
+ print("SSDP")
+ pprint(self.ssdp.entries)
+ print("")
+ print("GDM")
+ pprint(self.gdm.entries)
+ print("")
+ print("LMS")
+ pprint(self.lms.entries)
+ print("")
+ print("Tellstick")
+ pprint(self.tellstick.entries)
+ print("")
+ print("Xbox SmartGlass")
+ pprint(self.xbox_smartglass.entries)
diff --git a/netdisco/gdm.py b/netdisco/gdm.py
new file mode 100644
index 0000000..ce81ff0
--- /dev/null
+++ b/netdisco/gdm.py
@@ -0,0 +1,110 @@
+"""
+Support for discovery using GDM (Good Day Mate), multicast protocol by Plex.
+
+Inspired by
+ hippojay's plexGDM:
+ https://github.com/hippojay/script.plexbmc.helper/resources/lib/plexgdm.py
+ iBaa's PlexConnect: https://github.com/iBaa/PlexConnect/PlexAPI.py
+"""
+import socket
+import struct
+
+
+class GDM:
+ """Base class to discover GDM services."""
+
+ def __init__(self):
+ self.entries = []
+ self.last_scan = None
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Return all found entries.
+
+ Will scan for entries if not scanned recently.
+ """
+ self.scan()
+ return list(self.entries)
+
+ def find_by_content_type(self, value):
+ """Return a list of entries that match the content_type."""
+ self.scan()
+ return [entry for entry in self.entries
+ if value in entry['data']['Content_Type']]
+
+ def find_by_data(self, values):
+ """Return a list of entries that match the search parameters."""
+ self.scan()
+ return [entry for entry in self.entries
+ if all(item in entry['data'].items()
+ for item in values.items())]
+
+ def update(self):
+ """Scan for new GDM services.
+
+ Example of the dict list assigned to self.entries by this function:
+ [{'data': {
+ 'Content-Type': 'plex/media-server',
+ 'Host': '53f4b5b6023d41182fe88a99b0e714ba.plex.direct',
+ 'Name': 'myfirstplexserver',
+ 'Port': '32400',
+ 'Resource-Identifier': '646ab0aa8a01c543e94ba975f6fd6efadc36b7',
+ 'Updated-At': '1444852697',
+ 'Version': '0.9.12.13.1464-4ccd2ca',
+ },
+ 'from': ('10.10.10.100', 32414)}]
+ """
+
+ gdm_ip = '239.0.0.250' # multicast to PMS
+ gdm_port = 32414
+ gdm_msg = 'M-SEARCH * HTTP/1.0'.encode('ascii')
+ gdm_timeout = 1
+
+ self.entries = []
+
+ # setup socket for discovery -> multicast message
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.settimeout(gdm_timeout)
+
+ # Set the time-to-live for messages for local network
+ sock.setsockopt(socket.IPPROTO_IP,
+ socket.IP_MULTICAST_TTL,
+ struct.pack("B", gdm_timeout))
+
+ try:
+ # Send data to the multicast group
+ sock.sendto(gdm_msg, (gdm_ip, gdm_port))
+
+ # Look for responses from all recipients
+ while True:
+ try:
+ data, server = sock.recvfrom(1024)
+ data = data.decode('utf-8')
+ if '200 OK' in data.splitlines()[0]:
+ data = {k: v.strip() for (k, v) in (
+ line.split(':') for line in
+ data.splitlines() if ':' in line)}
+ self.entries.append({'data': data,
+ 'from': server})
+ except socket.timeout:
+ break
+ finally:
+ sock.close()
+
+
+def main():
+ """Test GDM discovery."""
+ from pprint import pprint
+
+ gdm = GDM()
+
+ pprint("Scanning GDM...")
+ gdm.update()
+ pprint(gdm.entries)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/lms.py b/netdisco/lms.py
new file mode 100644
index 0000000..6026a86
--- /dev/null
+++ b/netdisco/lms.py
@@ -0,0 +1,78 @@
+"""Squeezebox/Logitech Media server discovery."""
+import socket
+
+from .const import ATTR_HOST, ATTR_PORT
+
+DISCOVERY_PORT = 3483
+DEFAULT_DISCOVERY_TIMEOUT = 2
+
+
+class LMS:
+ """Base class to discover Logitech Media servers."""
+
+ def __init__(self):
+ """Initialize the Logitech discovery."""
+ self.entries = []
+ self.last_scan = None
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Scan and return all found entries."""
+ self.scan()
+ return list(self.entries)
+
+ def update(self):
+ """Scan network for Logitech Media Servers."""
+ lms_ip = '<broadcast>'
+ lms_port = DISCOVERY_PORT
+ lms_msg = b"eJSON\0"
+ lms_timeout = DEFAULT_DISCOVERY_TIMEOUT
+
+ entries = []
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.settimeout(lms_timeout)
+ sock.bind(('', 0))
+
+ try:
+ sock.sendto(lms_msg, (lms_ip, lms_port))
+
+ while True:
+ try:
+ data, server = sock.recvfrom(1024)
+ if data.startswith(b'E'):
+ # Full response is EJSON\xYYXXXX
+ # Where YY is length of port string (ie 4)
+ # And XXXX is the web interface port (ie 9000)
+ port = None
+ if data.startswith(b'JSON', 1):
+ length = data[5:6][0]
+ port = int(data[0-length:])
+ entries.append({
+ ATTR_HOST: server[0],
+ ATTR_PORT: port,
+ })
+ except socket.timeout:
+ break
+ finally:
+ sock.close()
+ self.entries = entries
+
+
+def main():
+ """Test LMS discovery."""
+ from pprint import pprint
+
+ lms = LMS()
+
+ pprint("Scanning for Logitech Media Servers...")
+ lms.update()
+ pprint(lms.entries)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/mdns.py b/netdisco/mdns.py
new file mode 100644
index 0000000..8826346
--- /dev/null
+++ b/netdisco/mdns.py
@@ -0,0 +1,45 @@
+"""Add support for discovering mDNS services."""
+import zeroconf
+
+
+class MDNS:
+ """Base class to discover mDNS services."""
+
+ def __init__(self):
+ """Initialize the discovery."""
+ self.zeroconf = None
+ self.services = []
+ self._browsers = []
+
+ def register_service(self, service):
+ """Register a mDNS service."""
+ self.services.append(service)
+
+ def start(self):
+ """Start discovery."""
+ try:
+ self.zeroconf = zeroconf.Zeroconf()
+
+ for service in self.services:
+ self._browsers.append(zeroconf.ServiceBrowser(
+ self.zeroconf, service.typ, service))
+ except Exception: # pylint: disable=broad-except
+ self.stop()
+ raise
+
+ def stop(self):
+ """Stop discovering."""
+ while self._browsers:
+ self._browsers.pop().cancel()
+
+ for service in self.services:
+ service.reset()
+
+ if self.zeroconf:
+ self.zeroconf.close()
+ self.zeroconf = None
+
+ @property
+ def entries(self):
+ """Return all entries in the cache."""
+ return self.zeroconf.cache.entries()
diff --git a/netdisco/service.py b/netdisco/service.py
new file mode 100644
index 0000000..3cfb6b1
--- /dev/null
+++ b/netdisco/service.py
@@ -0,0 +1,88 @@
+"""Provide service that scans the network in intervals."""
+import logging
+import threading
+import time
+from collections import defaultdict
+
+from .discovery import NetworkDiscovery
+
+DEFAULT_INTERVAL = 300 # seconds
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class DiscoveryService(threading.Thread):
+ """Service that will scan the network for devices each `interval` seconds.
+
+ Add listeners to the service to be notified of new services found.
+ """
+
+ def __init__(self, interval=DEFAULT_INTERVAL):
+ """Initialize the discovery."""
+ super(DiscoveryService, self).__init__()
+
+ # Scanning interval
+ self.interval = interval
+
+ # Listeners for new services
+ self.listeners = []
+
+ # To track when we have to stop
+ self._stop = threading.Event()
+
+ # Tell Python not to wait till this thread exits
+ self.daemon = True
+
+ # The discovery object
+ self.discovery = None
+
+ # Dict to keep track of found services. We do not want to
+ # broadcast the same found service twice.
+ self._found = defaultdict(list)
+
+ def add_listener(self, listener):
+ """Add a listener for new services."""
+ self.listeners.append(listener)
+
+ def stop(self):
+ """Stop the service."""
+ self._stop.set()
+
+ def run(self):
+ """Start the discovery service."""
+ self.discovery = NetworkDiscovery()
+
+ while True:
+ self._scan()
+
+ seconds_since_scan = 0
+
+ while seconds_since_scan < self.interval:
+ if self._stop.is_set():
+ return
+
+ time.sleep(1)
+ seconds_since_scan += 1
+
+ def _scan(self):
+ """Scan for new devices."""
+ _LOGGER.info("Scanning")
+ self.discovery.scan()
+
+ for disc in self.discovery.discover():
+ for service in self.discovery.get_info(disc):
+ self._service_found(disc, service)
+
+ self.discovery.stop()
+
+ def _service_found(self, disc, service):
+ """Tell listeners a service was found."""
+ if service not in self._found[disc]:
+ self._found[disc].append(service)
+
+ for listener in self.listeners:
+ try:
+ listener(disc, service)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Error calling listener")
diff --git a/netdisco/smartglass.py b/netdisco/smartglass.py
new file mode 100644
index 0000000..4244a2b
--- /dev/null
+++ b/netdisco/smartglass.py
@@ -0,0 +1,156 @@
+"""Xbox One SmartGlass device discovery."""
+import socket
+import struct
+import binascii
+from datetime import timedelta
+
+
+DISCOVERY_PORT = 5050
+DISCOVERY_ADDRESS_BCAST = '<broadcast>'
+DISCOVERY_ADDRESS_MCAST = '239.255.255.250'
+DISCOVERY_REQUEST = 0xDD00
+DISCOVERY_RESPONSE = 0xDD01
+DISCOVERY_TIMEOUT = timedelta(seconds=2)
+
+"""
+SmartGlass Client type
+XboxOne = 1
+Xbox360 = 2
+WindowsDesktop = 3
+WindowsStore = 4
+WindowsPhone = 5
+iPhone = 6
+iPad = 7
+Android = 8
+"""
+DISCOVERY_CLIENT_TYPE = 4
+
+
+class XboxSmartGlass:
+ """Base class to discover Xbox SmartGlass devices."""
+
+ def __init__(self):
+ """Initialize the Xbox SmartGlass discovery."""
+ self.entries = []
+ self._discovery_payload = self.discovery_packet()
+
+ @staticmethod
+ def discovery_packet():
+ """Assemble discovery payload."""
+ version = 0
+ flags = 0
+ min_version = 0
+ max_version = 2
+
+ payload = struct.pack(
+ '>IHHH',
+ flags, DISCOVERY_CLIENT_TYPE, min_version, max_version
+ )
+ header = struct.pack(
+ '>HHH',
+ DISCOVERY_REQUEST, len(payload), version
+ )
+ return header + payload
+
+ @staticmethod
+ def parse_discovery_response(data):
+ """Parse console's discovery response."""
+ pos = 0
+ # Header
+ # pkt_type, payload_len, version = struct.unpack_from(
+ # '>HHH',
+ # data, pos
+ # )
+ pos += 6
+ # Payload
+ flags, type_, name_len = struct.unpack_from(
+ '>IHH',
+ data, pos
+ )
+ pos += 8
+ name = data[pos:pos + name_len]
+ pos += name_len + 1 # including null terminator
+ uuid_len = struct.unpack_from(
+ '>H',
+ data, pos
+ )[0]
+ pos += 2
+ uuid = data[pos:pos + uuid_len]
+ pos += uuid_len + 1 # including null terminator
+ last_error, cert_len = struct.unpack_from(
+ '>IH',
+ data, pos
+ )
+ pos += 6
+ cert = data[pos:pos + cert_len]
+
+ return {
+ 'device_type': type_,
+ 'flags': flags,
+ 'name': name.decode('utf-8'),
+ 'uuid': uuid.decode('utf-8'),
+ 'last_error': last_error,
+ 'certificate': binascii.hexlify(cert).decode('utf-8')
+ }
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Scan and return all found entries."""
+ self.scan()
+ return self.entries
+
+ @staticmethod
+ def verify_packet(data):
+ """Parse packet if it has correct magic"""
+ if len(data) < 2:
+ return None
+
+ pkt_type = struct.unpack_from('>H', data)[0]
+ if pkt_type != DISCOVERY_RESPONSE:
+ return None
+
+ return XboxSmartGlass.parse_discovery_response(data)
+
+ def update(self):
+ """Scan network for Xbox SmartGlass devices."""
+ entries = []
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.settimeout(DISCOVERY_TIMEOUT.seconds)
+ sock.sendto(self._discovery_payload,
+ (DISCOVERY_ADDRESS_BCAST, DISCOVERY_PORT))
+ sock.sendto(self._discovery_payload,
+ (DISCOVERY_ADDRESS_MCAST, DISCOVERY_PORT))
+
+ while True:
+ try:
+ data, (address, _) = sock.recvfrom(1024)
+
+ response = self.verify_packet(data)
+ if response:
+ entries.append((address, response))
+
+ except socket.timeout:
+ break
+
+ self.entries = entries
+
+ sock.close()
+
+
+def main():
+ """Test XboxOne discovery."""
+ from pprint import pprint
+ xbsmartglass = XboxSmartGlass()
+ pprint("Scanning for Xbox One SmartGlass consoles devices..")
+ xbsmartglass.update()
+ pprint(xbsmartglass.entries)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/ssdp.py b/netdisco/ssdp.py
new file mode 100644
index 0000000..55cb08b
--- /dev/null
+++ b/netdisco/ssdp.py
@@ -0,0 +1,290 @@
+"""Module that implements SSDP protocol."""
+import re
+import select
+import socket
+import logging
+from datetime import datetime, timedelta
+from xml.etree import ElementTree
+
+import requests
+import zeroconf
+
+from netdisco.util import etree_to_dict
+
+DISCOVER_TIMEOUT = 2
+# MX is a suggested random wait time for a device to reply, so should be
+# bound by our discovery timeout.
+SSDP_MX = DISCOVER_TIMEOUT
+SSDP_TARGET = ("239.255.255.250", 1900)
+
+RESPONSE_REGEX = re.compile(r'\n(.*?)\: *(.*)\r')
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=59)
+
+# Devices and services
+ST_ALL = "ssdp:all"
+
+# Devices only, some devices will only respond to this query
+ST_ROOTDEVICE = "upnp:rootdevice"
+
+
+class SSDP:
+ """Control the scanning of uPnP devices and services and caches output."""
+
+ def __init__(self):
+ """Initialize the discovery."""
+ self.entries = []
+ self.last_scan = None
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Return all found entries.
+
+ Will scan for entries if not scanned recently.
+ """
+ self.update()
+
+ return list(self.entries)
+
+ # pylint: disable=invalid-name
+ def find_by_st(self, st):
+ """Return a list of entries that match the ST."""
+ self.update()
+
+ return [entry for entry in self.entries
+ if entry.st == st]
+
+ def find_by_device_description(self, values):
+ """Return a list of entries that match the description.
+
+ Pass in a dict with values to match against the device tag in the
+ description.
+ """
+ self.update()
+
+ seen = set()
+ results = []
+
+ # Make unique based on the location since we don't care about ST here
+ for entry in self.entries:
+ location = entry.location
+
+ if location not in seen and entry.match_device_description(values):
+ results.append(entry)
+ seen.add(location)
+
+ return results
+
+ def update(self, force_update=False):
+ """Scan for new uPnP devices and services."""
+ if self.last_scan is None or force_update or \
+ datetime.now()-self.last_scan > MIN_TIME_BETWEEN_SCANS:
+
+ self.remove_expired()
+
+ self.entries.extend(
+ entry for entry in scan()
+ if entry not in self.entries)
+
+ self.last_scan = datetime.now()
+
+ def remove_expired(self):
+ """Filter out expired entries."""
+ self.entries = [entry for entry in self.entries
+ if not entry.is_expired]
+
+
+class UPNPEntry:
+ """Found uPnP entry."""
+
+ DESCRIPTION_CACHE = {'_NO_LOCATION': {}}
+
+ def __init__(self, values):
+ """Initialize the discovery."""
+ self.values = values
+ self.created = datetime.now()
+
+ if 'cache-control' in self.values:
+ cache_directive = self.values['cache-control']
+ max_age = re.findall(r'max-age *= *\d+', cache_directive)
+ if max_age:
+ cache_seconds = int(max_age[0].split('=')[1])
+ self.expires = self.created + timedelta(seconds=cache_seconds)
+ else:
+ self.expires = None
+ else:
+ self.expires = None
+
+ @property
+ def is_expired(self):
+ """Return if the entry is expired or not."""
+ return self.expires is not None and datetime.now() > self.expires
+
+ # pylint: disable=invalid-name
+ @property
+ def st(self):
+ """Return ST value."""
+ return self.values.get('st')
+
+ @property
+ def location(self):
+ """Return Location value."""
+ return self.values.get('location')
+
+ @property
+ def description(self):
+ """Return the description from the uPnP entry."""
+ url = self.values.get('location', '_NO_LOCATION')
+
+ if url not in UPNPEntry.DESCRIPTION_CACHE:
+ try:
+ xml = requests.get(url, timeout=5).text
+ if not xml:
+ # Samsung Smart TV sometimes returns an empty document the
+ # first time. Retry once.
+ xml = requests.get(url, timeout=5).text
+
+ tree = ElementTree.fromstring(xml)
+
+ UPNPEntry.DESCRIPTION_CACHE[url] = \
+ etree_to_dict(tree).get('root', {})
+ except requests.RequestException:
+ logging.getLogger(__name__).debug(
+ "Error fetching description at %s", url)
+
+ UPNPEntry.DESCRIPTION_CACHE[url] = {}
+
+ except ElementTree.ParseError:
+ logging.getLogger(__name__).debug(
+ "Found malformed XML at %s: %s", url, xml)
+
+ UPNPEntry.DESCRIPTION_CACHE[url] = {}
+
+ return UPNPEntry.DESCRIPTION_CACHE[url]
+
+ def match_device_description(self, values):
+ """Fetch description and matches against it.
+
+ Values should only contain lowercase keys.
+ """
+ device = self.description.get('device')
+
+ if device is None:
+ return False
+
+ return all(device.get(key) in val
+ if isinstance(val, list)
+ else val == device.get(key)
+ for key, val in values.items())
+
+ @classmethod
+ def from_response(cls, response):
+ """Create a uPnP entry from a response."""
+ return UPNPEntry({key.lower(): item for key, item
+ in RESPONSE_REGEX.findall(response)})
+
+ def __eq__(self, other):
+ """Return the comparison."""
+ return (self.__class__ == other.__class__ and
+ self.values == other.values)
+
+ def __repr__(self):
+ """Return the entry."""
+ return "<UPNPEntry {} - {}>".format(self.location or '', self.st or '')
+
+
+def ssdp_request(ssdp_st, ssdp_mx=SSDP_MX):
+ """Return request bytes for given st and mx."""
+ return "\r\n".join([
+ 'M-SEARCH * HTTP/1.1',
+ 'ST: {}'.format(ssdp_st),
+ 'MX: {:d}'.format(ssdp_mx),
+ 'MAN: "ssdp:discover"',
+ 'HOST: {}:{}'.format(*SSDP_TARGET),
+ '', '']).encode('utf-8')
+
+
+# pylint: disable=invalid-name,too-many-locals,too-many-branches
+def scan(timeout=DISCOVER_TIMEOUT):
+ """Send a message over the network to discover uPnP devices.
+
+ Inspired by Crimsdings
+ https://github.com/crimsdings/ChromeCast/blob/master/cc_discovery.py
+
+ Protocol explanation:
+ https://embeddedinn.wordpress.com/tutorials/upnp-device-architecture/
+ """
+ ssdp_requests = ssdp_request(ST_ALL), ssdp_request(ST_ROOTDEVICE)
+
+ stop_wait = datetime.now() + timedelta(seconds=timeout)
+
+ sockets = []
+ for addr in zeroconf.get_all_addresses():
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+ # Set the time-to-live for messages for local network
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL,
+ SSDP_MX)
+ sock.bind((addr, 0))
+ sockets.append(sock)
+ except socket.error:
+ pass
+
+ entries = {}
+ for sock in [s for s in sockets]:
+ try:
+ for req in ssdp_requests:
+ sock.sendto(req, SSDP_TARGET)
+ sock.setblocking(False)
+ except socket.error:
+ sockets.remove(sock)
+ sock.close()
+
+ try:
+ while sockets:
+ time_diff = stop_wait - datetime.now()
+ seconds_left = time_diff.total_seconds()
+ if seconds_left <= 0:
+ break
+
+ ready = select.select(sockets, [], [], seconds_left)[0]
+
+ for sock in ready:
+ try:
+ data, address = sock.recvfrom(1024)
+ response = data.decode("utf-8")
+ except UnicodeDecodeError:
+ logging.getLogger(__name__).debug(
+ 'Ignoring invalid unicode response from %s', address)
+ continue
+ except socket.error:
+ logging.getLogger(__name__).exception(
+ "Socket error while discovering SSDP devices")
+ sockets.remove(sock)
+ sock.close()
+ continue
+
+ entry = UPNPEntry.from_response(response)
+ entries[(entry.st, entry.location)] = entry
+
+ finally:
+ for s in sockets:
+ s.close()
+
+ return sorted(entries.values(), key=lambda entry: entry.location or '')
+
+
+def main():
+ """Test SSDP discovery."""
+ from pprint import pprint
+
+ print("Scanning SSDP..")
+ pprint(scan())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/tellstick.py b/netdisco/tellstick.py
new file mode 100644
index 0000000..24cf243
--- /dev/null
+++ b/netdisco/tellstick.py
@@ -0,0 +1,72 @@
+"""Tellstick device discovery."""
+import socket
+from datetime import timedelta
+import logging
+
+
+DISCOVERY_PORT = 30303
+DISCOVERY_ADDRESS = '<broadcast>'
+DISCOVERY_PAYLOAD = b"D"
+DISCOVERY_TIMEOUT = timedelta(seconds=2)
+
+
+class Tellstick:
+ """Base class to discover Tellstick devices."""
+
+ def __init__(self):
+ """Initialize the Tellstick discovery."""
+ self.entries = []
+
+ def scan(self):
+ """Scan the network."""
+ self.update()
+
+ def all(self):
+ """Scan and return all found entries."""
+ self.scan()
+ return self.entries
+
+ def update(self):
+ """Scan network for Tellstick devices."""
+ entries = []
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.settimeout(DISCOVERY_TIMEOUT.seconds)
+ sock.sendto(DISCOVERY_PAYLOAD, (DISCOVERY_ADDRESS, DISCOVERY_PORT))
+
+ while True:
+ try:
+ data, (address, _) = sock.recvfrom(1024)
+ entry = data.decode("ascii").split(":")
+ # expecting product, mac, activation code, version
+ if len(entry) != 4:
+ continue
+ entry = (address,) + tuple(entry)
+ entries.append(entry)
+
+ except socket.timeout:
+ break
+ except UnicodeDecodeError:
+ # Catch invalid responses
+ logging.getLogger(__name__).debug(
+ 'Ignoring invalid unicode response from %s', address)
+ continue
+
+ self.entries = entries
+
+ sock.close()
+
+
+def main():
+ """Test Tellstick discovery."""
+ from pprint import pprint
+ tellstick = Tellstick()
+ pprint("Scanning for Tellstick devices..")
+ tellstick.update()
+ pprint(tellstick.entries)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/netdisco/util.py b/netdisco/util.py
new file mode 100644
index 0000000..65475b4
--- /dev/null
+++ b/netdisco/util.py
@@ -0,0 +1,29 @@
+"""Util functions used by Netdisco."""
+from collections import defaultdict
+
+
+# Taken from http://stackoverflow.com/a/10077069
+# pylint: disable=invalid-name
+def etree_to_dict(t):
+ """Convert an ETree object to a dict."""
+ # strip namespace
+ tag_name = t.tag[t.tag.find("}")+1:]
+
+ d = {tag_name: {} if t.attrib else None}
+ children = list(t)
+ if children:
+ dd = defaultdict(list)
+ for dc in map(etree_to_dict, children):
+ for k, v in dc.items():
+ dd[k].append(v)
+ d = {tag_name: {k: v[0] if len(v) == 1 else v for k, v in dd.items()}}
+ if t.attrib:
+ d[tag_name].update(('@' + k, v) for k, v in t.attrib.items())
+ if t.text:
+ text = t.text.strip()
+ if children or t.attrib:
+ if text:
+ d[tag_name]['#text'] = text
+ else:
+ d[tag_name] = text
+ return d