3486 lines
115 KiB
Python
3486 lines
115 KiB
Python
# coding=utf-8
|
|
from __future__ import absolute_import, division, print_function
|
|
__author__ = "Gina Häußge <osd@foosel.net> based on work by David Braam"
|
|
__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"
|
|
__copyright__ = "Copyright (C) 2013 David Braam - Released under terms of the AGPLv3 License"
|
|
|
|
|
|
import os
|
|
import glob
|
|
import time
|
|
import re
|
|
import threading
|
|
|
|
try:
|
|
import queue
|
|
except ImportError:
|
|
import Queue as queue
|
|
from past.builtins import basestring
|
|
|
|
import logging
|
|
import serial
|
|
|
|
import octoprint.plugin
|
|
|
|
from collections import deque
|
|
|
|
from octoprint.util.avr_isp import stk500v2
|
|
from octoprint.util.avr_isp import ispBase
|
|
|
|
from octoprint.settings import settings, default_settings
|
|
from octoprint.events import eventManager, Events
|
|
from octoprint.filemanager import valid_file_type
|
|
from octoprint.filemanager.destinations import FileDestinations
|
|
from octoprint.util import get_exception_string, sanitize_ascii, filter_non_ascii, CountedEvent, RepeatedTimer, \
|
|
to_unicode, bom_aware_open, TypedQueue, TypeAlreadyInQueue, chunks
|
|
|
|
try:
|
|
import _winreg
|
|
except:
|
|
pass
|
|
|
|
_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<commandGM>[GM]\d+)|(?P<commandT>T)\d+|(?P<commandF>F)\d+)")
|
|
"""Regex for a GCODE command."""
|
|
|
|
regex_float = re.compile(regex_float_pattern)
|
|
"""Regex for a float value."""
|
|
|
|
regexes_parameters = dict(
|
|
floatE=re.compile("(^|[^A-Za-z])[Ee](?P<value>%s)" % regex_float_pattern),
|
|
floatF=re.compile("(^|[^A-Za-z])[Ff](?P<value>%s)" % regex_float_pattern),
|
|
floatP=re.compile("(^|[^A-Za-z])[Pp](?P<value>%s)" % regex_float_pattern),
|
|
floatR=re.compile("(^|[^A-Za-z])[Rr](?P<value>%s)" % regex_float_pattern),
|
|
floatS=re.compile("(^|[^A-Za-z])[Ss](?P<value>%s)" % regex_float_pattern),
|
|
floatX=re.compile("(^|[^A-Za-z])[Xx](?P<value>%s)" % regex_float_pattern),
|
|
floatY=re.compile("(^|[^A-Za-z])[Yy](?P<value>%s)" % regex_float_pattern),
|
|
floatZ=re.compile("(^|[^A-Za-z])[Zz](?P<value>%s)" % regex_float_pattern),
|
|
intN=re.compile("(^|[^A-Za-z])[Nn](?P<value>%s)" % regex_int_pattern),
|
|
intS=re.compile("(^|[^A-Za-z])[Ss](?P<value>%s)" % regex_int_pattern),
|
|
intT=re.compile("(^|[^A-Za-z])[Tt](?P<value>%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<current>[0-9]*)/(?P<total>[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<name>.*?)\s+Size:\s*(?P<size>%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("(?P<tool>B|T(?P<toolnum>\d*)):\s*(?P<actual>%s)(\s*\/?\s*(?P<target>%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<toolnum>\d+):(?P<target>%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<target>%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)
|
|
"""
|
|
|
|
regex_position = re.compile("X:(?P<x>{float})\s*Y:(?P<y>{float})\s*Z:(?P<z>{float})\s*E:(?P<e>{float})".format(float=regex_float_pattern))
|
|
"""Regex for matching position reporting.
|
|
|
|
Groups will be as follows:
|
|
|
|
* ``x``: X coordinate
|
|
* ``y``: Y coordinate
|
|
* ``z``: Z coordinate
|
|
* ``e``: E coordinate
|
|
"""
|
|
|
|
regex_firmware_splitter = re.compile("\s*([A-Z0-9_]+):")
|
|
"""Regex to use for splitting M115 responses."""
|
|
|
|
regex_resend_linenumber = re.compile("(N|N:)?(?P<n>%s)" % regex_int_pattern)
|
|
"""Regex to use for request line numbers in resend requests"""
|
|
|
|
def serialList():
|
|
baselist=[]
|
|
if os.name=="nt":
|
|
try:
|
|
key=_winreg.OpenKey(_winreg.HKEY_LOCAL_MACHINE,"HARDWARE\\DEVICEMAP\\SERIALCOMM")
|
|
i=0
|
|
while(1):
|
|
baselist+=[_winreg.EnumValue(key,i)[1]]
|
|
i+=1
|
|
except:
|
|
pass
|
|
baselist = baselist \
|
|
+ glob.glob("/dev/ttyUSB*") \
|
|
+ glob.glob("/dev/ttyACM*") \
|
|
+ glob.glob("/dev/tty.usb*") \
|
|
+ glob.glob("/dev/cu.*") \
|
|
+ glob.glob("/dev/cuaU*") \
|
|
+ glob.glob("/dev/rfcomm*")
|
|
|
|
additionalPorts = settings().get(["serial", "additionalPorts"])
|
|
for additional in additionalPorts:
|
|
baselist += glob.glob(additional)
|
|
|
|
prev = settings().get(["serial", "port"])
|
|
if prev in baselist:
|
|
baselist.remove(prev)
|
|
baselist.insert(0, prev)
|
|
if settings().getBoolean(["devel", "virtualPrinter", "enabled"]):
|
|
baselist.append("VIRTUAL")
|
|
return baselist
|
|
|
|
def baudrateList():
|
|
# sorted by likelihood
|
|
candidates = [115200, 250000, 230400, 57600, 38400, 19200, 9600]
|
|
|
|
# additional baudrates prepended, sorted descending
|
|
additionalBaudrates = settings().get(["serial", "additionalBaudrates"])
|
|
for additional in sorted(additionalBaudrates, reverse=True):
|
|
try:
|
|
candidates.insert(0, int(additional))
|
|
except:
|
|
_logger.warn("{} is not a valid additional baudrate, ignoring it".format(additional))
|
|
|
|
# last used baudrate = first to try, move to start
|
|
prev = settings().getInt(["serial", "baudrate"])
|
|
if prev in candidates:
|
|
candidates.remove(prev)
|
|
candidates.insert(0, prev)
|
|
|
|
return candidates
|
|
|
|
gcodeToEvent = {
|
|
# pause for user input
|
|
"M226": Events.WAITING,
|
|
"M0": Events.WAITING,
|
|
"M1": Events.WAITING,
|
|
# dwell command
|
|
"G4": Events.DWELL,
|
|
|
|
# part cooler
|
|
"M245": Events.COOLING,
|
|
|
|
# part conveyor
|
|
"M240": Events.CONVEYOR,
|
|
|
|
# part ejector
|
|
"M40": Events.EJECT,
|
|
|
|
# user alert
|
|
"M300": Events.ALERT,
|
|
|
|
# home print head
|
|
"G28": Events.HOME,
|
|
|
|
# emergency stop
|
|
"M112": Events.E_STOP,
|
|
|
|
# motors on/off
|
|
"M80": Events.POWER_ON,
|
|
"M81": Events.POWER_OFF,
|
|
}
|
|
|
|
class PositionRecord(object):
|
|
def __init__(self, *args, **kwargs):
|
|
self.x = kwargs.get("x")
|
|
self.y = kwargs.get("y")
|
|
self.z = kwargs.get("z")
|
|
self.e = kwargs.get("e")
|
|
self.f = kwargs.get("f")
|
|
self.t = kwargs.get("t")
|
|
|
|
def copy_from(self, other):
|
|
self.x = other.x
|
|
self.y = other.y
|
|
self.z = other.z
|
|
self.e = other.e
|
|
self.f = other.f
|
|
self.t = other.t
|
|
|
|
def as_dict(self):
|
|
return dict(x=self.x,
|
|
y=self.y,
|
|
z=self.z,
|
|
e=self.e,
|
|
t=self.t,
|
|
f=self.f)
|
|
|
|
class TemperatureRecord(object):
|
|
def __init__(self):
|
|
self._tools = dict()
|
|
self._bed = (None, None)
|
|
|
|
def copy_from(self, other):
|
|
self._tools = other.tools
|
|
self._bed = other.bed
|
|
|
|
def set_tool(self, tool, actual=None, target=None):
|
|
current = self._tools.get(tool, (None, None))
|
|
self._tools[tool] = self._to_new_tuple(current, actual, target)
|
|
|
|
def set_bed(self, actual=None, target=None):
|
|
current = self._bed
|
|
self._bed = self._to_new_tuple(current, actual, target)
|
|
|
|
@property
|
|
def tools(self):
|
|
return dict(self._tools)
|
|
|
|
@property
|
|
def bed(self):
|
|
return self._bed
|
|
|
|
def as_script_dict(self):
|
|
result = dict()
|
|
|
|
tools = self.tools
|
|
for tool, data in tools.items():
|
|
result[tool] = dict(actual=data[0],
|
|
target=data[1])
|
|
|
|
bed = self.bed
|
|
result["b"] = dict(actual=bed[0],
|
|
target=bed[1])
|
|
|
|
return result
|
|
|
|
@classmethod
|
|
def _to_new_tuple(cls, current, actual, target):
|
|
if current is None or not isinstance(current, tuple) or len(current) != 2:
|
|
current = (None, None)
|
|
|
|
if actual is None and target is None:
|
|
return current
|
|
|
|
old_actual, old_target = current
|
|
|
|
if actual is None:
|
|
return old_actual, target
|
|
elif target is None:
|
|
return actual, old_target
|
|
else:
|
|
return actual, target
|
|
|
|
class MachineCom(object):
|
|
STATE_NONE = 0
|
|
STATE_OPEN_SERIAL = 1
|
|
STATE_DETECT_SERIAL = 2
|
|
STATE_DETECT_BAUDRATE = 3
|
|
STATE_CONNECTING = 4
|
|
STATE_OPERATIONAL = 5
|
|
STATE_PRINTING = 6
|
|
STATE_PAUSED = 7
|
|
STATE_CLOSED = 8
|
|
STATE_ERROR = 9
|
|
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")
|
|
|
|
if port == None:
|
|
port = settings().get(["serial", "port"])
|
|
if baudrate == None:
|
|
settingsBaudrate = settings().getInt(["serial", "baudrate"])
|
|
if settingsBaudrate is None:
|
|
baudrate = 0
|
|
else:
|
|
baudrate = settingsBaudrate
|
|
if callbackObject == None:
|
|
callbackObject = MachineComPrintCallback()
|
|
|
|
self._port = port
|
|
self._baudrate = baudrate
|
|
self._callback = callbackObject
|
|
self._printerProfileManager = printerProfileManager
|
|
self._state = self.STATE_NONE
|
|
self._serial = None
|
|
self._baudrateDetectList = baudrateList()
|
|
self._baudrateDetectRetry = 0
|
|
self._temperatureTargetSetThreshold = 25
|
|
self._tempOffsets = dict()
|
|
self._command_queue = TypedQueue()
|
|
self._currentZ = None
|
|
self._currentF = None
|
|
self._heatupWaitStartTime = None
|
|
self._heatupWaitTimeLost = 0.0
|
|
self._pauseWaitStartTime = None
|
|
self._pauseWaitTimeLost = 0.0
|
|
self._currentTool = 0
|
|
self._formerTool = None
|
|
|
|
self._long_running_command = False
|
|
self._heating = False
|
|
self._dwelling_until = False
|
|
self._connection_closing = False
|
|
|
|
self._timeout = None
|
|
self._timeout_intervals = dict()
|
|
for key, value in settings().get(["serial", "timeout"], merged=True, asdict=True).items():
|
|
try:
|
|
self._timeout_intervals[key] = float(value)
|
|
except:
|
|
pass
|
|
|
|
self._consecutive_timeouts = 0
|
|
self._consecutive_timeout_maximums = dict()
|
|
for key, value in settings().get(["serial", "maxCommunicationTimeouts"], merged=True, asdict=True).items():
|
|
try:
|
|
self._consecutive_timeout_maximums[key] = int(value)
|
|
except:
|
|
pass
|
|
|
|
self._max_write_passes = settings().getInt(["serial", "maxWritePasses"])
|
|
|
|
self._hello_command = settings().get(["serial", "helloCommand"])
|
|
self._trigger_ok_for_m29 = settings().getBoolean(["serial", "triggerOkForM29"])
|
|
|
|
self._hello_command = settings().get(["serial", "helloCommand"])
|
|
|
|
self._alwaysSendChecksum = settings().getBoolean(["feature", "alwaysSendChecksum"])
|
|
self._neverSendChecksum = settings().getBoolean(["feature", "neverSendChecksum"])
|
|
self._sendChecksumWithUnknownCommands = settings().getBoolean(["feature", "sendChecksumWithUnknownCommands"])
|
|
self._unknownCommandsNeedAck = settings().getBoolean(["feature", "unknownCommandsNeedAck"])
|
|
self._sdAlwaysAvailable = settings().getBoolean(["feature", "sdAlwaysAvailable"])
|
|
self._sdRelativePath = settings().getBoolean(["feature", "sdRelativePath"])
|
|
self._blockWhileDwelling = settings().getBoolean(["feature", "blockWhileDwelling"])
|
|
self._currentLine = 1
|
|
self._line_mutex = threading.RLock()
|
|
self._resendDelta = None
|
|
self._lastLines = deque([], 50)
|
|
self._lastCommError = None
|
|
self._lastResendNumber = None
|
|
self._currentResendCount = 0
|
|
self._resendSwallowRepetitions = settings().getBoolean(["feature", "ignoreIdenticalResends"])
|
|
self._resendSwallowRepetitionsCounter = 0
|
|
|
|
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"])
|
|
|
|
self._resendActive = False
|
|
|
|
self._terminal_log = deque([], 20)
|
|
|
|
self._disconnect_on_errors = settings().getBoolean(["serial", "disconnectOnErrors"])
|
|
self._ignore_errors = settings().getBoolean(["serial", "ignoreErrorsFromFirmware"])
|
|
|
|
self._log_resends = settings().getBoolean(["serial", "logResends"])
|
|
|
|
# don't log more resends than 5 / 60s
|
|
self._log_resends_rate_start = None
|
|
self._log_resends_rate_count = 0
|
|
self._log_resends_max = 5
|
|
self._log_resends_rate_frame = 60
|
|
|
|
self._long_running_commands = settings().get(["serial", "longRunningCommands"])
|
|
self._checksum_requiring_commands = settings().get(["serial", "checksumRequiringCommands"])
|
|
|
|
self._clear_to_send = CountedEvent(max=10, name="comm.clear_to_send")
|
|
self._send_queue = TypedQueue()
|
|
self._temperature_timer = None
|
|
self._sd_status_timer = None
|
|
|
|
# hooks
|
|
self._pluginManager = octoprint.plugin.plugin_manager()
|
|
|
|
self._gcode_hooks = dict(
|
|
queuing=self._pluginManager.get_hooks("octoprint.comm.protocol.gcode.queuing"),
|
|
queued=self._pluginManager.get_hooks("octoprint.comm.protocol.gcode.queued"),
|
|
sending=self._pluginManager.get_hooks("octoprint.comm.protocol.gcode.sending"),
|
|
sent=self._pluginManager.get_hooks("octoprint.comm.protocol.gcode.sent")
|
|
)
|
|
self._received_message_hooks = self._pluginManager.get_hooks("octoprint.comm.protocol.gcode.received")
|
|
|
|
self._printer_action_hooks = self._pluginManager.get_hooks("octoprint.comm.protocol.action")
|
|
self._gcodescript_hooks = self._pluginManager.get_hooks("octoprint.comm.protocol.scripts")
|
|
self._serial_factory_hooks = self._pluginManager.get_hooks("octoprint.comm.transport.serial.factory")
|
|
|
|
# SD status data
|
|
self._sdEnabled = settings().getBoolean(["feature", "sdSupport"])
|
|
self._sdAvailable = False
|
|
self._sdFileList = False
|
|
self._sdFiles = []
|
|
self._sdFileToSelect = None
|
|
self._ignore_select = False
|
|
self._manualStreaming = False
|
|
|
|
self.last_temperature = TemperatureRecord()
|
|
self.pause_temperature = TemperatureRecord()
|
|
self.cancel_temperature = TemperatureRecord()
|
|
|
|
self.last_position = PositionRecord()
|
|
self.pause_position = PositionRecord()
|
|
self.cancel_position = PositionRecord()
|
|
|
|
self._record_pause_data = False
|
|
self._record_cancel_data = False
|
|
|
|
# print job
|
|
self._currentFile = None
|
|
|
|
# multithreading locks
|
|
self._jobLock = threading.RLock()
|
|
self._sendingLock = threading.RLock()
|
|
|
|
# monitoring thread
|
|
self._monitoring_active = True
|
|
self.monitoring_thread = threading.Thread(target=self._monitor, name="comm._monitor")
|
|
self.monitoring_thread.daemon = True
|
|
self.monitoring_thread.start()
|
|
|
|
# sending thread
|
|
self._send_queue_active = True
|
|
self.sending_thread = threading.Thread(target=self._send_loop, name="comm.sending_thread")
|
|
self.sending_thread.daemon = True
|
|
self.sending_thread.start()
|
|
|
|
def __del__(self):
|
|
self.close()
|
|
|
|
@property
|
|
def _active(self):
|
|
return self._monitoring_active and self._send_queue_active
|
|
|
|
##~~ internal state management
|
|
|
|
def _changeState(self, newState):
|
|
if self._state == newState:
|
|
return
|
|
|
|
if newState == self.STATE_CLOSED or newState == self.STATE_CLOSED_WITH_ERROR:
|
|
if settings().getBoolean(["feature", "sdSupport"]):
|
|
self._sdFileList = False
|
|
self._sdFiles = []
|
|
self._callback.on_comm_sd_files([])
|
|
|
|
if self._currentFile is not None:
|
|
if self.isBusy():
|
|
self._recordFilePosition()
|
|
self._currentFile.close()
|
|
|
|
oldState = self.getStateString()
|
|
self._state = newState
|
|
self._log('Changing monitoring state from \'%s\' to \'%s\'' % (oldState, self.getStateString()))
|
|
self._callback.on_comm_state_change(newState)
|
|
|
|
def _dual_log(self, message, level=logging.ERROR):
|
|
self._logger.log(level, message)
|
|
self._log(message)
|
|
|
|
def _log(self, message):
|
|
self._terminal_log.append(message)
|
|
self._callback.on_comm_log(message)
|
|
self._serialLogger.debug(message)
|
|
|
|
def _to_logfile_with_terminal(self, message=None, level=logging.INFO):
|
|
log = "Last lines in terminal:\n" + "\n".join(map(lambda x: "| " + x, list(self._terminal_log)))
|
|
if message is not None:
|
|
log = message + "\n| " + log
|
|
self._logger.log(level, log)
|
|
|
|
def _addToLastLines(self, cmd):
|
|
self._lastLines.append(cmd)
|
|
|
|
##~~ getters
|
|
|
|
def getState(self):
|
|
return self._state
|
|
|
|
def getStateId(self, state=None):
|
|
if state is None:
|
|
state = self._state
|
|
|
|
possible_states = filter(lambda x: x.startswith("STATE_"), self.__class__.__dict__.keys())
|
|
for possible_state in possible_states:
|
|
if getattr(self, possible_state) == state:
|
|
return possible_state[len("STATE_"):]
|
|
|
|
return "UNKNOWN"
|
|
|
|
def getStateString(self, state=None):
|
|
if state is None:
|
|
state = self._state
|
|
|
|
if state == self.STATE_NONE:
|
|
return "Offline"
|
|
if state == self.STATE_OPEN_SERIAL:
|
|
return "Opening serial port"
|
|
if state == self.STATE_DETECT_SERIAL:
|
|
return "Detecting serial port"
|
|
if state == self.STATE_DETECT_BAUDRATE:
|
|
return "Detecting baudrate"
|
|
if state == self.STATE_CONNECTING:
|
|
return "Connecting"
|
|
if state == self.STATE_OPERATIONAL:
|
|
return "Operational"
|
|
if state == self.STATE_PRINTING:
|
|
if self.isSdFileSelected():
|
|
return "Printing from SD"
|
|
elif self.isStreaming():
|
|
return "Sending file to SD"
|
|
else:
|
|
return "Printing"
|
|
if state == self.STATE_PAUSED:
|
|
return "Paused"
|
|
if state == self.STATE_CLOSED:
|
|
return "Offline"
|
|
if state == self.STATE_ERROR:
|
|
return "Error: %s" % (self.getErrorString())
|
|
if state == self.STATE_CLOSED_WITH_ERROR:
|
|
return "Offline: %s" % (self.getErrorString())
|
|
if state == self.STATE_TRANSFERING_FILE:
|
|
return "Transfering file to SD"
|
|
return "Unknown State (%d)" % (self._state)
|
|
|
|
def getErrorString(self):
|
|
return self._errorValue
|
|
|
|
def isClosedOrError(self):
|
|
return self._state == self.STATE_ERROR or self._state == self.STATE_CLOSED_WITH_ERROR or self._state == self.STATE_CLOSED
|
|
|
|
def isError(self):
|
|
return self._state == self.STATE_ERROR or self._state == self.STATE_CLOSED_WITH_ERROR
|
|
|
|
def isOperational(self):
|
|
return self._state == self.STATE_OPERATIONAL or self._state == self.STATE_PRINTING or self._state == self.STATE_PAUSED or self._state == self.STATE_TRANSFERING_FILE
|
|
|
|
def isPrinting(self):
|
|
return self._state == self.STATE_PRINTING
|
|
|
|
def isSdPrinting(self):
|
|
return self.isSdFileSelected() and self.isPrinting()
|
|
|
|
def isSdFileSelected(self):
|
|
return self._currentFile is not None and isinstance(self._currentFile, PrintingSdFileInformation)
|
|
|
|
def isStreaming(self):
|
|
return self._currentFile is not None and isinstance(self._currentFile, StreamingGcodeFileInformation)
|
|
|
|
def isPaused(self):
|
|
return self._state == self.STATE_PAUSED
|
|
|
|
def isBusy(self):
|
|
return self.isPrinting() or self.isPaused()
|
|
|
|
def isSdReady(self):
|
|
return self._sdAvailable
|
|
|
|
def getPrintProgress(self):
|
|
if self._currentFile is None:
|
|
return None
|
|
return self._currentFile.getProgress()
|
|
|
|
def getPrintFilepos(self):
|
|
if self._currentFile is None:
|
|
return None
|
|
return self._currentFile.getFilepos()
|
|
|
|
def getPrintTime(self):
|
|
if self._currentFile is None or self._currentFile.getStartTime() is None:
|
|
return None
|
|
else:
|
|
return time.time() - self._currentFile.getStartTime() - self._pauseWaitTimeLost
|
|
|
|
def getCleanedPrintTime(self):
|
|
printTime = self.getPrintTime()
|
|
if printTime is None:
|
|
return None
|
|
|
|
cleanedPrintTime = printTime - self._heatupWaitTimeLost
|
|
if cleanedPrintTime < 0:
|
|
cleanedPrintTime = 0.0
|
|
return cleanedPrintTime
|
|
|
|
def getTemp(self):
|
|
return self.last_temperature.tools
|
|
|
|
def getBedTemp(self):
|
|
return self.last_temperature.bed
|
|
|
|
def getOffsets(self):
|
|
return dict(self._tempOffsets)
|
|
|
|
def getCurrentTool(self):
|
|
return self._currentTool
|
|
|
|
def getConnection(self):
|
|
return self._port, self._baudrate
|
|
|
|
def getTransport(self):
|
|
return self._serial
|
|
|
|
##~~ external interface
|
|
|
|
def close(self, is_error=False, wait=True, timeout=10.0, *args, **kwargs):
|
|
"""
|
|
Closes the connection to the printer.
|
|
|
|
If ``is_error`` is False, will attempt to send the ``beforePrinterDisconnected``
|
|
gcode script. If ``is_error`` is False and ``wait`` is True, will wait
|
|
until all messages in the send queue (including the ``beforePrinterDisconnected``
|
|
gcode script) have been sent to the printer.
|
|
|
|
Arguments:
|
|
is_error (bool): Whether the closing takes place due to an error (True)
|
|
or not (False, default)
|
|
wait (bool): Whether to wait for all messages in the send
|
|
queue to be processed before closing (True, default) or not (False)
|
|
"""
|
|
|
|
# legacy parameters
|
|
is_error = kwargs.get("isError", is_error)
|
|
|
|
if self._connection_closing:
|
|
return
|
|
self._connection_closing = True
|
|
|
|
if self._temperature_timer is not None:
|
|
try:
|
|
self._temperature_timer.cancel()
|
|
except:
|
|
pass
|
|
|
|
if self._sd_status_timer is not None:
|
|
try:
|
|
self._sd_status_timer.cancel()
|
|
except:
|
|
pass
|
|
|
|
def deactivate_monitoring_and_send_queue():
|
|
self._monitoring_active = False
|
|
self._send_queue_active = False
|
|
|
|
printing = self.isPrinting() or self.isPaused()
|
|
if self._serial is not None:
|
|
if not is_error:
|
|
self.sendGcodeScript("beforePrinterDisconnected")
|
|
if wait:
|
|
if timeout is not None:
|
|
stop = time.time() + timeout
|
|
while (self._command_queue.unfinished_tasks or self._send_queue.unfinished_tasks) and time.time() < stop:
|
|
time.sleep(0.1)
|
|
else:
|
|
self._command_queue.join()
|
|
self._send_queue.join()
|
|
|
|
deactivate_monitoring_and_send_queue()
|
|
|
|
try:
|
|
self._serial.close()
|
|
except:
|
|
self._logger.exception("Error while trying to close serial port")
|
|
is_error = True
|
|
|
|
if is_error:
|
|
self._changeState(self.STATE_CLOSED_WITH_ERROR)
|
|
else:
|
|
self._changeState(self.STATE_CLOSED)
|
|
else:
|
|
deactivate_monitoring_and_send_queue()
|
|
self._serial = None
|
|
|
|
if settings().getBoolean(["feature", "sdSupport"]):
|
|
self._sdFileList = []
|
|
|
|
if printing:
|
|
self._callback.on_comm_print_job_failed()
|
|
|
|
def setTemperatureOffset(self, offsets):
|
|
self._tempOffsets.update(offsets)
|
|
|
|
def fakeOk(self):
|
|
self._handle_ok()
|
|
|
|
def sendCommand(self, cmd, cmd_type=None, processed=False, force=False, on_sent=None):
|
|
cmd = to_unicode(cmd, errors="replace")
|
|
if not processed:
|
|
cmd = process_gcode_line(cmd)
|
|
if not cmd:
|
|
return False
|
|
|
|
if self.isPrinting() and not self.isSdFileSelected():
|
|
try:
|
|
self._command_queue.put((cmd, cmd_type, on_sent), item_type=cmd_type)
|
|
return True
|
|
except TypeAlreadyInQueue as e:
|
|
self._logger.debug("Type already in command queue: " + e.type)
|
|
return False
|
|
elif self.isOperational() or force:
|
|
return self._sendCommand(cmd, cmd_type=cmd_type, on_sent=on_sent)
|
|
|
|
def sendGcodeScript(self, scriptName, replacements=None):
|
|
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(),
|
|
last_position=self.last_position,
|
|
last_temperature=self.last_temperature.as_script_dict()
|
|
))
|
|
|
|
if scriptName == "afterPrintPaused" or scriptName == "beforePrintResumed":
|
|
context.update(dict(pause_position=self.pause_position,
|
|
pause_temperature=self.pause_temperature.as_script_dict()))
|
|
elif scriptName == "afterPrintCancelled":
|
|
context.update(dict(cancel_position=self.cancel_position,
|
|
cancel_temperature=self.cancel_temperature.as_script_dict()))
|
|
|
|
template = settings().loadScript("gcode", scriptName, context=context)
|
|
if template is None:
|
|
scriptLines = []
|
|
else:
|
|
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:
|
|
retval = self._gcodescript_hooks[hook](self, "gcode", scriptName)
|
|
except:
|
|
self._logger.exception("Error while processing gcodescript hook %s" % hook)
|
|
else:
|
|
if retval is None:
|
|
continue
|
|
if not isinstance(retval, (list, tuple)) or not len(retval) == 2:
|
|
continue
|
|
|
|
def to_list(data):
|
|
if isinstance(data, str):
|
|
data = map(str.strip, data.split("\n"))
|
|
elif isinstance(data, unicode):
|
|
data = map(unicode.strip, data.split("\n"))
|
|
|
|
if isinstance(data, (list, tuple)):
|
|
return list(data)
|
|
else:
|
|
return None
|
|
|
|
prefix, suffix = map(to_list, retval)
|
|
if prefix:
|
|
scriptLines = list(prefix) + scriptLines
|
|
if suffix:
|
|
scriptLines += list(suffix)
|
|
|
|
for line in scriptLines:
|
|
self.sendCommand(line)
|
|
return "\n".join(scriptLines)
|
|
|
|
def startPrint(self, pos=None):
|
|
if not self.isOperational() or self.isPrinting():
|
|
return
|
|
|
|
if self._currentFile is None:
|
|
raise ValueError("No file selected for printing")
|
|
|
|
self._heatupWaitStartTime = None
|
|
self._heatupWaitTimeLost = 0.0
|
|
self._pauseWaitStartTime = 0
|
|
self._pauseWaitTimeLost = 0.0
|
|
|
|
try:
|
|
with self._jobLock:
|
|
self._currentFile.start()
|
|
|
|
self._changeState(self.STATE_PRINTING)
|
|
|
|
self.resetLineNumbers()
|
|
|
|
self._callback.on_comm_print_job_started()
|
|
|
|
if self.isSdFileSelected():
|
|
#self.sendCommand("M26 S0") # setting the sd pos apparently sometimes doesn't work, so we re-select
|
|
# the file instead
|
|
|
|
# make sure to ignore the "file selected" later on, otherwise we'll reset our progress data
|
|
self._ignore_select = True
|
|
self.sendCommand("M23 {filename}".format(filename=self._currentFile.getFilename()))
|
|
if pos is not None and isinstance(pos, int) and pos > 0:
|
|
self._currentFile.setFilepos(pos)
|
|
self.sendCommand("M26 S{}".format(pos))
|
|
else:
|
|
self._currentFile.setFilepos(0)
|
|
|
|
self.sendCommand("M24")
|
|
|
|
self._sd_status_timer = RepeatedTimer(self._timeout_intervals.get("sdStatus", 1.0), self._poll_sd_status, run_first=True)
|
|
self._sd_status_timer.start()
|
|
else:
|
|
if pos is not None and isinstance(pos, int) and pos > 0:
|
|
self._currentFile.seek(pos)
|
|
|
|
line = self._getNext()
|
|
if line is not None:
|
|
self.sendCommand(line)
|
|
|
|
# now make sure we actually do something, up until now we only filled up the queue
|
|
self._sendFromQueue()
|
|
except:
|
|
self._logger.exception("Error while trying to start printing")
|
|
self._errorValue = get_exception_string()
|
|
self._changeState(self.STATE_ERROR)
|
|
eventManager().fire(Events.ERROR, {"error": self.getErrorString()})
|
|
|
|
def startFileTransfer(self, filename, localFilename, remoteFilename):
|
|
if not self.isOperational() or self.isBusy():
|
|
logging.info("Printer is not operational or busy")
|
|
return
|
|
|
|
with self._jobLock:
|
|
self.resetLineNumbers()
|
|
|
|
self._currentFile = StreamingGcodeFileInformation(filename, localFilename, remoteFilename)
|
|
self._currentFile.start()
|
|
|
|
self.sendCommand("M28 %s" % remoteFilename)
|
|
eventManager().fire(Events.TRANSFER_STARTED, {"local": localFilename, "remote": remoteFilename})
|
|
self._callback.on_comm_file_transfer_started(remoteFilename, self._currentFile.getFilesize())
|
|
|
|
def selectFile(self, filename, sd):
|
|
if self.isBusy():
|
|
return
|
|
|
|
if sd:
|
|
if not self.isOperational():
|
|
# printer is not connected, can't use SD
|
|
return
|
|
|
|
if filename.startswith("/") and self._sdRelativePath:
|
|
filename = filename[1:]
|
|
|
|
self._sdFileToSelect = filename
|
|
self.sendCommand("M23 %s" % filename)
|
|
else:
|
|
self._currentFile = PrintingGcodeFileInformation(filename, offsets_callback=self.getOffsets, current_tool_callback=self.getCurrentTool)
|
|
self._callback.on_comm_file_selected(filename, self._currentFile.getFilesize(), False)
|
|
|
|
def unselectFile(self):
|
|
if self.isBusy():
|
|
return
|
|
|
|
self._currentFile = None
|
|
self._callback.on_comm_file_selected(None, None, False)
|
|
|
|
def _cancel_preparation_done(self):
|
|
self._recordFilePosition()
|
|
self._callback.on_comm_print_job_cancelled()
|
|
|
|
def cancelPrint(self, firmware_error=None):
|
|
if not self.isOperational() or self.isStreaming():
|
|
return
|
|
|
|
if not self.isBusy() or self._currentFile is None:
|
|
# we aren't even printing, nothing to cancel...
|
|
return
|
|
|
|
def _on_M400_sent():
|
|
# we don't call on_print_job_cancelled on our callback here
|
|
# because we do this only after our M114 has been answered
|
|
# by the firmware
|
|
self._record_cancel_data = True
|
|
self.sendCommand("M114")
|
|
|
|
with self._jobLock:
|
|
self._changeState(self.STATE_OPERATIONAL)
|
|
|
|
if self.isSdFileSelected():
|
|
self.sendCommand("M25") # pause print
|
|
self.sendCommand("M27") # get current byte position in file
|
|
self.sendCommand("M26 S0") # reset position in file to byte 0
|
|
if self._sd_status_timer is not None:
|
|
try:
|
|
self._sd_status_timer.cancel()
|
|
except:
|
|
pass
|
|
|
|
self.sendCommand("M400", on_sent=_on_M400_sent)
|
|
|
|
def _pause_preparation_done(self):
|
|
self._callback.on_comm_print_job_paused()
|
|
|
|
def setPause(self, pause):
|
|
if self.isStreaming():
|
|
return
|
|
|
|
if not self._currentFile:
|
|
return
|
|
|
|
with self._jobLock:
|
|
if not pause and self.isPaused():
|
|
if self._pauseWaitStartTime:
|
|
self._pauseWaitTimeLost = self._pauseWaitTimeLost + (time.time() - self._pauseWaitStartTime)
|
|
self._pauseWaitStartTime = None
|
|
|
|
self._changeState(self.STATE_PRINTING)
|
|
self._callback.on_comm_print_job_resumed()
|
|
|
|
if self.isSdFileSelected():
|
|
self.sendCommand("M24")
|
|
self.sendCommand("M27")
|
|
else:
|
|
line = self._getNext()
|
|
if line is not None:
|
|
self.sendCommand(line)
|
|
|
|
# now make sure we actually do something, up until now we only filled up the queue
|
|
self._sendFromQueue()
|
|
|
|
elif pause and self.isPrinting():
|
|
if not self._pauseWaitStartTime:
|
|
self._pauseWaitStartTime = time.time()
|
|
|
|
self._changeState(self.STATE_PAUSED)
|
|
if self.isSdFileSelected():
|
|
self.sendCommand("M25") # pause print
|
|
|
|
def _on_M400_sent():
|
|
# we don't call on_print_job_paused on our callback here
|
|
# because we do this only after our M114 has been answered
|
|
# by the firmware
|
|
self._record_pause_data = True
|
|
self.sendCommand("M114")
|
|
|
|
self.sendCommand("M400", on_sent=_on_M400_sent)
|
|
|
|
def getSdFiles(self):
|
|
return self._sdFiles
|
|
|
|
def deleteSdFile(self, filename):
|
|
if not self._sdEnabled:
|
|
return
|
|
|
|
if not self.isOperational() or (self.isBusy() and
|
|
isinstance(self._currentFile, PrintingSdFileInformation) and
|
|
self._currentFile.getFilename() == filename):
|
|
# do not delete a file from sd we are currently printing from
|
|
return
|
|
|
|
self.sendCommand("M30 %s" % filename.lower())
|
|
self.refreshSdFiles()
|
|
|
|
def refreshSdFiles(self):
|
|
if not self._sdEnabled:
|
|
return
|
|
|
|
if not self.isOperational() or self.isBusy():
|
|
return
|
|
|
|
self.sendCommand("M20")
|
|
|
|
def initSdCard(self):
|
|
if not self._sdEnabled:
|
|
return
|
|
|
|
if not self.isOperational():
|
|
return
|
|
|
|
self.sendCommand("M21")
|
|
if self._sdAlwaysAvailable:
|
|
self._sdAvailable = True
|
|
self.refreshSdFiles()
|
|
self._callback.on_comm_sd_state_change(self._sdAvailable)
|
|
|
|
def releaseSdCard(self):
|
|
if not self._sdEnabled:
|
|
return
|
|
|
|
if not self.isOperational() or (self.isBusy() and self.isSdFileSelected()):
|
|
# do not release the sd card if we are currently printing from it
|
|
return
|
|
|
|
self.sendCommand("M22")
|
|
self._sdAvailable = False
|
|
self._sdFiles = []
|
|
|
|
self._callback.on_comm_sd_state_change(self._sdAvailable)
|
|
self._callback.on_comm_sd_files(self._sdFiles)
|
|
|
|
def sayHello(self):
|
|
self.sendCommand(self._hello_command, force=True)
|
|
self._clear_to_send.set()
|
|
|
|
def resetLineNumbers(self, number=0):
|
|
if not self.isOperational():
|
|
return
|
|
|
|
self.sendCommand("M110 N%d" % number)
|
|
|
|
##~~ record aborted file positions
|
|
|
|
def getFilePosition(self):
|
|
if self._currentFile is None:
|
|
return None
|
|
|
|
origin = self._currentFile.getFileLocation()
|
|
filename = self._currentFile.getFilename()
|
|
pos = self._currentFile.getFilepos()
|
|
|
|
return dict(origin=origin,
|
|
filename=filename,
|
|
pos=pos)
|
|
|
|
def _recordFilePosition(self):
|
|
if self._currentFile is None:
|
|
return
|
|
data = self.getFilePosition()
|
|
self._callback.on_comm_record_fileposition(data["origin"], data["filename"], data["pos"])
|
|
|
|
##~~ communication monitoring and handling
|
|
|
|
def _processTemperatures(self, line):
|
|
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):
|
|
tool = "T%d" % n
|
|
if not tool in parsedTemps.keys():
|
|
continue
|
|
|
|
actual, target = parsedTemps[tool]
|
|
self.last_temperature.set_tool(n, actual=actual, target=target)
|
|
|
|
# bed temperature
|
|
if "B" in parsedTemps.keys():
|
|
actual, target = parsedTemps["B"]
|
|
self.last_temperature.set_bed(actual=actual, target=target)
|
|
|
|
##~~ Serial monitor processing received messages
|
|
|
|
def _monitor(self):
|
|
feedback_controls, feedback_matcher = convert_feedback_controls(settings().get(["controls"]))
|
|
feedback_errors = []
|
|
pause_triggers = convert_pause_triggers(settings().get(["printerParameters", "pauseTriggers"]))
|
|
|
|
disable_external_heatup_detection = not settings().getBoolean(["feature", "externalHeatupDetection"])
|
|
|
|
self._consecutive_timeouts = 0
|
|
|
|
#Open the serial port.
|
|
if not self._openSerial():
|
|
return
|
|
|
|
try_hello = not settings().getBoolean(["feature", "waitForStartOnConnect"])
|
|
|
|
self._log("Connected to: %s, starting monitor" % self._serial)
|
|
if self._baudrate == 0:
|
|
self._serial.timeout = 0.01
|
|
try_hello = False
|
|
self._log("Starting baud rate detection")
|
|
self._changeState(self.STATE_DETECT_BAUDRATE)
|
|
else:
|
|
self._changeState(self.STATE_CONNECTING)
|
|
|
|
#Start monitoring the serial port.
|
|
self._timeout = get_new_timeout("communication", self._timeout_intervals)
|
|
|
|
startSeen = False
|
|
supportRepetierTargetTemp = settings().getBoolean(["feature", "repetierTargetTemp"])
|
|
supportWait = settings().getBoolean(["feature", "supportWait"])
|
|
|
|
connection_timeout = settings().getFloat(["serial", "timeout", "connection"])
|
|
detection_timeout = settings().getFloat(["serial", "timeout", "detection"])
|
|
|
|
# enqueue the "hello command" first thing
|
|
if try_hello:
|
|
self.sayHello()
|
|
|
|
while self._monitoring_active:
|
|
now = time.time()
|
|
try:
|
|
line = self._readline()
|
|
if line is None:
|
|
break
|
|
if line.strip() is not "":
|
|
self._consecutive_timeouts = 0
|
|
self._timeout = get_new_timeout("communication", self._timeout_intervals)
|
|
|
|
if self._dwelling_until and now > self._dwelling_until:
|
|
self._dwelling_until = False
|
|
|
|
##~~ debugging output handling
|
|
if line.startswith("//"):
|
|
debugging_output = line[2:].strip()
|
|
if debugging_output.startswith("action:"):
|
|
action_command = debugging_output[len("action:"):].strip()
|
|
|
|
if action_command == "pause":
|
|
self._log("Pausing on request of the printer...")
|
|
self.setPause(True)
|
|
elif action_command == "resume":
|
|
self._log("Resuming on request of the printer...")
|
|
self.setPause(False)
|
|
elif action_command == "disconnect":
|
|
self._log("Disconnecting on request of the printer...")
|
|
self._callback.on_comm_force_disconnect()
|
|
else:
|
|
for hook in self._printer_action_hooks:
|
|
try:
|
|
self._printer_action_hooks[hook](self, line, action_command)
|
|
except:
|
|
self._logger.exception("Error while calling hook {} with action command {}".format(self._printer_action_hooks[hook], action_command))
|
|
continue
|
|
else:
|
|
continue
|
|
|
|
def convert_line(line):
|
|
if line is None:
|
|
return None, None
|
|
stripped_line = line.strip().strip("\0")
|
|
return stripped_line, stripped_line.lower()
|
|
|
|
##~~ Error handling
|
|
line = self._handleErrors(line)
|
|
line, lower_line = convert_line(line)
|
|
|
|
##~~ SD file list
|
|
# if we are currently receiving an sd file list, each line is just a filename, so just read it and abort processing
|
|
if self._sdFileList and not "End file list" in line:
|
|
preprocessed_line = lower_line
|
|
fileinfo = preprocessed_line.rsplit(None, 1)
|
|
if len(fileinfo) > 1:
|
|
# we might have extended file information here, so let's split filename and size and try to make them a bit nicer
|
|
filename, size = fileinfo
|
|
try:
|
|
size = int(size)
|
|
except ValueError:
|
|
# whatever that was, it was not an integer, so we'll just use the whole line as filename and set size to None
|
|
filename = preprocessed_line
|
|
size = None
|
|
else:
|
|
# no extended file information, so only the filename is there and we set size to None
|
|
filename = preprocessed_line
|
|
size = None
|
|
|
|
if valid_file_type(filename, "machinecode"):
|
|
if filter_non_ascii(filename):
|
|
self._logger.warn("Got a file from printer's SD that has a non-ascii filename (%s), that shouldn't happen according to the protocol" % filename)
|
|
else:
|
|
if not filename.startswith("/"):
|
|
# file from the root of the sd -- we'll prepend a /
|
|
filename = "/" + filename
|
|
self._sdFiles.append((filename, size))
|
|
continue
|
|
|
|
handled = False
|
|
|
|
# process oks
|
|
if line.startswith("ok") or (self.isPrinting() and supportWait and line == "wait"):
|
|
# ok only considered handled if it's alone on the line, might be
|
|
# a response to an M105 or an M114
|
|
self._handle_ok()
|
|
needs_further_handling = "T:" in line or "T0:" in line or "B:" in line or "C:" in line or \
|
|
"X:" in line or "NAME:" in line
|
|
handled = (line == "wait" or line == "ok" or not needs_further_handling)
|
|
|
|
# process resends
|
|
elif lower_line.startswith("resend") or lower_line.startswith("rs"):
|
|
self._handleResendRequest(line)
|
|
handled = True
|
|
|
|
# process timeouts
|
|
elif line == "" and (not self._blockWhileDwelling or not self._dwelling_until or now > self._dwelling_until) and now > self._timeout:
|
|
# timeout only considered handled if the printer is printing
|
|
self._handle_timeout()
|
|
handled = self.isPrinting()
|
|
|
|
# we don't have to process the rest if the line has already been handled fully
|
|
if handled and self._state not in (self.STATE_CONNECTING, self.STATE_DETECT_BAUDRATE):
|
|
continue
|
|
|
|
# position report processing
|
|
if 'X:' in line and 'Y:' in line and 'Z:' in line:
|
|
match = regex_position.search(line)
|
|
if match:
|
|
# we don't know T or F when printing from SD since
|
|
# there's no way to query it from the firmware and
|
|
# no way to track it ourselves when not streaming
|
|
# the file - this all sucks sooo much
|
|
self.last_position.valid = True
|
|
self.last_position.x = float(match.group("x"))
|
|
self.last_position.y = float(match.group("y"))
|
|
self.last_position.z = float(match.group("z"))
|
|
self.last_position.e = float(match.group("e"))
|
|
self.last_position.t = self._currentTool if not self.isSdFileSelected() else None
|
|
self.last_position.f = self._currentF if not self.isSdFileSelected() else None
|
|
|
|
reason = None
|
|
|
|
if self._record_pause_data:
|
|
reason = "pause"
|
|
self._record_pause_data = False
|
|
self.pause_position.copy_from(self.last_position)
|
|
self.pause_temperature.copy_from(self.last_temperature)
|
|
self._pause_preparation_done()
|
|
|
|
if self._record_cancel_data:
|
|
reason = "cancel"
|
|
self._record_cancel_data = False
|
|
self.cancel_position.copy_from(self.last_position)
|
|
self.cancel_temperature.copy_from(self.last_temperature)
|
|
self._cancel_preparation_done()
|
|
|
|
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 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.last_temperature.tools, self.last_temperature.bed)
|
|
|
|
elif supportRepetierTargetTemp and ('TargetExtr' in line or 'TargetBed' in line):
|
|
matchExtr = regex_repetierTempExtr.match(line)
|
|
matchBed = regex_repetierTempBed.match(line)
|
|
|
|
if matchExtr is not None:
|
|
toolNum = int(matchExtr.group(1))
|
|
try:
|
|
target = float(matchExtr.group(2))
|
|
self.last_temperature.set_tool(toolNum, target=target)
|
|
self._callback.on_comm_temperature_update(self.last_temperature.tools, self.last_temperature.bed)
|
|
except ValueError:
|
|
pass
|
|
elif matchBed is not None:
|
|
try:
|
|
target = float(matchBed.group(1))
|
|
self.last_temperature.set_bed(target=target)
|
|
self._callback.on_comm_temperature_update(self.last_temperature.tools, self.last_temperature.bed)
|
|
except ValueError:
|
|
pass
|
|
|
|
##~~ firmware name & version
|
|
elif "NAME:" in line:
|
|
# looks like a response to M115
|
|
data = parse_firmware_line(line)
|
|
firmware_name = data.get("FIRMWARE_NAME")
|
|
|
|
if firmware_name is None:
|
|
# Malyan's "Marlin compatible firmware" isn't actually Marlin compatible and doesn't even
|
|
# report its firmware name properly in response to M115. Wonderful - why stick to established
|
|
# protocol when you can do your own thing, right?
|
|
#
|
|
# Example: NAME: Malyan VER: 2.9 MODEL: M200 HW: HA02
|
|
#
|
|
# We do a bit of manual fiddling around here to circumvent that issue and get ourselves a
|
|
# reliable firmware name (NAME + VER) out of the Malyan M115 response.
|
|
name = data.get("NAME")
|
|
ver = data.get("VER")
|
|
if "malyan" in name.lower() and ver:
|
|
firmware_name = name.strip() + " " + ver.strip()
|
|
|
|
if not self._firmware_info_received and firmware_name:
|
|
firmware_name = firmware_name.strip()
|
|
self._logger.info("Printer reports firmware name \"{}\"".format(firmware_name))
|
|
|
|
if "repetier" in firmware_name.lower() or "anet_a8" in firmware_name.lower():
|
|
self._logger.info("Detected Repetier firmware, enabling relevant features for issue free communication")
|
|
|
|
self._alwaysSendChecksum = True
|
|
self._resendSwallowRepetitions = True
|
|
self._blockWhileDwelling = True
|
|
supportRepetierTargetTemp = True
|
|
disable_external_heatup_detection = True
|
|
|
|
sd_always_available = self._sdAlwaysAvailable
|
|
self._sdAlwaysAvailable = True
|
|
if not sd_always_available and not self._sdAvailable:
|
|
self.initSdCard()
|
|
|
|
elif "reprapfirmware" in firmware_name.lower():
|
|
self._logger.info("Detected RepRapFirmware, enabling relevant features for issue free communication")
|
|
self._sdRelativePath = True
|
|
|
|
elif "malyan" in firmware_name.lower():
|
|
self._logger.info("Detected Malyan firmware, enabling relevant features for issue free communication")
|
|
|
|
self._alwaysSendChecksum = True
|
|
self._blockWhileDwelling = True
|
|
|
|
sd_always_available = self._sdAlwaysAvailable
|
|
self._sdAlwaysAvailable = True
|
|
if not sd_always_available and not self._sdAvailable:
|
|
self.initSdCard()
|
|
|
|
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:
|
|
self._sdAvailable = False
|
|
self._sdFiles = []
|
|
self._callback.on_comm_sd_state_change(self._sdAvailable)
|
|
elif 'Not SD printing' in line:
|
|
if self.isSdFileSelected() and self.isPrinting():
|
|
# something went wrong, printer is reporting that we actually are not printing right now...
|
|
self._sdFilePos = 0
|
|
self._changeState(self.STATE_OPERATIONAL)
|
|
elif 'SD card ok' in line and not self._sdAvailable:
|
|
self._sdAvailable = True
|
|
self.refreshSdFiles()
|
|
self._callback.on_comm_sd_state_change(self._sdAvailable)
|
|
elif 'Begin file list' in line:
|
|
self._sdFiles = []
|
|
self._sdFileList = True
|
|
elif 'End file list' in line:
|
|
self._sdFileList = False
|
|
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 = 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 = regex_sdFileOpened.search(line)
|
|
if match:
|
|
name = match.group("name")
|
|
size = int(match.group("size"))
|
|
else:
|
|
name = "Unknown"
|
|
size = 0
|
|
if self._sdFileToSelect:
|
|
name = self._sdFileToSelect
|
|
self._sdFileToSelect = None
|
|
self._currentFile = PrintingSdFileInformation(name, size)
|
|
elif 'File selected' in line:
|
|
if self._ignore_select:
|
|
self._ignore_select = False
|
|
elif self._currentFile is not None and self.isSdFileSelected():
|
|
# final answer to M23, at least on Marlin, Repetier and Sprinter: "File selected"
|
|
self._callback.on_comm_file_selected(self._currentFile.getFilename(), self._currentFile.getFilesize(), True)
|
|
elif 'Writing to file' in line and self.isStreaming():
|
|
self._changeState(self.STATE_PRINTING)
|
|
elif 'Done printing file' in line and self.isSdPrinting():
|
|
# printer is reporting file finished printing
|
|
self._sdFilePos = 0
|
|
self._callback.on_comm_print_job_done()
|
|
self._changeState(self.STATE_OPERATIONAL)
|
|
if self._sd_status_timer is not None:
|
|
try:
|
|
self._sd_status_timer.cancel()
|
|
except:
|
|
pass
|
|
elif 'Done saving file' in line:
|
|
if self._trigger_ok_for_m29:
|
|
# workaround for most versions of Marlin out in the wild
|
|
# not sending an ok after saving a file
|
|
self._handle_ok()
|
|
elif 'File deleted' in line and line.strip().endswith("ok"):
|
|
# buggy Marlin version that doesn't send a proper line break after the "File deleted" statement, fixed in
|
|
# current versions
|
|
self._handle_ok()
|
|
|
|
##~~ Message handling
|
|
self._callback.on_comm_message(line)
|
|
|
|
##~~ Parsing for feedback commands
|
|
if feedback_controls and feedback_matcher and not "_all" in feedback_errors:
|
|
try:
|
|
self._process_registered_message(line, feedback_matcher, feedback_controls, feedback_errors)
|
|
except:
|
|
# something went wrong while feedback matching
|
|
self._logger.exception("Error while trying to apply feedback control matching, disabling it")
|
|
feedback_errors.append("_all")
|
|
|
|
##~~ Parsing for pause triggers
|
|
if pause_triggers and not self.isStreaming():
|
|
if "enable" in pause_triggers.keys() and pause_triggers["enable"].search(line) is not None:
|
|
self.setPause(True)
|
|
elif "disable" in pause_triggers.keys() and pause_triggers["disable"].search(line) is not None:
|
|
self.setPause(False)
|
|
elif "toggle" in pause_triggers.keys() and pause_triggers["toggle"].search(line) is not None:
|
|
self.setPause(not self.isPaused())
|
|
|
|
### Baudrate detection
|
|
if self._state == self.STATE_DETECT_BAUDRATE:
|
|
if line == '' or time.time() > self._timeout:
|
|
if self._baudrateDetectRetry > 0:
|
|
self._serial.timeout = detection_timeout
|
|
self._baudrateDetectRetry -= 1
|
|
self._serial.write('\n')
|
|
self._log("Baudrate test retry: %d" % (self._baudrateDetectRetry))
|
|
self.sayHello()
|
|
elif len(self._baudrateDetectList) > 0:
|
|
baudrate = self._baudrateDetectList.pop(0)
|
|
try:
|
|
self._serial.baudrate = baudrate
|
|
if self._serial.timeout != connection_timeout:
|
|
self._serial.timeout = connection_timeout
|
|
self._log("Trying baudrate: %d" % (baudrate))
|
|
self._baudrateDetectRetry = 5
|
|
self._timeout = get_new_timeout("communication", self._timeout_intervals)
|
|
self._serial.write('\n')
|
|
self.sayHello()
|
|
except:
|
|
self._log("Unexpected error while setting baudrate {}: {}".format(baudrate, get_exception_string()))
|
|
self._logger.exception("Unexpceted error while setting baudrate {}".format(baudrate))
|
|
else:
|
|
self.close(wait=False)
|
|
self._errorValue = "No more baudrates to test, and no suitable baudrate found."
|
|
self._changeState(self.STATE_ERROR)
|
|
eventManager().fire(Events.ERROR, {"error": self.getErrorString()})
|
|
elif 'start' in line or 'ok' in line:
|
|
self._onConnected()
|
|
if 'start' in line:
|
|
self._clear_to_send.set()
|
|
|
|
### Connection attempt
|
|
elif self._state == self.STATE_CONNECTING:
|
|
if "start" in line and not startSeen:
|
|
startSeen = True
|
|
self.sayHello()
|
|
elif line.startswith("ok") or (supportWait and line == "wait"):
|
|
if line == "wait":
|
|
# if it was a wait we probably missed an ok, so let's simulate that now
|
|
self._handle_ok()
|
|
self._onConnected()
|
|
elif time.time() > self._timeout:
|
|
self._log("There was a timeout while trying to connect to the printer")
|
|
self.close(wait=False)
|
|
|
|
### Operational (idle or busy)
|
|
elif self._state in (self.STATE_OPERATIONAL,
|
|
self.STATE_PRINTING,
|
|
self.STATE_PAUSED,
|
|
self.STATE_TRANSFERING_FILE):
|
|
if line == "start": # exact match, to be on the safe side
|
|
message = "Printer sent 'start' while already operational. External reset? " \
|
|
"Resetting line numbers to be on the safe side"
|
|
self._log(message)
|
|
self._logger.warn(message)
|
|
self.resetLineNumbers()
|
|
|
|
except:
|
|
self._logger.exception("Something crashed inside the serial connection loop, please report this in OctoPrint's bug tracker:")
|
|
|
|
errorMsg = "See octoprint.log for details"
|
|
self._log(errorMsg)
|
|
self._errorValue = errorMsg
|
|
eventManager().fire(Events.ERROR, {"error": self.getErrorString()})
|
|
self.close(is_error=True)
|
|
self._log("Connection closed, closing down monitor")
|
|
|
|
def _handle_ok(self):
|
|
self._clear_to_send.set()
|
|
|
|
# reset long running commands, persisted current tools and heatup counters on ok
|
|
|
|
self._long_running_command = False
|
|
|
|
if self._formerTool is not None:
|
|
self._currentTool = self._formerTool
|
|
self._formerTool = None
|
|
|
|
self._finish_heatup()
|
|
|
|
if not self._state in (self.STATE_PRINTING, self.STATE_OPERATIONAL, self.STATE_PAUSED):
|
|
return
|
|
|
|
# process queues ongoing resend requests and queues if we are operational
|
|
|
|
if self._resendDelta is not None:
|
|
self._resendNextCommand()
|
|
else:
|
|
self._resendActive = False
|
|
self._continue_sending()
|
|
|
|
return
|
|
|
|
def _handle_timeout(self):
|
|
if self._state not in (self.STATE_PRINTING,
|
|
self.STATE_PAUSED,
|
|
self.STATE_OPERATIONAL):
|
|
return
|
|
|
|
general_message = "Configure long running commands or increase communication timeout if that happens regularly on specific commands or long moves."
|
|
|
|
# figure out which consecutive timeout maximum we have to use
|
|
if self._long_running_command:
|
|
consecutive_max = self._consecutive_timeout_maximums.get("long", 0)
|
|
elif self._state in (self.STATE_PRINTING,):
|
|
consecutive_max = self._consecutive_timeout_maximums.get("printing", 0)
|
|
else:
|
|
consecutive_max = self._consecutive_timeout_maximums.get("idle", 0)
|
|
|
|
# now increment the timeout counter
|
|
self._consecutive_timeouts += 1
|
|
self._logger.debug("Now at {} consecutive timeouts".format(self._consecutive_timeouts))
|
|
|
|
if 0 < consecutive_max < self._consecutive_timeouts:
|
|
# too many consecutive timeouts, we give up
|
|
message = "No response from printer after {} consecutive communication timeouts, considering it dead.".format(consecutive_max + 1)
|
|
self._logger.info(message)
|
|
self._log(message + " " + general_message)
|
|
self._errorValue = "Too many consecutive timeouts, printer still connected and alive?"
|
|
eventManager().fire(Events.ERROR, {"error": self._errorValue})
|
|
self.close(is_error=True)
|
|
|
|
elif self._resendActive:
|
|
# resend active, resend same command instead of triggering a new one
|
|
message = "Communication timeout during an active resend, resending same line again to trigger response from printer."
|
|
self._logger.info(message)
|
|
self._log(message + " " + general_message)
|
|
if self._resendSameCommand():
|
|
self._clear_to_send.set()
|
|
|
|
elif self._heating:
|
|
# blocking heatup active, consider that finished
|
|
message = "Timeout while in an active heatup, considering heatup to be over."
|
|
self._logger.info(message)
|
|
self._finish_heatup()
|
|
|
|
elif self._long_running_command:
|
|
# long running command active, ignore timeout
|
|
self._logger.debug("Ran into a communication timeout, but a command known to be a long runner is currently active")
|
|
|
|
elif self._state in (self.STATE_PRINTING, self.STATE_PAUSED):
|
|
# printing, try to tickle the printer
|
|
message = "Communication timeout while printing, trying to trigger response from printer."
|
|
self._logger.info(message)
|
|
self._log(message + " " + general_message)
|
|
if self._sendCommand("M105", cmd_type="temperature"):
|
|
self._clear_to_send.set()
|
|
|
|
elif self._clear_to_send.blocked():
|
|
# timeout while idle and no oks left, let's try to tickle the printer
|
|
message = "Communication timeout while idle, trying to trigger response from printer."
|
|
self._logger.info(message)
|
|
self._log(message + " " + general_message)
|
|
self._clear_to_send.set()
|
|
|
|
def _finish_heatup(self):
|
|
if self._heatupWaitStartTime:
|
|
self._heatupWaitTimeLost = self._heatupWaitTimeLost + (time.time() - self._heatupWaitStartTime)
|
|
self._heatupWaitStartTime = None
|
|
self._heating = False
|
|
|
|
def _continue_sending(self):
|
|
while self._active:
|
|
|
|
if self._state == self.STATE_OPERATIONAL or self._state == self.STATE_PAUSED or self.isSdPrinting():
|
|
# just send stuff from the command queue and be done with it
|
|
return self._sendFromQueue()
|
|
|
|
elif self._state == self.STATE_PRINTING:
|
|
# we are printing, we really want to send either something from the command
|
|
# queue or the next line from our file, so we only return here if we actually DO
|
|
# send something
|
|
if self._sendFromQueue():
|
|
# we found something in the queue to send
|
|
return True
|
|
|
|
elif self._sendNext():
|
|
# we sent the next line from the file
|
|
return True
|
|
|
|
self._logger.debug("No command sent on ok while printing, doing another iteration")
|
|
|
|
def _process_registered_message(self, line, feedback_matcher, feedback_controls, feedback_errors):
|
|
feedback_match = feedback_matcher.search(line)
|
|
if feedback_match is None:
|
|
return
|
|
|
|
for match_key in feedback_match.groupdict():
|
|
try:
|
|
feedback_key = match_key[len("group"):]
|
|
if not feedback_key in feedback_controls or feedback_key in feedback_errors or feedback_match.group(match_key) is None:
|
|
continue
|
|
matched_part = feedback_match.group(match_key)
|
|
|
|
if feedback_controls[feedback_key]["matcher"] is None:
|
|
continue
|
|
|
|
match = feedback_controls[feedback_key]["matcher"].search(matched_part)
|
|
if match is None:
|
|
continue
|
|
|
|
outputs = dict()
|
|
for template_key, template in feedback_controls[feedback_key]["templates"].items():
|
|
try:
|
|
output = template.format(*match.groups())
|
|
except KeyError:
|
|
output = template.format(**match.groupdict())
|
|
except:
|
|
output = None
|
|
|
|
if output is not None:
|
|
outputs[template_key] = output
|
|
eventManager().fire(Events.REGISTERED_MESSAGE_RECEIVED, dict(key=feedback_key, matched=matched_part, outputs=outputs))
|
|
except:
|
|
self._logger.exception("Error while trying to match feedback control output, disabling key {key}".format(key=match_key))
|
|
feedback_errors.append(match_key)
|
|
|
|
def _poll_temperature(self):
|
|
"""
|
|
Polls the temperature after the temperature timeout, re-enqueues itself.
|
|
|
|
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._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):
|
|
"""
|
|
Polls the sd printing status after the sd status 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 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)
|
|
self._temperature_timer.start()
|
|
|
|
self._changeState(self.STATE_OPERATIONAL)
|
|
|
|
self.resetLineNumbers()
|
|
if self._firmware_detection:
|
|
self.sendCommand("M115")
|
|
|
|
if self._sdAvailable:
|
|
self.refreshSdFiles()
|
|
else:
|
|
self.initSdCard()
|
|
|
|
payload = dict(port=self._port, baudrate=self._baudrate)
|
|
eventManager().fire(Events.CONNECTED, payload)
|
|
self.sendGcodeScript("afterPrinterConnected", replacements=dict(event=payload))
|
|
|
|
def _getTemperatureTimerInterval(self):
|
|
busy_default = 4.0
|
|
target_default = 2.0
|
|
|
|
if self.isBusy():
|
|
return self._timeout_intervals.get("temperature", busy_default)
|
|
|
|
tools = self.last_temperature.tools
|
|
for temp in [tools[k][1] for k in tools.keys()]:
|
|
if temp > self._temperatureTargetSetThreshold:
|
|
return self._timeout_intervals.get("temperatureTargetSet", target_default)
|
|
|
|
bed = self.last_temperature.bed
|
|
if bed and len(bed) > 0 and bed[1] > self._temperatureTargetSetThreshold:
|
|
return self._timeout_intervals.get("temperatureTargetSet", target_default)
|
|
|
|
return self._timeout_intervals.get("temperature", busy_default)
|
|
|
|
def _sendFromQueue(self):
|
|
# We loop here to make sure that if we do NOT send the first command
|
|
# from the queue, we'll send the second (if there is one). We do not
|
|
# want to get stuck here by throwing away commands.
|
|
while True:
|
|
if self.isStreaming():
|
|
# command queue irrelevant
|
|
return False
|
|
|
|
try:
|
|
entry = self._command_queue.get(block=False)
|
|
except queue.Empty:
|
|
# nothing in command queue
|
|
return False
|
|
|
|
try:
|
|
if isinstance(entry, tuple):
|
|
if not len(entry) == 3:
|
|
# something with that entry is broken, ignore it and fetch
|
|
# the next one
|
|
continue
|
|
cmd, cmd_type, callback = entry
|
|
else:
|
|
cmd = entry
|
|
cmd_type = None
|
|
callback = None
|
|
|
|
if self._sendCommand(cmd, cmd_type=cmd_type, on_sent=callback):
|
|
# we actually did add this cmd to the send queue, so let's
|
|
# return, we are done here
|
|
return True
|
|
finally:
|
|
self._command_queue.task_done()
|
|
|
|
def _detect_port(self):
|
|
potentials = serialList()
|
|
self._log("Serial port list: %s" % (str(potentials)))
|
|
|
|
if len(potentials) == 1:
|
|
# short cut: only one port, let's try that
|
|
return potentials[0]
|
|
|
|
elif len(potentials) > 1:
|
|
programmer = stk500v2.Stk500v2()
|
|
|
|
for p in serialList():
|
|
serial_obj = None
|
|
|
|
try:
|
|
self._log("Trying {}".format(p))
|
|
programmer.connect(p)
|
|
serial_obj = programmer.leaveISP()
|
|
except ispBase.IspError as e:
|
|
self._log("Could not enter programming mode on {}, might not be a printer or just not allow programming mode".format(p))
|
|
self._logger.info("Could not enter programming mode on {}: {}".format(p, e))
|
|
except:
|
|
self._log("Could not connect to {}: {}".format(p, get_exception_string()))
|
|
self._logger.exception("Could not connect to {}".format(p))
|
|
|
|
found = serial_obj is not None
|
|
programmer.close()
|
|
|
|
if found:
|
|
return p
|
|
|
|
return None
|
|
|
|
def _openSerial(self):
|
|
def default(_, port, baudrate, read_timeout):
|
|
if port is None or port == 'AUTO':
|
|
# no known port, try auto detection
|
|
self._changeState(self.STATE_DETECT_SERIAL)
|
|
port = self._detect_port()
|
|
if port is None:
|
|
self._errorValue = 'Failed to autodetect serial port, please set it manually.'
|
|
self._changeState(self.STATE_ERROR)
|
|
eventManager().fire(Events.ERROR, {"error": self.getErrorString()})
|
|
self._log("Failed to autodetect serial port, please set it manually.")
|
|
return None
|
|
|
|
# connect to regular serial port
|
|
self._log("Connecting to: %s" % port)
|
|
if baudrate == 0:
|
|
baudrates = baudrateList()
|
|
serial_obj = serial.Serial(str(port), 115200 if 115200 in baudrates else baudrates[0], timeout=read_timeout, writeTimeout=10000, parity=serial.PARITY_ODD)
|
|
else:
|
|
serial_obj = serial.Serial(str(port), baudrate, timeout=read_timeout, writeTimeout=10000, parity=serial.PARITY_ODD)
|
|
serial_obj.close()
|
|
serial_obj.parity = serial.PARITY_NONE
|
|
serial_obj.open()
|
|
|
|
return serial_obj
|
|
|
|
serial_factories = self._serial_factory_hooks.items() + [("default", default)]
|
|
for name, factory in serial_factories:
|
|
try:
|
|
serial_obj = factory(self, self._port, self._baudrate, settings().getFloat(["serial", "timeout", "connection"]))
|
|
except:
|
|
exception_string = get_exception_string()
|
|
self._errorValue = "Connection error, see Terminal tab"
|
|
self._changeState(self.STATE_ERROR)
|
|
eventManager().fire(Events.ERROR, {"error": self.getErrorString()})
|
|
|
|
error_message = "Unexpected error while connecting to serial port: %s %s (hook %s)" % (self._port, exception_string, name)
|
|
self._log(error_message)
|
|
self._logger.exception(error_message)
|
|
|
|
if "failed to set custom baud rate" in exception_string.lower():
|
|
self._log("Your installation does not support custom baudrates (e.g. 250000) for connecting to your printer. This is a problem of the pyserial library that OctoPrint depends on. Please update to a pyserial version that supports your baudrate or switch your printer's firmware to a standard baudrate (e.g. 115200). See https://github.com/foosel/OctoPrint/wiki/OctoPrint-support-for-250000-baud-rate-on-Raspbian")
|
|
|
|
return False
|
|
|
|
if serial_obj is not None:
|
|
# first hook to succeed wins, but any can pass on to the next
|
|
self._changeState(self.STATE_OPEN_SERIAL)
|
|
self._serial = serial_obj
|
|
self._clear_to_send.clear()
|
|
return True
|
|
|
|
return False
|
|
|
|
def _handleErrors(self, line):
|
|
if line is None:
|
|
return
|
|
|
|
lower_line = line.lower()
|
|
|
|
# No matter the state, if we see an error, goto the error state and store the error for reference.
|
|
if lower_line.startswith('error:') or line.startswith('!!'):
|
|
#Oh YEAH, consistency.
|
|
# 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 regex_minMaxError.match(line):
|
|
line = line.rstrip() + self._readline()
|
|
|
|
if 'line number' in lower_line or 'checksum' in lower_line or 'format error' in lower_line or 'expected line' in lower_line:
|
|
#Skip the communication errors, as those get corrected.
|
|
self._lastCommError = line[6:] if lower_line.startswith("error:") else line[2:]
|
|
pass
|
|
elif 'volume.init' in lower_line or "openroot" in lower_line or 'workdir' in lower_line\
|
|
or "error writing to file" in lower_line or "cannot open" in lower_line\
|
|
or "cannot enter" in lower_line:
|
|
#Also skip errors with the SD card
|
|
pass
|
|
elif 'unknown command' in lower_line:
|
|
#Ignore unkown command errors, it could be a typo or some missing feature
|
|
pass
|
|
elif not self.isError():
|
|
error_text = line[6:] if lower_line.startswith("error:") else line[2:]
|
|
self._to_logfile_with_terminal("Received an error from the printer's firmware: {}".format(error_text), level=logging.WARN)
|
|
if not self._ignore_errors:
|
|
if self._disconnect_on_errors:
|
|
self._errorValue = error_text
|
|
self._changeState(self.STATE_ERROR)
|
|
eventManager().fire(Events.ERROR, {"error": self.getErrorString()})
|
|
elif self.isPrinting():
|
|
self.cancelPrint(firmware_error=error_text)
|
|
self._clear_to_send.set()
|
|
else:
|
|
self._log("WARNING! Received an error from the printer's firmware, ignoring that as configured but you might want to investigate what happened here! Error: {}".format(error_text))
|
|
self._clear_to_send.set()
|
|
return line
|
|
|
|
def _readline(self):
|
|
if self._serial is None:
|
|
return None
|
|
|
|
try:
|
|
ret = self._serial.readline()
|
|
except Exception as ex:
|
|
if not self._connection_closing:
|
|
self._logger.exception("Unexpected error while reading from serial port")
|
|
self._log("Unexpected error while reading serial port, please consult octoprint.log for details: %s" % (get_exception_string()))
|
|
if isinstance(ex, serial.SerialException):
|
|
self._dual_log("Please see https://bit.ly/octoserial for possible reasons of this.",
|
|
level=logging.ERROR)
|
|
self._errorValue = get_exception_string()
|
|
self.close(is_error=True)
|
|
return None
|
|
|
|
if ret != "":
|
|
try:
|
|
self._log("Recv: " + sanitize_ascii(ret))
|
|
except ValueError as e:
|
|
self._log("WARN: While reading last line: %s" % e)
|
|
self._log("Recv: " + repr(ret))
|
|
|
|
for name, hook in self._received_message_hooks.items():
|
|
try:
|
|
ret = hook(self, ret)
|
|
except:
|
|
self._logger.exception("Error while processing hook {name}:".format(**locals()))
|
|
else:
|
|
if ret is None:
|
|
return ""
|
|
|
|
return ret
|
|
|
|
def _getNext(self):
|
|
if self._currentFile is None:
|
|
return None
|
|
|
|
line = self._currentFile.getNext()
|
|
if line is None:
|
|
if self.isStreaming():
|
|
self._sendCommand("M29")
|
|
|
|
remote = self._currentFile.getRemoteFilename()
|
|
payload = {
|
|
"local": self._currentFile.getLocalFilename(),
|
|
"remote": remote,
|
|
"time": self.getPrintTime()
|
|
}
|
|
|
|
self._currentFile = None
|
|
self._changeState(self.STATE_OPERATIONAL)
|
|
self._callback.on_comm_file_transfer_done(remote)
|
|
eventManager().fire(Events.TRANSFER_DONE, payload)
|
|
self.refreshSdFiles()
|
|
else:
|
|
self._callback.on_comm_print_job_done()
|
|
self._changeState(self.STATE_OPERATIONAL)
|
|
return line
|
|
|
|
def _sendNext(self):
|
|
with self._jobLock:
|
|
while self._active:
|
|
# we loop until we've actually enqueued a line for sending
|
|
if self._state != self.STATE_PRINTING:
|
|
# we are no longer printing, return false
|
|
return False
|
|
|
|
line = self._getNext()
|
|
if line is None:
|
|
# end of file, return false
|
|
return False
|
|
|
|
result = self._sendCommand(line)
|
|
self._callback.on_comm_progress()
|
|
if result:
|
|
# line sent, return true
|
|
return True
|
|
|
|
self._logger.debug("Command \"{}\" from file not enqueued, doing another iteration".format(line))
|
|
|
|
def _handleResendRequest(self, line):
|
|
try:
|
|
lineToResend = parse_resend_line(line)
|
|
if lineToResend is None:
|
|
return False
|
|
|
|
if self._resendDelta is None and lineToResend == self._currentLine:
|
|
# We don't expect to have an active resend request and the printer is requesting a resend of
|
|
# a line we haven't yet sent.
|
|
#
|
|
# This means the printer got a line from us with N = self._currentLine - 1 but had already
|
|
# acknowledged that. This can happen if the last line was resent due to a timeout during
|
|
# an active (prior) resend request.
|
|
#
|
|
# We will ignore this resend request and just continue normally.
|
|
self._logger.debug("Ignoring resend request for line %d == current line, we haven't sent that yet so the printer got N-1 twice from us, probably due to a timeout" % lineToResend)
|
|
return False
|
|
|
|
lastCommError = self._lastCommError
|
|
self._lastCommError = None
|
|
|
|
resendDelta = self._currentLine - lineToResend
|
|
|
|
if lastCommError is not None \
|
|
and ("line number" in lastCommError.lower() or "expected line" in lastCommError.lower()) \
|
|
and lineToResend == self._lastResendNumber \
|
|
and self._resendDelta is not None and self._currentResendCount < self._resendDelta:
|
|
self._logger.debug("Ignoring resend request for line %d, that still originates from lines we sent before we got the first resend request" % lineToResend)
|
|
self._currentResendCount += 1
|
|
return True
|
|
|
|
# If we ignore resend repetitions (Repetier firmware...), check if we
|
|
# need to do this now. If the same line number has been requested we
|
|
# already saw and resent, we'll ignore it up to <counter> times.
|
|
if self._resendSwallowRepetitions and lineToResend == self._lastResendNumber and self._resendSwallowRepetitionsCounter > 0:
|
|
self._logger.debug("Ignoring resend request for line %d, that is probably a repetition sent by the firmware to ensure it arrives, not a real request" % lineToResend)
|
|
self._resendSwallowRepetitionsCounter -= 1
|
|
return True
|
|
|
|
self._resendActive = True
|
|
self._resendDelta = resendDelta
|
|
self._lastResendNumber = lineToResend
|
|
self._currentResendCount = 0
|
|
self._resendSwallowRepetitionsCounter = settings().getInt(["feature", "identicalResendsCountdown"])
|
|
|
|
if self._resendDelta > len(self._lastLines) or len(self._lastLines) == 0 or self._resendDelta < 0:
|
|
self._errorValue = "Printer requested line %d but no sufficient history is available, can't resend" % lineToResend
|
|
self._log(self._errorValue)
|
|
self._logger.warn(self._errorValue + ". Printer requested line {}, current line is {}, line history has {} entries.".format(lineToResend, self._currentLine, len(self._lastLines)))
|
|
if self.isPrinting():
|
|
# abort the print, there's nothing we can do to rescue it now
|
|
self._changeState(self.STATE_ERROR)
|
|
eventManager().fire(Events.ERROR, {"error": self.getErrorString()})
|
|
else:
|
|
# reset resend delta, we can't do anything about it
|
|
self._resendDelta = None
|
|
|
|
# if we log resends, make sure we don't log more resends than the set rate within a window
|
|
#
|
|
# this it to prevent the log from getting flooded for extremely bad communication issues
|
|
if self._log_resends:
|
|
now = time.time()
|
|
new_rate_window = self._log_resends_rate_start is None or self._log_resends_rate_start + self._log_resends_rate_frame < now
|
|
in_rate = self._log_resends_rate_count < self._log_resends_max
|
|
|
|
if new_rate_window or in_rate:
|
|
if new_rate_window:
|
|
self._log_resends_rate_start = now
|
|
self._log_resends_rate_count = 0
|
|
|
|
self._to_logfile_with_terminal("Got a resend request from the printer: requested line = {}, current line = {}".format(lineToResend, self._currentLine))
|
|
self._log_resends_rate_count += 1
|
|
|
|
return True
|
|
finally:
|
|
if self._supportResendsWithoutOk:
|
|
# simulate an ok if our flags indicate that the printer needs that for resend requests to work
|
|
self._handle_ok()
|
|
|
|
def _resendSameCommand(self):
|
|
return self._resendNextCommand(again=True)
|
|
|
|
def _resendNextCommand(self, again=False):
|
|
self._lastCommError = None
|
|
|
|
# Make sure we are only handling one sending job at a time
|
|
with self._sendingLock:
|
|
if again:
|
|
# If we are about to last line from the active resend request
|
|
# again, we first need to increment resend delta. It might already
|
|
# be set to None if the last resend line was already sent, so
|
|
# if that's the case we set it to 0. It will then be incremented,
|
|
# the last line will be sent again, and then the delta will be
|
|
# decremented and set to None again, completing the cycle.
|
|
if self._resendDelta is None:
|
|
self._resendDelta = 0
|
|
self._resendDelta += 1
|
|
|
|
cmd = self._lastLines[-self._resendDelta]
|
|
lineNumber = self._currentLine - self._resendDelta
|
|
|
|
result = self._enqueue_for_sending(cmd, linenumber=lineNumber)
|
|
|
|
self._resendDelta -= 1
|
|
if self._resendDelta <= 0:
|
|
self._resendDelta = None
|
|
self._lastResendNumber = None
|
|
self._currentResendCount = 0
|
|
|
|
return result
|
|
|
|
def _sendCommand(self, cmd, cmd_type=None, on_sent=None):
|
|
# Make sure we are only handling one sending job at a time
|
|
with self._sendingLock:
|
|
if self._serial is None:
|
|
return False
|
|
|
|
gcode = None
|
|
|
|
if not self.isStreaming():
|
|
# trigger the "queuing" phase only if we are not streaming to sd right now
|
|
results = self._process_command_phase("queuing", cmd, cmd_type, gcode=gcode)
|
|
|
|
if not results:
|
|
# command is no more, return
|
|
return False
|
|
else:
|
|
results = [(cmd, cmd_type, gcode)]
|
|
|
|
# process helper
|
|
def process(cmd, cmd_type, gcode, on_sent=None):
|
|
if cmd is None:
|
|
# no command, next entry
|
|
return False
|
|
|
|
if gcode and gcode in gcodeToEvent:
|
|
# if this is a gcode bound to an event, trigger that now
|
|
eventManager().fire(gcodeToEvent[gcode])
|
|
|
|
# actually enqueue the command for sending
|
|
if self._enqueue_for_sending(cmd, command_type=cmd_type, on_sent=on_sent):
|
|
if not self.isStreaming():
|
|
# trigger the "queued" phase only if we are not streaming to sd right now
|
|
self._process_command_phase("queued", cmd, cmd_type, gcode=gcode)
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
# split off the final command, because that needs special treatment
|
|
if len(results) > 1:
|
|
last_command = results[-1]
|
|
results = results[:-1]
|
|
else:
|
|
last_command = results[0]
|
|
results = []
|
|
|
|
# track if we enqueued anything at all
|
|
enqueued_something = False
|
|
|
|
# process all but the last ...
|
|
for (cmd, cmd_type, gcode) in results:
|
|
enqueued_something = process(cmd, cmd_type, gcode) or enqueued_something
|
|
|
|
# ... and then process the last one with the on_sent callback attached
|
|
cmd, cmd_type, gcode = last_command
|
|
enqueued_something = process(cmd, cmd_type, gcode, on_sent=on_sent) or enqueued_something
|
|
|
|
return enqueued_something
|
|
|
|
##~~ send loop handling
|
|
|
|
def _enqueue_for_sending(self, command, linenumber=None, command_type=None, on_sent=None):
|
|
"""
|
|
Enqueues a command and optional linenumber to use for it in the send queue.
|
|
|
|
Arguments:
|
|
command (str): The command to send.
|
|
linenumber (int): The line number with which to send the command. May be ``None`` in which case the command
|
|
will be sent without a line number and checksum.
|
|
command_type (str): Optional command type, if set and command type is already in the queue the
|
|
command won't be enqueued
|
|
on_sent (callable): Optional callable to call after command has been sent to printer.
|
|
"""
|
|
|
|
try:
|
|
self._send_queue.put((command, linenumber, command_type, on_sent, False), item_type=command_type)
|
|
return True
|
|
except TypeAlreadyInQueue as e:
|
|
self._logger.debug("Type already in send queue: " + e.type)
|
|
return False
|
|
|
|
def _send_loop(self):
|
|
"""
|
|
The send loop is reponsible of sending commands in ``self._send_queue`` over the line, if it is cleared for
|
|
sending (through received ``ok`` responses from the printer's firmware.
|
|
"""
|
|
|
|
self._clear_to_send.wait()
|
|
|
|
while self._send_queue_active:
|
|
try:
|
|
# wait until we have something in the queue
|
|
entry = self._send_queue.get()
|
|
|
|
try:
|
|
# make sure we are still active
|
|
if not self._send_queue_active:
|
|
break
|
|
|
|
# sleep if we are dwelling
|
|
now = time.time()
|
|
if self._blockWhileDwelling and self._dwelling_until and now < self._dwelling_until:
|
|
time.sleep(self._dwelling_until - now)
|
|
self._dwelling_until = False
|
|
|
|
# fetch command, command type and optional linenumber and sent callback from queue
|
|
command, linenumber, command_type, on_sent, processed = entry
|
|
|
|
# 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 = 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
|
|
# send directly without any processing (since that already took place on the first sending!)
|
|
self._do_send_with_checksum(command, linenumber)
|
|
|
|
else:
|
|
if not processed:
|
|
# trigger "sending" phase if we didn't so far
|
|
results = self._process_command_phase("sending", command, command_type, gcode=gcode)
|
|
|
|
if not results:
|
|
# No, we are not going to send this, that was a last-minute bail.
|
|
# However, since we already are in the send queue, our _monitor
|
|
# loop won't be triggered with the reply from this unsent command
|
|
# now, so we try to tickle the processing of any active
|
|
# command queues manually
|
|
self._continue_sending()
|
|
|
|
# and now let's fetch the next item from the queue
|
|
continue
|
|
|
|
if len(results) > 1:
|
|
# last command gets on_sent attached
|
|
last = results[-1]
|
|
self._send_queue.prepend((last[0], None, None, on_sent, True))
|
|
on_sent = None
|
|
|
|
# middle gets prepended reversed (so order gets restored)
|
|
if len(results) > 2:
|
|
to_prepend = reversed(results[1:-1])
|
|
for m in to_prepend:
|
|
self._send_queue.prepend((m[0], None, None, None, True))
|
|
|
|
# we only actually send the first entry here
|
|
command, _, gcode = results[0]
|
|
|
|
if command.strip() == "":
|
|
self._logger.info("Refusing to send an empty line to the printer")
|
|
|
|
# same here, tickle the queues manually
|
|
self._continue_sending()
|
|
|
|
# and fetch the next item
|
|
continue
|
|
|
|
# now comes the part where we increase line numbers and send stuff - no turning back now
|
|
command_requiring_checksum = gcode is not None and gcode in self._checksum_requiring_commands
|
|
command_allowing_checksum = gcode is not None or self._sendChecksumWithUnknownCommands
|
|
checksum_enabled = not self._neverSendChecksum and (self.isPrinting() or
|
|
self._alwaysSendChecksum or
|
|
not self._firmware_info_received)
|
|
|
|
command_to_send = command.encode("ascii", errors="replace")
|
|
if command_requiring_checksum or (command_allowing_checksum and checksum_enabled):
|
|
self._do_increment_and_send_with_checksum(command_to_send)
|
|
else:
|
|
self._do_send_without_checksum(command_to_send)
|
|
|
|
# trigger "sent" phase and use up one "ok"
|
|
if on_sent is not None and callable(on_sent):
|
|
# we have a sent callback for this specific command, let's execute it now
|
|
on_sent()
|
|
self._process_command_phase("sent", command, command_type, gcode=gcode)
|
|
|
|
# we only need to use up a clear if the command we just sent was either a gcode command or if we also
|
|
# require ack's for unknown commands
|
|
use_up_clear = self._unknownCommandsNeedAck
|
|
if gcode is not None:
|
|
use_up_clear = True
|
|
|
|
if use_up_clear:
|
|
# if we need to use up a clear, do that now
|
|
self._clear_to_send.clear()
|
|
else:
|
|
# Otherwise we need to tickle the read queue - there might not be a reply
|
|
# to this command, so our _monitor loop will stay waiting until timeout. We
|
|
# definitely do not want that, so we tickle the queue manually here
|
|
self._continue_sending()
|
|
|
|
finally:
|
|
# no matter _how_ we exit this block, we signal that we
|
|
# are done processing the last fetched queue entry
|
|
self._send_queue.task_done()
|
|
|
|
# now we just wait for the next clear and then start again
|
|
self._clear_to_send.wait()
|
|
except:
|
|
self._logger.exception("Caught an exception in the send loop")
|
|
self._log("Closing down send loop")
|
|
|
|
def _process_command_phase(self, phase, command, command_type=None, gcode=None):
|
|
if gcode is None:
|
|
gcode = gcode_command_for_cmd(command)
|
|
results = [(command, command_type, gcode)]
|
|
|
|
if (self.isStreaming() and self.isPrinting()) or phase not in ("queuing", "queued", "sending", "sent"):
|
|
return results
|
|
|
|
# send it through the phase specific handlers provided by plugins
|
|
for name, hook in self._gcode_hooks[phase].items():
|
|
new_results = []
|
|
for command, command_type, gcode in results:
|
|
try:
|
|
hook_results = hook(self, phase, command, command_type, gcode)
|
|
except:
|
|
self._logger.exception("Error while processing hook {name} for phase {phase} and command {command}:".format(**locals()))
|
|
else:
|
|
normalized = _normalize_command_handler_result(command, command_type, gcode, hook_results)
|
|
new_results += normalized
|
|
if not new_results:
|
|
# hook handler returned None or empty list for all commands, so we'll stop here and return a full out empty result
|
|
return []
|
|
results = new_results
|
|
|
|
# if it's a gcode command send it through the specific handler if it exists
|
|
new_results = []
|
|
modified = False
|
|
for command, command_type, gcode in results:
|
|
if gcode is not None:
|
|
gcode_handler = "_gcode_" + gcode + "_" + phase
|
|
if hasattr(self, gcode_handler):
|
|
handler_results = getattr(self, gcode_handler)(command, cmd_type=command_type)
|
|
new_results += _normalize_command_handler_result(command, command_type, gcode, handler_results)
|
|
modified = True
|
|
else:
|
|
new_results.append((command, command_type, gcode))
|
|
modified = True
|
|
if modified:
|
|
if not new_results:
|
|
# gcode handler returned None or empty list for all commands, so we'll stop here and return a full out empty result
|
|
return []
|
|
else:
|
|
results = new_results
|
|
|
|
# send it through the phase specific command handler if it exists
|
|
command_phase_handler = "_command_phase_" + phase
|
|
if hasattr(self, command_phase_handler):
|
|
new_results = []
|
|
for command, command_type, gcode in results:
|
|
handler_results = getattr(self, command_phase_handler)(command, cmd_type=command_type, gcode=gcode)
|
|
new_results += _normalize_command_handler_result(command, command_type, gcode, handler_results)
|
|
results = new_results
|
|
|
|
# finally return whatever we resulted on
|
|
return results
|
|
|
|
##~~ actual sending via serial
|
|
|
|
def _do_increment_and_send_with_checksum(self, cmd):
|
|
with self._line_mutex:
|
|
linenumber = self._currentLine
|
|
self._addToLastLines(cmd)
|
|
self._currentLine += 1
|
|
self._do_send_with_checksum(cmd, linenumber)
|
|
|
|
def _do_send_with_checksum(self, command, linenumber):
|
|
command_to_send = "N" + str(linenumber) + " " + command
|
|
checksum = 0
|
|
for c in command_to_send:
|
|
checksum ^= ord(c)
|
|
command_to_send = command_to_send + "*" + str(checksum)
|
|
self._do_send_without_checksum(command_to_send)
|
|
|
|
def _do_send_without_checksum(self, cmd):
|
|
if self._serial is None:
|
|
return
|
|
|
|
self._log("Send: " + str(cmd))
|
|
|
|
cmd += "\n"
|
|
written = 0
|
|
passes = 0
|
|
while written < len(cmd):
|
|
to_send = cmd[written:]
|
|
old_written = written
|
|
|
|
try:
|
|
result = self._serial.write(to_send)
|
|
if result is None or not isinstance(result, int):
|
|
# probably some plugin not returning the written bytes, assuming all of them
|
|
written += len(cmd)
|
|
else:
|
|
written += result
|
|
except serial.SerialTimeoutException:
|
|
self._log("Serial timeout while writing to serial port, trying again.")
|
|
try:
|
|
result = self._serial.write(to_send)
|
|
if result is None or not isinstance(result, int):
|
|
# probably some plugin not returning the written bytes, assuming all of them
|
|
written += len(cmd)
|
|
else:
|
|
written += result
|
|
except Exception as ex:
|
|
if not self._connection_closing:
|
|
self._logger.exception("Unexpected error while writing to serial port")
|
|
self._log("Unexpected error while writing to serial port: %s" % (get_exception_string()))
|
|
if isinstance(ex, serial.SerialException):
|
|
self._dual_log("Please see https://bit.ly/octoserial for possible reasons of this.",
|
|
level=logging.ERROR)
|
|
self._errorValue = get_exception_string()
|
|
self.close(is_error=True)
|
|
break
|
|
except Exception as ex:
|
|
if not self._connection_closing:
|
|
self._logger.exception("Unexpected error while writing to serial port")
|
|
self._log("Unexpected error while writing to serial port: %s" % (get_exception_string()))
|
|
if isinstance(ex, serial.SerialException):
|
|
self._dual_log("Please see https://bit.ly/octoserial for possible reasons of this.",
|
|
level=logging.ERROR)
|
|
self._errorValue = get_exception_string()
|
|
self.close(is_error=True)
|
|
break
|
|
|
|
if old_written == written:
|
|
# nothing written this pass
|
|
passes += 1
|
|
if passes > self._max_write_passes:
|
|
# nothing written in max consecutive passes, we give up
|
|
message = "Could not write anything to the serial port in {} tries, something appears to be wrong with the printer communication".format(self._max_write_passes)
|
|
self._dual_log(message, level=logging.ERROR)
|
|
self._errorValue = "Could not write to serial port"
|
|
self.close(is_error=True)
|
|
break
|
|
|
|
##~~ command handlers
|
|
|
|
def _gcode_T_sent(self, cmd, cmd_type=None):
|
|
toolMatch = regexes_parameters["intT"].search(cmd)
|
|
if toolMatch:
|
|
self._currentTool = int(toolMatch.group("value"))
|
|
|
|
def _gcode_G0_sent(self, cmd, cmd_type=None):
|
|
if "Z" in cmd or "F" in cmd:
|
|
# track Z
|
|
match = regexes_parameters["floatZ"].search(cmd)
|
|
if match:
|
|
try:
|
|
z = float(match.group("value"))
|
|
if self._currentZ != z:
|
|
self._currentZ = z
|
|
self._callback.on_comm_z_change(z)
|
|
except ValueError:
|
|
pass
|
|
|
|
# track F
|
|
match = regexes_parameters["floatF"].search(cmd)
|
|
if match:
|
|
try:
|
|
f = float(match.group("value"))
|
|
self._currentF = f
|
|
except ValueError:
|
|
pass
|
|
_gcode_G1_sent = _gcode_G0_sent
|
|
|
|
def _gcode_G28_sent(self, cmd, cmd_type=None):
|
|
if "F" in cmd:
|
|
match = regexes_parameters["floatF"].search(cmd)
|
|
if match:
|
|
try:
|
|
f = float(match.group("value"))
|
|
self._currentF = f
|
|
except ValueError:
|
|
pass
|
|
|
|
def _gcode_M0_queuing(self, cmd, cmd_type=None):
|
|
self.setPause(True)
|
|
return None, # Don't send the M0 or M1 to the machine, as M0 and M1 are handled as an LCD menu pause.
|
|
_gcode_M1_queuing = _gcode_M0_queuing
|
|
|
|
def _gcode_M25_queuing(self, cmd, cmd_type=None):
|
|
# M25 while not printing from SD will be handled as pause. This way it can be used as another marker
|
|
# for GCODE induced pausing. Send it to the printer anyway though.
|
|
if self.isPrinting() and not self.isSdPrinting():
|
|
self.setPause(True)
|
|
|
|
def _gcode_M28_sent(self, cmd, cmd_type=None):
|
|
if not self.isStreaming():
|
|
self._log("Detected manual streaming. Disabling temperature polling. Finish writing with M29. Do NOT attempt to print while manually streaming!")
|
|
self._manualStreaming = True
|
|
|
|
def _gcode_M29_sent(self, cmd, cmd_type=None):
|
|
if self._manualStreaming:
|
|
self._log("Manual streaming done. Re-enabling temperature polling. All is well.")
|
|
self._manualStreaming = False
|
|
|
|
def _gcode_M140_queuing(self, cmd, cmd_type=None):
|
|
if not self._printerProfileManager.get_current_or_default()["heatedBed"]:
|
|
self._log("Warn: Not sending \"{}\", printer profile has no heated bed".format(cmd))
|
|
return None, # Don't send bed commands if we don't have a heated bed
|
|
_gcode_M190_queuing = _gcode_M140_queuing
|
|
|
|
def _gcode_M104_sent(self, cmd, cmd_type=None, wait=False, support_r=False):
|
|
toolNum = self._currentTool
|
|
toolMatch = regexes_parameters["intT"].search(cmd)
|
|
|
|
if toolMatch:
|
|
toolNum = int(toolMatch.group("value"))
|
|
|
|
if wait:
|
|
self._formerTool = self._currentTool
|
|
self._currentTool = toolNum
|
|
|
|
match = regexes_parameters["floatS"].search(cmd)
|
|
if not match and support_r:
|
|
match = regexes_parameters["floatR"].search(cmd)
|
|
|
|
if match:
|
|
try:
|
|
target = float(match.group("value"))
|
|
self.last_temperature.set_tool(toolNum, target=target)
|
|
self._callback.on_comm_temperature_update(self.last_temperature.tools, self.last_temperature.bed)
|
|
except ValueError:
|
|
pass
|
|
|
|
def _gcode_M140_sent(self, cmd, cmd_type=None, wait=False, support_r=False):
|
|
match = regexes_parameters["floatS"].search(cmd)
|
|
if not match and support_r:
|
|
match = regexes_parameters["floatR"].search(cmd)
|
|
|
|
if match:
|
|
try:
|
|
target = float(match.group("value"))
|
|
self.last_temperature.set_bed(target=target)
|
|
self._callback.on_comm_temperature_update(self.last_temperature.tools, self.last_temperature.bed)
|
|
except ValueError:
|
|
pass
|
|
|
|
def _gcode_M109_sent(self, cmd, cmd_type=None):
|
|
self._heatupWaitStartTime = time.time()
|
|
self._long_running_command = True
|
|
self._heating = True
|
|
self._gcode_M104_sent(cmd, cmd_type, wait=True, support_r=True)
|
|
|
|
def _gcode_M190_sent(self, cmd, cmd_type=None):
|
|
self._heatupWaitStartTime = time.time()
|
|
self._long_running_command = True
|
|
self._heating = True
|
|
self._gcode_M140_sent(cmd, cmd_type, wait=True, support_r=True)
|
|
|
|
def _gcode_M116_sent(self, cmd, cmd_type=None):
|
|
self._heatupWaitStartTime = time.time()
|
|
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)
|
|
if match:
|
|
try:
|
|
newLineNumber = int(match.group("value"))
|
|
except:
|
|
pass
|
|
|
|
with self._line_mutex:
|
|
self._logger.info("M110 detected, setting current line number to {}".format(newLineNumber))
|
|
|
|
# send M110 command with new line number
|
|
self._currentLine = newLineNumber
|
|
|
|
# after a reset of the line number we have no way to determine what line exactly the printer now wants
|
|
self._lastLines.clear()
|
|
self._resendDelta = None
|
|
|
|
def _gcode_M112_queuing(self, cmd, cmd_type=None):
|
|
# emergency stop, jump the queue with the M112
|
|
self._do_send_without_checksum("M112")
|
|
self._do_increment_and_send_with_checksum("M112")
|
|
|
|
# No idea if the printer is still listening or if M112 won. Just in case
|
|
# we'll now try to also manually make sure all heaters are shut off - better
|
|
# safe than sorry. We do this ignoring the queue since at this point it
|
|
# is irrelevant whether the printer has sent enough ack's or not, we
|
|
# are going to shutdown the connection in a second anyhow.
|
|
for tool in range(self._printerProfileManager.get_current_or_default()["extruder"]["count"]):
|
|
self._do_increment_and_send_with_checksum("M104 T{tool} S0".format(tool=tool))
|
|
if self._printerProfileManager.get_current_or_default()["heatedBed"]:
|
|
self._do_increment_and_send_with_checksum("M140 S0")
|
|
|
|
# close to reset host state
|
|
self._errorValue = "Closing serial port due to emergency stop M112."
|
|
self._log(self._errorValue)
|
|
self.close(is_error=True)
|
|
|
|
# fire the M112 event since we sent it and we're going to prevent the caller from seeing it
|
|
gcode = "M112"
|
|
if gcode in gcodeToEvent:
|
|
eventManager().fire(gcodeToEvent[gcode])
|
|
|
|
# return None 1-tuple to eat the one that is queuing because we don't want to send it twice
|
|
# I hope it got it the first time because as far as I can tell, there is no way to know
|
|
return None,
|
|
|
|
def _gcode_G4_sent(self, cmd, cmd_type=None):
|
|
# we are intending to dwell for a period of time, increase the timeout to match
|
|
p_match = regexes_parameters["floatP"].search(cmd)
|
|
s_match = regexes_parameters["floatS"].search(cmd)
|
|
|
|
_timeout = 0
|
|
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", self._timeout_intervals) + _timeout
|
|
self._dwelling_until = time.time() + _timeout
|
|
|
|
##~~ command phase handlers
|
|
|
|
def _command_phase_sending(self, cmd, cmd_type=None, gcode=None):
|
|
if gcode is not None and gcode in self._long_running_commands:
|
|
self._long_running_command = True
|
|
|
|
### MachineCom callback ################################################################################################
|
|
|
|
class MachineComPrintCallback(object):
|
|
def on_comm_log(self, message):
|
|
pass
|
|
|
|
def on_comm_temperature_update(self, temp, bedTemp):
|
|
pass
|
|
|
|
def on_comm_position_update(self, position, reason=None):
|
|
pass
|
|
|
|
def on_comm_state_change(self, state):
|
|
pass
|
|
|
|
def on_comm_message(self, message):
|
|
pass
|
|
|
|
def on_comm_progress(self):
|
|
pass
|
|
|
|
def on_comm_print_job_started(self):
|
|
pass
|
|
|
|
def on_comm_print_job_failed(self):
|
|
pass
|
|
|
|
def on_comm_print_job_done(self):
|
|
pass
|
|
|
|
def on_comm_print_job_cancelled(self):
|
|
pass
|
|
|
|
def on_comm_print_job_paused(self):
|
|
pass
|
|
|
|
def on_comm_print_job_resumed(self):
|
|
pass
|
|
|
|
def on_comm_z_change(self, newZ):
|
|
pass
|
|
|
|
def on_comm_file_selected(self, filename, filesize, sd):
|
|
pass
|
|
|
|
def on_comm_sd_state_change(self, sdReady):
|
|
pass
|
|
|
|
def on_comm_sd_files(self, files):
|
|
pass
|
|
|
|
def on_comm_file_transfer_started(self, filename, filesize):
|
|
pass
|
|
|
|
def on_comm_file_transfer_done(self, filename):
|
|
pass
|
|
|
|
def on_comm_force_disconnect(self):
|
|
pass
|
|
|
|
def on_comm_record_fileposition(self, origin, name, pos):
|
|
pass
|
|
|
|
### Printing file information classes ##################################################################################
|
|
|
|
class PrintingFileInformation(object):
|
|
"""
|
|
Encapsulates information regarding the current file being printed: file name, current position, total size and
|
|
time the print started.
|
|
Allows to reset the current file position to 0 and to calculate the current progress as a floating point
|
|
value between 0 and 1.
|
|
"""
|
|
|
|
def __init__(self, filename):
|
|
self._logger = logging.getLogger(__name__)
|
|
self._filename = filename
|
|
self._pos = 0
|
|
self._size = None
|
|
self._start_time = None
|
|
|
|
def getStartTime(self):
|
|
return self._start_time
|
|
|
|
def getFilename(self):
|
|
return self._filename
|
|
|
|
def getFilesize(self):
|
|
return self._size
|
|
|
|
def getFilepos(self):
|
|
return self._pos
|
|
|
|
def getFileLocation(self):
|
|
return FileDestinations.LOCAL
|
|
|
|
def getProgress(self):
|
|
"""
|
|
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._size is None or not self._size > 0:
|
|
return -1
|
|
return float(self._pos) / float(self._size)
|
|
|
|
def reset(self):
|
|
"""
|
|
Resets the current file position to 0.
|
|
"""
|
|
self._pos = 0
|
|
|
|
def start(self):
|
|
"""
|
|
Marks the print job as started and remembers the start time.
|
|
"""
|
|
self._start_time = time.time()
|
|
|
|
def close(self):
|
|
"""
|
|
Closes the print job.
|
|
"""
|
|
pass
|
|
|
|
class PrintingSdFileInformation(PrintingFileInformation):
|
|
"""
|
|
Encapsulates information regarding an ongoing print from SD.
|
|
"""
|
|
|
|
def __init__(self, filename, size):
|
|
PrintingFileInformation.__init__(self, filename)
|
|
self._size = size
|
|
|
|
def setFilepos(self, pos):
|
|
"""
|
|
Sets the current file position.
|
|
"""
|
|
self._pos = pos
|
|
|
|
def getFileLocation(self):
|
|
return FileDestinations.SDCARD
|
|
|
|
class PrintingGcodeFileInformation(PrintingFileInformation):
|
|
"""
|
|
Encapsulates information regarding an ongoing direct print. Takes care of the needed file handle and ensures
|
|
that the file is closed in case of an error.
|
|
"""
|
|
|
|
def __init__(self, filename, offsets_callback=None, current_tool_callback=None):
|
|
PrintingFileInformation.__init__(self, filename)
|
|
|
|
self._handle = None
|
|
self._handle_mutex = threading.RLock()
|
|
|
|
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._size = os.stat(self._filename).st_size
|
|
self._pos = 0
|
|
self._read_lines = 0
|
|
|
|
def seek(self, offset):
|
|
with self._handle_mutex:
|
|
if self._handle is None:
|
|
return
|
|
|
|
self._handle.seek(offset)
|
|
self._pos = self._handle.tell()
|
|
self._read_lines = 0
|
|
|
|
def start(self):
|
|
"""
|
|
Opens the file for reading and determines the file size.
|
|
"""
|
|
PrintingFileInformation.start(self)
|
|
with self._handle_mutex:
|
|
self._handle = bom_aware_open(self._filename, encoding="utf-8", errors="replace")
|
|
self._pos = self._handle.tell()
|
|
if self._handle.encoding.endswith("-sig"):
|
|
# Apparently we found an utf-8 bom in the file.
|
|
# We need to add its length to our pos because it will
|
|
# be stripped transparently and we'll have no chance
|
|
# catching that.
|
|
import codecs
|
|
self._pos += len(codecs.BOM_UTF8)
|
|
self._read_lines = 0
|
|
|
|
def close(self):
|
|
"""
|
|
Closes the file if it's still open.
|
|
"""
|
|
PrintingFileInformation.close(self)
|
|
with self._handle_mutex:
|
|
if self._handle is not None:
|
|
try:
|
|
self._handle.close()
|
|
except:
|
|
pass
|
|
self._handle = None
|
|
|
|
def getNext(self):
|
|
"""
|
|
Retrieves the next line for printing.
|
|
"""
|
|
with self._handle_mutex:
|
|
if self._handle is None:
|
|
raise ValueError("File %s is not open for reading" % self._filename)
|
|
|
|
try:
|
|
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
|
|
|
|
processed = None
|
|
while processed is None:
|
|
if self._handle is None:
|
|
# file got closed just now
|
|
self._pos = self._size
|
|
self._report_stats()
|
|
return None
|
|
|
|
# we need to manually keep track of our pos here since
|
|
# codecs' readline will make our handle's tell not
|
|
# return the actual number of bytes read, but also the
|
|
# already buffered bytes (for detecting the newlines)
|
|
line = self._handle.readline()
|
|
self._pos += len(line.encode("utf-8"))
|
|
|
|
if not line:
|
|
self.close()
|
|
processed = self._process(line, offsets, current_tool)
|
|
self._read_lines += 1
|
|
return processed
|
|
except Exception as e:
|
|
self.close()
|
|
self._logger.exception("Exception while processing line")
|
|
raise e
|
|
|
|
def _process(self, line, offsets, current_tool):
|
|
return process_gcode_line(line, offsets=offsets, current_tool=current_tool)
|
|
|
|
def _report_stats(self):
|
|
duration = time.time() - self._start_time
|
|
self._logger.info("Finished in {:.3f} s.".format(duration))
|
|
pass
|
|
|
|
class StreamingGcodeFileInformation(PrintingGcodeFileInformation):
|
|
def __init__(self, path, localFilename, remoteFilename):
|
|
PrintingGcodeFileInformation.__init__(self, path)
|
|
self._localFilename = localFilename
|
|
self._remoteFilename = remoteFilename
|
|
|
|
def start(self):
|
|
PrintingGcodeFileInformation.start(self)
|
|
self._start_time = time.time()
|
|
|
|
def getLocalFilename(self):
|
|
return self._localFilename
|
|
|
|
def getRemoteFilename(self):
|
|
return self._remoteFilename
|
|
|
|
def _process(self, line, offsets, current_tool):
|
|
return process_gcode_line(line)
|
|
|
|
def _report_stats(self):
|
|
duration = time.time() - self._start_time
|
|
stats = dict(lines=self._read_lines,
|
|
rate=float(self._read_lines) / duration,
|
|
time_per_line=duration * 1000.0 / float(self._read_lines),
|
|
duration=duration)
|
|
self._logger.info("Finished in {duration:.3f} s. Approx. transfer rate of {rate:.3f} lines/s or {time_per_line:.3f} ms per line".format(**stats))
|
|
|
|
def get_new_timeout(type, intervals):
|
|
now = time.time()
|
|
return now + intervals.get(type, 0.0)
|
|
|
|
|
|
_temp_command_regex = re.compile("^M(?P<command>104|109|140|190)(\s+T(?P<tool>\d+)|\s+S(?P<temperature>[-+]?\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
|
|
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
|
|
|
|
def convert_pause_triggers(configured_triggers):
|
|
if not configured_triggers:
|
|
return dict()
|
|
|
|
triggers = {
|
|
"enable": [],
|
|
"disable": [],
|
|
"toggle": []
|
|
}
|
|
for trigger in configured_triggers:
|
|
if not "regex" in trigger or not "type" in trigger:
|
|
continue
|
|
|
|
try:
|
|
regex = trigger["regex"]
|
|
t = trigger["type"]
|
|
if t in triggers:
|
|
# make sure regex is valid
|
|
re.compile(regex)
|
|
# add to type list
|
|
triggers[t].append(regex)
|
|
except:
|
|
# invalid regex or something like this, we'll just skip this entry
|
|
pass
|
|
|
|
result = dict()
|
|
for t in triggers.keys():
|
|
if len(triggers[t]) > 0:
|
|
result[t] = re.compile("|".join(map(lambda pattern: "({pattern})".format(pattern=pattern), triggers[t])))
|
|
return result
|
|
|
|
|
|
def convert_feedback_controls(configured_controls):
|
|
if not configured_controls:
|
|
return dict(), None
|
|
|
|
def preprocess_feedback_control(control, result):
|
|
if "key" in control and "regex" in control and "template" in control:
|
|
# key is always the md5sum of the regex
|
|
key = control["key"]
|
|
|
|
if result[key]["pattern"] is None or result[key]["matcher"] is None:
|
|
# regex has not been registered
|
|
try:
|
|
result[key]["matcher"] = re.compile(control["regex"])
|
|
result[key]["pattern"] = control["regex"]
|
|
except Exception as exc:
|
|
logging.getLogger(__name__).warn("Invalid regex {regex} for custom control: {exc}".format(regex=control["regex"], exc=str(exc)))
|
|
|
|
result[key]["templates"][control["template_key"]] = control["template"]
|
|
|
|
elif "children" in control:
|
|
for c in control["children"]:
|
|
preprocess_feedback_control(c, result)
|
|
|
|
def prepare_result_entry():
|
|
return dict(pattern=None, matcher=None, templates=dict())
|
|
|
|
from collections import defaultdict
|
|
feedback_controls = defaultdict(prepare_result_entry)
|
|
|
|
for control in configured_controls:
|
|
preprocess_feedback_control(control, feedback_controls)
|
|
|
|
feedback_pattern = []
|
|
for match_key, entry in feedback_controls.items():
|
|
if entry["matcher"] is None or entry["pattern"] is None:
|
|
continue
|
|
feedback_pattern.append("(?P<group{key}>{pattern})".format(key=match_key, pattern=entry["pattern"]))
|
|
feedback_matcher = re.compile("|".join(feedback_pattern))
|
|
|
|
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, 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, let's check if Tc is too.
|
|
# If it is, we just throw away T (it's redundant). It
|
|
# it isn't, we first copy T to Tc, then throw T away.
|
|
#
|
|
# The easier construct would be to always overwrite Tc
|
|
# with T and throw away T, but that assumes that if
|
|
# both are present, T has the same value as Tc. That
|
|
# might not necessarily be the case (weird firmware)
|
|
# so we err on the side of caution here and trust Tc
|
|
# over T.
|
|
if current_tool_key not in reported_extruders:
|
|
# T and T0 are present, but Tc is missing - copy
|
|
# T to Tc
|
|
result[current_tool_key] = result["T"]
|
|
# throw away T, it's redundant (now)
|
|
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:<T0> T1:<T1> T2:<T2> ... B:<B>
|
|
#
|
|
# becomes
|
|
#
|
|
# T0:<T0> T1:<T1> T2:<T2> ... B:<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 Tc in the parsed data, current should always stay
|
|
# 0 for single extruder printers. E.g. for current_tool == 1:
|
|
#
|
|
# T:<T1>
|
|
#
|
|
# becomes
|
|
#
|
|
# T1:<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 parse_firmware_line(line):
|
|
"""
|
|
Parses the provided firmware info line.
|
|
|
|
The result will be a dictionary mapping from the contained keys to the contained
|
|
values.
|
|
|
|
Arguments:
|
|
line (str): the line to parse
|
|
|
|
Returns:
|
|
dict: a dictionary with the parsed data
|
|
"""
|
|
|
|
result = dict()
|
|
split_line = regex_firmware_splitter.split(line.strip())[1:] # first entry is empty start of trimmed string
|
|
for key, value in chunks(split_line, 2):
|
|
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:<capability name in caps>:<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.
|
|
|
|
Args:
|
|
line (str): the line to parse
|
|
|
|
Returns:
|
|
int or None: the extracted line number to resend, or None if no number could be extracted
|
|
"""
|
|
|
|
match = regex_resend_linenumber.search(line)
|
|
if match is not None:
|
|
return int(match.group("n"))
|
|
|
|
return None
|
|
|
|
|
|
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"]
|
|
elif settings().getBoolean(["feature", "supportFAsCommand"]) and "commandF" in values and values["commandF"]:
|
|
return values["commandF"]
|
|
else:
|
|
# this should never happen
|
|
return None
|
|
|
|
|
|
def _normalize_command_handler_result(command, command_type, gcode, handler_results):
|
|
"""
|
|
Normalizes a command handler result.
|
|
|
|
Handler results can be either ``None``, a single result entry or a list of result
|
|
entries.
|
|
|
|
``None`` results are ignored, the provided ``command``, ``command_type``
|
|
and ``gcode`` are returned in that case (as single-entry list with one
|
|
3-tuple as entry).
|
|
|
|
Single result entries are either:
|
|
|
|
* a single string defining a replacement ``command``
|
|
* a 1-tuple defining a replacement ``command``
|
|
* a 2-tuple defining a replacement ``command`` and ``command_type``
|
|
|
|
A ``command`` that is ``None`` will lead to the entry being ignored for
|
|
the normalized result.
|
|
|
|
The method returns a list of normalized result entries. Normalized result
|
|
entries always are a 3-tuple consisting of ``command``, ``command_type``
|
|
and ``gcode``, the latter two being allowed to be ``None``. The list may
|
|
be empty in which case the command is to be suppressed.
|
|
|
|
Examples:
|
|
>>> _normalize_command_handler_result("M105", None, "M105", None)
|
|
[('M105', None, 'M105')]
|
|
>>> _normalize_command_handler_result("M105", None, "M105", "M110")
|
|
[('M110', None, 'M110')]
|
|
>>> _normalize_command_handler_result("M105", None, "M105", ["M110"])
|
|
[('M110', None, 'M110')]
|
|
>>> _normalize_command_handler_result("M105", None, "M105", ["M110", "M117 Foobar"])
|
|
[('M110', None, 'M110'), ('M117 Foobar', None, 'M117')]
|
|
>>> _normalize_command_handler_result("M105", None, "M105", [("M110",), "M117 Foobar"])
|
|
[('M110', None, 'M110'), ('M117 Foobar', None, 'M117')]
|
|
>>> _normalize_command_handler_result("M105", None, "M105", [("M110", "lineno_reset"), "M117 Foobar"])
|
|
[('M110', 'lineno_reset', 'M110'), ('M117 Foobar', None, 'M117')]
|
|
>>> _normalize_command_handler_result("M105", None, "M105", [])
|
|
[]
|
|
>>> _normalize_command_handler_result("M105", None, "M105", ["M110", None])
|
|
[('M110', None, 'M110')]
|
|
>>> _normalize_command_handler_result("M105", None, "M105", [("M110",), (None, "ignored")])
|
|
[('M110', None, 'M110')]
|
|
>>> _normalize_command_handler_result("M105", None, "M105", [("M110",), ("M117 Foobar", "display_message"), ("tuple", "of unexpected", "length"), ("M110", "lineno_reset")])
|
|
[('M110', None, 'M110'), ('M117 Foobar', 'display_message', 'M117'), ('M110', 'lineno_reset', 'M110')]
|
|
|
|
Arguments:
|
|
command (str or None): The command for which the handler result was
|
|
generated
|
|
command_type (str or None): The command type for which the handler
|
|
result was generated
|
|
gcode (str or None): The GCODE for which the handler result was
|
|
generated
|
|
handler_results: The handler result(s) to normalized. Can be either
|
|
a single result entry or a list of result entries.
|
|
|
|
Returns:
|
|
(list) - A list of normalized handler result entries, which are
|
|
3-tuples consisting of ``command``, ``command_type`` and
|
|
``gcode``, the latter two of which may be ``None``.
|
|
"""
|
|
|
|
original = (command, command_type, gcode)
|
|
|
|
if handler_results is None:
|
|
# handler didn't return anything, we'll just continue
|
|
return [original]
|
|
|
|
if not isinstance(handler_results, list):
|
|
handler_results = [handler_results,]
|
|
|
|
result = []
|
|
for handler_result in handler_results:
|
|
# we iterate over all handler result entries and process each one
|
|
# individually here
|
|
|
|
if handler_result is None:
|
|
# entry is None, we'll ignore that entry and continue
|
|
continue
|
|
|
|
if isinstance(handler_result, basestring):
|
|
# entry is just a string, replace command with it
|
|
command = handler_result
|
|
gcode = gcode_command_for_cmd(command)
|
|
result.append((command, command_type, gcode))
|
|
|
|
elif isinstance(handler_result, tuple):
|
|
# entry is a tuple, extract command and command_type
|
|
hook_result_length = len(handler_result)
|
|
if hook_result_length == 1:
|
|
# handler returned just the command
|
|
command, = handler_result
|
|
elif hook_result_length == 2:
|
|
# handler returned command and command_type
|
|
command, command_type = handler_result
|
|
else:
|
|
# handler returned a tuple of an unexpected length, ignore
|
|
# and continue
|
|
continue
|
|
|
|
if command is None:
|
|
# command is None, ignore it and continue
|
|
continue
|
|
|
|
gcode = gcode_command_for_cmd(command)
|
|
result.append((command, command_type, gcode))
|
|
|
|
# reset to original
|
|
command, command_type, gcode = original
|
|
|
|
return result
|
|
|
|
|
|
# --- Test code for speed testing the comm layer via command line follows
|
|
|
|
|
|
def upload_cli():
|
|
"""
|
|
Usage: python -m octoprint.util.comm <port> <baudrate> <local path> <remote path>
|
|
|
|
Uploads <local path> to <remote path> on SD card of printer on port <port>, using baudrate <baudrate>.
|
|
"""
|
|
|
|
import sys
|
|
from octoprint.util import Object
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# fetch port, baudrate, filename and target from commandline
|
|
if len(sys.argv) < 5:
|
|
print("Usage: comm.py <port> <baudrate> <local path> <target path>")
|
|
sys.exit(-1)
|
|
|
|
port = sys.argv[1]
|
|
baudrate = sys.argv[2]
|
|
path = sys.argv[3]
|
|
target = sys.argv[4]
|
|
|
|
# init settings & plugin manager
|
|
settings(init=True)
|
|
octoprint.plugin.plugin_manager(init=True)
|
|
|
|
# create dummy callback
|
|
class MyMachineComCallback(MachineComPrintCallback):
|
|
progress_interval = 1
|
|
|
|
def __init__(self, path, target):
|
|
self.finished = threading.Event()
|
|
self.finished.clear()
|
|
|
|
self.comm = None
|
|
self.error = False
|
|
self.started = False
|
|
|
|
self._path = path
|
|
self._target = target
|
|
|
|
def on_comm_file_transfer_started(self, filename, filesize):
|
|
# transfer started, report
|
|
logger.info("Started file transfer of {}, size {}B".format(filename, filesize))
|
|
self.started = True
|
|
|
|
def on_comm_file_transfer_done(self, filename):
|
|
# transfer done, report, print stats and finish
|
|
logger.info("Finished file transfer of {}".format(filename))
|
|
self.finished.set()
|
|
|
|
def on_comm_state_change(self, state):
|
|
if state in (MachineCom.STATE_ERROR, MachineCom.STATE_CLOSED_WITH_ERROR):
|
|
# report and exit on errors
|
|
logger.error("Error/closed with error, exiting.")
|
|
self.error = True
|
|
self.finished.set()
|
|
|
|
elif state in (MachineCom.STATE_OPERATIONAL,) and not self.started:
|
|
# start transfer once we are operational
|
|
self.comm.startFileTransfer(self._path, os.path.basename(self._path), self._target)
|
|
|
|
callback = MyMachineComCallback(path, target)
|
|
|
|
# mock printer profile manager
|
|
profile = dict(heatedBed=False,
|
|
extruder=dict(count=1))
|
|
printer_profile_manager = Object()
|
|
printer_profile_manager.get_current_or_default = lambda: profile
|
|
|
|
# initialize serial
|
|
comm = MachineCom(port=port, baudrate=baudrate, callbackObject=callback, printerProfileManager=printer_profile_manager)
|
|
callback.comm = comm
|
|
|
|
# wait for file transfer to finish
|
|
callback.finished.wait()
|
|
|
|
# close connection
|
|
comm.close()
|
|
|
|
logger.info("Done, exiting...")
|
|
|
|
if __name__ == "__main__":
|
|
upload_cli()
|