You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

279 lines
10 KiB

#
# (c) 2017 Aleph Objects, Inc.
#
# The code in this page is free software: you can
# redistribute it and/or modify it under the terms of the GNU
# General Public License (GNU GPL) as published by the Free Software
# Foundation, either version 3 of the License, or (at your option)
# any later version. The code is distributed WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
#
# Marlin implements an error correcting scheme on the serial connections.
# GCode commands are sent with a line number and a checksum. If Marlin
# detects an error, it requests that the transmission resume from the
# last known good line number.
#
# In addition to this resend mechanism, Marlin also implements flow
# control. Sent commands are acknowleged by "ok" when complete.
# However, to optimize path planning, Marlin contains a command
# buffer and slicing software should send enough commands to keep
# that buffer filled (minus a reserve capacity for emergency commands
# such as an emergency STOP). It is thus necessary to send a certain
# number prior the earlier ones being acknowleged and to track how
# many commands have been sent but not yet acknowleged.
#
# The class MarlinSerialProtocol implements the error correction
# and also manages acknowlegements and flow control.
#
# Note: Marlin does not implement a well defined protocol, so a
# lot of this implementation is guesswork.
#
# The prototypical use case for this class is as follows:
#
# for line in enumerate(gcode):
# serial.sendCmdLine(line)
# while(not serial.clearToSend()):
# serial.readline()
#
import functools
import re
class GCodeHistory:
"""This class implements a history of GCode commands. Right now, we
keep the entire history, but this probably could be reduced to a
smaller range. This class keeps a pointer to the position of
the first unsent command, which typically is the one just recently
appended, but after a resend request from Marlin that position can
rewound to further back."""
def __init__(self):
self.clear()
def clear(self):
self.list = [None] # Pad so the first element is at index 1
self.pos = 1
def append(self, cmd):
self.list.append(cmd)
def rewindTo(self, position):
self.pos = max(1,min(position, len(self.list)))
def getAppendPosition(self):
"""Returns the position at which the next append will happen"""
return len(self.list)
def getNextCommand(self):
"""Returns the next unsent command."""
if(not self.atEnd()):
res = self.pos, self.list[self.pos]
self.pos += 1;
return res
def atEnd(self):
return self.pos == len(self.list)
def position(self):
return self.pos
class MarlinSerialProtocol:
"""This class implements the Marlin serial protocol, such
as adding a checksum to each line, replying to resend
requests and keeping the Marlin buffer full"""
def __init__(self, serial, onResendCallback=None):
self.serial = serial
self.marlinBufSize = 4
self.marlinReserve = 1
self.history = GCodeHistory()
self.asap = []
self.slow_commands = re.compile(b"M109|M190|G28|G29")
self.slow_timeout = 400
self.fast_timeout = 15
self.onResendCallback = onResendCallback
self.restart()
def _stripCommentsAndWhitespace(self, str):
return str.split(b';', 1)[0].strip()
def _replaceEmptyLineWithM105(self, str):
"""Marlin will not accept empty lines, so replace blanks with M115 (Get Extruder Temperature)"""
return b"M105" if str == b"" else str
def _computeChecksum(self, data):
"""Computes the GCODE checksum, this is the XOR of all characters in the payload, including the position"""
return functools.reduce(lambda x,y: x^y, map(ord, data) if isinstance(data, str) else list(data))
def _addPositionAndChecksum(self, position, cmd):
"""An GCODE with line number and checksum consists of N{position}{cmd}*{checksum}"""
data = b"N%d%s" % (position, cmd)
return b"N%d%s*%d" % (position, cmd, self._computeChecksum(data))
def _sendImmediate(self, cmd):
self.serial.write(cmd + b'\n')
self.serial.flush()
self.pendingOk += 1
def _sendToMarlin(self):
"""Sends as many commands as are available and to fill the Marlin buffer.
Commands are first read from the asap queue, then read from the
history. Generally only the most recently history command is sent;
but after a resend request, we may be further back in the history
than that"""
while(len(self.asap) and self.marlinBufferCapacity() > 0):
cmd = self.asap.pop(0);
self._sendImmediate(cmd)
while(not self.history.atEnd() and self.marlinBufferCapacity() > 0):
pos, cmd = self.history.getNextCommand();
self._sendImmediate(cmd)
def _isResendRequest(self, line):
"""If the line is a resend command from Marlin, returns the line number. This
code was taken from Cura 1, I do not know why it is so convoluted."""
if b"resend" in line.lower() or b"rs" in line:
try:
return int(line.replace(b"N:",b" ").replace(b"N",b" ").replace(b":",b" ").split()[-1])
except:
if b"rs" in line:
try:
return int(line.split()[1])
except:
return None
def _isNoLineNumberErr(self, line):
"""If Marlin encounters a checksum without a line number, it will request
a resend. This often happens at the start of a print, when a command is
sent prior to Marlin being ready to listen."""
if line.startswith(b"Error:No Line Number with checksum"):
m = re.search(b"Last Line: (\d+)", line);
if(m):
return int(m.group(1)) + 1
def _resetMarlinLineCounter(self):
"""Sends a command requesting that Marlin reset its line counter to match
our own position"""
cmd = self._addPositionAndChecksum(self.history.position()-1, b"M110")
self._sendImmediate(cmd)
def _stallWatchdog(self, line):
"""Watches for a stall in the print. This can happen if a number of
okays are lost in transmission. To recover, we send Marlin an invalid
command (no line number, with an asterisk). One it requests a resend,
we will back into a known good state."""
if line == b"":
if self.stallCountdown > 0:
self.stallCountdown -= 1
else:
self.stallCountdown = 2
self._sendImmediate(b"\nM105*\n")
else:
estimated_duration = self.slow_timeout if self.slow_commands.search(line) else self.fast_timeout
self.stallCountdown = max(estimated_duration, self.stallCountdown-1)
def _resendFrom(self, position):
"""If Marlin requests a resend, we need to backtrack."""
self.history.rewindTo(position)
self.pendingOk = 0
self.consecutiveOk = 0
if self.onResendCallback:
self.onResendCallback(position)
def _flushReadBuffer(self):
while self.serial.readline() != b"":
pass
def sendCmdReliable(self, line):
"""Adds command line (can contain comments or blanks) to the queue for reliable
transmission. Queued commands will be processed during calls to readLine() or
clearToSend()"""
if isinstance(line, str):
line = line.encode()
line = self._stripCommentsAndWhitespace(line)
cmd = self._replaceEmptyLineWithM105(line)
cmd = self._addPositionAndChecksum(self.history.getAppendPosition(), cmd)
self.history.append(cmd)
def sendCmdUnreliable(self, line):
"""Sends a command (can contain comments or blanks) prior to any other
history commands. Commands will be processed during calls to
readLine() or clearToSend()"""
if isinstance(line, str):
line = line.encode()
cmd = self._stripCommentsAndWhitespace(line)
if cmd:
self.asap.append(cmd)
def _gotOkay(self):
if self.pendingOk > 0:
self.pendingOk -= 1
def readline(self):
"""This reads data from Marlin. If no data is available '' will be returned after
the comm timeout."""
self._sendToMarlin()
line = self.serial.readline()
# An okay means Marlin acknowledged a command. This means
# a slot has been freed in the Marlin buffer for a new
# command.
if line.startswith(b"ok"):
self._gotOkay()
# Watch for and attempt to recover from complete stalls.
self._stallWatchdog(line)
# Sometimes Marlin replies with an "Error:", but not an "ok".
# So if we got an error, followed by a timeout, stop waiting
# for an "ok" as it probably ain't coming
if line.startswith(b"ok"):
self.gotError = False
elif line.startswith(b"Error:"):
self.gotError = True;
elif line == b"" and self.gotError:
self.gotError = False
self._gotOkay()
# Handle resend requests from Marlin. This happens when Marlin
# detects a command with a checksum or line number error.
resendPos = self._isResendRequest(line) or self._isNoLineNumberErr(line)
if resendPos:
# If we got a resend requests, purge lines until input buffer is empty
# or timeout, but watch for any subsequent resend requests (we must
# only act on the last).
while self.serial.in_waiting and line != b"":
line = self.serial.readline()
resendPos = self._isResendRequest(line) or self._isNoLineNumberErr(line) or resendPos
# Process the last received resend request:
if resendPos > self.history.position():
# If Marlin is asking us to step forward in time, reset its counter.
self._resetMarlinLineCounter()
else:
# Otherwise rewind to where Marlin wants us to resend from.
self._resendFrom(resendPos)
# Report a timeout to the calling code.
line = b""
return line
def clearToSend(self):
self._sendToMarlin()
return self.marlinBufferCapacity() > 0
def marlinBufferCapacity(self):
"""Returns how many buffer positions are open in Marlin. This is the difference between
the non-reserved buffer spots and the number of not yet acknowleged commands."""
return (self.marlinBufSize - self.marlinReserve) - self.pendingOk
def restart(self):
"""Clears all buffers and issues a M110 to Marlin. Call this at the start of every print."""
self.history.clear()
self.pendingOk = 0
self.stallCountdown = self.fast_timeout
self.gotError = False
self._flushReadBuffer()
self._resetMarlinLineCounter()
def close(self):
self.serial.close()