diff options
Diffstat (limited to 'netdisco')
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 |