Added plugin helpers, added ssdp/zeroconf browsing in discovery plugin as first helpers

This commit is contained in:
Gina Häußge 2014-09-10 22:28:03 +02:00
parent 08e4c05129
commit 56b1705df4
4 changed files with 243 additions and 60 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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