From 975cc5ccfc9a8fd2b5bdf47d53a5056669096c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Fri, 6 Mar 2015 01:42:09 +0100 Subject: [PATCH] GCODE scripts are now Jinja templates Refactored some things in octoprint.util.comm and octoprint.settings, added migration function to get users of the devel version up to date with their gcode scripts. Migration function will be removed again one week from now. --- src/octoprint/printer/standard.py | 20 +- src/octoprint/server/__init__.py | 7 +- src/octoprint/server/api/settings.py | 28 +- src/octoprint/settings.py | 377 ++++++++++++++++++++++++--- src/octoprint/users.py | 2 +- src/octoprint/util/comm.py | 263 ++++++++++--------- tests/util/__init__.py | 10 + tests/util/test_comm_helpers.py | 70 +++++ 8 files changed, 584 insertions(+), 193 deletions(-) create mode 100644 tests/util/__init__.py create mode 100644 tests/util/test_comm_helpers.py diff --git a/src/octoprint/printer/standard.py b/src/octoprint/printer/standard.py index f4dad98b..906f2fb4 100644 --- a/src/octoprint/printer/standard.py +++ b/src/octoprint/printer/standard.py @@ -299,7 +299,7 @@ class Printer(PrinterInterface, comm.MachineComPrintCallback): raise ValueError("offsets must be a dict") validated_keys = filter(lambda x: PrinterInterface.valid_tool_regex.match(x), offsets.keys()) - validated_values = filter(lambda x: isinstance(value, (int, long, float)), offsets.values()) + validated_values = filter(lambda x: isinstance(x, (int, long, float)), offsets.values()) if len(validated_keys) != len(offsets): raise ValueError("offsets contains invalid keys: {offsets}".format(offsets=offsets)) @@ -309,22 +309,8 @@ class Printer(PrinterInterface, comm.MachineComPrintCallback): if self._comm is None: return - tool, bed = self._comm.getOffsets() - - validatedOffsets = dict() - - for key in offsets: - value = offsets[key] - if key == "bed": - bed = value - validatedOffsets[key] = value - elif key.startswith("tool"): - toolNum = int(key[len("tool"):]) - tool[toolNum] = value - validatedOffsets[key] = value - - self._comm.setTemperatureOffset(tool, bed) - self._stateMonitor.set_temp_offsets(validatedOffsets) + self._comm.setTemperatureOffset(offsets) + self._stateMonitor.set_temp_offsets(offsets) def _convert_rate_value(self, factor, min=0, max=200): if not isinstance(factor, (int, float, long)): diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index e1c2ce72..a3258eb0 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -512,7 +512,7 @@ class Server(): debug = self._debug # first initialize the settings singleton and make sure it uses given configfile and basedir if available - self._initSettings(self._configfile, self._basedir) + settings(init=True, basedir=self._basedir, configfile=self._configfile) # then initialize logging self._initLogging(self._debug, self._logConf) @@ -731,9 +731,6 @@ class Server(): if "geteuid" in dir(os) and os.geteuid() == 0: exit("You should not run OctoPrint as root!") - def _initSettings(self, configfile, basedir): - settings(init=True, basedir=basedir, configfile=configfile) - def _initLogging(self, debug, logConf=None): defaultConfig = { "version": 1, @@ -785,7 +782,7 @@ class Server(): defaultConfig["root"]["level"] = "DEBUG" if logConf is None: - logConf = os.path.join(settings().settings_dir, "logging.yaml") + logConf = os.path.join(settings().getBaseFolder("base"), "logging.yaml") configFromFile = {} if os.path.exists(logConf) and os.path.isfile(logConf): diff --git a/src/octoprint/server/api/settings.py b/src/octoprint/server/api/settings.py index 5b00c830..6da2cc9a 100644 --- a/src/octoprint/server/api/settings.py +++ b/src/octoprint/server/api/settings.py @@ -93,9 +93,25 @@ def getSettings(): "events": s.get(["system", "events"]) }, "terminalFilters": s.get(["terminalFilters"]), - "scripts": s.get(["scripts"], merged=True) + "scripts": { + "gcode": { + "afterPrinterConnected": None, + "beforePrintStarted": None, + "afterPrintCancelled": None, + "afterPrintDone": None, + "beforePrintPaused": None, + "afterPrintResumed": None, + "snippets": dict() + } + } } + gcode_scripts = s.listScripts("gcode") + if gcode_scripts: + data["scripts"] = dict(gcode=dict()) + for name in gcode_scripts: + data["scripts"]["gcode"][name] = s.loadScript("gcode", name, source=True) + def process_plugin_result(name, plugin, result): if result: if not "plugins" in data: @@ -196,10 +212,12 @@ def setSettings(): if "actions" in data["system"].keys(): s.set(["system", "actions"], data["system"]["actions"]) if "events" in data["system"].keys(): s.set(["system", "events"], data["system"]["events"]) - if "scripts" in data: - if "gcode" in data["scripts"]: - gcode_scripts = data["scripts"]["gcode"] - s.set(["scripts", "gcode"], octoprint.util.dict_merge(s.get(["scripts", "gcode"], merged=True), gcode_scripts)) + if "scripts" in data: + if "gcode" in data["scripts"] and isinstance(data["scripts"]["gcode"], dict): + for name, script in data["scripts"]["gcode"].items(): + if name == "snippets": + continue + s.saveScript("gcode", name, script.replace("\r\n", "\n").replace("\r", "\n")) if "plugins" in data: for name, plugin in octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SettingsPlugin).items(): diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index 2412cb51..b1a82a99 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -1,6 +1,27 @@ # coding=utf-8 +""" +This module represents OctoPrint's settings management. Within this module the default settings for the core +application are defined and the instance of the :class:`Settings` is held, which offers getter and setter +methods for the raw configuration values as well as various convenience methods to access the paths to base folders +of various types and the configuration file itself. + +.. autodata:: default_settings + :annotation: = dict(...) + +.. autodata:: valid_boolean_trues + +.. autofunction:: settings + +.. autoclass:: Settings + :members: + :undoc-members: +""" + +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 sys import os @@ -9,18 +30,44 @@ import logging import re import uuid -APPNAME="OctoPrint" +_APPNAME = "OctoPrint" -instance = None +_instance = None -def settings(init=False, configfile=None, basedir=None): - global instance - if instance is None: +def settings(init=False, basedir=None, configfile=None): + """ + Factory method for initially constructing and consecutively retrieving the :class:`~octoprint.settings.Settings` + singleton. + + Arguments: + init (boolean): A flag indicating whether this is the initial call to construct the singleton (True) or not + (False, default). If this is set to True and the plugin manager has already been initialized, a :class:`ValueError` + will be raised. The same will happen if the plugin manager has not yet been initialized and this is set to + False. + basedir (str): Path of the base directoy for all of OctoPrint's settings, log files, uploads etc. If not set + the default will be used: ``~/.octoprint`` on Linux, ``%APPDATA%/OctoPrint`` on Windows and + ``~/Library/Application Support/OctoPrint`` on MacOS. + configfile (str): Path of the configuration file (``config.yaml``) to work on. If not set the default will + be used: ``/config.yaml`` for ``basedir`` as defined above. + + Returns: + Settings: The fully initialized :class:`Settings` instance. + + Raises: + ValueError: ``init`` is True but settings are already initialized or vice versa. + """ + global _instance + if _instance is not None: if init: - instance = Settings(configfile, basedir) + raise ValueError("Settings Manager already initialized") + + else: + if init: + _instance = Settings(configfile=configfile, basedir=basedir) else: raise ValueError("Settings not initialized yet") - return instance + + return _instance default_settings = { "serial": { @@ -100,7 +147,8 @@ default_settings = { "watched": None, "plugins": None, "slicingProfiles": None, - "printerProfiles": None + "printerProfiles": None, + "scripts": None }, "temperature": { "profiles": [ @@ -156,17 +204,9 @@ default_settings = { "plugins": {}, "scripts": { "gcode": { - "afterPrinterConnected": None, - "beforePrintStarted": None, - "afterPrintDone": None, - "afterPrintCancelled": "{disable_steppers}\n{disable_hotends}\n{disable_bed}\n{disable_fan}", - "afterPrintPaused": None, - "beforePrintResumed": None, - "templates": { - "disable_steppers": "M84", - "disable_hotends": "M104 T{tool:d} S0", - "disable_bed": "M140 S0", - "disable_fan": "M106 S0" + "afterPrintCancelled": "; disable motors\nM84\n\n;disable all heaters\n{% snippet 'disable_hotends' %}\nM140 S0\n\n;disable fan\nM106 S0", + "snippets": { + "disable_hotends": "{% for tool in range(printer_profile.extruder.count) %}M104 T{{ tool }} S0\n{% endfor %}" } } }, @@ -197,69 +237,268 @@ default_settings = { } } } +"""The default settings of the core application.""" valid_boolean_trues = [True, "true", "yes", "y", "1"] +""" Values that are considered to be equivalent to the boolean ``True`` value, used for type conversion in various places.""" class Settings(object): + """ + The :class:`Settings` class allows managing all of OctoPrint's settings. It takes care of initializing the settings + directory, loading the configuration from ``config.yaml``, persisting changes to disk etc and provides access + methods for getting and setting specific values from the overall settings structure via paths. + + A general word on the concept of paths, since they play an important role in OctoPrint's settings management. A + path is basically a list or tuple consisting of keys to follow down into the settings (which are basically like + a ``dict``) in order to set or retrieve a specific value (or more than one). For example, for a settings + structure like the following:: + + serial: + port: "/dev/ttyACM0" + baudrate: 250000 + timeouts: + communication: 20.0 + temperature: 5.0 + sdStatus: 1.0 + connection: 10.0 + server: + host: "0.0.0.0" + port: 5000 + + the following paths could be used: + + ========================================== ============================================================================ + Path Value + ========================================== ============================================================================ + ``["serial", "port"]`` :: + + "/dev/ttyACM0" + + ``["serial", "timeouts"]`` :: + + communication: 20.0 + temperature: 5.0 + sdStatus: 1.0 + connection: 10.0 + + ``["serial", "timeouts", "temperature"]`` :: + + 5.0 + + ``["server", "port"]`` :: + + 5000 + + ========================================== ============================================================================ + + However, these would be invalid paths: ``["key"]``, ``["serial", "port", "value"]``, ``["server", "host", 3]``. + """ def __init__(self, configfile=None, basedir=None): self._logger = logging.getLogger(__name__) - self.settings_dir = None + self._basedir = None self._config = None self._dirty = False self._mtime = None - self._init_settings_dir(basedir) + self._init_basedir(basedir) if configfile is not None: self._configfile = configfile else: - self._configfile = os.path.join(self.settings_dir, "config.yaml") + self._configfile = os.path.join(self._basedir, "config.yaml") self.load(migrate=True) if self.get(["api", "key"]) is None: self.set(["api", "key"], ''.join('%02X' % ord(z) for z in uuid.uuid4().bytes)) self.save(force=True) - def _init_settings_dir(self, basedir): + self._script_env = self._init_script_templating() + + def _init_basedir(self, basedir): if basedir is not None: - self.settings_dir = basedir + self._basedir = basedir else: - self.settings_dir = _resolveSettingsDir(APPNAME) + self._basedir = _default_basedir(_APPNAME) - if not os.path.isdir(self.settings_dir): - os.makedirs(self.settings_dir) + if not os.path.isdir(self._basedir): + os.makedirs(self._basedir) - def _getDefaultFolder(self, type): + def _get_default_folder(self, type): folder = default_settings["folder"][type] if folder is None: - folder = os.path.join(self.settings_dir, type.replace("_", os.path.sep)) + folder = os.path.join(self._basedir, type.replace("_", os.path.sep)) return folder + def _init_script_templating(self): + from jinja2 import Environment, BaseLoader, FileSystemLoader, ChoiceLoader, TemplateNotFound + from jinja2.nodes import Include, Const + from jinja2.ext import Extension + + class SnippetExtension(Extension): + tags = {"snippet"} + fields = Include.fields + + def parse(self, parser): + node = parser.parse_include() + if not node.template.value.startswith("/"): + node.template.value = "snippets/" + node.template.value + return node + + class SettingsScriptLoader(BaseLoader): + def __init__(self, s): + self._settings = s + + def get_source(self, environment, template): + parts = template.split("/") + if not len(parts): + raise TemplateNotFound(template) + + script = self._settings.get(["scripts"], merged=True) + for part in parts: + if isinstance(script, dict) and part in script: + script = script[part] + else: + raise TemplateNotFound(template) + source = script + if source is None: + raise TemplateNotFound(template) + mtime = self._settings._mtime + return source, None, lambda: mtime == self._settings._last_modified + + def list_templates(self): + scripts = self._settings.get(["scripts"], merged=True) + return self._get_templates(scripts) + + def _get_templates(self, scripts): + templates = [] + for key in scripts: + if isinstance(scripts[key], dict): + templates += map(lambda x: key + "/" + x, self._get_templates(scripts[key])) + elif isinstance(scripts[key], basestring): + templates.append(key) + return templates + + class SelectLoader(BaseLoader): + def __init__(self, default, mapping, sep=":"): + self._default = default + self._mapping = mapping + self._sep = sep + + def get_source(self, environment, template): + if self._sep in template: + prefix, name = template.split(self._sep, 1) + if not prefix in self._mapping: + raise TemplateNotFound(template) + return self._mapping[prefix].get_source(environment, name) + return self._default.get_source(environment, template) + + def list_templates(self): + return self._default.list_templates() + + class RelEnvironment(Environment): + def __init__(self, prefix_sep=":", *args, **kwargs): + Environment.__init__(self, *args, **kwargs) + self._prefix_sep = prefix_sep + + def join_path(self, template, parent): + prefix, name = self._split_prefix(template) + + if name.startswith("/"): + return self._join_prefix(prefix, name[1:]) + else: + _, parent_name = self._split_prefix(parent) + parent_base = parent_name.split("/")[:-1] + return self._join_prefix(prefix, "/".join(parent_base) + "/" + name) + + def _split_prefix(self, template): + if self._prefix_sep in template: + return template.split(self._prefix_sep, 1) + else: + return "", template + + def _join_prefix(self, prefix, template): + if len(prefix): + return prefix + self._prefix_sep + template + else: + return template + + file_system_loader = FileSystemLoader(self.getBaseFolder("scripts")) + settings_loader = SettingsScriptLoader(self) + choice_loader = ChoiceLoader([file_system_loader, settings_loader]) + select_loader = SelectLoader(choice_loader, dict(bundled=settings_loader, file=file_system_loader)) + return RelEnvironment(loader=select_loader, extensions=[SnippetExtension]) + + def _get_script_template(self, script_type, name, source=False): + from jinja2 import TemplateNotFound + + template_name = script_type + "/" + name + try: + if source: + template_name, _, _ = self._script_env.loader.get_source(self._script_env, template_name) + return template_name + else: + return self._script_env.get_template(template_name) + except TemplateNotFound: + return None + except: + self._logger.exception("Exception while trying to resolve template {template_name}".format(**locals())) + return None + + def _get_scripts(self, script_type): + return self._script_env.list_templates(filter_func=lambda x: x.startswith(script_type+"/")) + #~~ load and save def load(self, migrate=False): if os.path.exists(self._configfile) and os.path.isfile(self._configfile): with open(self._configfile, "r") as f: self._config = yaml.safe_load(f) - self._mtime = self._last_modified() + self._mtime = self._last_modified # changed from else to handle cases where the file exists, but is empty / 0 bytes if not self._config: self._config = {} if migrate: - self._migrateConfig() + self._migrate_config() - def _migrateConfig(self): + def _migrate_config(self): dirty = False - for migrate in (self._migrate_event_config, self._migrate_reverse_proxy_config, self._migrate_printer_parameters): + for migrate in (self._migrate_event_config, self._migrate_reverse_proxy_config, self._migrate_printer_parameters, self._migrate_gcode_scripts): dirty = migrate() or dirty if dirty: self.save(force=True) + def _migrate_gcode_scripts(self): + """ + Migrates an old development version of gcode scripts to the new template based format. + """ + + dirty = False + if "scripts" in self._config: + if "gcode" in self._config["scripts"]: + if "templates" in self._config["scripts"]["gcode"]: + del self._config["scripts"]["gcode"]["templates"] + + replacements = dict( + disable_steppers="M84", + disable_hotends="{% snippet 'disable_hotends' %}", + disable_bed="M140 S0", + disable_fan="M106 S0" + ) + + for name, script in self._config["scripts"]["gcode"].items(): + self.saveScript("gcode", name, script.format(**replacements)) + del self._config["scripts"] + dirty = True + return dirty + def _migrate_printer_parameters(self): + """ + Migrates the old "printer > parameters" data structure to the new printer profile mechanism. + """ default_profile = self._config["printerProfiles"]["defaultProfile"] if "printerProfiles" in self._config and "defaultProfile" in self._config["printerProfiles"] else dict() dirty = False @@ -320,6 +559,10 @@ class Settings(object): return dirty def _migrate_reverse_proxy_config(self): + """ + Migrates the old "server > baseUrl" and "server > scheme" configuration entries to + "server > reverseProxy > prefixFallback" and "server > reverseProxy > schemeFallback". + """ if "server" in self._config.keys() and ("baseUrl" in self._config["server"] or "scheme" in self._config["server"]): prefix = "" if "baseUrl" in self._config["server"]: @@ -343,6 +586,10 @@ class Settings(object): return False def _migrate_event_config(self): + """ + Migrates the old event configuration format of type "events > gcodeCommandTrigger" and + "event > systemCommandTrigger" to the new events format. + """ if "events" in self._config.keys() and ("gcodeCommandTrigger" in self._config["events"] or "systemCommandTrigger" in self._config["events"]): self._logger.info("Migrating config (event subscriptions)...") @@ -439,7 +686,12 @@ class Settings(object): self.load() return True + @property def _last_modified(self): + """ + Returns: + int: The last modification time of the configuration file. + """ stat = os.stat(self._configfile) return stat.st_mtime @@ -533,19 +785,47 @@ class Settings(object): return value.lower() in valid_boolean_trues return value is not None - def getBaseFolder(self, type): - if type not in default_settings["folder"].keys(): + def getBaseFolder(self, type, create=True): + if type not in default_settings["folder"].keys() + ["base"]: return None + if type == "base": + return self._basedir + folder = self.get(["folder", type]) if folder is None: - folder = self._getDefaultFolder(type) + folder = self._get_default_folder(type) if not os.path.isdir(folder): - os.makedirs(folder) + if create: + os.makedirs(folder) + else: + raise IOError("No such folder: {folder}".format(folder=folder)) return folder + def listScripts(self, script_type): + return map(lambda x: x[len(script_type + "/"):], filter(lambda x: x.startswith(script_type + "/"), self._get_scripts(script_type))) + + def loadScript(self, script_type, name, context=None, source=False): + if context is None: + context = dict() + + template = self._get_script_template(script_type, name, source=source) + if template is None: + return None + + if source: + script = template + else: + try: + script = template.render(**context) + except: + self._logger.exception("Exception while trying to render script {script_type}:{name}".format(**locals())) + return None + + return script + def getFeedbackControls(self): feedbackControls = [] for control in self.get(["controls"]): @@ -600,7 +880,7 @@ class Settings(object): if len(path) == 0: return - if self._mtime is not None and self._last_modified() != self._mtime: + if self._mtime is not None and self._last_modified != self._mtime: self.load() config = self._config @@ -620,10 +900,10 @@ class Settings(object): return key = path.pop(0) - if not force and key in defaults.keys() and key in config.keys() and defaults[key] == value: + if not force and key in defaults and key in config and defaults[key] == value: del config[key] self._dirty = True - elif force or (not key in config.keys() and defaults[key] != value) or (key in config.keys() and config[key] != value): + elif force or (not key in config and defaults[key] != value) or (key in config and config[key] != value): if value is None: del config[key] else: @@ -669,7 +949,7 @@ class Settings(object): return None currentPath = self.getBaseFolder(type) - defaultPath = self._getDefaultFolder(type) + defaultPath = self._get_default_folder(type) if (path is None or path == defaultPath) and "folder" in self._config.keys() and type in self._config["folder"].keys(): del self._config["folder"][type] if not self._config["folder"]: @@ -681,7 +961,20 @@ class Settings(object): self._config["folder"][type] = path self._dirty = True -def _resolveSettingsDir(applicationName): + def saveScript(self, script_type, name, script): + script_folder = self.getBaseFolder("scripts") + filename = os.path.realpath(os.path.join(script_folder, script_type, name)) + if not filename.startswith(script_folder): + # oops, jail break, that shouldn't happen + raise ValueError("Invalid script path to save to: {filename} (from {script_type}:{name})".format(**locals())) + + path, _ = os.path.split(filename) + if not os.path.exists(path): + os.makedirs(path) + with open(filename, "w+") as f: + f.write(script) + +def _default_basedir(applicationName): # taken from http://stackoverflow.com/questions/1084697/how-do-i-store-desktop-application-data-in-a-cross-platform-way-for-python if sys.platform == "darwin": from AppKit import NSSearchPathForDirectoriesInDomains diff --git a/src/octoprint/users.py b/src/octoprint/users.py index be5b36d5..8429258d 100644 --- a/src/octoprint/users.py +++ b/src/octoprint/users.py @@ -157,7 +157,7 @@ class FilebasedUserManager(UserManager): userfile = settings().get(["accessControl", "userfile"]) if userfile is None: - userfile = os.path.join(settings().settings_dir, "users.yaml") + userfile = os.path.join(settings().getBaseFolder("base"), "users.yaml") self._userfile = userfile self._users = {} self._dirty = False diff --git a/src/octoprint/util/comm.py b/src/octoprint/util/comm.py index bfe0d66c..179eb8aa 100644 --- a/src/octoprint/util/comm.py +++ b/src/octoprint/util/comm.py @@ -141,16 +141,15 @@ class MachineCom(object): self._baudrateDetectList = baudrateList() self._baudrateDetectRetry = 0 self._temp = {} - self._tempOffset = {} self._bedTemp = None - self._bedTempOffset = 0 + self._tempOffsets = dict() self._commandQueue = queue.Queue() self._currentZ = None self._heatupWaitStartTime = None self._heatupWaitTimeLost = 0.0 self._pauseWaitStartTime = None self._pauseWaitTimeLost = 0.0 - self._currentExtruder = 0 + self._currentTool = 0 self._blocking_command = False self._heating = False @@ -356,7 +355,10 @@ class MachineCom(object): return self._bedTemp def getOffsets(self): - return self._tempOffset, self._bedTempOffset + return dict(self._tempOffsets) + + def getCurrentTool(self): + return self._currentTool def getConnection(self): return self._port, self._baudrate @@ -405,38 +407,40 @@ class MachineCom(object): eventManager().fire(Events.PRINT_FAILED, payload) eventManager().fire(Events.DISCONNECTED) - def setTemperatureOffset(self, tool=None, bed=None): - if tool is not None: - self._tempOffset = tool + def setTemperatureOffset(self, offsets): + self._tempOffsets.update(offsets) - if bed is not None: - self._bedTempOffset = bed - - def sendCommand(self, cmd, cmd_type=None): + def sendCommand(self, cmd, cmd_type=None, processed=False): cmd = cmd.encode('ascii', 'replace') + if not processed: + cmd = process_gcode_line(cmd) + if not cmd: + return + if self.isPrinting() and not self.isSdFileSelected(): self._commandQueue.put((cmd, cmd_type)) elif self.isOperational(): self._sendCommand((cmd, cmd_type)) def sendGcodeScript(self, scriptName, replacements=None): - gcodeScripts = settings().get(["scripts", "gcode"], merged=True) - - if scriptName in gcodeScripts and gcodeScripts[scriptName]: - script = gcodeScripts[scriptName] - else: - return - - if replacements is None: - replacements = dict() - replacements.update(dict( - disable_steppers=gcodeScripts["templates"]["disable_steppers"], - disable_hotends="\n".join(map(lambda x: gcodeScripts["templates"]["disable_hotends"].format(tool=x), range(self._printerProfileManager.get_current_or_default()["extruder"]["count"]))), - disable_bed=gcodeScripts["templates"]["disable_bed"], - disable_fan=gcodeScripts["templates"]["disable_fan"] + context = dict() + if replacements is not None and isinstance(replacements, dict): + context.update(replacements) + context.update(dict( + printer_profile=self._printerProfileManager.get_current_or_default() )) - script = script.format(**replacements) - scriptLines = map(str.strip, script.split("\n")) + + template = settings().loadScript("gcode", scriptName, context=context) + if template is None: + return None + + scriptLines = filter( + lambda x: x is not None and x.strip() != "", + map( + lambda x: process_gcode_line(x, offsets=self._tempOffsets, current_tool=self._currentTool), + template.split("\n") + ) + ) for hook in self._gcodescript_hooks: try: @@ -543,7 +547,7 @@ class MachineCom(object): self._sdFileToSelect = filename self.sendCommand("M23 %s" % filename) else: - self._currentFile = PrintingGcodeFileInformation(filename, self.getOffsets) + self._currentFile = PrintingGcodeFileInformation(filename, offsets_callback=self.getOffsets, current_tool_callback=self.getCurrentTool) eventManager().fire(Events.FILE_SELECTED, { "file": self._currentFile.getFilename(), "origin": self._currentFile.getFileLocation() @@ -1444,7 +1448,7 @@ class MachineCom(object): def _gcode_T(self, cmd): toolMatch = self._regex_paramTInt.search(cmd) if toolMatch: - self._currentExtruder = int(toolMatch.group(1)) + self._currentTool = int(toolMatch.group(1)) return cmd def _gcode_G0(self, cmd): @@ -1467,7 +1471,7 @@ class MachineCom(object): _gcode_M1 = _gcode_M0 def _gcode_M104(self, cmd): - toolNum = self._currentExtruder + toolNum = self._currentTool toolMatch = self._regex_paramTInt.search(cmd) if toolMatch: toolNum = int(toolMatch.group(1)) @@ -1613,22 +1617,23 @@ class PrintingFileInformation(object): """ def __init__(self, filename): + self._logger = logging.getLogger(__name__) self._filename = filename - self._filepos = 0 - self._filesize = None - self._startTime = None + self._pos = 0 + self._size = None + self._start_time = None def getStartTime(self): - return self._startTime + return self._start_time def getFilename(self): return self._filename def getFilesize(self): - return self._filesize + return self._size def getFilepos(self): - return self._filepos + return self._pos def getFileLocation(self): return FileDestinations.LOCAL @@ -1638,36 +1643,36 @@ class PrintingFileInformation(object): The current progress of the file, calculated as relation between file position and absolute size. Returns -1 if file size is None or < 1. """ - if self._filesize is None or not self._filesize > 0: + if self._size is None or not self._size > 0: return -1 - return float(self._filepos) / float(self._filesize) + return float(self._pos) / float(self._size) def reset(self): """ Resets the current file position to 0. """ - self._filepos = 0 + self._pos = 0 def start(self): """ Marks the print job as started and remembers the start time. """ - self._startTime = time.time() + self._start_time = time.time() class PrintingSdFileInformation(PrintingFileInformation): """ Encapsulates information regarding an ongoing print from SD. """ - def __init__(self, filename, filesize): + def __init__(self, filename, size): PrintingFileInformation.__init__(self, filename) - self._filesize = filesize + self._size = size - def setFilepos(self, filepos): + def setFilepos(self, pos): """ Sets the current file position. """ - self._filepos = filepos + self._pos = pos def getFileLocation(self): return FileDestinations.SDCARD @@ -1678,118 +1683,68 @@ class PrintingGcodeFileInformation(PrintingFileInformation): that the file is closed in case of an error. """ - def __init__(self, filename, offsetCallback): + def __init__(self, filename, offsets_callback=None, current_tool_callback=None): PrintingFileInformation.__init__(self, filename) - self._filehandle = None + self._handle = None - self._filesetMenuModehandle = None - self._firstLine = None - self._currentTool = 0 + self._first_line = None - self._offsetCallback = offsetCallback - self._regex_tempCommand = re.compile("M(104|109|140|190)") - self._regex_tempCommandTemperature = re.compile("S([-+]?\d*\.?\d*)") - self._regex_tempCommandTool = re.compile("T(\d+)") - self._regex_toolCommand = re.compile("^T(\d+)") + self._offsets_callback = offsets_callback + self._current_tool_callback = current_tool_callback if not os.path.exists(self._filename) or not os.path.isfile(self._filename): raise IOError("File %s does not exist" % self._filename) - self._filesize = os.stat(self._filename).st_size + self._size = os.stat(self._filename).st_size + self._pos = 0 def start(self): """ Opens the file for reading and determines the file size. Start time won't be recorded until 100 lines in """ PrintingFileInformation.start(self) - self._filehandle = open(self._filename, "r") + self._handle = open(self._filename, "r") def getNext(self): """ Retrieves the next line for printing. """ - if self._filehandle is None: + if self._handle is None: raise ValueError("File %s is not open for reading" % self._filename) + offsets = self._offsets_callback() if self._offsets_callback is not None else None + current_tool = self._current_tool_callback() if self._current_tool_callback is not None else None + try: - processedLine = None - while processedLine is None: - if self._filehandle is None: + processed = None + while processed is None: + if self._handle is None: # file got closed just now return None - line = self._filehandle.readline() + line = self._handle.readline() if not line: - self._filehandle.close() - self._filehandle = None - processedLine = self._processLine(line) - self._filepos = self._filehandle.tell() + self._handle.close() + self._handle = None + processed = process_gcode_line(line, offsets=offsets, current_tool=current_tool) + self._pos = self._handle.tell() - return processedLine - except Exception as (e): - if self._filehandle is not None: - self._filehandle.close() - self._filehandle = None + return processed + except Exception as e: + if self._handle is not None: + self._handle.close() + self._handle = None + self._logger.exception("Exception while processing line") raise e - def _processLine(self, line): - if ";" in line: - line = line[0:line.find(";")] - line = line.strip() - if len(line) > 0: - toolMatch = self._regex_toolCommand.match(line) - if toolMatch is not None: - # track tool changes - self._currentTool = int(toolMatch.group(1)) - else: - ## apply offsets - if self._offsetCallback is not None: - tempMatch = self._regex_tempCommand.match(line) - if tempMatch is not None: - # if we have a temperature command, retrieve current offsets - tempOffset, bedTempOffset = self._offsetCallback() - if tempMatch.group(1) == "104" or tempMatch.group(1) == "109": - # extruder temperature, determine which one and retrieve corresponding offset - toolNum = self._currentTool - - toolNumMatch = self._regex_tempCommandTool.search(line) - if toolNumMatch is not None: - try: - toolNum = int(toolNumMatch.group(1)) - except ValueError: - pass - - offset = tempOffset[toolNum] if toolNum in tempOffset.keys() and tempOffset[toolNum] is not None else 0 - elif tempMatch.group(1) == "140" or tempMatch.group(1) == "190": - # bed temperature - offset = bedTempOffset - else: - # unknown, should never happen - offset = 0 - - if not offset == 0: - # if we have an offset != 0, we need to get the temperature to be set and apply the offset to it - tempValueMatch = self._regex_tempCommandTemperature.search(line) - if tempValueMatch is not None: - try: - temp = float(tempValueMatch.group(1)) - if temp > 0: - newTemp = temp + offset - line = line.replace("S" + tempValueMatch.group(1), "S%f" % newTemp) - except ValueError: - pass - return line - else: - return None - class StreamingGcodeFileInformation(PrintingGcodeFileInformation): def __init__(self, path, localFilename, remoteFilename): - PrintingGcodeFileInformation.__init__(self, path, None) + PrintingGcodeFileInformation.__init__(self, path) self._localFilename = localFilename self._remoteFilename = remoteFilename def start(self): PrintingGcodeFileInformation.start(self) - self._startTime = time.time() + self._start_time = time.time() def getLocalFilename(self): return self._localFilename @@ -1841,4 +1796,66 @@ def get_interval(type): if type not in default_settings["serial"]["timeout"]: return 0 else: - return settings().getFloat(["serial", "timeout", type]) \ No newline at end of file + return settings().getFloat(["serial", "timeout", type]) + +_temp_command_regex = re.compile("^M(?P104|109|140|190)(\s+T(?P\d+)|\s+S(?P[-+]?\d*\.?\d*))+") + +def apply_temperature_offsets(line, offsets, current_tool=None): + if offsets is None: + return line + + match = _temp_command_regex.match(line) + if match is None: + return line + + groups = match.groupdict() + if not "temperature" in groups or groups["temperature"] is None: + return line + + offset = 0 + if current_tool is not None and (groups["command"] == "104" or groups["command"] == "109"): + # extruder temperature, determine which one and retrieve corresponding offset + tool_num = current_tool + if "tool" in groups and groups["tool"] is not None: + tool_num = int(groups["tool"]) + + tool_key = "tool%d" % tool_num + offset = offsets[tool_key] if tool_key in offsets and offsets[tool_key] else 0 + + elif groups["command"] == "140" or groups["command"] == "190": + # bed temperature + offset = offsets["bed"] if "bed" in offsets else 0 + + if offset == 0: + return line + + temperature = float(groups["temperature"]) + if temperature == 0: + return line + + return line[:match.start("temperature")] + "%f" % (temperature + offset) + line[match.end("temperature"):] + +def strip_comment(line): + if not ";" in line: + # shortcut + return line + + escaped = False + result = [] + for c in line: + if c == ";" and not escaped: + break + result += c if c != "\\" or escaped else "" + escaped = (c == "\\") and not escaped + return "".join(result) + +def process_gcode_line(line, offsets=None, current_tool=None): + line = strip_comment(line).strip() + if not len(line): + return None + + if offsets is not None: + line = apply_temperature_offsets(line, offsets, current_tool=current_tool) + + return line + diff --git a/tests/util/__init__.py b/tests/util/__init__.py new file mode 100644 index 00000000..263ea4e9 --- /dev/null +++ b/tests/util/__init__.py @@ -0,0 +1,10 @@ +# coding=utf-8 +""" +Unit tests for ``octoprint.util``. +""" + +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" diff --git a/tests/util/test_comm_helpers.py b/tests/util/test_comm_helpers.py new file mode 100644 index 00000000..6f5c4a43 --- /dev/null +++ b/tests/util/test_comm_helpers.py @@ -0,0 +1,70 @@ +# 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 unittest + +from ddt import ddt, data, unpack + +@ddt +class TestCommHelpers(unittest.TestCase): + + @data( + ("M117 Test", "M117 Test"), + ("M117 Test ; foo", "M117 Test "), + ("M117 Test \\; foo", "M117 Test ; foo"), + ("M117 Test \\\\; foo", "M117 Test \\"), + ("M117 Test \\\\\\; foo", "M117 Test \\; foo"), + ("; foo", "") + ) + @unpack + def test_strip_comment(self, input, expected): + from octoprint.util import comm + self.assertEquals(expected, comm.strip_comment(input)) + + @data( + ("M117 Test", None, None, "M117 Test"), + ("", None, None, None), + (" \t \r \n", None, None, None), + ("M117 Test", dict(), 0, "M117 Test") + ) + @unpack + def test_process_gcode_line(self, input, offsets, current_tool, expected): + from octoprint.util import comm + self.assertEquals(expected, comm.process_gcode_line(input, offsets=offsets, current_tool=current_tool)) + + @data( + ("M104 S200", None, None, None), + ("M117 Test", dict(), None, None), + ("M104 T0", dict(), None, None), + ("M104 S220", dict(tool0=10, tool1=20, bed=30), 0, 230.0), + ("M104 T1 S220", dict(tool0=10, tool1=20, bed=30), 0, 240.0), + ("M104 S220", dict(tool0=10, tool1=20, bed=30), 1, 240.0), + ("M140 S100", dict(tool0=10, tool1=20, bed=30), 1, 130.0), + ("M190 S100", dict(tool0=10, tool1=20, bed=30), 1, 130.0), + ("M109 S220", dict(tool0=10, tool1=20, bed=30), 0, 230.0), + ("M109 S220", dict(), 0, None), + ("M140 S100", dict(), 0, None), + ("M104 S220", dict(tool0=0), 0, None), + ("M104 S220", dict(tool0=20), None, None), + ("M104 S0", dict(tool0=20), 0, None) + ) + @unpack + def test_apply_temperature_offsets(self, input, offsets, current_tool, expected): + from octoprint.util import comm + actual = comm.apply_temperature_offsets(input, offsets, current_tool=current_tool) + + if expected is None: + self.assertEquals(input, actual) + else: + import re + match = re.search("S(\d+(\.\d+)?)", actual) + if not match: + self.fail("No temperature found") + temperature = float(match.group(1)) + self.assertEquals(expected, temperature) + self.assertEquals(input[:match.start(1)], actual[:match.start(1)]) + self.assertEquals(input[match.end(1):], actual[match.end(1):]) \ No newline at end of file