diff --git a/docs/G-Codes.md b/docs/G-Codes.md index 7e2f2d26..28959647 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -115,6 +115,10 @@ The following standard commands are supported: [the FAQ](FAQ.md#how-do-i-upgrade-to-the-latest-software)). - `FIRMWARE_RESTART`: This is similar to a RESTART command, but it also clears any error state from the micro-controller. +- `SAVE_CONFIG`: This command will overwrite the main printer config + file and restart the host software. This command is used in + conjunction with other calibration commands to store the results of + calibration tests. - `STATUS`: Report the Klipper host software status. - `HELP`: Report the list of available extended G-Code commands. diff --git a/klippy/configfile.py b/klippy/configfile.py index 9c4ad4e1..b8945462 100644 --- a/klippy/configfile.py +++ b/klippy/configfile.py @@ -1,9 +1,9 @@ -# Code for reading the Klipper config file +# Code for reading and writing the Klipper config file # # Copyright (C) 2016-2018 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. -import ConfigParser +import os, re, time, logging, ConfigParser, StringIO error = ConfigParser.Error @@ -75,31 +75,104 @@ class ConfigWrapper: return [self.getsection(s) for s in self.fileconfig.sections() if s.startswith(prefix)] -class ConfigLogger: - def __init__(self, fileconfig, printer): - self.lines = ["===== Config file ====="] - fileconfig.write(self) - self.lines.append("=======================") - printer.set_rollover_info("config", "\n".join(self.lines)) - def write(self, data): - self.lines.append(data.strip()) +AUTOSAVE_HEADER = """ +#*# <---------------------- SAVE_CONFIG ----------------------> +#*# DO NOT EDIT THIS BLOCK OR BELOW. The contents are auto-generated. +#*# +""" class PrinterConfig: def __init__(self, printer): self.printer = printer - def read_config(self, filename): + self.autosave = None + gcode = self.printer.lookup_object('gcode') + gcode.register_command("SAVE_CONFIG", self.cmd_SAVE_CONFIG, + desc=self.cmd_SAVE_CONFIG_help) + def _read_config_file(self, filename): + try: + f = open(filename, 'rb') + data = f.read() + f.close() + except: + msg = "Unable to open config file %s" % (filename,) + logging.exception(msg) + raise error(msg) + return data.replace('\r\n', '\n') + def _find_autosave_data(self, data): + regular_data = data + autosave_data = "" + pos = data.find(AUTOSAVE_HEADER) + if pos >= 0: + regular_data = data[:pos] + autosave_data = data[pos + len(AUTOSAVE_HEADER):].strip() + # Check for errors and strip line prefixes + if "\n#*# " in regular_data: + logging.warn("Can't read autosave from config file" + " - autosave state corrupted") + return data, "" + out = [""] + for line in autosave_data.split('\n'): + if ((not line.startswith("#*#") + or (len(line) >= 4 and not line.startswith("#*# "))) + and autosave_data): + logging.warn("Can't read autosave from config file" + " - modifications after header") + return data, "" + out.append(line[4:]) + out.append("") + return regular_data, "\n".join(out) + comment_r = re.compile('[#;].*$') + value_r = re.compile('[^A-Za-z0-9_].*$') + def _strip_duplicates(self, data, config): + fileconfig = config.fileconfig + # Comment out fields in 'data' that are defined in 'config' + lines = data.split('\n') + section = None + is_dup_field = False + for lineno, line in enumerate(lines): + pruned_line = self.comment_r.sub('', line).rstrip() + if not pruned_line: + continue + if pruned_line[0].isspace(): + if is_dup_field: + lines[lineno] = '#' + lines[lineno] + continue + is_dup_field = False + if pruned_line[0] == '[': + section = pruned_line[1:-1].strip() + continue + field = self.value_r.sub('', pruned_line) + if config.fileconfig.has_option(section, field): + is_dup_field = True + lines[lineno] = '#' + lines[lineno] + return "\n".join(lines) + def _build_config_wrapper(self, data): + sfile = StringIO.StringIO(data) fileconfig = ConfigParser.RawConfigParser() - res = fileconfig.read(filename) - if not res: - raise error("Unable to open config file %s" % (filename,)) + fileconfig.readfp(sfile) return ConfigWrapper(self.printer, fileconfig, {}, 'printer') + def _build_config_string(self, config): + sfile = StringIO.StringIO() + config.fileconfig.write(sfile) + return sfile.getvalue().strip() + def read_config(self, filename): + return self._build_config_wrapper(self._read_config_file(filename)) def read_main_config(self): filename = self.printer.get_start_args()['config_file'] - return self.read_config(filename) + data = self._read_config_file(filename) + regular_data, autosave_data = self._find_autosave_data(data) + regular_config = self._build_config_wrapper(regular_data) + autosave_data = self._strip_duplicates(autosave_data, regular_config) + self.autosave = self._build_config_wrapper(autosave_data) + return self._build_config_wrapper(regular_data + autosave_data) def check_unused_options(self, config): - access_tracking = config.access_tracking fileconfig = config.fileconfig objects = dict(self.printer.lookup_objects()) + # Determine all the fields that have been accessed + access_tracking = dict(config.access_tracking) + for section in self.autosave.fileconfig.sections(): + for option in self.autosave.fileconfig.options(section): + access_tracking[(section.lower(), option.lower())] = 1 # Validate that there are no undefined parameters in the config file valid_sections = { s: 1 for s, o in access_tracking } for section_name in fileconfig.sections(): @@ -113,4 +186,62 @@ class PrinterConfig: raise error("Option '%s' is not valid in section '%s'" % ( option, section)) def log_config(self, config): - ConfigLogger(config.fileconfig, self.printer) + lines = ["===== Config file =====", + self._build_config_string(config), + "======================="] + self.printer.set_rollover_info("config", "\n".join(lines)) + # Autosave functions + def set(self, section, option, value): + if not self.autosave.fileconfig.has_section(section): + self.autosave.fileconfig.add_section(section) + svalue = str(value) + self.autosave.fileconfig.set(section, option, svalue) + logging.info("save_config: set [%s] %s = %s", section, option, svalue) + def remove_section(self, section): + self.autosave.fileconfig.remove_section(section) + cmd_SAVE_CONFIG_help = "Overwrite config file and restart" + def cmd_SAVE_CONFIG(self, params): + if not self.autosave.fileconfig.sections(): + return + gcode = self.printer.lookup_object('gcode') + # Create string containing autosave data + autosave_data = self._build_config_string(self.autosave) + lines = [('#*# ' + l).strip() + for l in autosave_data.split('\n')] + lines.insert(0, "\n" + AUTOSAVE_HEADER.rstrip()) + lines.append("") + autosave_data = '\n'.join(lines) + # Read in and validate current config file + cfgname = self.printer.get_start_args()['config_file'] + try: + data = self._read_config_file(cfgname) + regular_data, old_autosave_data = self._find_autosave_data(data) + config = self._build_config_wrapper(regular_data) + except error as e: + msg = "Unable to parse existing config on SAVE_CONFIG" + logging.exception(msg) + raise gcode.error(msg) + regular_data = self._strip_duplicates(regular_data, self.autosave) + data = regular_data.rstrip() + autosave_data + # Determine filenames + datestr = time.strftime("-%Y%m%d_%H%M%S") + backup_name = cfgname + datestr + temp_name = cfgname + "_autosave" + if cfgname.endswith(".cfg"): + backup_name = cfgname[:-4] + datestr + ".cfg" + temp_name = cfgname[:-4] + "_autosave.cfg" + # Create new config file with temporary name and swap with main config + logging.info("SAVE_CONFIG to '%s' (backup in '%s')", + cfgname, backup_name) + try: + f = open(temp_name, 'wb') + f.write(data) + f.close() + os.rename(cfgname, backup_name) + os.rename(temp_name, cfgname) + except: + msg = "Unable to write config file during SAVE_CONFIG" + logging.exception(msg) + raise gcode.error(msg) + # Request a restart + gcode.request_restart('restart')