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