+ {{ _('Take note that timelapse configuration is disabled while your printer is printing.') }}
+
{{ _('Timelapse Configuration') }}
diff --git a/src/octoprint/util/comm.py b/src/octoprint/util/comm.py
index f29e974e..788299f8 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,10 +1379,10 @@ 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():
+ if 'line number' in line.lower() or 'checksum' in line.lower() or 'format error' in line.lower() or 'expected line' in line.lower():
#Skip the communication errors, as those get corrected.
self._lastCommError = line[6:] if line.startswith("Error:") else line[2:]
pass
@@ -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)
+