diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py index 4de642d1..45359346 100644 --- a/src/octoprint/plugin/core.py +++ b/src/octoprint/plugin/core.py @@ -24,6 +24,8 @@ class PluginInfo(object): attr_implementations = '__plugin_implementations__' + attr_helpers = '__plugin_helpers__' + attr_check = '__plugin_check__' def __init__(self, key, location, instance, version=None): @@ -72,6 +74,10 @@ class PluginInfo(object): def implementations(self): return self._get_instance_attribute(self.__class__.attr_implementations, default=[]) + @property + def helpers(self): + return self._get_instance_attribute(self.__class__.attr_helpers, default={}) + @property def check(self): return self._get_instance_attribute(self.__class__.attr_check, default=lambda: True) @@ -161,32 +167,32 @@ class PluginManager(object): plugin = self._load_plugin_from_module(key, module_name=module_name, version=version) if plugin: - plugins[id] = plugin + plugins[key] = plugin return plugins - def _load_plugin_from_module(self, id, folder=None, module_name=None, version=None): + def _load_plugin_from_module(self, key, folder=None, module_name=None, version=None): # TODO error handling if folder: - module = imp.find_module(id, [folder]) + module = imp.find_module(key, [folder]) elif module_name: module = imp.find_module(module_name) else: return None - plugin = self._load_plugin(id, *module, version=version) + plugin = self._load_plugin(key, *module, version=version) if plugin.check(): return plugin else: self.logger.warn("Plugin \"{plugin}\" did not pass check, disabling it".format(plugin=str(plugin))) return None - def _load_plugin(self, id, f, filename, description, version=None): - instance = imp.load_module(id, f, filename, description) - return PluginInfo(id, filename, instance, version=version) + def _load_plugin(self, key, f, filename, description, version=None): + instance = imp.load_module(key, f, filename, description) + return PluginInfo(key, filename, instance, version=version) - def _is_plugin_disabled(self, id): - return id in self.plugin_disabled_list or id.endswith('disabled') + def _is_plugin_disabled(self, key): + return key in self.plugin_disabled_list or key.endswith('disabled') def reload_plugins(self): self.logger.info("Loading plugins from {folders} and installed plugin packages...".format(folders=", ".join(self.plugin_folders))) @@ -228,6 +234,17 @@ class PluginManager(object): return set() return {impl[0]: impl[1] for impl in result} + def get_helpers(self, name, helpers=None): + if not name in self.plugins: + return None + plugin = self.plugins[name] + + all_helpers = plugin.helpers + if helpers: + return dict((k, v) for (k, v) in all_helpers.items() if k in helpers) + else: + return all_helpers + class Plugin(object): pass diff --git a/src/octoprint/plugins/discovery/__init__.py b/src/octoprint/plugins/discovery/__init__.py index 64409169..67ac0e82 100644 --- a/src/octoprint/plugins/discovery/__init__.py +++ b/src/octoprint/plugins/discovery/__init__.py @@ -6,6 +6,7 @@ __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agp __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms of the AGPLv3 License" +import collections import logging import os import flask @@ -13,13 +14,19 @@ import flask import octoprint.plugin import octoprint.util +try: + import pybonjour +except: + pybonjour = False + default_settings = { "publicHost": None, "publicPort": None, "pathPrefix": None, "httpUsername": None, "httpPassword": None, - "upnpUuid": None + "upnpUuid": None, + "zeroConf": [] } s = octoprint.plugin.plugin_settings("discovery", defaults=default_settings) @@ -54,8 +61,7 @@ def discovery(): friendlyName=get_instance_name(), manufacturer="The OctoPrint project", manufacturerUrl="http://www.octoprint.org", - modelDescription="Some funny description", #TODO - modelName="Some funny name", #TODO + modelName="OctoPrint", uuid=UUID, presentationUrl=flask.url_for("index", _external=True))) response.headers['Content-Type'] = 'application/xml' @@ -72,8 +78,7 @@ class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin, self.port = None # zeroconf - self._octoprint_sd_ref = None - self._http_sd_ref = None + self._sd_refs = {} # upnp/ssdp self._ssdp_monitor_active = False @@ -109,8 +114,20 @@ class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin, self.host = host self.port = port - self._zeroconf_register(host, port) - self._ssdp_register(host, port) + # Zeroconf + self.zeroconf_register("_http._tcp", get_instance_name(), txt_record=self._create_base_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: + self.zeroconf_register( + zeroconf["service"], + zeroconf["name"] if "name" in zeroconf else get_instance_name(), + port=zeroconf["port"] if "port" in zeroconf else None, + txt_record=zeroconf["txtRecord"] if "txtRecord" in zeroconf else None + ) + + # SSDP + self._ssdp_register() #~~ ShutdownPlugin API @@ -144,32 +161,96 @@ class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin, # ZeroConf - def _zeroconf_register(self, host, port): - import pybonjour + def zeroconf_register(self, service_type, name, port=None, txt_record=None): + if not pybonjour: + return def register_callback(sd_ref, flags, error_code, name, reg_type, domain): if error_code == pybonjour.kDNSServiceErr_NoError: self.logger.info("Registered {name} for {reg_type} with domain {domain}".format(**locals())) - instance_name = get_instance_name() - - self._octoprint_sd_ref = pybonjour.DNSServiceRegister( - name=instance_name, - regtype='_octoprint._tcp', - port=port, - txtRecord=pybonjour.TXTRecord(self._create_octoprint_txt_record_dict()), + params = dict( + name=name, + regtype=service_type, + port=port if port else self.port, callBack=register_callback ) - pybonjour.DNSServiceProcessResult(self._octoprint_sd_ref) + if txt_record: + params["txtRecord"] = pybonjour.TXTRecord(txt_record) - self._http_sd_ref = pybonjour.DNSServiceRegister( - name=instance_name, - regtype='_http._tcp', - port=port, - txtRecord=pybonjour.TXTRecord(self._create_base_txt_record_dict()), - callBack=register_callback - ) - pybonjour.DNSServiceProcessResult(self._http_sd_ref) + self._sd_refs[service_type] = pybonjour.DNSServiceRegister(**params) + pybonjour.DNSServiceProcessResult(self._sd_refs[service_type]) + + def zeroconf_browse(self, service_type, block=False, callback=None, timeout=5): + if not pybonjour: + return None + + import threading + import time + import select + + result = [] + result_available = threading.Event() + result_available.clear() + + 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 + if txt_record: + record = pybonjour.TXTRecord.parse(txt_record) + txt_record_dict = dict() + for key, value in record: + txt_record_dict[key] = value + + name = fullname[:fullname.find(service_type) - 1].replace("\\032", " ") + host = hosttarget[:-1] + + self.logger.debug("Resolved a result for Zeroconf resolution of {service_type}: {name} @ {host}".format(service_type=service_type, name=name, host=host)) + result.append(dict( + name=name, + host=host, + port=port, + txt_record=txt_record_dict + )) + + def browse_callback(sd_ref, flags, interface_index, error_code, service_name, regtype, reply_domain): + if error_code != pybonjour.kDNSServiceErr_NoError: + return + + if not (flags & pybonjour.kDNSServiceFlagsAdd): + return + + 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: + pybonjour.DNSServiceProcessResult(resolve_ref) + finally: + resolve_ref.close() + + self.logger.debug("Browsing Zeroconf for {service_type}".format(service_type=service_type)) + + 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) + if sd_ref in ready[0]: + pybonjour.DNSServiceProcessResult(sd_ref) + finally: + sd_ref.close() + + if callback: + callback(result) + result_available.set() + + browse_thread = threading.Thread(target=browse) + browse_thread.daemon = True + browse_thread.start() + + if block: + result_available.wait() + return result def _create_octoprint_txt_record_dict(self): entries = self._create_base_txt_record_dict() @@ -209,12 +290,80 @@ class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin, # SSDP/UPNP - def _ssdp_register(self, host, port): + def ssdp_browse(self, query, block=False, callback=None, timeout=1, retries=5): + import threading + + import httplib + import io + class Response(httplib.HTTPResponse): + def __init__(self, response_text): + self.fp = io.BytesIO(response_text) + self.debuglevel = 0 + self.strict = 0 + self.msg = None + self._method = None + self.begin() + + result = [] + result_available = threading.Event() + result_available.clear() + + def browse(): + import socket + + socket.setdefaulttimeout(timeout) + + search_message = "".join([ + "M-SEARCH * HTTP/1.1\r\n", + "ST: {query}\r\n", + "MX: 3\r\n", + "MAN: \"ssdp:discovery\"\r\n", + "HOST: 239.255.255.250:1900\r\n\r\n" + ]) + + for _ in xrange(retries): + for addr in octoprint.util.interface_addresses(): + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + sock.bind((addr, 0)) + + message = search_message.format(query=query) + for _ in xrange(2): + sock.sendto(message, ("239.255.255.250", 1900)) + + try: + data = sock.recv(1024) + except socket.timeout: + pass + else: + response = Response(data) + + result.append(response.getheader("Location")) + except Exception as e: + self.logger.exception("oops with {addr}".format(addr=addr)) + pass + + if callback: + callback(result) + result_available.set() + + browse_thread = threading.Thread(target=browse) + browse_thread.daemon = True + browse_thread.start() + + if block: + result_available.wait() + + return result + + def _ssdp_register(self): import threading self._ssdp_monitor_active = True - self._ssdp_monitor_thread = threading.Thread(target=self._ssdp_monitor, args=[host, port], kwargs=dict(timeout=self._ssdp_notify_timeout)) + self._ssdp_monitor_thread = threading.Thread(target=self._ssdp_monitor, kwargs=dict(timeout=self._ssdp_notify_timeout)) self._ssdp_monitor_thread.daemon = True self._ssdp_monitor_thread.start() @@ -222,9 +371,9 @@ class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin, self._ssdp_monitor_active = False if self.host and self.port: for _ in xrange(2): - self._ssdp_notify(self.host, self.port, alive=False) + self._ssdp_notify(alive=False) - def _ssdp_notify(self, host, port, alive=True): + def _ssdp_notify(self, alive=True): import socket import time @@ -241,24 +390,28 @@ class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin, sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) sock.bind((addr, 0)) - location = "http://{addr}:{port}/plugin/discovery/discovery.xml".format(addr=addr, port=port) + location = "http://{addr}:{port}/plugin/discovery/discovery.xml".format(addr=addr, port=self.port) - self.logger.debug("Sending NOTIFY {}".format("alive" if alive else "byebye")) - notify_message = "".join(["NOTIFY * HTTP/1.1\r\n", "Server: Python/2.7\r\n", "Cache-Control: max-age=900\r\n", "Location: {location}\r\n", "NTS: {nts}\r\n", "NT: upnp:rootdevice\r\n", "USN: uuid:{uuid}::upnp:rootdevice\r\n", "Host: 239.255.255.250:1900\r\n\r\n"]) + self.logger.debug("Sending NOTIFY {} via {}".format("alive" if alive else "byebye", addr)) + notify_message = "".join([ + "NOTIFY * HTTP/1.1\r\n", + "Server: Python/2.7\r\n", + "Cache-Control: max-age=900\r\n", + "Location: {location}\r\n", + "NTS: {nts}\r\n", + "NT: upnp:rootdevice\r\n", + "USN: uuid:{uuid}::upnp:rootdevice\r\n", + "Host: 239.255.255.250:1900\r\n\r\n" + ]) message = notify_message.format(uuid=UUID, location=location, nts="ssdp:alive" if alive else "ssdp:byebye") for _ in xrange(2): sock.sendto(message, ("239.255.255.250", 1900)) - - try: - sock.recv(1024) - except socket.timeout: - pass except Exception as e: pass self._ssdp_last_notify = time.time() - def _ssdp_monitor(self, host, port, timeout=5): + def _ssdp_monitor(self, timeout=5): from BaseHTTPServer import BaseHTTPRequestHandler from StringIO import StringIO @@ -266,7 +419,13 @@ class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin, socket.setdefaulttimeout(timeout) - location_message = "".join(["HTTP/1.1 200 OK\r\n", "ST: upnp:rootdevice\r\n", "USN: uuid:{uuid}::upnp:rootdevice\r\n", "Location: {location}\r\n", "Cache-Control: max-age=60\r\n\r\n"]) + location_message = "".join([ + "HTTP/1.1 200 OK\r\n", + "ST: upnp:rootdevice\r\n", + "USN: uuid:{uuid}::upnp:rootdevice\r\n", + "Location: {location}\r\n", + "Cache-Control: max-age=60\r\n\r\n" + ]) class Request(BaseHTTPRequestHandler): @@ -289,7 +448,7 @@ class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin, self.logger.info("Registered {} for SSDP".format(get_instance_name())) - self._ssdp_notify(host, port, alive=True) + self._ssdp_notify(alive=True) try: while (self._ssdp_monitor_active): @@ -301,13 +460,13 @@ class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin, if not interface_address: self.logger.warn("Can't determine address to user for client {}, not sending a M-SEARCH reply".format(address)) continue - message = location_message.format(uuid=UUID, location="http://{host}:{port}/plugin/discovery/discovery.xml".format(host=interface_address, port=port)) + message = location_message.format(uuid=UUID, location="http://{host}:{port}/plugin/discovery/discovery.xml".format(host=interface_address, port=self.port)) sock.sendto(message, address) self.logger.debug("Sent M-SEARCH reply for {path} and {st} to {address!r}".format(path=request.path, st=request.headers["ST"], address=address)) except socket.timeout: pass finally: - self._ssdp_notify(host, port, alive=True) + self._ssdp_notify(alive=True) finally: try: sock.close() @@ -318,17 +477,23 @@ class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin, __plugin_name__ = "Discovery" __plugin_version__ = "0.1" __plugin_description__ = "Makes the OctoPrint instance discoverable via Bonjour/Avahi/Zeroconf and uPnP" -__plugin_implementations__ = [] def __plugin_check__(): - try: - import pybonjour - except: + if not pybonjour: # no pybonjour available, we can't continue - logging.getLogger("octoprint.plugins." + __name__).info("pybonjour is not installed, Discovery Plugin won't be available. Please manually install pybonjour and restart OctoPrint") - return False + logging.getLogger("octoprint.plugins." + __name__).info("pybonjour is not installed, Zeroconf Discovery won't be available") + + discovery_plugin = DiscoveryPlugin() global __plugin_implementations__ - __plugin_implementations__ = [DiscoveryPlugin(),] + __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 + return True diff --git a/src/octoprint/server/api/__init__.py b/src/octoprint/server/api/__init__.py index f5a8d49e..910b14d7 100644 --- a/src/octoprint/server/api/__init__.py +++ b/src/octoprint/server/api/__init__.py @@ -114,7 +114,7 @@ def afterApiRequests(resp): def pluginData(name): api_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SimpleApiPlugin) if not name in api_plugins: - return make_response(404) + make_response(404) api_plugin = api_plugins[name] response = api_plugin.on_api_get(request) diff --git a/src/octoprint/util/__init__.py b/src/octoprint/util/__init__.py index 02a79938..14876a93 100644 --- a/src/octoprint/util/__init__.py +++ b/src/octoprint/util/__init__.py @@ -262,7 +262,8 @@ def interface_addresses(family=None): ifaddresses = netifaces.ifaddresses(interface) if family in ifaddresses: for ifaddress in ifaddresses[family]: - yield ifaddress["addr"] + if not ifaddress["addr"].startswith("169.254."): + yield ifaddress["addr"] def address_for_client(host, port): import socket