diff --git a/src/octoprint/plugins/virtual_printer/virtual.py b/src/octoprint/plugins/virtual_printer/virtual.py index ecca69a9..632cddf4 100644 --- a/src/octoprint/plugins/virtual_printer/virtual.py +++ b/src/octoprint/plugins/virtual_printer/virtual.py @@ -18,6 +18,7 @@ from serial import SerialTimeoutException from octoprint.settings import settings from octoprint.plugin import plugin_manager +from octoprint.util import RepeatedTimer class VirtualPrinter(object): command_regex = re.compile("^([GMTF])(\d+)") @@ -113,6 +114,10 @@ class VirtualPrinter(object): self._okFormatString = settings().get(["devel", "virtualPrinter", "okFormatString"]) + self._capabilities = settings().get(["devel", "virtualPrinter", "capabilities"]) + + self._temperature_reporter = None + self.currentLine = 0 self.lastN = 0 @@ -405,11 +410,26 @@ class VirtualPrinter(object): output = self._m115FormatString.format(firmware_name=self._firmwareName) self._send(output) + if settings().getBoolean(["devel", "virtualPrinter", "m115ReportCapabilities"]): + for cap, enabled in self._capabilities.items(): + self._send("Cap:{}:{}".format(cap.upper(), "1" if enabled else "0")) + def _gcode_M117(self, data): # we'll just use this to echo a message, to allow playing around with pause triggers if self._echoOnM117: self._send("echo:%s" % re.search("M117\s+(.*)", data).group(1)) + def _gcode_M155(self, data): + interval = int(re.search("S([0-9]+)", data).group(1)) + if self._temperature_reporter is not None: + self._temperature_reporter.cancel() + + if interval > 0: + self._temperature_reporter = RepeatedTimer(interval, lambda: self._send(self._generateTemperatureOutput())) + self._temperature_reporter.start() + else: + self._temperature_reporter = None + def _gcode_M220(self, data): self._feedrate_multiplier = float(re.search('S([0-9]+)', data).group(1)) @@ -675,9 +695,8 @@ class VirtualPrinter(object): else: self._send("Not SD printing") - def _processTemperatureQuery(self): + def _generateTemperatureOutput(self): includeTarget = not settings().getBoolean(["devel", "virtualPrinter", "repetierStyleTargetTemperature"]) - includeOk = not self._okBeforeCommandOutput # send simulated temperature data if self.temperatureCount > 1: @@ -709,6 +728,11 @@ class VirtualPrinter(object): output = "T:%.2f B:%.2f" % (self.temp[0], self.bedTemp) output += " @:64\n" + return output + + def _processTemperatureQuery(self): + includeOk = not self._okBeforeCommandOutput + output = self._generateTemperatureOutput() if includeOk: output = "{} {}".format(self._ok(), output) diff --git a/src/octoprint/server/api/settings.py b/src/octoprint/server/api/settings.py index 7351ade1..a5230811 100644 --- a/src/octoprint/server/api/settings.py +++ b/src/octoprint/server/api/settings.py @@ -143,6 +143,7 @@ def getSettings(): "timeoutCommunication": s.getFloat(["serial", "timeout", "communication"]), "timeoutTemperature": s.getFloat(["serial", "timeout", "temperature"]), "timeoutTemperatureTargetSet": s.getFloat(["serial", "timeout", "temperatureTargetSet"]), + "timeoutTemperatureAutoreport": s.getFloat(["serial", "timeout", "temperatureAutoreport"]), "timeoutSdStatus": s.getFloat(["serial", "timeout", "sdStatus"]), "log": s.getBoolean(["serial", "log"]), "additionalPorts": s.get(["serial", "additionalPorts"]), @@ -349,6 +350,7 @@ def _saveSettings(data): if "timeoutCommunication" in data["serial"]: s.setFloat(["serial", "timeout", "communication"], data["serial"]["timeoutCommunication"]) if "timeoutTemperature" in data["serial"]: s.setFloat(["serial", "timeout", "temperature"], data["serial"]["timeoutTemperature"]) if "timeoutTemperatureTargetSet" in data["serial"]: s.setFloat(["serial", "timeout", "temperatureTargetSet"], data["serial"]["timeoutTemperatureTargetSet"]) + if "timeoutTemperatureAutoreport" in data["serial"]: s.setFloat(["serial", "timeout", "temperatureAutoreport"], data["serial"]["timeoutTemperatureAutoreport"]) if "timeoutSdStatus" in data["serial"]: s.setFloat(["serial", "timeout", "sdStatus"], data["serial"]["timeoutSdStatus"]) if "additionalPorts" in data["serial"] and isinstance(data["serial"]["additionalPorts"], (list, tuple)): s.set(["serial", "additionalPorts"], data["serial"]["additionalPorts"]) if "additionalBaudrates" in data["serial"] and isinstance(data["serial"]["additionalBaudrates"], (list, tuple)): s.set(["serial", "additionalBaudrates"], data["serial"]["additionalBaudrates"]) diff --git a/src/octoprint/settings.py b/src/octoprint/settings.py index 0e525274..396bc0bc 100644 --- a/src/octoprint/settings.py +++ b/src/octoprint/settings.py @@ -94,6 +94,7 @@ default_settings = { "communication": 30, "temperature": 5, "temperatureTargetSet": 2, + "temperatureAutoreport": 2, "sdStatus": 1 }, "maxCommunicationTimeouts": { @@ -299,7 +300,7 @@ default_settings = { "apps": {} }, "terminalFilters": [ - { "name": "Suppress temperature messages", "regex": "(Send: (N\d+\s+)?M105)|(Recv: ok (B|T\d*):)" }, + { "name": "Suppress temperature messages", "regex": "(Send: (N\d+\s+)?M105)|(Recv:\s+(ok\s+)?(B|T\d*):)" }, { "name": "Suppress SD status messages", "regex": "(Send: (N\d+\s+)?M27)|(Recv: SD printing byte)" }, { "name": "Suppress wait responses", "regex": "Recv: wait"} ], @@ -364,7 +365,11 @@ default_settings = { "simulateReset": True, "preparedOks": [], "okFormatString": "ok", - "m115FormatString": "FIRMWARE_NAME: {firmware_name} PROTOCOL_VERSION:1.0" + "m115FormatString": "FIRMWARE_NAME: {firmware_name} PROTOCOL_VERSION:1.0", + "m115ReportCapabilities": False, + "capabilities": { + "AUTOREPORT_TEMP": True + } } } } diff --git a/src/octoprint/static/js/app/viewmodels/settings.js b/src/octoprint/static/js/app/viewmodels/settings.js index d15f42d0..e481f38d 100644 --- a/src/octoprint/static/js/app/viewmodels/settings.js +++ b/src/octoprint/static/js/app/viewmodels/settings.js @@ -156,6 +156,7 @@ $(function() { self.serial_timeoutCommunication = ko.observable(undefined); self.serial_timeoutTemperature = ko.observable(undefined); self.serial_timeoutTemperatureTargetSet = ko.observable(undefined); + self.serial_timeoutTemperatureAutoreport = ko.observable(undefined); self.serial_timeoutSdStatus = ko.observable(undefined); self.serial_log = ko.observable(undefined); self.serial_additionalPorts = ko.observable(undefined); diff --git a/src/octoprint/templates/dialogs/settings/serialconnection.jinja2 b/src/octoprint/templates/dialogs/settings/serialconnection.jinja2 index 0beeae73..528889eb 100644 --- a/src/octoprint/templates/dialogs/settings/serialconnection.jinja2 +++ b/src/octoprint/templates/dialogs/settings/serialconnection.jinja2 @@ -19,7 +19,7 @@
- +
@@ -35,6 +35,16 @@ {{ _('When a target temperature is set')}}
+
+ +
+
+ + s +
+ {{ _('Autoreport interval to request from firmware')}} +
+
diff --git a/src/octoprint/util/comm.py b/src/octoprint/util/comm.py index e409ca2c..c1c30d3b 100644 --- a/src/octoprint/util/comm.py +++ b/src/octoprint/util/comm.py @@ -62,6 +62,7 @@ regexes_parameters = dict( floatY=re.compile("(^|[^A-Za-z])[Yy](?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), + intS=re.compile("(^|[^A-Za-z])[Ss](?P%s)" % regex_int_pattern), intT=re.compile("(^|[^A-Za-z])[Tt](?P%s)" % regex_int_pattern) ) """Regexes for parsing various GCODE command parameters.""" @@ -254,6 +255,8 @@ class MachineCom(object): STATE_CLOSED_WITH_ERROR = 10 STATE_TRANSFERING_FILE = 11 + CAPABILITY_AUTOREPORT_TEMP = "AUTOREPORT_TEMP" + def __init__(self, port = None, baudrate=None, callbackObject=None, printerProfileManager=None): self._logger = logging.getLogger(__name__) self._serialLogger = logging.getLogger("SERIAL") @@ -336,8 +339,12 @@ class MachineCom(object): self._resendSwallowRepetitions = settings().getBoolean(["feature", "ignoreIdenticalResends"]) self._resendSwallowRepetitionsCounter = 0 - self._firmwareDetection = settings().getBoolean(["feature", "firmwareDetection"]) - self._firmwareInfoReceived = not self._firmwareDetection + self._firmware_detection = settings().getBoolean(["feature", "firmwareDetection"]) + self._firmware_info_received = not self._firmware_detection + self._firmware_info = dict() + self._firmware_capabilities = dict() + + self._temperature_autoreporting = False self._supportResendsWithoutOk = settings().getBoolean(["serial", "supportResendsWithoutOk"]) @@ -1212,11 +1219,16 @@ class MachineCom(object): self._callback.on_comm_position_update(self.last_position.as_dict(), reason=reason) # temperature processing - elif ' T:' in line or line.startswith('T:') or ' T0:' in line or line.startswith('T0:') or ((' B:' in line or line.startswith('B:')) and not 'A:' in line): - if not disable_external_heatup_detection and not line.strip().startswith("ok") and not self._heating and self._firmwareInfoReceived: + elif ' T:' in line or line.startswith('T:') or ' T0:' in line or line.startswith('T0:') \ + or ((' B:' in line or line.startswith('B:')) and not 'A:' in line): + + if not disable_external_heatup_detection and not self._temperature_autoreporting \ + and not line.strip().startswith("ok") and not self._heating \ + and self._firmware_info_received: self._logger.debug("Externally triggered heatup detected") self._heating = True self._heatupWaitStartTime = time.time() + self._processTemperatures(line) self._callback.on_comm_temperature_update(self._temp, self._bedTemp) @@ -1268,7 +1280,7 @@ class MachineCom(object): if "malyan" in name.lower() and ver: firmware_name = name.strip() + " " + ver.strip() - if not self._firmwareInfoReceived and firmware_name: + if not self._firmware_info_received and firmware_name: firmware_name = firmware_name.strip() self._logger.info("Printer reports firmware name \"{}\"".format(firmware_name)) @@ -1301,7 +1313,20 @@ class MachineCom(object): if not sd_always_available and not self._sdAvailable: self.initSdCard() - self._firmwareInfoReceived = True + self._firmware_info_received = True + self._firmware_info = data + self._firmware_name = firmware_name + + ##~~ Firmware capability report triggered by M115 + elif lower_line.startswith("cap:"): + parsed = parse_capability_line(lower_line) + if parsed is not None: + capability, enabled = parsed + self._firmware_capabilities[capability] = enabled + + if capability == self.CAPABILITY_AUTOREPORT_TEMP and enabled: + self._logger.info("Firmware states that it supports temperature autoreporting") + self._set_autoreport_temperature() ##~~ SD Card handling elif 'SD init fail' in line or 'volume.init failed' in line or 'openRoot failed' in line: @@ -1613,11 +1638,11 @@ class MachineCom(object): """ Polls the temperature after the temperature timeout, re-enqueues itself. - If the printer is not operational, closing the connection, not printing from sd, busy with a long running - command or heating, no poll will be done. + If the printer is not operational, capable of auto-reporting temperatures, closing the connection, not printing + from sd, busy with a long running command or heating, no poll will be done. """ - if self.isOperational() and not self._connection_closing and not self.isStreaming() and not self._long_running_command and not self._heating and not self._dwelling_until and not self._manualStreaming: + if self.isOperational() and not self._temperature_autoreporting and not self._connection_closing and not self.isStreaming() and not self._long_running_command and not self._heating and not self._dwelling_until and not self._manualStreaming: self.sendCommand("M105", cmd_type="temperature_poll") def _poll_sd_status(self): @@ -1631,6 +1656,14 @@ class MachineCom(object): if self.isOperational() and not self._connection_closing and self.isSdPrinting() and not self._long_running_command and not self._dwelling_until and not self._heating: self.sendCommand("M27", cmd_type="sd_status_poll") + def _set_autoreport_temperature(self, interval=None): + if interval is None: + try: + interval = int(self._timeout_intervals.get("temperatureAutoreport", 2)) + except: + interval = 2 + self.sendCommand("M155 S{}".format(interval)) + def _onConnected(self): self._serial.timeout = settings().getFloat(["serial", "timeout", "communication"]) self._temperature_timer = RepeatedTimer(self._getTemperatureTimerInterval, self._poll_temperature, run_first=True) @@ -1639,7 +1672,7 @@ class MachineCom(object): self._changeState(self.STATE_OPERATIONAL) self.resetLineNumbers() - if self._firmwareDetection: + if self._firmware_detection: self.sendCommand("M115") if self._sdAvailable: @@ -2176,7 +2209,7 @@ class MachineCom(object): command_allowing_checksum = gcode is not None or self._sendChecksumWithUnknownCommands checksum_enabled = not self._neverSendChecksum and (self.isPrinting() or self._alwaysSendChecksum or - not self._firmwareInfoReceived) + not self._firmware_info_received) command_to_send = command.encode("ascii", errors="replace") if command_requiring_checksum or (command_allowing_checksum and checksum_enabled): @@ -2484,6 +2517,16 @@ class MachineCom(object): self._long_running_command = True self._heating = True + def _gcode_M155_sending(self, cmd, cmd_type=None): + match = regexes_parameters["intS"].search(cmd) + if match: + try: + interval = int(match.group("value")) + self._temperature_autoreporting = self._firmware_capabilities.get(self.CAPABILITY_AUTOREPORT_TEMP, False) \ + and (interval > 0) + except: + pass + def _gcode_M110_sending(self, cmd, cmd_type=None): newLineNumber = 0 match = regexes_parameters["intN"].search(cmd) @@ -3111,6 +3154,43 @@ def parse_firmware_line(line): result[key] = value return result +def parse_capability_line(line): + """ + Parses the provided firmware capability line. + + Lines are expected to be of the format + + Cap::<0 or 1> + + e.g. + + Cap:AUTOREPORT_TEMP:1 + Cap:TOGGLE_LIGHTS:0 + + Args: + line (str): the line to parse + + Returns: + tuple: a 2-tuple of the parsed capability name and whether it's on (true) or off (false), or None if the line + could not be parsed + """ + + line = line.lower() + if line.startswith("cap:"): + line = line[len("cap:"):] + + parts = line.split(":") + if len(parts) != 2: + # wrong format, can't parse this + return None + + capability, flag = parts + if not flag in ("0", "1"): + # wrong format, can't parse this + return None + + return capability.upper(), flag == "1" + def parse_resend_line(line): """ Parses the provided resend line and returns requested line number. diff --git a/tests/util/test_comm_helpers.py b/tests/util/test_comm_helpers.py index b3eb49b6..3042bfe5 100644 --- a/tests/util/test_comm_helpers.py +++ b/tests/util/test_comm_helpers.py @@ -247,6 +247,22 @@ class TestCommHelpers(unittest.TestCase): result = parse_firmware_line(line) self.assertDictEqual(expected, result) + @data( + ("Cap:EEPROM:1", ("EEPROM", True)), + ("Cap:EEPROM:0", ("EEPROM", False)), + ("AUTOREPORT_TEMP:1", ("AUTOREPORT_TEMP", True)), + ("AUTOREPORT_TEMP:0", ("AUTOREPORT_TEMP", False)), + ("TOO:MANY:FIELDS", None), + ("Cap:", None), + ("TOOLITTLEFIELDS", None), + ("WRONG:FLAG", None), + ) + @unpack + def test_parse_capability_line(self, line, expected): + from octoprint.util.comm import parse_capability_line + result = parse_capability_line(line) + self.assertEqual(expected, result) + @data( ("Resend:23", 23), ("Resend: N23", 23),