Added some comments to discovery plugin

This commit is contained in:
Gina Häußge 2014-09-12 11:22:06 +02:00
parent 1c0721f179
commit e69c1e8e93
2 changed files with 248 additions and 127 deletions

View file

@ -5,6 +5,9 @@ __author__ = "Gina Häußge <osd@foosel.net>"
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
__copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License"
"""
The SSDP/UPNP implementations has been largely inspired by https://gist.github.com/schlamar/2428250
"""
import logging
import os
@ -18,6 +21,33 @@ try:
except:
pybonjour = False
__plugin_name__ = "Discovery"
__plugin_version__ = "0.1"
__plugin_description__ = "Makes the OctoPrint instance discoverable via Bonjour/Avahi/Zeroconf and uPnP"
def __plugin_init__():
if not pybonjour:
# no pybonjour available, we can't use that
logging.getLogger("octoprint.plugins." + __name__).info("pybonjour is not installed, Zeroconf Discovery won't be available")
discovery_plugin = DiscoveryPlugin()
global __plugin_implementations__
__plugin_implementations__ = [discovery_plugin]
global __plugin_helpers__
__plugin_helpers__ = dict(
ssdp_browse=discovery_plugin.ssdp_browse
)
if pybonjour:
__plugin_helpers__.update(dict(
zeroconf_browse=discovery_plugin.zeroconf_browse,
zeroconf_register=discovery_plugin.zeroconf_register,
zeroconf_unregister=discovery_plugin.zeroconf_unregister
))
default_settings = {
"publicHost": None,
"publicPort": None,
@ -38,7 +68,6 @@ default_settings = {
}
s = octoprint.plugin.plugin_settings("discovery", defaults=default_settings)
def get_uuid():
upnpUuid = s.get(["upnpUuid"])
if upnpUuid is None:
@ -60,6 +89,8 @@ def get_instance_name():
return "OctoPrint instance on {}".format(socket.gethostname())
#~~ custom blueprint for providing discovery.xml
blueprint = flask.Blueprint("plugin.discovery", __name__, template_folder=os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates"))
@blueprint.route("/discovery.xml")
@ -91,16 +122,15 @@ def discovery():
response.headers['Content-Type'] = 'application/xml'
return response
class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
octoprint.plugin.ShutdownPlugin,
octoprint.plugin.BlueprintPlugin,
octoprint.plugin.SettingsPlugin):
octoprint.plugin.BlueprintPlugin):
ssdp_multicast_addr = "239.255.255.250"
ssdp_multicast_port = 1900
def __init__(self):
self.logger = logging.getLogger("octoprint.plugins." + __name__)
@ -117,22 +147,12 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
self._ssdp_notify_timeout = 10
self._ssdp_last_notify = 0
##~~ BlueprintPlugin API
##~~ BlueprintPlugin API -- used for providing the SSDP device descriptor XML
def get_blueprint(self):
return blueprint
##~~ TemplatePlugin API (part of SettingsPlugin)
def get_template_vars(self):
return dict(
_settings_menu_entry="Network discovery"
)
def get_template_folder(self):
return os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates")
#~~ StartupPlugin API
##~~ StartupPlugin API -- used for registering OctoPrint's Zeroconf and SSDP services upon application startup
def on_startup(self, host, port):
public_host = s.get(["publicHost"])
@ -146,7 +166,7 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
self.port = port
# Zeroconf
self.zeroconf_register("_http._tcp", get_instance_name(), txt_record=self._create_base_txt_record_dict())
self.zeroconf_register("_http._tcp", get_instance_name(), txt_record=self._create_http_txt_record_dict())
self.zeroconf_register("_octoprint._tcp", get_instance_name(), txt_record=self._create_octoprint_txt_record_dict())
for zeroconf in s.get(["zeroConf"]):
if "service" in zeroconf:
@ -160,7 +180,7 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
# SSDP
self._ssdp_register()
#~~ ShutdownPlugin API
##~~ ShutdownPlugin API -- used for unregistering OctoPrint's Zeroconf and SSDP service upon application shutdown
def on_shutdown(self):
for key in self._sd_refs:
@ -169,37 +189,25 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
self._ssdp_unregister()
#~~ SettingsPlugin API
def on_settings_load(self):
return {
"publicHost": s.get(["publicHost"]),
"publicPort": s.getInt(["publicPort"]),
"pathPrefix": s.get(["pathPrefix"]),
"httpUsername": s.get(["httpUsername"]),
"httpPassword": s.get(["httpPassword"])
}
def on_settings_save(self, data):
if "publicHost" in data and data["publicHost"]:
s.set(["publicHost"], data["publicHost"])
if "publicPort" in data and data["publicPort"]:
s.setInt(["publicPort"], data["publicPort"])
if "pathPrefix" in data and data["pathPrefix"]:
s.set(["pathPrefix"], data["pathPrefix"])
if "httpUsername" in data and data["httpUsername"]:
s.set(["httpUsername"], data["httpUsername"])
if "httpPassword" in data and data["httpPassword"]:
s.set(["httpPassword"], data["httpPassword"])
#~~ internals
##~~ helpers
# ZeroConf
def zeroconf_register(self, reg_type, name, port=None, txt_record=None, timeout=5):
def zeroconf_register(self, reg_type, name=None, port=None, txt_record=None):
"""
Registers a new service with Zeroconf/Bonjour/Avahi.
:param reg_type: type of service to register, e.g. "_gntp._tcp"
:param name: displayable name of the service, if not given defaults to the OctoPrint instance name
:param port: port to register for the service, if not given defaults to OctoPrint's (public) port
:param txt_record: optional txt record to attach to the service, dictionary of key-value-pairs
"""
if not pybonjour:
return
if not name:
name = get_instance_name()
if not port:
port = self.port
@ -215,10 +223,21 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
self._sd_refs[key] = pybonjour.DNSServiceRegister(**params)
self.logger.info("Registered {name} for {reg_type}".format(**locals()))
def zeroconf_unregister(self, reg_type, port):
def zeroconf_unregister(self, reg_type, port=None):
"""
Unregisteres a previously registered Zeroconf/Bonjour/Avahi service identified by service and port.
:param reg_type: the type of the service to be unregistered
:param port: the port of the service to be unregistered, defaults to OctoPrint's (public) port if not given
:return:
"""
if not pybonjour:
return
if not port:
port = self.port
key = (reg_type, port)
if not key in self._sd_refs:
return
@ -230,18 +249,56 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
except:
self.logger.exception("Could not unregister {reg_type} on port {port}".format(reg_type=reg_type, port=port))
def zeroconf_browse(self, service_type, block=False, callback=None, timeout=5, resolve_timeout=5):
def zeroconf_browse(self, service_type, block=True, callback=None, browse_timeout=5, resolve_timeout=5):
"""
Browses for services on the local network providing the specified service type. Can be used either blocking or
non-blocking.
The non-blocking version (default behaviour) will not return until the lookup has completed and
return all results that were found.
For non-blocking version, set `block` to `False` and provide a `callback` to be called once the lookup completes.
If no callback is provided in non-blocking mode, a ValueError will be raised.
The results are provided as a list of discovered services, with each service being described by a dictionary
with the following keys:
* `name`: display name of the service
* `host`: host name of the service
* `post`: port the service is listening on
* `txt_record`: TXT record of the service as a dictionary, exact contents depend on the service
Callbacks will be called with that list as the single parameter supplied to them. Thus, the following is an
example for a valid callback:
def browse_callback(results):
for result in results:
print "Name: {name}, Host: {host}, Port: {port}, TXT: {txt_record!r}".format(**result)
:param service_type: the service type to browse for
:param block: whether to block, defaults to True
:param callback: callback to call once lookup has completed, must be set when `block` is set to `False`
:param browse_timeout: timeout for browsing operation
:param resolve_timeout: timeout for resolving operations for discovered records
:return: if `block` is `True` a list of the discovered services, an empty list otherwise (results will then be
supplied to the callback instead)
"""
if not pybonjour:
return None
import threading
import time
import select
if not block and not callback:
raise ValueError("Non-blocking mode but no callback given")
result = []
result_available = threading.Event()
result_available.clear()
resolved = []
def resolve_callback(sd_ref, flags, interface_index, error_code, fullname, hosttarget, port, txt_record):
if error_code == pybonjour.kDNSServiceErr_NoError:
txt_record_dict = None
@ -261,6 +318,7 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
port=port,
txt_record=txt_record_dict
))
resolved.append(True)
def browse_callback(sd_ref, flags, interface_index, error_code, service_name, regtype, reply_domain):
if error_code != pybonjour.kDNSServiceErr_NoError:
@ -271,14 +329,16 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
self.logger.debug("Got a browsing result for Zeroconf resolution of {service_type}, resolving...".format(service_type=service_type))
resolve_ref = pybonjour.DNSServiceResolve(0, interface_index, service_name, regtype, reply_domain, resolve_callback)
try:
while True:
while not resolved:
ready = select.select([resolve_ref], [], [], resolve_timeout)
if resolve_ref in ready[0]:
pybonjour.DNSServiceProcessResult(resolve_ref)
else:
self.logger.warn("Timeout while trying to resolve a service")
if resolve_ref not in ready[0]:
break
pybonjour.DNSServiceProcessResult(resolve_ref)
else:
resolved.pop()
finally:
resolve_ref.close()
@ -286,10 +346,13 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
def browse():
sd_ref = pybonjour.DNSServiceBrowse(regtype=service_type, callBack=browse_callback)
start = time.time()
try:
while start + timeout > time.time():
ready = select.select([sd_ref], [], [], timeout)
while True:
ready = select.select([sd_ref], [], [], browse_timeout)
if not ready[0]:
break
if sd_ref in ready[0]:
pybonjour.DNSServiceProcessResult(sd_ref)
finally:
@ -305,56 +368,41 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
if block:
result_available.wait()
return result
def _create_octoprint_txt_record_dict(self):
entries = self._create_base_txt_record_dict()
import octoprint.server
import octoprint.server.api
entries.update(dict(
version=octoprint.server.VERSION,
api=octoprint.server.api.VERSION,
))
modelName = s.get(["model", "name"])
if modelName:
entries.update(dict(model=modelName))
vendor = s.get(["model", "vendor"])
if vendor:
entries.update(dict(vendor=vendor))
return entries
def _create_base_txt_record_dict(self):
# determine path entry
path = "/"
if s.get(["pathPrefix"]):
path = s.get(["pathPrefix"])
return result
else:
prefix = s.globalGet(["server", "reverseProxy", "prefixFallback"])
if prefix:
path = prefix
# fetch username and password (if set)
username = s.get(["httpUsername"])
password = s.get(["httpPassword"])
entries = dict(
path=path
)
if username and password:
entries.update(dict(u=username, p=password))
return entries
return []
# SSDP/UPNP
## The SSDP/UPNP implementations has been largely inspired by https://gist.github.com/schlamar/2428250
def ssdp_browse(self, query, block=True, callback=None, timeout=1, retries=5):
"""
Browses for UPNP services matching the supplied query. Can be used either blocking or
non-blocking.
The non-blocking version (default behaviour) will not return until the lookup has completed and
return all results that were found.
For non-blocking version, set `block` to `False` and provide a `callback` to be called once the lookup completes.
If no callback is provided in non-blocking mode, a ValueError will be raised.
The results are provided as a list of discovered locations of device descriptor files.
Callbacks will be called with that list as the single parameter supplied to them. Thus, the following is an
example for a valid callback:
def browse_callback(results):
for result in results:
print "Location: {}".format(result)
:param query: the SSDP query to send, e.g. "upnp:rootdevice" to search for all devices
:param block: whether to block, defaults to True
:param callback: callback to call in non-blocking mode when lookup has finished, must be set if block is False
:param timeout: timeout in seconds to wait for replies to the M-SEARCH query per interface, defaults to 1
:param retries: number of retries to perform the lookup on all interfaces, defaults to 5
:return: if `block` is `True` a list of the discovered devices, an empty list otherwise (results will then be
supplied to the callback instead)
"""
def ssdp_browse(self, query, block=False, callback=None, timeout=1, retries=5):
import threading
import httplib
@ -407,7 +455,7 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
response = Response(data)
result.append(response.getheader("Location"))
except Exception as e:
except:
pass
if callback:
@ -420,10 +468,88 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
if block:
result_available.wait()
return result
else:
return []
return result
##~~ internals
# Zeroconf
def _create_http_txt_record_dict(self):
"""
Creates a TXT record for the _http._tcp Zeroconf service supplied by this OctoPrint instance.
Defines the keys for _http._tcp as defined in http://www.dns-sd.org/txtrecords.html
:return: a dictionary containing the defined key-value-pairs, ready to be turned into a TXT record
"""
# determine path entry
path = "/"
if s.get(["pathPrefix"]):
path = s.get(["pathPrefix"])
else:
prefix = s.globalGet(["server", "reverseProxy", "prefixFallback"])
if prefix:
path = prefix
# fetch username and password (if set)
username = s.get(["httpUsername"])
password = s.get(["httpPassword"])
entries = dict(
path=path
)
if username and password:
entries.update(dict(u=username, p=password))
return entries
def _create_octoprint_txt_record_dict(self):
"""
Creates a TXT record for the _octoprint._tcp Zeroconf service supplied by this OctoPrint instance.
The following keys are defined:
* `path`: path prefix to actual OctoPrint instance, inherited from _http._tcp
* `u`: username if HTTP Basic Auth is used, optional, inherited from _http._tcp
* `p`: password if HTTP Basic Auth is used, optional, inherited from _http._tcp
* `version`: OctoPrint software version
* `api`: OctoPrint API version
* `model`: Model of the device that is running OctoPrint
* `vendor`: Vendor of the device that is running OctoPrint
:return: a dictionary containing the defined key-value-pairs, ready to be turned into a TXT record
"""
entries = self._create_http_txt_record_dict()
import octoprint.server
import octoprint.server.api
entries.update(dict(
version=octoprint.server.VERSION,
api=octoprint.server.api.VERSION,
))
modelName = s.get(["model", "name"])
if modelName:
entries.update(dict(model=modelName))
vendor = s.get(["model", "vendor"])
if vendor:
entries.update(dict(vendor=vendor))
return entries
# SSDP/UPNP
def _ssdp_register(self):
"""
Registers the OctoPrint instance as basic service with a presentation URL pointing to the web interface
"""
import threading
self._ssdp_monitor_active = True
@ -433,19 +559,31 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
self._ssdp_monitor_thread.start()
def _ssdp_unregister(self):
"""
Unregisters the OctoPrint instance again
"""
self._ssdp_monitor_active = False
if self.host and self.port:
for _ in xrange(2):
self._ssdp_notify(alive=False)
def _ssdp_notify(self, alive=True):
"""
Sends an SSDP notify message across the connected networks.
:param alive: True to send an "ssdp:alive" message, False to send an "ssdp:byebye" message
"""
import socket
import time
if self._ssdp_last_notify + self._ssdp_notify_timeout > time.time():
if alive and self._ssdp_last_notify + self._ssdp_notify_timeout > time.time():
# we just sent an alive, no need to send another one now
return
if alive and not self._ssdp_monitor_active:
# the monitor already shut down, alive messages don't make sense anymore as byebye will shortly follow
return
for addr in octoprint.util.interface_addresses():
@ -474,13 +612,20 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
mcast_addr=self.__class__.ssdp_multicast_addr,
mcast_port=self.__class__.ssdp_multicast_port)
for _ in xrange(2):
# send twice, stuff might get lost, it's only UDP
sock.sendto(message, (self.__class__.ssdp_multicast_addr, self.__class__.ssdp_multicast_port))
except Exception as e:
except:
pass
self._ssdp_last_notify = time.time()
def _ssdp_monitor(self, timeout=5):
"""
Monitor thread that listens on the multicast address for M-SEARCH requests and answers them if they are relevant
:param timeout: timeout after which to stop waiting for M-SEARCHs for a short while in order to put out an
alive message
"""
from BaseHTTPServer import BaseHTTPRequestHandler
from StringIO import StringIO
@ -543,27 +688,3 @@ class DiscoveryPlugin(octoprint.plugin.StartupPlugin,
pass
__plugin_name__ = "Discovery"
__plugin_version__ = "0.1"
__plugin_description__ = "Makes the OctoPrint instance discoverable via Bonjour/Avahi/Zeroconf and uPnP"
def __plugin_check__():
if not pybonjour:
# no pybonjour available, we can't continue
logging.getLogger("octoprint.plugins." + __name__).info("pybonjour is not installed, Zeroconf Discovery won't be available")
discovery_plugin = DiscoveryPlugin()
global __plugin_implementations__
__plugin_implementations__ = [discovery_plugin]
global __plugin_helpers__
__plugin_helpers__ = dict(
ssdp_browse=discovery_plugin.ssdp_browse
)
if pybonjour:
__plugin_helpers__["zeroconf_browse"] = discovery_plugin.zeroconf_browse
__plugin_helpers__["zeroconf_register"] = discovery_plugin.zeroconf_register
return True