diff --git a/src/octoprint/util/comm.py b/src/octoprint/util/comm.py index f29e974e..db7c908a 100644 --- a/src/octoprint/util/comm.py +++ b/src/octoprint/util/comm.py @@ -33,6 +33,77 @@ except: _logger = logging.getLogger(__name__) +# a bunch of regexes we'll need for the communication parsing... + +regex_float_pattern = "[-+]?[0-9]*\.?[0-9]+" +regex_positive_float_pattern = "[+]?[0-9]*\.?[0-9]+" +regex_int_pattern = "\d+" + +regex_command = re.compile("^\s*((?P[GM]\d+)|(?PT)\d+)") +"""Regex for a GCODE command.""" + +regex_float = re.compile(regex_float_pattern) +"""Regex for a float value.""" + +regexes_parameters = dict( + floatP=re.compile("(^|[^A-Za-z])[Pp](?P%s)" % regex_float_pattern), + floatS=re.compile("(^|[^A-Za-z])[Ss](?P%s)" % regex_float_pattern), + floatZ=re.compile("(^|[^A-Za-z])[Zz](?P%s)" % regex_float_pattern), + intN=re.compile("(^|[^A-Za-z])[Nn](?P%s)" % regex_int_pattern), + intT=re.compile("(^|[^A-Za-z])[Tt](?P%s)" % regex_int_pattern) +) +"""Regexes for parsing various GCODE command parameters.""" + +regex_minMaxError = re.compile("Error:[0-9]\n") +"""Regex matching first line of min/max errors from the firmware.""" + +regex_sdPrintingByte = re.compile("(?P[0-9]*)/(?P[0-9]*)") +"""Regex matching SD printing status reports. + +Groups will be as follows: + + * ``current``: current byte position in file being printed + * ``total``: total size of file being printed +""" + +regex_sdFileOpened = re.compile("File opened:\s*(?P.*?)\s+Size:\s*(?P%s)" % regex_int_pattern) +"""Regex matching "File opened" messages from the firmware. + +Groups will be as follows: + + * ``name``: name of the file reported as having been opened (str) + * ``size``: size of the file in bytes (int) +""" + +regex_temp = re.compile("(?PB|T(?P\d*)):\s*(?P%s)(\s*\/?\s*(?P%s))?" % (regex_positive_float_pattern, regex_positive_float_pattern)) +"""Regex matching temperature entries in line. + +Groups will be as follows: + + * ``tool``: whole tool designator, incl. optional ``toolnum`` (str) + * ``toolnum``: tool number, if provided (int) + * ``actual``: actual temperature (float) + * ``target``: target temperature, if provided (float) +""" + +regex_repetierTempExtr = re.compile("TargetExtr(?P\d+):(?P%s)" % regex_positive_float_pattern) +"""Regex for matching target temp reporting from Repetier. + +Groups will be as follows: + + * ``toolnum``: number of the extruder to which the target temperature + report belongs (int) + * ``target``: new target temperature (float) +""" + +regex_repetierTempBed = re.compile("TargetBed:(?P%s)" % regex_positive_float_pattern) +"""Regex for matching target temp reporting from Repetier for beds. + +Groups will be as follows: + + * ``target``: new target temperature (float) +""" + def serialList(): baselist=[] if os.name=="nt": @@ -210,28 +281,6 @@ class MachineCom(object): self._currentFile = None # regexes - floatPattern = "[-+]?[0-9]*\.?[0-9]+" - positiveFloatPattern = "[+]?[0-9]*\.?[0-9]+" - intPattern = "\d+" - self._regex_command = re.compile("^\s*([GM]\d+|T)") - self._regex_float = re.compile(floatPattern) - self._regex_paramZFloat = re.compile("Z(%s)" % floatPattern) - self._regex_paramSInt = re.compile("S(%s)" % intPattern) - self._regex_paramNInt = re.compile("N(%s)" % intPattern) - self._regex_paramTInt = re.compile("T(%s)" % intPattern) - self._regex_minMaxError = re.compile("Error:[0-9]\n") - self._regex_sdPrintingByte = re.compile("([0-9]*)/([0-9]*)") - self._regex_sdFileOpened = re.compile("File opened:\s*(.*?)\s+Size:\s*(%s)" % intPattern) - - # Regex matching temperature entries in line. Groups will be as follows: - # - 1: whole tool designator incl. optional toolNumber ("T", "Tn", "B") - # - 2: toolNumber, if given ("", "n", "") - # - 3: actual temperature - # - 4: whole target substring, if given (e.g. " / 22.0") - # - 5: target temperature - self._regex_temp = re.compile("(B|T(\d*)):\s*(%s)(\s*\/?\s*(%s))?" % (positiveFloatPattern, positiveFloatPattern)) - self._regex_repetierTempExtr = re.compile("TargetExtr([0-9]+):(%s)" % positiveFloatPattern) - self._regex_repetierTempBed = re.compile("TargetBed:(%s)" % positiveFloatPattern) self._long_running_commands = settings().get(["serial", "longRunningCommands"]) @@ -783,51 +832,9 @@ class MachineCom(object): ##~~ communication monitoring and handling - def _parseTemperatures(self, line): - result = {} - maxToolNum = 0 - for match in re.finditer(self._regex_temp, line): - tool = match.group(1) - toolNumber = int(match.group(2)) if match.group(2) and len(match.group(2)) > 0 else None - if toolNumber > maxToolNum: - maxToolNum = toolNumber - - try: - actual = float(match.group(3)) - target = None - if match.group(4) and match.group(5): - target = float(match.group(5)) - - result[tool] = (toolNumber, actual, target) - except ValueError: - # catch conversion issues, we'll rather just not get the temperature update instead of killing the connection - pass - - if "T0" in result.keys() and "T" in result.keys(): - del result["T"] - - return maxToolNum, result - def _processTemperatures(self, line): - maxToolNum, parsedTemps = self._parseTemperatures(line) - - # extruder temperatures - if not "T0" in parsedTemps.keys() and not "T1" in parsedTemps.keys() and "T" in parsedTemps.keys(): - # no T1 so only single reporting, "T" is our one and only extruder temperature - toolNum, actual, target = parsedTemps["T"] - - if target is not None: - self._temp[0] = (actual, target) - elif 0 in self._temp.keys() and self._temp[0] is not None and isinstance(self._temp[0], tuple): - (oldActual, oldTarget) = self._temp[0] - self._temp[0] = (actual, oldTarget) - else: - self._temp[0] = (actual, None) - elif not "T0" in parsedTemps.keys() and "T" in parsedTemps.keys(): - # Smoothieware sends multi extruder temperature data this way: "T: T1: ..." and therefore needs some special treatment... - _, actual, target = parsedTemps["T"] - del parsedTemps["T"] - parsedTemps["T0"] = (0, actual, target) + current_tool = self._currentTool if self._currentTool is not None else 0 + maxToolNum, parsedTemps = parse_temperature_line(line, current_tool) if "T0" in parsedTemps.keys(): for n in range(maxToolNum + 1): @@ -835,18 +842,18 @@ class MachineCom(object): if not tool in parsedTemps.keys(): continue - toolNum, actual, target = parsedTemps[tool] + actual, target = parsedTemps[tool] if target is not None: - self._temp[toolNum] = (actual, target) - elif toolNum in self._temp.keys() and self._temp[toolNum] is not None and isinstance(self._temp[toolNum], tuple): - (oldActual, oldTarget) = self._temp[toolNum] - self._temp[toolNum] = (actual, oldTarget) + self._temp[n] = (actual, target) + elif n in self._temp and self._temp[n] is not None and isinstance(self._temp[n], tuple): + (oldActual, oldTarget) = self._temp[n] + self._temp[n] = (actual, oldTarget) else: - self._temp[toolNum] = (actual, None) + self._temp[n] = (actual, None) # bed temperature if "B" in parsedTemps.keys(): - toolNum, actual, target = parsedTemps["B"] + actual, target = parsedTemps["B"] if target is not None: self._bedTemp = (actual, target) elif self._bedTemp is not None and isinstance(self._bedTemp, tuple): @@ -973,8 +980,8 @@ class MachineCom(object): self._callback.on_comm_temperature_update(self._temp, self._bedTemp) elif supportRepetierTargetTemp and ('TargetExtr' in line or 'TargetBed' in line): - matchExtr = self._regex_repetierTempExtr.match(line) - matchBed = self._regex_repetierTempBed.match(line) + matchExtr = regex_repetierTempExtr.match(line) + matchBed = regex_repetierTempBed.match(line) if matchExtr is not None: toolNum = int(matchExtr.group(1)) @@ -1028,18 +1035,18 @@ class MachineCom(object): self._callback.on_comm_sd_files(self._sdFiles) elif 'SD printing byte' in line and self.isSdPrinting(): # answer to M27, at least on Marlin, Repetier and Sprinter: "SD printing byte %d/%d" - match = self._regex_sdPrintingByte.search(line) - self._currentFile.setFilepos(int(match.group(1))) + match = regex_sdPrintingByte.search(line) + self._currentFile.setFilepos(int(match.group("current"))) self._callback.on_comm_progress() elif 'File opened' in line and not self._ignore_select: # answer to M23, at least on Marlin, Repetier and Sprinter: "File opened:%s Size:%d" - match = self._regex_sdFileOpened.search(line) + match = regex_sdFileOpened.search(line) if self._sdFileToSelect: name = self._sdFileToSelect self._sdFileToSelect = None else: - name = match.group(1) - self._currentFile = PrintingSdFileInformation(name, int(match.group(2))) + name = match.group("name") + self._currentFile = PrintingSdFileInformation(name, int(match.group("size"))) elif 'File selected' in line: if self._ignore_select: self._ignore_select = False @@ -1372,7 +1379,7 @@ class MachineCom(object): # Marlin reports an MIN/MAX temp error as "Error:x\n: Extruder switched off. MAXTEMP triggered !\n" # But a bed temp error is reported as "Error: Temperature heated bed switched off. MAXTEMP triggered !!" # So we can have an extra newline in the most common case. Awesome work people. - if self._regex_minMaxError.match(line): + if regex_minMaxError.match(line): line = line.rstrip() + self._readline() if 'line number' in line.lower() or 'checksum' in line.lower() or 'expected line' in line.lower(): @@ -1536,25 +1543,6 @@ class MachineCom(object): # trigger the "queued" phase only if we are not streaming to sd right now self._process_command_phase("queued", cmd, cmd_type, gcode=gcode) - def gcode_command_for_cmd(self, cmd): - """ - Tries to parse the provided ``cmd`` and extract the GCODE command identifier from it (e.g. "G0" for "G0 X10.0"). - - Arguments: - cmd (str): The command to try to parse. - - Returns: - str or None: The GCODE command identifier if it could be parsed, or None if not. - """ - if not cmd: - return None - - gcode = self._regex_command.search(cmd) - if not gcode: - return None - - return gcode.group(1) - ##~~ send loop handling def _enqueue_for_sending(self, command, linenumber=None, command_type=None): @@ -1596,7 +1584,7 @@ class MachineCom(object): # some firmwares (e.g. Smoothie) might support additional in-band communication that will not # stick to the acknowledgement behaviour of GCODE, so we check here if we have a GCODE command # at hand here and only clear our clear_to_send flag later if that's the case - gcode = self.gcode_command_for_cmd(command) + gcode = gcode_command_for_cmd(command) if linenumber is not None: # line number predetermined - this only happens for resends, so we'll use the number and @@ -1657,7 +1645,7 @@ class MachineCom(object): return command, command_type, gcode if gcode is None: - gcode = self.gcode_command_for_cmd(command) + gcode = gcode_command_for_cmd(command) # send it through the phase specific handlers provided by plugins for name, hook in self._gcode_hooks[phase].items(): @@ -1712,7 +1700,7 @@ class MachineCom(object): # handler returned a tuple of an unexpected length return original_tuple - gcode = self.gcode_command_for_cmd(command) + gcode = gcode_command_for_cmd(command) return command, command_type, gcode ##~~ actual sending via serial @@ -1750,13 +1738,13 @@ class MachineCom(object): ##~~ command handlers def _gcode_T_sent(self, cmd, cmd_type=None): - toolMatch = self._regex_paramTInt.search(cmd) + toolMatch = regexes_parameters["intT"].search(cmd) if toolMatch: self._currentTool = int(toolMatch.group(1)) def _gcode_G0_sent(self, cmd, cmd_type=None): if 'Z' in cmd: - match = self._regex_paramZFloat.search(cmd) + match = regexes_parameters["floatZ"].search(cmd) if match: try: z = float(match.group(1)) @@ -1774,10 +1762,10 @@ class MachineCom(object): def _gcode_M104_sent(self, cmd, cmd_type=None): toolNum = self._currentTool - toolMatch = self._regex_paramTInt.search(cmd) + toolMatch = regexes_parameters["intT"].search(cmd) if toolMatch: toolNum = int(toolMatch.group(1)) - match = self._regex_paramSInt.search(cmd) + match = regexes_parameters["floatS"].search(cmd) if match: try: target = float(match.group(1)) @@ -1790,7 +1778,7 @@ class MachineCom(object): pass def _gcode_M140_sent(self, cmd, cmd_type=None): - match = self._regex_paramSInt.search(cmd) + match = regexes_parameters["floatS"].search(cmd) if match: try: target = float(match.group(1)) @@ -1816,10 +1804,10 @@ class MachineCom(object): def _gcode_M110_sending(self, cmd, cmd_type=None): newLineNumber = None - match = self._regex_paramNInt.search(cmd) + match = regexes_parameters["intN"].search(cmd) if match: try: - newLineNumber = int(match.group(1)) + newLineNumber = int(match.group("value")) except: pass else: @@ -1837,16 +1825,14 @@ class MachineCom(object): def _gcode_G4_sent(self, cmd, cmd_type=None): # we are intending to dwell for a period of time, increase the timeout to match - cmd = cmd.upper() - p_idx = cmd.find('P') - s_idx = cmd.find('S') + p_match = regexes_parameters["floatP"].search(cmd) + s_match = regexes_parameters["floatS"].search(cmd) + _timeout = 0 - if p_idx != -1: - # dwell time is specified in milliseconds - _timeout = float(cmd[p_idx+1:]) / 1000.0 - elif s_idx != -1: - # dwell time is specified in seconds - _timeout = float(cmd[s_idx+1:]) + if p_match: + _timeout = float(p_match.group("value")) / 1000.0 + elif s_match: + _timeout = float(s_match.group("value")) self._timeout = get_new_timeout("communication") + _timeout ##~~ command phase handlers @@ -2236,3 +2222,159 @@ def convert_feedback_controls(configured_controls): return feedback_controls, feedback_matcher +def canonicalize_temperatures(parsed, current): + """ + Canonicalizes the temperatures provided in parsed. + + Will make sure that returned result only contains extruder keys + like Tn, so always qualified with a tool number. + + The algorithm for cleaning up the parsed keys is the following: + + * If ``T`` is not included with the reported extruders, return + * If more than just ``T`` is reported: + * If both ``T`` and ``T0`` are reported set ``Tc`` to ``T``, remove + ``T`` from the result. + * Else set ``T0`` to ``T`` and delete ``T`` (Smoothie extra). + * If only ``T`` is reported, set ``Tc`` to ``T`` and delete ``T`` + * return + + Arguments: + parsed (dict): the parsed temperatures (mapping tool => (actual, target)) + to canonicalize + current (int): the current active extruder + Returns: + dict: the canonicalized version of ``parsed`` + """ + + reported_extruders = filter(lambda x: x.startswith("T"), parsed.keys()) + if not "T" in reported_extruders: + # Our reported_extruders are either empty or consist purely + # of Tn keys, no need for any action + return parsed + + current_tool_key = "T%d" % current + result = dict(parsed) + + if len(reported_extruders) > 1: + if "T0" in reported_extruders: + # Both T and T0 are present, so T contains the current + # extruder's temperature, e.g. for current_tool == 1: + # + # T: T0: T2: ... B: + # + # becomes + # + # T0: T1: T2: ... B: + # + # Same goes if Tc is already present, it will be overwritten: + # + # T: T0: T1: T2: ... B: + # + # becomes + # + # T0: T1: T2: ... B: + result[current_tool_key] = result["T"] + del result["T"] + else: + # So T is there, but T0 isn't. That looks like Smoothieware which + # always reports the first extruder T0 as T: + # + # T: T1: T2: ... B: + # + # becomes + # + # T0: T1: T2: ... B: + result["T0"] = result["T"] + del result["T"] + + else: + # We only have T. That can mean two things: + # + # * we only have one extruder at all, or + # * we are currently parsing a response to M109/M190, which on + # some firmwares doesn't report the full M105 output while + # waiting for the target temperature to be reached but only + # reports the current tool and bed + # + # In both cases it is however safe to just move our T over + # to T in the parsed data, current should always stay + # 0 for single extruder printers. E.g. for current_tool == 1: + # + # T: + # + # becomes + # + # T1: + + result[current_tool_key] = result["T"] + del result["T"] + + return result + +def parse_temperature_line(line, current): + """ + Parses the provided temperature line. + + The result will be a dictionary mapping from the extruder or bed key to + a tuple with current and target temperature. The result will be canonicalized + with :func:`canonicalize_temperatures` before returning. + + Arguments: + line (str): the temperature line to parse + current (int): the current active extruder + + Returns: + tuple: a 2-tuple with the maximum tool number and a dict mapping from + key to (actual, target) tuples, with key either matching ``Tn`` for ``n >= 0`` or ``B`` + """ + + result = {} + maxToolNum = 0 + for match in re.finditer(regex_temp, line): + values = match.groupdict() + tool = values["tool"] + toolnum = values.get("toolnum", None) + toolNumber = int(toolnum) if toolnum is not None and len(toolnum) else None + if toolNumber > maxToolNum: + maxToolNum = toolNumber + + try: + actual = float(match.group(3)) + target = None + if match.group(4) and match.group(5): + target = float(match.group(5)) + + result[tool] = (actual, target) + except ValueError: + # catch conversion issues, we'll rather just not get the temperature update instead of killing the connection + pass + + return max(maxToolNum, current), canonicalize_temperatures(result, current) + +def gcode_command_for_cmd(cmd): + """ + Tries to parse the provided ``cmd`` and extract the GCODE command identifier from it (e.g. "G0" for "G0 X10.0"). + + Arguments: + cmd (str): The command to try to parse. + + Returns: + str or None: The GCODE command identifier if it could be parsed, or None if not. + """ + if not cmd: + return None + + gcode = regex_command.search(cmd) + if not gcode: + return None + + values = gcode.groupdict() + if "commandGM" in values and values["commandGM"]: + return values["commandGM"] + elif "commandT" in values and values["commandT"]: + return values["commandT"] + else: + # this should never happen + return None + diff --git a/tests/util/test_comm_helpers.py b/tests/util/test_comm_helpers.py index 3015e6f6..e7b906f1 100644 --- a/tests/util/test_comm_helpers.py +++ b/tests/util/test_comm_helpers.py @@ -156,3 +156,76 @@ class TestCommHelpers(unittest.TestCase): self.assertEquals(x_template, x["templates"][x_template_key]) self.assertEquals("(?P{temp_regex})|(?P{x_regex})".format(**locals()), matcher.pattern) + + @data( + ("G4 P2.0", "floatP", True, "2.0"), + ("M109 S220.0", "floatS", True, "220.0"), + ("G1 X10.0 Y10.0 Z0.2", "floatZ", True, "0.2"), + ("G1X10.0Y10.0Z0.2", "floatZ", True, "0.2"), + ("g1x10.0y10.0z0.2", "floatZ", True, "0.2"), + ("M110 N0", "intN", True, "0"), + ("M104 S220.0 T1", "intT", True, "1"), + ("M104 T1 S220.0", "intT", True, "1"), + ("N100 M110", "intN", True, "100"), + ("NP100", "floatP", False, None), + ) + @unpack + def test_parameter_regexes(self, line, parameter, should_match, expected_value): + from octoprint.util.comm import regexes_parameters + + regex = regexes_parameters[parameter] + match = regex.search(line) + + if should_match: + self.assertIsNotNone(match) + self.assertEquals(expected_value, match.group("value")) + else: + self.assertIsNone(match) + + @data( + ("G0 X0", "G0"), + ("G28 X0 Y0", "G28"), + ("M109 S220.0 T1", "M109"), + ("M117 Hello World", "M117"), + ("T0", "T"), + ("T3", "T"), + (None, None), + ("No match", None) + ) + @unpack + def test_gcode_command_for_cmd(self, cmd, expected): + from octoprint.util.comm import gcode_command_for_cmd + result = gcode_command_for_cmd(cmd) + self.assertEquals(expected, result) + + @data( + ("T:23.0 B:60.0", 0, dict(T0=(23.0, None), B=(60.0, None)), 0), + ("T:23.0 B:60.0", 1, dict(T1=(23.0, None), B=(60.0, None)), 1), + ("T:23.0/220.0 B:60.0/70.0", 0, dict(T0=(23.0, 220.0), B=(60.0, 70.0)), 0), + ("ok T:23.0/220.0 T0:23.0/220.0 T1:50.2/210.0 T2:39.4/220.0 B:60.0", 0, dict(T0=(23.0, 220.0), T1=(50.2, 210.0), T2=(39.4, 220.0), B=(60.0, None)), 2), + ("ok T:50.2/210.0 T0:23.0/220.0 T1:50.2/210.0 T2:39.4/220.0 B:60.0", 1, dict(T0=(23.0, 220.0), T1=(50.2, 210.0), T2=(39.4, 220.0), B=(60.0, None)), 2) + ) + @unpack + def test_process_temperature_line(self, line, current, expected_result, expected_max): + from octoprint.util.comm import parse_temperature_line + maxtool, result = parse_temperature_line(line, current) + self.assertDictEqual(expected_result, result) + self.assertEquals(expected_max, maxtool) + + @data( + (dict(T=(23.0,None)), 0, dict(T0=(23.0, None))), + (dict(T=(23.0,None)), 1, dict(T1=(23.0, None))), + (dict(T=(23.0, None), T0=(23.0, None), T1=(42.0, None)), 0, dict(T0=(23.0, None), T1=(42.0, None))), + (dict(T=(42.0, None), T0=(23.0, None), T1=(42.0, None)), 1, dict(T0=(23.0, None), T1=(42.0, None))), + (dict(T=(21.0, None), T0=(23.0, None), T1=(42.0, None)), 0, dict(T0=(21.0, None), T1=(42.0, None))), + (dict(T=(41.0, None), T0=(23.0, None), T1=(42.0, None)), 1, dict(T0=(23.0, None), T1=(41.0, None))), + (dict(T=(23.0, None), T1=(42.0, None)), 1, dict(T0=(23.0, None), T1=(42.0, None))), + (dict(T0=(23.0, None), T1=(42.0, None)), 1, dict(T0=(23.0, None), T1=(42.0, None))) + + ) + @unpack + def test_canonicalize_temperatures(self, parsed, current, expected): + from octoprint.util.comm import canonicalize_temperatures + result = canonicalize_temperatures(parsed, current) + self.assertDictEqual(expected, result) +