From e1366ef90f516349ea59e27f28164e7c129923cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 5 Sep 2014 17:11:11 +0200 Subject: [PATCH] First experiments with a plugin system, service discovery and connectivity ensurance --- src/octoprint/plugin/__init__.py | 96 +++++++++ src/octoprint/plugin/core.py | 185 ++++++++++++++++++ src/octoprint/plugin/types.py | 52 +++++ src/octoprint/plugins/discovery/__init__.py | 123 ++++++++++++ src/octoprint/plugins/netconnectd/__init__.py | 134 +++++++++++++ .../netconnectd/static/css/netconnectd.css | 1 + .../netconnectd/static/js/netconnectd.js | 145 ++++++++++++++ .../netconnectd/static/less/netconnectd.less | 29 +++ .../netconnectd_settings_dialog.jinja2 | 78 ++++++++ src/octoprint/server/__init__.py | 57 +++++- src/octoprint/server/api/__init__.py | 40 ++++ src/octoprint/server/api/settings.py | 23 ++- src/octoprint/settings.py | 55 +++--- src/octoprint/static/js/app/dataupdater.js | 66 +++---- src/octoprint/static/js/app/main.js | 173 ++++++++++------ .../static/js/app/viewmodels/connection.js | 4 + .../static/js/app/viewmodels/control.js | 5 +- .../static/js/app/viewmodels/files.js | 7 + .../static/js/app/viewmodels/gcode.js | 4 + src/octoprint/static/js/app/viewmodels/log.js | 12 +- .../static/js/app/viewmodels/loginstate.js | 12 +- .../static/js/app/viewmodels/settings.js | 19 +- .../static/js/app/viewmodels/timelapse.js | 28 ++- src/octoprint/templates/index.jinja2 | 34 ++++ src/octoprint/templates/settings.jinja2 | 14 ++ tests/test_plugin_core.py | 66 +++++++ tests/test_plugins/hook_plugin.py | 10 + tests/test_plugins/mixed_plugin/__init__.py | 17 ++ tests/test_plugins/settings_plugin.py | 12 ++ tests/test_plugins/startup_plugin.py | 12 ++ 30 files changed, 1362 insertions(+), 151 deletions(-) create mode 100644 src/octoprint/plugin/__init__.py create mode 100644 src/octoprint/plugin/core.py create mode 100644 src/octoprint/plugin/types.py create mode 100644 src/octoprint/plugins/discovery/__init__.py create mode 100644 src/octoprint/plugins/netconnectd/__init__.py create mode 100644 src/octoprint/plugins/netconnectd/static/css/netconnectd.css create mode 100644 src/octoprint/plugins/netconnectd/static/js/netconnectd.js create mode 100644 src/octoprint/plugins/netconnectd/static/less/netconnectd.less create mode 100644 src/octoprint/plugins/netconnectd/templates/netconnectd_settings_dialog.jinja2 create mode 100644 tests/test_plugin_core.py create mode 100644 tests/test_plugins/hook_plugin.py create mode 100644 tests/test_plugins/mixed_plugin/__init__.py create mode 100644 tests/test_plugins/settings_plugin.py create mode 100644 tests/test_plugins/startup_plugin.py diff --git a/src/octoprint/plugin/__init__.py b/src/octoprint/plugin/__init__.py new file mode 100644 index 00000000..63f30d06 --- /dev/null +++ b/src/octoprint/plugin/__init__.py @@ -0,0 +1,96 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__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" + +import os + +from octoprint.settings import settings +from octoprint.plugin.core import (PluginInfo, PluginManager, Plugin) +from octoprint.plugin.types import * + +# singleton +_instance = None + +def plugin_manager(init=False, plugin_folders=None, plugin_types=None): + global _instance + if _instance is None: + if init: + if plugin_folders is None: + plugin_folders = (settings().getBaseFolder("plugins"), os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "plugins"))) + if plugin_types is None: + plugin_types = [StartupPlugin, TemplatePlugin, SettingsPlugin, SimpleApiPlugin, AssetPlugin] + + _instance = PluginManager(plugin_folders, plugin_types) + else: + raise ValueError("Plugin Manager not initialized yet") + return _instance + + +def plugin_settings(plugin_key, defaults=None): + return PluginSettings(settings(), plugin_key, defaults=defaults) + + +class PluginSettings(object): + def __init__(self, settings, plugin_key, defaults=None): + self.settings = settings + self.plugin_key = plugin_key + + if defaults is None: + defaults = dict() + self.defaults = dict(plugins=dict()) + self.defaults["plugins"][plugin_key] = defaults + + def prefix_path(path): + return ['plugins', self.plugin_key] + path + + def prefix_path_in_args(args, index=0): + result = [] + if index == 0: + result.append(prefix_path(args[0])) + result.extend(args[1:]) + else: + args_before = args[:index - 1] + args_after = args[index + 1:] + result.extend(args_before) + result.append(prefix_path(args[index])) + result.extend(args_after) + return result + + def add_defaults_to_kwargs(kwargs): + kwargs.update(dict(defaults=self.defaults)) + return kwargs + + self.access_methods = { + 'get': (lambda args: prefix_path_in_args(args), lambda kwargs: add_defaults_to_kwargs(kwargs)), + 'getInt': (lambda args,: prefix_path_in_args(args), lambda kwargs: add_defaults_to_kwargs(kwargs)), + 'getFloat': (lambda args,: prefix_path_in_args(args), lambda kwargs: add_defaults_to_kwargs(kwargs)), + 'getBoolean': (lambda args,: prefix_path_in_args(args), lambda kwargs: add_defaults_to_kwargs(kwargs)), + 'set': (lambda args: prefix_path_in_args(args), lambda kwargs: add_defaults_to_kwargs(kwargs)), + 'setInt': (lambda args: prefix_path_in_args(args), lambda kwargs: add_defaults_to_kwargs(kwargs)), + 'setFloat': (lambda args: prefix_path_in_args(args), lambda kwargs: add_defaults_to_kwargs(kwargs)), + 'setBoolean': (lambda args: prefix_path_in_args(args), lambda kwargs: add_defaults_to_kwargs(kwargs)) + } + + def globalGet(self, path): + return self.settings.get(path) + + def globalGetInt(self, path): + return self.settings.getInt(path) + + def globalGetFloat(self, path): + return self.settings.getFloat(path) + + def globalGetBoolean(self, path): + return self.settings.getBoolean(path) + + def __getattr__(self, item): + if item in self.access_methods and hasattr(self.settings, item) and callable(getattr(self.settings, item)): + orig_item = getattr(self.settings, item) + args_mapper, kwargs_mapper = self.access_methods[item] + + return lambda *args, **kwargs: orig_item(*args_mapper(args), **kwargs_mapper(kwargs)) + else: + return getattr(self.settings, item) diff --git a/src/octoprint/plugin/core.py b/src/octoprint/plugin/core.py new file mode 100644 index 00000000..f82ce15e --- /dev/null +++ b/src/octoprint/plugin/core.py @@ -0,0 +1,185 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__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" + + +import os +import imp +from collections import defaultdict +import logging + + +class PluginInfo(object): + + attr_name = '__plugin_name__' + + attr_description = '__plugin_description__' + + attr_version = '__plugin_version__' + + attr_hooks = '__plugin_hooks__' + + attr_implementations = '__plugin_implementations__' + + attr_check = '__plugin_check__' + + def __init__(self, key, location, instance): + self.key = key + self.location = location + self.instance = instance + + def __str__(self): + return "{name} ({version})".format(name=self.name, version=self.version if self.version else "unknown") + + def get_hook(self, hook): + if not hook in self.hooks: + return None + return self.hooks[hook] + + def get_implementations(self, *types): + result = set() + for implementation in self.implementations: + matches_all = True + for type in types: + if not isinstance(implementation, type): + matches_all = False + if matches_all: + result.add(implementation) + return result + + @property + def name(self): + return self._get_instance_attribute(self.__class__.attr_name, default=None) + + @property + def description(self): + return self._get_instance_attribute(self.__class__.attr_description, default=None) + + @property + def version(self): + return self._get_instance_attribute(self.__class__.attr_version, default=None) + + @property + def hooks(self): + return self._get_instance_attribute(self.__class__.attr_hooks, default={}) + + @property + def implementations(self): + return self._get_instance_attribute(self.__class__.attr_implementations, default=[]) + + @property + def check(self): + return self._get_instance_attribute(self.__class__.attr_check, default=lambda: True) + + def _get_instance_attribute(self, attr, default=None): + if not hasattr(self.instance, attr): + return default + return getattr(self.instance, attr) + + +class PluginManager(object): + + def __init__(self, plugin_folders, plugin_types, plugin_disabled_list=None): + self.logger = logging.getLogger(__name__) + + if plugin_disabled_list is None: + plugin_disabled_list = [] + + self.plugin_folders = plugin_folders + self.plugin_types = plugin_types + self.plugin_disabled_list = plugin_disabled_list + + self.plugins = dict() + self.plugin_hooks = defaultdict(list) + self.plugin_implementations = defaultdict(list) + + self.reload_plugins() + + def _find_plugins(self): + plugins = dict() + + for folder in self.plugin_folders: + if not os.path.exists(folder): + self.logger.warn("Plugin folder {folder} could not be found, skipping it".format(folder=folder)) + continue + + entries = os.listdir(folder) + for entry in entries: + path = os.path.join(folder, entry) + if os.path.isdir(path) and os.path.isfile(os.path.join(path, "__init__.py")): + id = entry + elif os.path.isfile(path) and entry.endswith(".py"): + id = entry[:-3] # strip off the .py extension + else: + continue + + if self._is_plugin_disabled(id): + # plugin is disabled, ignore it + continue + + if id in plugins: + # plugin is already defined, ignore it + continue + + module = imp.find_module(id, [folder]) + plugin = self._load_plugin(id, *module) + if plugin.check(): + plugins[id] = plugin + else: + self.logger.warn("Plugin \"{plugin}\" did not pass check, disabling it".format(plugin=str(plugin))) + + return plugins + + def _load_plugin(self, id, f, filename, description): + instance = imp.load_module(id, f, filename, description) + return PluginInfo(id, filename, instance) + + def _is_plugin_disabled(self, id): + return id in self.plugin_disabled_list or id.endswith('disabled') + + def reload_plugins(self): + self.logger.info("Loading plugins from {folders}...".format(folders=", ".join(self.plugin_folders))) + self.plugins = self._find_plugins() + + for name, plugin in self.plugins.items(): + for hook, callback in plugin.hooks.items(): + self.plugin_hooks[hook].append((name, callback)) + for plugin_type in self.plugin_types: + implementations = plugin.get_implementations(plugin_type) + self.plugin_implementations[plugin_type] += ( (name, implementation) for implementation in implementations ) + + if len(self.plugins) <= 0: + self.logger.info("No plugins found") + else: + self.logger.info("Found {count} plugin(s): {plugins}".format(count=len(self.plugins), plugins=", ".join(map(lambda x: str(x), self.plugins.values())))) + + def get_plugin(self, name): + if not name in self.plugins: + return None + return self.plugins[name].instance + + def get_hooks(self, hook): + if not hook in self.plugin_hooks: + return [] + return {hook[0]: hook[1] for hook in self.plugin_hooks[hook]} + + def get_implementations(self, *types): + result = None + + for t in types: + implementations = self.plugin_implementations[t] + if result is None: + result = set(implementations) + else: + result = result.intersection(implementations) + + if result is None: + return set() + return {impl[0]: impl[1] for impl in result} + + +class Plugin(object): + pass diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py new file mode 100644 index 00000000..f23ca21b --- /dev/null +++ b/src/octoprint/plugin/types.py @@ -0,0 +1,52 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__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" + + +from .core import Plugin + + +class StartupPlugin(Plugin): + def on_startup(self, host, port): + pass + + +class AssetPlugin(Plugin): + def get_asset_folder(self): + return None + + def get_assets(self): + return [] + + +class TemplatePlugin(Plugin): + def get_template_vars(self): + return dict() + + def get_template_folder(self): + return None + + +class SimpleApiPlugin(Plugin): + def get_api_commands(self): + return None + + def on_api_command(self, command, data): + return None + + def on_api_get(self, request): + return None + + +class SettingsPlugin(TemplatePlugin): + def on_settings_load(self): + return None + + def on_settings_save(self, data): + pass + + + diff --git a/src/octoprint/plugins/discovery/__init__.py b/src/octoprint/plugins/discovery/__init__.py new file mode 100644 index 00000000..5f5f592e --- /dev/null +++ b/src/octoprint/plugins/discovery/__init__.py @@ -0,0 +1,123 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__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" + + +import logging +import os + +import octoprint.plugin + +default_settings = { + "publicPort": None, + "pathPrefix": None +} +s = octoprint.plugin.plugin_settings("netconnectd", defaults=default_settings) + + +class DiscoveryPlugin(octoprint.plugin.types.StartupPlugin): + def __init__(self): + self.logger = logging.getLogger("octoprint.plugins." + __name__) + + self.octoprint_sd_ref = None + self.http_sd_ref = None + + ##~~ 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 + + def on_startup(self, host, port): + self._bonjour_register(host, port) + + #~~ SettingsPlugin API + + def on_settings_load(self): + return { + "publicPort": s.getInt(["publicPort"]) + } + + def on_settings_save(self, data): + if "publicPort" in data and data["publicPort"]: + s.setInt(["publicPort"], data["publicPort"]) + + #~~ internals + + def _bonjour_register(self, host, port): + import pybonjour + import octoprint._version + + 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())) + + if s.getInt(["publicPort"]): + port = s.getInt(["publicPort"]) + + prefix = s.globalGet(["server", "reverseProxy", "prefixFallback"]) + path = "/" + if s.get(["pathPrefix"]): + path = s.get(["pathPrefix"]) + elif prefix: + path = prefix + + domain = "local" + + self.octoprint_sd_ref = pybonjour.DNSServiceRegister( + name="OctoPrint API", + regtype='_octoprint._tcp', + port=port, + domain=domain, + txtRecord=pybonjour.TXTRecord({'version': octoprint._version.get_versions()['version'], 'path': path}), + callBack=register_callback + ) + pybonjour.DNSServiceProcessResult(self.octoprint_sd_ref) + + self.http_sd_ref = pybonjour.DNSServiceRegister( + name="octoprint", + regtype='_http._tcp', + port=port, + domain=domain, + txtRecord=pybonjour.TXTRecord({'path': path}), + callBack=register_callback + ) + pybonjour.DNSServiceProcessResult(self.http_sd_ref) + + self.workstation_sd_ref = pybonjour.DNSServiceRegister( + name="octoprint", + regtype='_workstation._tcp', + host="octoprint.local", + port=9, + domain=domain, + callBack=register_callback + ) + pybonjour.DNSServiceProcessResult(self.workstation_sd_ref) + + +__plugin_name__ = "Discovery" +__plugin_version__ = "0.1" +__plugin_description__ = "Makes the OctoPrint instance discoverable via Bonjour/Avahi/Zeroconf" +__plugin_implementations__ = [] + +def __plugin_check__(): + try: + import pybonjour + except: + # 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 + + global __plugin_implementations__ + __plugin_implementations__ = [DiscoveryPlugin(),] + return True + diff --git a/src/octoprint/plugins/netconnectd/__init__.py b/src/octoprint/plugins/netconnectd/__init__.py new file mode 100644 index 00000000..b3b4f641 --- /dev/null +++ b/src/octoprint/plugins/netconnectd/__init__.py @@ -0,0 +1,134 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__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" + + +import os +import logging +from flask import jsonify + +import octoprint.plugin + + +default_settings = { + "socket": "/var/run/netconnectd.sock" +} + + +s = octoprint.plugin.plugin_settings("netconnectd", defaults=default_settings) + + +class NetconnectdSettingsPlugin(octoprint.plugin.SettingsPlugin, octoprint.plugin.SimpleApiPlugin, octoprint.plugin.AssetPlugin): + + def __init__(self): + self.logger = logging.getLogger(__name__) + + ##~~ SettingsPlugin + + def on_settings_load(self): + return { + "socket": s.get(["socket"]) + } + + def on_settings_save(self, data): + if "socket" in data and data["socket"]: + s.set(["socket"], data["socket"]) + + ##~~ TemplatePlugin API (part of SettingsPlugin) + + def get_template_vars(self): + return dict( + _settings_menu_entry="Network connection" + ) + + def get_template_folder(self): + return os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates") + + ##~~ SimpleApiPlugin API + + def get_api_commands(self): + return { + "start_ap": [], + "stop_ap": [], + "refresh_wifi": [], + "configure_wifi": ["ssid", "psk"], + } + + def on_api_get(self, request): + try: + wifis = self._get_wifi_list() + status = self._get_status() + except Exception as e: + return jsonify(dict(error=e.message)) + + return jsonify({ + "wifis": wifis, + "status": status + }) + + def on_api_command(self, command, data): + if command == "refresh_wifi": + return jsonify(self._get_wifi_list(force=True)) + + elif command == "configure_wifi": + if data["psk"]: + self.logger.info("Configuring wifi {ssid} and psk...".format(**data)) + else: + self.logger.info("Configuring wifi {ssid}...".format(**data)) + + elif command == "start_ap": + self.logger.info("Starting ap...") + + elif command == "stop_ap": + self.logger.info("Stopping ap...") + + ##~~ AssetPlugin API + + def get_asset_folder(self): + return os.path.join(os.path.dirname(os.path.realpath(__file__)), "static") + + def get_assets(self): + return { + "js": ["js/netconnectd.js"], + "css": ["css/netconnectd.css"], + "less": ["less/netconnectd.less"] + } + + ##~~ Private helpers + + def _get_wifi_list(self, force=False): + if force: + self.logger.info("Forcing wifi refresh...") + return [ + {"name": "A Test Wifi", "quality": 59, "encrypted": True}, + {"name": "TyrionDiesOnPage24", "quality": 90, "encrypted": True}, + {"name": "Giraffenhaus", "quality": 78, "encrypted": False}, + ] + + def _get_status(self): + return { + "ap": False, + "connectedToWifi": True + } + + +__plugin_name__ = "netconnectd client" +__plugin_version__ = "0.1" +__plugin_description__ = "Client for netconnectd that allows configuration of netconnectd through OctoPrint's settings dialog" +__plugin_implementations__ = [] + +def __plugin_check__(): + import sys + # TODO arm the check + #if not sys.platform == 'linux2': + # return False + + global __plugin_implementations__ + __plugin_implementations__ = [NetconnectdSettingsPlugin()] + return True + + + diff --git a/src/octoprint/plugins/netconnectd/static/css/netconnectd.css b/src/octoprint/plugins/netconnectd/static/css/netconnectd.css new file mode 100644 index 00000000..74db0912 --- /dev/null +++ b/src/octoprint/plugins/netconnectd/static/css/netconnectd.css @@ -0,0 +1 @@ +table th.settings_plugin_netconnectd_wifis_name,table td.settings_plugin_netconnectd_wifis_name{text-overflow:ellipsis;text-align:left}table th.settings_plugin_netconnectd_wifis_quality,table td.settings_plugin_netconnectd_wifis_quality{text-align:right;width:70px}table th.settings_plugin_netconnectd_wifis_action,table td.settings_plugin_netconnectd_wifis_action{text-align:center;width:70px}table th.settings_plugin_netconnectd_wifis_action a,table td.settings_plugin_netconnectd_wifis_action a{text-decoration:none;color:#000}table th.settings_plugin_netconnectd_wifis_action a.disabled,table td.settings_plugin_netconnectd_wifis_action a.disabled{color:#ccc;cursor:default} \ No newline at end of file diff --git a/src/octoprint/plugins/netconnectd/static/js/netconnectd.js b/src/octoprint/plugins/netconnectd/static/js/netconnectd.js new file mode 100644 index 00000000..93bcdab7 --- /dev/null +++ b/src/octoprint/plugins/netconnectd/static/js/netconnectd.js @@ -0,0 +1,145 @@ +$(function() { + function NetconnectdViewModel(parameters) { + var self = this; + + self.loginState = parameters[0]; + self.settingsViewModel = parameters[1]; + + self.settings = undefined; + + self.data = { + wifis: ko.observableArray([]), + status: { + ap: ko.observable(), + connectedToWifi: ko.observable() + } + }; + + self.editorWifiSsid = ko.observable(); + self.editorWifiPassphrase1 = ko.observable(); + self.editorWifiPassphrase2 = ko.observable(); + + self.editorWifiPassphraseMismatch = ko.computed(function() { + return self.editorWifiPassphrase1() != self.editorWifiPassphrase2(); + }); + + self.connectionStateText = ko.computed(function() { + if (self.data.status.ap()) { + return gettext("Access Point is active"); + } else if (self.data.status.connectedToWifi()) { + return gettext("Connected to configured Wifi"); + } else { + return gettext("Connected") + } + }); + + // initialize list helper + self.listHelper = new ItemListHelper( + "wifis", + { + "name": function (a, b) { + // sorts ascending + if (a["name"].toLocaleLowerCase() < b["name"].toLocaleLowerCase()) return -1; + if (a["name"].toLocaleLowerCase() > b["name"].toLocaleLowerCase()) return 1; + return 0; + }, + "quality": function (a, b) { + // sorts descending + if (a["quality"] > b["quality"]) return -1; + if (a["quality"] < b["quality"]) return 1; + return 0; + } + }, + { + }, + "quality", + [], + [], + 10 + ); + + self.fromResponse = function (response) { + if (response.error !== undefined) { + return; + } + ko.mapping.fromJS(response, self.data); + self.listHelper.updateItems(response.wifis); + }; + + self.configureWifi = function(data) { + if (data.encrypted) { + self.editorWifiSsid(data.ssid); + self.editorWifiPassphrase1(undefined); + self.editorWifiPassphrase2(undefined); + $("#settings_plugin_netconnectd_wificonfig").modal("show"); + } else { + self.confirmWifiConfiguration(); + } + }; + + self.confirmWifiConfiguration = function() { + self.sendWifiConfig(self.editorWifiSsid(), self.editorWifiPassphrase1(), function() { + self.editorWifiSsid(undefined); + self.editorWifiPassphrase1(undefined); + self.editorWifiPassphrase2(undefined); + $("#settings_plugin_netconnectd_wificonfig").modal("hide"); + }) + }; + + self.sendStartAp = function() { + self._postCommand("start_ap", {}); + }; + + self.sendStopAp = function() { + self._postCommand("stop_ap", {}); + }; + + self.sendWifiRefresh = function(force) { + if (force === undefined) force = false; + self._postCommand("list_wifi", {force: force}, function(response) { + self.fromResponse({"wifis": response}); + }); + }; + + self.sendWifiConfig = function(ssid, psk, callback) { + self._postCommand("configure_wifi", {ssid: ssid, psk: psk}); + }; + + self._postCommand = function (command, data, successCallback, failureCallback) { + var payload = _.extend(data, {command: command}); + + $.ajax({ + url: API_BASEURL + "plugin/netconnectd", + type: "POST", + dataType: "json", + data: payload, + success: function(response) { + if (successCallback) successCallback(response); + }, + fail: function() { + if (failureCallback) failureCallback(); + } + }); + }; + + self.requestData = function () { + $.ajax({ + url: API_BASEURL + "plugin/netconnectd", + type: "GET", + dataType: "json", + success: self.fromResponse + }); + }; + + self.onBeforeBinding = function() { + self.settings = self.settingsViewModel.settings; + }; + + self.onStartup = function() { + self.requestData(); + }; + } + + // view model class, parameters for constructor, container to bind to + ADDITIONAL_VIEWMODELS.push([NetconnectdViewModel, ["loginStateViewModel", "settingsViewModel"], document.getElementById("settings_plugin_netconnectd_dialog")]); +}); diff --git a/src/octoprint/plugins/netconnectd/static/less/netconnectd.less b/src/octoprint/plugins/netconnectd/static/less/netconnectd.less new file mode 100644 index 00000000..318e5faf --- /dev/null +++ b/src/octoprint/plugins/netconnectd/static/less/netconnectd.less @@ -0,0 +1,29 @@ +table { + th, td { + // log files + &.settings_plugin_netconnectd_wifis_name { + text-overflow: ellipsis; + text-align: left; + } + + &.settings_plugin_netconnectd_wifis_quality { + text-align: right; + width: 70px; + } + + &.settings_plugin_netconnectd_wifis_action { + text-align: center; + width: 70px; + + a { + text-decoration: none; + color: #000; + + &.disabled { + color: #ccc; + cursor: default; + } + } + } + } +} \ No newline at end of file diff --git a/src/octoprint/plugins/netconnectd/templates/netconnectd_settings_dialog.jinja2 b/src/octoprint/plugins/netconnectd/templates/netconnectd_settings_dialog.jinja2 new file mode 100644 index 00000000..3c61f393 --- /dev/null +++ b/src/octoprint/plugins/netconnectd/templates/netconnectd_settings_dialog.jinja2 @@ -0,0 +1,78 @@ +
+

+ {{ _('Connection state') }}: +

+ + + + + + + + + + + + + + + + + +
{{ _('Name') }}{{ _('Quality') }}{{ _('Action') }}
+ +
+ + +
+ {{ _('netconnectd socket') }}: +
+ + +
diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index 94cc20e7..d7e7f378 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -7,7 +7,7 @@ __copyright__ = "Copyright (C) 2014 The OctoPrint Project - Released under terms import uuid from sockjs.tornado import SockJSRouter -from flask import Flask, render_template, send_from_directory, g, request +from flask import Flask, render_template, send_from_directory, g, request, make_response from flask.ext.login import LoginManager from flask.ext.principal import Principal, Permission, RoleNeed, identity_loaded, UserNeed from flask.ext.babel import Babel @@ -30,6 +30,7 @@ gcodeManager = None userManager = None eventManager = None loginManager = None +pluginManager = None principals = Principal(app) admin_permission = Permission(RoleNeed("admin")) @@ -41,6 +42,7 @@ from octoprint.settings import settings import octoprint.gcodefiles as gcodefiles import octoprint.users as users import octoprint.events as events +import octoprint.plugin import octoprint.timelapse import octoprint._version import octoprint.util @@ -88,6 +90,16 @@ def get_locale(): @app.route("/") def index(): + settings_plugins = pluginManager.get_implementations(octoprint.plugin.SettingsPlugin) + settings_plugin_template_vars = dict() + for name, implementation in settings_plugins.items(): + settings_plugin_template_vars[name] = implementation.get_template_vars() + + asset_plugins = pluginManager.get_implementations(octoprint.plugin.AssetPlugin) + asset_plugin_urls = dict() + for name, implementation in asset_plugins.items(): + asset_plugin_urls[name] = implementation.get_assets() + return render_template( "index.jinja2", webcamStream=settings().get(["webcam", "stream"]), @@ -104,7 +116,9 @@ def index(): stylesheet=settings().get(["devel", "stylesheet"]), gcodeMobileThreshold=settings().get(["gcodeViewer", "mobileSizeThreshold"]), gcodeThreshold=settings().get(["gcodeViewer", "sizeThreshold"]), - uiApiKey=UI_API_KEY + uiApiKey=UI_API_KEY, + settingsPlugins=settings_plugin_template_vars, + assetPlugins=asset_plugin_urls ) @@ -113,6 +127,20 @@ def robotsTxt(): return send_from_directory(app.static_folder, "robots.txt") +@app.route("/plugin_assets//") +def plugin_assets(name, filename): + asset_plugins = pluginManager.get_implementations(octoprint.plugin.AssetPlugin) + + if not name in asset_plugins: + return make_response(404) + asset_plugin = asset_plugins[name] + asset_folder = asset_plugin.get_asset_folder() + if asset_folder is None: + make_response(404) + + return send_from_directory(asset_folder, filename) + + @identity_loaded.connect_via(app) def on_identity_loaded(sender, identity): user = load_user(identity.id) @@ -155,6 +183,7 @@ class Server(): global userManager global eventManager global loginManager + global pluginManager global debug from tornado.ioloop import IOLoop @@ -174,6 +203,23 @@ class Server(): eventManager = events.eventManager() gcodeManager = gcodefiles.GcodeManager() printer = Printer(gcodeManager) + pluginManager = octoprint.plugin.plugin_manager(init=True) + + # configure additional template folders for jinja2 + template_plugins = pluginManager.get_implementations(octoprint.plugin.TemplatePlugin) + additional_template_folders = [] + for plugin in template_plugins.values(): + folder = plugin.get_template_folder() + if folder is not None: + additional_template_folders.append(plugin.get_template_folder()) + + import jinja2 + jinja_loader = jinja2.ChoiceLoader([ + app.jinja_loader, + jinja2.FileSystemLoader(additional_template_folders) + ]) + app.jinja_loader = jinja_loader + del jinja2 # configure timelapse octoprint.timelapse.configureTimelapse() @@ -213,7 +259,6 @@ class Server(): if self._port is None: self._port = settings().getInt(["server", "port"]) - logger.info("Listening on http://%s:%d" % (self._host, self._port)) app.debug = self._debug from octoprint.server.api import api @@ -249,6 +294,12 @@ class Server(): observer.schedule(util.watchdog.UploadCleanupWatchdogHandler(gcodeManager), settings().getBaseFolder("uploads")) observer.start() + # now it's the turn of the startup plugins + startup_plugins = pluginManager.get_implementations(octoprint.plugin.StartupPlugin) + for name, plugin in startup_plugins.items(): + plugin.on_startup(self._host, self._port) + + logger.info("Listening on http://%s:%d" % (self._host, self._port)) try: IOLoop.instance().start() except KeyboardInterrupt: diff --git a/src/octoprint/server/api/__init__.py b/src/octoprint/server/api/__init__.py index ab60eb44..f5a8d49e 100644 --- a/src/octoprint/server/api/__init__.py +++ b/src/octoprint/server/api/__init__.py @@ -16,6 +16,7 @@ from flask.ext.principal import Identity, identity_changed, AnonymousIdentity import octoprint.util as util import octoprint.users import octoprint.server +import octoprint.plugin from octoprint.server import admin_permission, NO_CONTENT, UI_API_KEY from octoprint.settings import settings as s, valid_boolean_trues from octoprint.server.util import get_api_key, get_user_for_apikey @@ -107,10 +108,49 @@ def afterApiRequests(resp): return resp +#~~ data from plugins + +@api.route("/plugin/", methods=["GET"]) +def pluginData(name): + api_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SimpleApiPlugin) + if not name in api_plugins: + return make_response(404) + + api_plugin = api_plugins[name] + response = api_plugin.on_api_get(request) + + if response is not None: + return response + return NO_CONTENT + +#~~ commands for plugins + +@api.route("/plugin/", methods=["POST"]) +@restricted_access +def pluginCommand(name): + api_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SimpleApiPlugin) + if not name in api_plugins: + return make_response(404) + + api_plugin = api_plugins[name] + valid_commands = api_plugin.get_api_commands() + if valid_commands is None: + return make_response(405) + + command, data, response = util.getJsonCommandFromRequest(request, valid_commands) + if response is not None: + return response + + response = api_plugin.on_api_command(command, data) + if response is not None: + return response + return NO_CONTENT + #~~ first run setup @api.route("/setup", methods=["POST"]) +@restricted_access def firstRunSetup(): if not s().getBoolean(["server", "firstRun"]): abort(403) diff --git a/src/octoprint/server/api/settings.py b/src/octoprint/server/api/settings.py index 2b53f2f0..4e9c527e 100644 --- a/src/octoprint/server/api/settings.py +++ b/src/octoprint/server/api/settings.py @@ -16,6 +16,8 @@ from octoprint.server import admin_permission from octoprint.server.api import api from octoprint.server.util.flask import restricted_access +import octoprint.plugin + #~~ settings @@ -29,7 +31,7 @@ def getSettings(): connectionOptions = getConnectionOptions() - return jsonify({ + data = { "api": { "enabled": s.getBoolean(["api", "enabled"]), "key": s.get(["api", "key"]), @@ -102,7 +104,19 @@ def getSettings(): "path": s.get(["cura", "path"]), "config": s.get(["cura", "config"]) } - }) + } + + settings_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SettingsPlugin) + for name, plugin in settings_plugins.items(): + plugin_data = plugin.on_settings_load() + if plugin_data: + if not "plugins" in data: + data["plugins"] = dict() + if "__enabled" in plugin_data: + del plugin_data["__enabled"] + data["plugins"][name] = plugin_data + + return jsonify(data) @api.route("/settings", methods=["POST"]) @@ -204,6 +218,11 @@ def setSettings(): enabled = cura.get("enabled") s.setBoolean(["cura", "enabled"], enabled) + settings_plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SettingsPlugin) + for name, plugin in settings_plugins.items(): + if "plugins" in data and name in data["plugins"]: + plugin.on_settings_save(data["plugins"][name]) + s.save() return getSettings() diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index 7c3b2a89..8197dfa5 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -91,7 +91,8 @@ default_settings = { "timelapse_tmp": None, "logs": None, "virtualSd": None, - "watched": None + "watched": None, + "plugins": None }, "temperature": { "profiles": @@ -152,6 +153,7 @@ default_settings = { { "name": "Suppress M105 requests/responses", "regex": "(Send: M105)|(Recv: ok T\d*:)" }, { "name": "Suppress M27 requests/responses", "regex": "(Send: M27)|(Recv: SD printing byte)" } ], + "plugins": {}, "devel": { "stylesheet": "css", "virtualPrinter": { @@ -301,19 +303,6 @@ class Settings(object): self.save(force=True) self._logger.info("Migrated %d event subscriptions to new format and structure" % len(newEvents["subscriptions"])) - if migrate: - self._migrateConfig() - - def _migrateConfig(self): - if not self._config: - return - - dirty = False - for migrate in (self._migrate_event_config, self._migrate_reverse_proxy_config): - dirty = migrate() or dirty - if dirty: - self.save(force=True) - def _migrate_reverse_proxy_config(self): if "server" in self._config.keys() and ("baseUrl" in self._config["server"] or "scheme" in self._config["server"]): prefix = "" @@ -435,12 +424,13 @@ class Settings(object): #~~ getter - def get(self, path, asdict=False): + def get(self, path, asdict=False, defaults=None): if len(path) == 0: return None config = self._config - defaults = default_settings + if defaults is None: + defaults = default_settings while len(path) > 1: key = path.pop(0) @@ -484,8 +474,8 @@ class Settings(object): else: return results - def getInt(self, path): - value = self.get(path) + def getInt(self, path, defaults=None): + value = self.get(path, defaults=defaults) if value is None: return None @@ -495,8 +485,8 @@ class Settings(object): self._logger.warn("Could not convert %r to a valid integer when getting option %r" % (value, path)) return None - def getFloat(self, path): - value = self.get(path) + def getFloat(self, path, defaults=None): + value = self.get(path, defaults=defaults) if value is None: return None @@ -506,8 +496,8 @@ class Settings(object): self._logger.warn("Could not convert %r to a valid integer when getting option %r" % (value, path)) return None - def getBoolean(self, path): - value = self.get(path) + def getBoolean(self, path, defaults=None): + value = self.get(path, defaults=defaults) if value is None: return None if isinstance(value, bool): @@ -581,12 +571,13 @@ class Settings(object): #~~ setter - def set(self, path, value, force=False): + def set(self, path, value, force=False, defaults=None): if len(path) == 0: return config = self._config - defaults = default_settings + if defaults is None: + defaults = default_settings while len(path) > 1: key = path.pop(0) @@ -611,9 +602,9 @@ class Settings(object): config[key] = value self._dirty = True - def setInt(self, path, value, force=False): + def setInt(self, path, value, force=False, defaults=None): if value is None: - self.set(path, None, force) + self.set(path, None, force=force, defaults=defaults) return try: @@ -624,9 +615,9 @@ class Settings(object): self.set(path, intValue, force) - def setFloat(self, path, value, force=False): + def setFloat(self, path, value, force=False, defaults=None): if value is None: - self.set(path, None, force) + self.set(path, None, force=force, defaults=defaults) return try: @@ -637,13 +628,13 @@ class Settings(object): self.set(path, floatValue, force) - def setBoolean(self, path, value, force=False): + def setBoolean(self, path, value, force=False, defaults=None): if value is None or isinstance(value, bool): - self.set(path, value, force) + self.set(path, value, force=force, defaults=defaults) elif value.lower() in valid_boolean_trues: - self.set(path, True, force) + self.set(path, True, force=force, defaults=defaults) else: - self.set(path, False, force) + self.set(path, False, force=force, defaults=defaults) def setBaseFolder(self, type, path, force=False): if type not in default_settings["folder"].keys(): diff --git a/src/octoprint/static/js/app/dataupdater.js b/src/octoprint/static/js/app/dataupdater.js index e6db9b23..cd637261 100644 --- a/src/octoprint/static/js/app/dataupdater.js +++ b/src/octoprint/static/js/app/dataupdater.js @@ -1,16 +1,7 @@ -function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewModel, temperatureViewModel, controlViewModel, terminalViewModel, gcodeFilesViewModel, timelapseViewModel, gcodeViewModel, logViewModel) { +function DataUpdater(allViewModels) { var self = this; - self.loginStateViewModel = loginStateViewModel; - self.connectionViewModel = connectionViewModel; - self.printerStateViewModel = printerStateViewModel; - self.temperatureViewModel = temperatureViewModel; - self.controlViewModel = controlViewModel; - self.terminalViewModel = terminalViewModel; - self.gcodeFilesViewModel = gcodeFilesViewModel; - self.timelapseViewModel = timelapseViewModel; - self.gcodeViewModel = gcodeViewModel; - self.logViewModel = logViewModel; + self.allViewModels = allViewModels; self._socket = undefined; self._autoReconnecting = false; @@ -60,6 +51,10 @@ function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewM self._onmessage = function(e) { for (var prop in e.data) { + if (!e.data.hasOwnProperty(prop)) { + continue; + } + var data = e.data[prop]; switch (prop) { @@ -76,12 +71,11 @@ function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewM if ($("#offline_overlay").is(":visible")) { $("#offline_overlay").hide(); - self.logViewModel.requestData(); - self.timelapseViewModel.requestData(); - $("#webcam_image").attr("src", CONFIG_WEBCAM_STREAM + "?" + new Date().getTime()); - self.loginStateViewModel.requestData(); - self.gcodeFilesViewModel.requestData(); - self.gcodeViewModel.reset(); + _.each(self.allViewModels, function(viewModel) { + if (viewModel.hasOwnProperty("onDataUpdaterReconnect")) { + viewModel.onDataUpdaterReconnect(); + } + }); if ($('#tabs li[class="active"] a').attr("href") == "#control") { $("#webcam_image").attr("src", CONFIG_WEBCAM_STREAM + "?" + new Date().getTime()); @@ -91,25 +85,19 @@ function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewM break; } case "history": { - self.connectionViewModel.fromHistoryData(data); - self.printerStateViewModel.fromHistoryData(data); - self.temperatureViewModel.fromHistoryData(data); - self.controlViewModel.fromHistoryData(data); - self.terminalViewModel.fromHistoryData(data); - self.timelapseViewModel.fromHistoryData(data); - self.gcodeViewModel.fromHistoryData(data); - self.gcodeFilesViewModel.fromCurrentData(data); + _.each(self.allViewModels, function(viewModel) { + if (viewModel.hasOwnProperty("fromHistoryData")) { + viewModel.fromHistoryData(data); + } + }); break; } case "current": { - self.connectionViewModel.fromCurrentData(data); - self.printerStateViewModel.fromCurrentData(data); - self.temperatureViewModel.fromCurrentData(data); - self.controlViewModel.fromCurrentData(data); - self.terminalViewModel.fromCurrentData(data); - self.timelapseViewModel.fromCurrentData(data); - self.gcodeViewModel.fromCurrentData(data); - self.gcodeFilesViewModel.fromCurrentData(data); + _.each(self.allViewModels, function(viewModel) { + if (viewModel.hasOwnProperty("fromCurrentData")) { + viewModel.fromCurrentData(data); + } + }); break; } case "event": { @@ -162,11 +150,19 @@ function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewM break; } case "feedbackCommandOutput": { - self.controlViewModel.fromFeedbackCommandData(data); + _.each(self.allViewModels, function(viewModel) { + if (viewModel.hasOwnProperty("fromFeedbackCommandData")) { + viewModel.fromFeedbackCommandData(data); + } + }); break; } case "timelapse": { - self.printerStateViewModel.fromTimelapseData(data); + _.each(self.allViewModels, function(viewModel) { + if (viewModel.hasOwnProperty("fromTimelapseData")) { + viewModel.fromTimelapseData(data); + } + }); break; } } diff --git a/src/octoprint/static/js/app/main.js b/src/octoprint/static/js/app/main.js index d76a2080..5e0d6172 100644 --- a/src/octoprint/static/js/app/main.js +++ b/src/octoprint/static/js/app/main.js @@ -25,35 +25,6 @@ $(function() { gettext("Transfering file to SD") ]; - //~~ Initialize view models - var loginStateViewModel = new LoginStateViewModel(); - var usersViewModel = new UsersViewModel(loginStateViewModel); - var settingsViewModel = new SettingsViewModel(loginStateViewModel, usersViewModel); - var connectionViewModel = new ConnectionViewModel(loginStateViewModel, settingsViewModel); - var timelapseViewModel = new TimelapseViewModel(loginStateViewModel); - var printerStateViewModel = new PrinterStateViewModel(loginStateViewModel, timelapseViewModel); - var appearanceViewModel = new AppearanceViewModel(settingsViewModel); - var temperatureViewModel = new TemperatureViewModel(loginStateViewModel, settingsViewModel); - var controlViewModel = new ControlViewModel(loginStateViewModel, settingsViewModel); - var terminalViewModel = new TerminalViewModel(loginStateViewModel, settingsViewModel); - var gcodeFilesViewModel = new GcodeFilesViewModel(printerStateViewModel, loginStateViewModel); - var gcodeViewModel = new GcodeViewModel(loginStateViewModel, settingsViewModel); - var navigationViewModel = new NavigationViewModel(loginStateViewModel, appearanceViewModel, settingsViewModel, usersViewModel); - var logViewModel = new LogViewModel(loginStateViewModel); - - var dataUpdater = new DataUpdater( - loginStateViewModel, - connectionViewModel, - printerStateViewModel, - temperatureViewModel, - controlViewModel, - terminalViewModel, - gcodeFilesViewModel, - timelapseViewModel, - gcodeViewModel, - logViewModel - ); - PNotify.prototype.options.styling = "bootstrap2"; // work around a stupid iOS6 bug where ajax requests get cached and only work once, as described at @@ -71,13 +42,70 @@ $(function() { //~~ Show settings - to ensure centered $('#navbar_show_settings').click(function() { $('#settings_dialog').modal() - .css({ - width: 'auto', - 'margin-left': function() { return -($(this).width() /2); } - }); + .css({ + width: 'auto', + 'margin-left': function() { return -($(this).width() /2); } + }); return false; }); + //~~ Initialize view models + var loginStateViewModel = new LoginStateViewModel(); + var usersViewModel = new UsersViewModel(loginStateViewModel); + var settingsViewModel = new SettingsViewModel(loginStateViewModel, usersViewModel); + var connectionViewModel = new ConnectionViewModel(loginStateViewModel, settingsViewModel); + var timelapseViewModel = new TimelapseViewModel(loginStateViewModel); + var printerStateViewModel = new PrinterStateViewModel(loginStateViewModel, timelapseViewModel); + var appearanceViewModel = new AppearanceViewModel(settingsViewModel); + var temperatureViewModel = new TemperatureViewModel(loginStateViewModel, settingsViewModel); + var controlViewModel = new ControlViewModel(loginStateViewModel, settingsViewModel); + var terminalViewModel = new TerminalViewModel(loginStateViewModel, settingsViewModel); + var gcodeFilesViewModel = new GcodeFilesViewModel(printerStateViewModel, loginStateViewModel); + var gcodeViewModel = new GcodeViewModel(loginStateViewModel, settingsViewModel); + var navigationViewModel = new NavigationViewModel(loginStateViewModel, appearanceViewModel, settingsViewModel, usersViewModel); + var logViewModel = new LogViewModel(loginStateViewModel); + + var viewModelMap = { + loginStateViewModel: loginStateViewModel, + usersViewModel: usersViewModel, + settingsViewModel: settingsViewModel, + connectionViewModel: connectionViewModel, + timelapseViewModel: timelapseViewModel, + printerStateViewModel: printerStateViewModel, + appearanceViewModel: appearanceViewModel, + temperatureViewModel: temperatureViewModel, + controlViewModel: controlViewModel, + terminalViewModel: terminalViewModel, + gcodeFilesViewModel: gcodeFilesViewModel, + gcodeViewModel: gcodeViewModel, + navigationViewModel: navigationViewModel, + logViewModel: logViewModel + }; + + var allViewModels = _.values(viewModelMap); + + var additionalViewModels = []; + _.each(ADDITIONAL_VIEWMODELS, function(viewModel) { + var viewModelClass = viewModel[0]; + var viewModelParameters = viewModel[1]; + var viewModelBindTarget = viewModel[2]; + + var constructorParameters = []; + _.each(viewModelParameters, function(parameter) { + if (_.has(viewModelMap, parameter)) { + constructorParameters.push(viewModelMap[parameter]); + } else { + constructorParameters.push(undefined); + } + }); + + var viewModelInstance = new viewModelClass(constructorParameters); + additionalViewModels.push([viewModelInstance, viewModelBindTarget]); + allViewModels.push(viewModelInstance); + }); + + var dataUpdater = new DataUpdater(allViewModels); + //~~ Temperature $('#tabs a[data-toggle="tab"]').on('shown', function (e) { @@ -331,37 +359,62 @@ $(function() { } }; - ko.applyBindings(connectionViewModel, document.getElementById("connection_accordion")); - ko.applyBindings(printerStateViewModel, document.getElementById("state_accordion")); - ko.applyBindings(gcodeFilesViewModel, document.getElementById("files_accordion")); - ko.applyBindings(temperatureViewModel, document.getElementById("temp")); - ko.applyBindings(controlViewModel, document.getElementById("control")); - ko.applyBindings(terminalViewModel, document.getElementById("term")); - var gcode = document.getElementById("gcode"); - if (gcode) { - gcodeViewModel.initialize(); - ko.applyBindings(gcodeViewModel, gcode); - } - ko.applyBindings(settingsViewModel, document.getElementById("settings_dialog")); - ko.applyBindings(navigationViewModel, document.getElementById("navbar")); - ko.applyBindings(appearanceViewModel, document.getElementsByTagName("head")[0]); - ko.applyBindings(printerStateViewModel, document.getElementById("drop_overlay")); - ko.applyBindings(logViewModel, document.getElementById("logs")); + ko.bindingHandlers.invisible = { + init: function(element, valueAccessor, allBindings, viewModel, bindingContext) { + if (!valueAccessor()) return; + ko.bindingHandlers.style.update(element, function() { + return { visibility: 'hidden' }; + }) + } + }; - var timelapseElement = document.getElementById("timelapse"); - if (timelapseElement) { - ko.applyBindings(timelapseViewModel, timelapseElement); - } + settingsViewModel.requestData(function() { + ko.applyBindings(settingsViewModel, document.getElementById("settings_dialog")); + + ko.applyBindings(connectionViewModel, document.getElementById("connection_accordion")); + ko.applyBindings(printerStateViewModel, document.getElementById("state_accordion")); + ko.applyBindings(gcodeFilesViewModel, document.getElementById("files_accordion")); + ko.applyBindings(temperatureViewModel, document.getElementById("temp")); + ko.applyBindings(controlViewModel, document.getElementById("control")); + ko.applyBindings(terminalViewModel, document.getElementById("term")); + var gcode = document.getElementById("gcode"); + if (gcode) { + gcodeViewModel.initialize(); + ko.applyBindings(gcodeViewModel, gcode); + } + //ko.applyBindings(settingsViewModel, document.getElementById("settings_dialog")); + ko.applyBindings(navigationViewModel, document.getElementById("navbar")); + ko.applyBindings(appearanceViewModel, document.getElementsByTagName("head")[0]); + ko.applyBindings(printerStateViewModel, document.getElementById("drop_overlay")); + ko.applyBindings(logViewModel, document.getElementById("logs")); + + var timelapseElement = document.getElementById("timelapse"); + if (timelapseElement) { + ko.applyBindings(timelapseViewModel, timelapseElement); + } + + // apply bindings and signal startup + _.each(additionalViewModels, function(additionalViewModel) { + if (additionalViewModel[0].hasOwnProperty("onBeforeBinding")) { + additionalViewModel[0].onBeforeBinding(); + } + + // model instance, target container + ko.applyBindings(additionalViewModel[0], additionalViewModel[1]); + + if (additionalViewModel[0].hasOwnProperty("onAfterBinding")) { + additionalViewModel[0].onAfterBinding(); + } + }); + }); //~~ startup commands - loginStateViewModel.requestData(); - connectionViewModel.requestData(); - controlViewModel.requestData(); - gcodeFilesViewModel.requestData(); - timelapseViewModel.requestData(); - settingsViewModel.requestData(); - logViewModel.requestData(); + _.each(allViewModels, function(viewModel) { + if (viewModel.hasOwnProperty("onStartup")) { + viewModel.onStartup(); + } + }); loginStateViewModel.subscribe(function(change, data) { if ("login" == change) { diff --git a/src/octoprint/static/js/app/viewmodels/connection.js b/src/octoprint/static/js/app/viewmodels/connection.js index be2f2695..42450128 100644 --- a/src/octoprint/static/js/app/viewmodels/connection.js +++ b/src/octoprint/static/js/app/viewmodels/connection.js @@ -120,4 +120,8 @@ function ConnectionViewModel(loginStateViewModel, settingsViewModel) { }) } } + + self.onStartup = function() { + self.requestData(); + }; } diff --git a/src/octoprint/static/js/app/viewmodels/control.js b/src/octoprint/static/js/app/viewmodels/control.js index 0b0b826e..5770d845 100644 --- a/src/octoprint/static/js/app/viewmodels/control.js +++ b/src/octoprint/static/js/app/viewmodels/control.js @@ -236,6 +236,9 @@ function ControlViewModel(loginStateViewModel, settingsViewModel) { default: return "customControls_emptyTemplate"; } - } + }; + self.onStartup = function() { + self.requestData(); + }; } diff --git a/src/octoprint/static/js/app/viewmodels/files.js b/src/octoprint/static/js/app/viewmodels/files.js index ab224de2..217e96f0 100644 --- a/src/octoprint/static/js/app/viewmodels/files.js +++ b/src/octoprint/static/js/app/viewmodels/files.js @@ -304,5 +304,12 @@ function GcodeFilesViewModel(printerStateViewModel, loginStateViewModel) { } }; + self.onDataUpdaterReconnect = function() { + self.requestData(); + }; + + self.onStartup = function() { + self.requestData(); + }; } diff --git a/src/octoprint/static/js/app/viewmodels/gcode.js b/src/octoprint/static/js/app/viewmodels/gcode.js index 96c19ca1..bcfd9024 100644 --- a/src/octoprint/static/js/app/viewmodels/gcode.js +++ b/src/octoprint/static/js/app/viewmodels/gcode.js @@ -405,4 +405,8 @@ function GcodeViewModel(loginStateViewModel, settingsViewModel) { GCODE.ui.changeSelectedCommands(self.layerSlider.slider("getValue"), tuple[0], tuple[1]); }; + self.onDataUpdaterReconnect = function() { + self.reset(); + } + } diff --git a/src/octoprint/static/js/app/viewmodels/log.js b/src/octoprint/static/js/app/viewmodels/log.js index 4f407303..59d52a5c 100644 --- a/src/octoprint/static/js/app/viewmodels/log.js +++ b/src/octoprint/static/js/app/viewmodels/log.js @@ -49,7 +49,7 @@ function LogViewModel(loginStateViewModel) { return; self.listHelper.updateItems(files); - } + }; self.removeFile = function(filename) { $.ajax({ @@ -58,5 +58,13 @@ function LogViewModel(loginStateViewModel) { dataType: "json", success: self.requestData }); - } + }; + + self.onDataUpdaterReconnect = function() { + self.requestData(); + }; + + self.onStartup = function() { + self.requestData(); + }; } diff --git a/src/octoprint/static/js/app/viewmodels/loginstate.js b/src/octoprint/static/js/app/viewmodels/loginstate.js index dab4ad44..2b7c8315 100644 --- a/src/octoprint/static/js/app/viewmodels/loginstate.js +++ b/src/octoprint/static/js/app/viewmodels/loginstate.js @@ -29,7 +29,7 @@ function LoginStateViewModel() { data: {"passive": true}, success: self.fromResponse }) - } + }; self.fromResponse = function(response) { if (response && response.name) { @@ -85,5 +85,13 @@ function LoginStateViewModel() { self.fromResponse(response); } }) - } + }; + + self.onDataUpdaterReconnect = function() { + self.requestData(); + }; + + self.onStartup = function() { + self.requestData(); + }; } diff --git a/src/octoprint/static/js/app/viewmodels/settings.js b/src/octoprint/static/js/app/viewmodels/settings.js index 4411f14d..909be619 100644 --- a/src/octoprint/static/js/app/viewmodels/settings.js +++ b/src/octoprint/static/js/app/viewmodels/settings.js @@ -19,8 +19,9 @@ function SettingsViewModel(loginStateViewModel, usersViewModel) { {key: "green", name: gettext("green")}, {key: "blue", name: gettext("blue")}, {key: "violet", name: gettext("violet")}, - {key: "black", name: gettext("black")}, + {key: "black", name: gettext("black")} ]); + self.appearance_colorName = function(color) { switch (color) { case "red": @@ -166,6 +167,8 @@ function SettingsViewModel(loginStateViewModel, usersViewModel) { self.terminalFilters = ko.observableArray([]); + self.settings = undefined; + self.addTemperatureProfile = function() { self.temperature_profiles.push({name: "New", extruder:0, bed:0}); }; @@ -220,6 +223,12 @@ function SettingsViewModel(loginStateViewModel, usersViewModel) { }; self.fromResponse = function(response) { + if (self.settings === undefined) { + self.settings = ko.mapping.fromJS(response); + } else { + ko.mapping.fromJS(response, self.settings); + } + self.api_enabled(response.api.enabled); self.api_key(response.api.key); self.api_allowCrossOrigin(response.api.allowCrossOrigin); @@ -284,7 +293,9 @@ function SettingsViewModel(loginStateViewModel, usersViewModel) { }; self.saveData = function() { - var data = { + var data = ko.mapping.toJS(self.settings); + + data = _.extend(data, { "api" : { "enabled": self.api_enabled(), "key": self.api_key(), @@ -354,7 +365,7 @@ function SettingsViewModel(loginStateViewModel, usersViewModel) { "config": self.cura_config() }, "terminalFilters": self.terminalFilters() - }; + }); $.ajax({ url: API_BASEURL + "settings", @@ -367,6 +378,6 @@ function SettingsViewModel(loginStateViewModel, usersViewModel) { $("#settings_dialog").modal("hide"); } }); - } + }; } diff --git a/src/octoprint/static/js/app/viewmodels/timelapse.js b/src/octoprint/static/js/app/viewmodels/timelapse.js index 15a78da1..27b9b995 100644 --- a/src/octoprint/static/js/app/viewmodels/timelapse.js +++ b/src/octoprint/static/js/app/viewmodels/timelapse.js @@ -94,15 +94,15 @@ function TimelapseViewModel(loginStateViewModel) { self.persist(false); self.isDirty(false); - } + }; self.fromCurrentData = function(data) { self._processStateData(data.state); - } + }; self.fromHistoryData = function(data) { self._processStateData(data.state); - } + }; self._processStateData = function(data) { self.isErrorOrClosed(data.flags.closedOrError); @@ -112,7 +112,7 @@ function TimelapseViewModel(loginStateViewModel) { self.isError(data.flags.error); self.isReady(data.flags.ready); self.isLoading(data.flags.loading); - } + }; self.removeFile = function(filename) { $.ajax({ @@ -121,25 +121,33 @@ function TimelapseViewModel(loginStateViewModel) { dataType: "json", success: self.requestData }); - } + }; self.save = function(data, event) { - var data = { + var payload = { "type": self.timelapseType(), "postRoll": self.timelapsePostRoll(), "save": self.persist() - } + }; if (self.timelapseType() == "timed") { - data["interval"] = self.timelapseTimedInterval(); + payload["interval"] = self.timelapseTimedInterval(); } $.ajax({ url: API_BASEURL + "timelapse", type: "POST", dataType: "json", - data: data, + data: payload, success: self.fromResponse }); - } + }; + + self.onDataUpdaterReconnect = function() { + self.requestData(); + }; + + self.onStartup = function() { + self.requestData(); + }; } diff --git a/src/octoprint/templates/index.jinja2 b/src/octoprint/templates/index.jinja2 index 74178c15..58aa506b 100644 --- a/src/octoprint/templates/index.jinja2 +++ b/src/octoprint/templates/index.jinja2 @@ -13,11 +13,32 @@ + {% if stylesheet == "less" %} + + + {% for name, assets in assetPlugins.items() %} + {% if "less" in assets %} + {% for asset in assets["less"] %} + + {% endfor %} + {% endif %} + {% endfor %} + + {% else %} + + {% for name, assets in assetPlugins.items() %} + {% if "css" in assets %} + {% for asset in assets["css"] %} + + {% endfor %} + {% endif %} + {% endfor %} + {% endif %} @@ -609,6 +632,7 @@ + @@ -647,6 +671,16 @@ + + {% for name, assets in assetPlugins.items() %} + {% if "js" in assets %} + {% for asset in assets["js"] %} + + {% endfor %} + {% endif %} + {% endfor %} + + diff --git a/src/octoprint/templates/settings.jinja2 b/src/octoprint/templates/settings.jinja2 index b4c9ab2d..a1a05b0f 100644 --- a/src/octoprint/templates/settings.jinja2 +++ b/src/octoprint/templates/settings.jinja2 @@ -21,6 +21,14 @@
  • {{ _('Folders') }}
  • {{ _('Appearance') }}
  • {{ _('Logs') }}
  • + {% if settingsPlugins %} + + {% for plugin_name, vars in settingsPlugins.items() %} + {% if vars._settings_menu_entry %} +
  • {{ vars._settings_menu_entry }}
  • + {% endif %} + {% endfor %} + {% endif %}
    @@ -621,6 +629,12 @@
    {% endif %} + {% for plugin_name, vars in settingsPlugins.items() %} +
    + {% include plugin_name+"_settings_dialog.jinja2" %} +
    + {% endfor %} +

    {{ _('Logs') }}

    diff --git a/tests/test_plugin_core.py b/tests/test_plugin_core.py new file mode 100644 index 00000000..0dfcf8b6 --- /dev/null +++ b/tests/test_plugin_core.py @@ -0,0 +1,66 @@ +import unittest + +import octoprint.plugin +import octoprint.plugin.core + + +class PluginTestCase(unittest.TestCase): + + def setUp(self): + import logging + logging.basicConfig(level=logging.DEBUG) + + import os + plugin_folders = [os.path.join(os.path.dirname(os.path.realpath(__file__)), "test_plugins")] + plugin_types = [octoprint.plugin.SettingsPlugin, octoprint.plugin.StartupPlugin] + self.plugin_manager = octoprint.plugin.core.PluginManager(plugin_folders, plugin_types) + + def test_plugin_loading(self): + self.assertEquals(4, len(self.plugin_manager.plugins)) + self.assertEquals(1, len(self.plugin_manager.plugin_hooks)) + self.assertEquals(2, len(self.plugin_manager.plugin_implementations)) + + self.assertTrue("octoprint.core.startup" in self.plugin_manager.plugin_hooks) + self.assertEquals(1, len(self.plugin_manager.plugin_hooks["octoprint.core.startup"])) + + self.assertTrue(octoprint.plugin.StartupPlugin in self.plugin_manager.plugin_implementations) + self.assertEquals(2, len(self.plugin_manager.plugin_implementations[octoprint.plugin.StartupPlugin])) + + self.assertTrue(octoprint.plugin.SettingsPlugin in self.plugin_manager.plugin_implementations) + self.assertEquals(2, len(self.plugin_manager.plugin_implementations[octoprint.plugin.SettingsPlugin])) + + def test_get_plugin(self): + plugin = self.plugin_manager.get_plugin("hook_plugin") + self.assertIsNotNone(plugin) + self.assertEquals("Hook Plugin", plugin.__plugin_name__) + + plugin = self.plugin_manager.get_plugin("mixed_plugin") + self.assertIsNotNone(plugin) + self.assertEquals("Mixed Plugin", plugin.__plugin_name__) + + plugin = self.plugin_manager.get_plugin("unknown_plugin") + self.assertIsNone(plugin) + + def test_get_hooks(self): + hooks = self.plugin_manager.get_hooks("octoprint.core.startup") + self.assertEquals(1, len(hooks)) + self.assertTrue("hook_plugin" in hooks) + self.assertEquals("success", hooks["hook_plugin"]()) + + hooks = self.plugin_manager.get_hooks("octoprint.printing.print") + self.assertEquals(0, len(hooks)) + + def test_get_implementation(self): + implementations = self.plugin_manager.get_implementations(octoprint.plugin.StartupPlugin) + self.assertEquals(2, len(implementations)) + self.assertTrue('startup_plugin' in implementations) + self.assertTrue('mixed_plugin' in implementations) + + implementations = self.plugin_manager.get_implementations(octoprint.plugin.SettingsPlugin) + self.assertEquals(2, len(implementations)) + self.assertTrue('settings_plugin' in implementations) + self.assertTrue('mixed_plugin' in implementations) + + implementations = self.plugin_manager.get_implementations(octoprint.plugin.StartupPlugin, octoprint.plugin.SettingsPlugin) + self.assertEquals(1, len(implementations)) + self.assertTrue('mixed_plugin' in implementations) diff --git a/tests/test_plugins/hook_plugin.py b/tests/test_plugins/hook_plugin.py new file mode 100644 index 00000000..03b1057f --- /dev/null +++ b/tests/test_plugins/hook_plugin.py @@ -0,0 +1,10 @@ +from __future__ import absolute_import + + +def hook_startup(): + return "success" + + +__plugin_name__ = "Hook Plugin" +__plugin_description__ = "Test hook plugin" +__plugin_hooks__ = {'octoprint.core.startup': hook_startup} \ No newline at end of file diff --git a/tests/test_plugins/mixed_plugin/__init__.py b/tests/test_plugins/mixed_plugin/__init__.py new file mode 100644 index 00000000..c8c8996a --- /dev/null +++ b/tests/test_plugins/mixed_plugin/__init__.py @@ -0,0 +1,17 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__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" + +import octoprint.plugin + + +class TestMixedPlugin(octoprint.plugin.StartupPlugin, octoprint.plugin.SettingsPlugin): + pass + + +__plugin_name__ = "Mixed Plugin" +__plugin_description__ = "Test mixed plugin" +__plugin_implementations__ = (TestMixedPlugin(),) \ No newline at end of file diff --git a/tests/test_plugins/settings_plugin.py b/tests/test_plugins/settings_plugin.py new file mode 100644 index 00000000..01fb6524 --- /dev/null +++ b/tests/test_plugins/settings_plugin.py @@ -0,0 +1,12 @@ +from __future__ import absolute_import + +import octoprint.plugin + + +class TestSettingsPlugin(octoprint.plugin.SettingsPlugin): + pass + + +__plugin_name__ = "Settings Plugin" +__plugin_description__ = "Test settings plugin" +__plugin_implementations__ = (TestSettingsPlugin(),) \ No newline at end of file diff --git a/tests/test_plugins/startup_plugin.py b/tests/test_plugins/startup_plugin.py new file mode 100644 index 00000000..7c3202b5 --- /dev/null +++ b/tests/test_plugins/startup_plugin.py @@ -0,0 +1,12 @@ +from __future__ import absolute_import + +import octoprint.plugin + + +class TestStartupPlugin(octoprint.plugin.StartupPlugin): + pass + + +__plugin_name__ = "Startup Plugin" +__plugin_description__ = "Test startup plugin" +__plugin_implementations__ = (TestStartupPlugin(),) \ No newline at end of file