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.

256 lines
9.4 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.sendCommand(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):
self.serial = serial
self.marlinBufSize = 4
self.marlinReserve = 1
self.history = GCodeHistory()
self.slow_commands = re.compile("M109|M190|G28|G29")
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 read from the history. Generally only the most recently
appended command is sent; but after a resend request, we may be
further back in the history than that"""
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 a command
with an invalid checksum. 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 = 30
self._sendImmediate("\nN0M105*0\n")
else:
estimated_duration = 15 if self.slow_commands.search(line) else 2
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
def _flushReadBuffer(self):
while self.serial.readline() != b"":
pass
def enqueueCommand(self, cmd):
"""Adds a command to the sending queue. Queued commands will only be processed during calls to readLine()"""
if isinstance(cmd, str):
cmd = cmd.encode()
cmd = self._stripCommentsAndWhitespace(cmd)
cmd = self._replaceEmptyLineWithM105(cmd)
cmd = self._addPositionAndChecksum(self.history.getAppendPosition(), cmd)
self.history.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 timeout, but watch
# for any subsequent resend requests (we must only act on the last).
while 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 = 30
self.gotError = False
self._flushReadBuffer()
self._resetMarlinLineCounter()
def close(self):
self.serial.close()