diff --git a/tests/gcodeSender.py b/tests/gcodeSender.py index cea75d336..d1c79ae62 100755 --- a/tests/gcodeSender.py +++ b/tests/gcodeSender.py @@ -24,8 +24,6 @@ from pyMarlin import * import argparse import serial -UseNoisySerial = True - def load_gcode(filename): with open(filename, "r") as f: gcode = f.readlines() @@ -33,7 +31,7 @@ def load_gcode(filename): return gcode def send_gcode_test(filename, serial): - gcode = load_gcode("test.gco"); + gcode = load_gcode(filename); for i, line in enumerate(gcode): serial.sendCommand(line) @@ -43,10 +41,11 @@ def send_gcode_test(filename, serial): print("Progress: %d" % (i*100/len(gcode)), end='\r') parser = argparse.ArgumentParser(description='''sends gcode to a printer while injecting errors to test error recovery.''') -parser.add_argument('-f', '--fake', help='Use a fake Marlin simulation instead of serial port, for self-testing.', action='store_false', dest='port') parser.add_argument('-p', '--port', help='Serial port.', default='/dev/ttyACM1') +parser.add_argument('-f', '--fake', help='Use a fake Marlin simulation instead of serial port, for self-testing.', action='store_false', dest='port') parser.add_argument('-e', '--errors', help='Corrupt 1 out N lines to exercise error recovery.', default='0', type=int) parser.add_argument('-l', '--log', help='Write log file.') +parser.add_argument('-b', '--baud', help='Sets the baud rate for the serial port.', default='115000', type=int) parser.add_argument('filename', help='file containing gcode.') args = parser.parse_args() @@ -54,26 +53,23 @@ print() if args.port: print("Serial port: ", args.port) - sio = serial.Serial(args.port, 115000, timeout = 3, writeTimeout = 10000) + print("Baud rate: ", args.baud) + sio = serial.Serial(args.port, args.baud, timeout = 3, writeTimeout = 10000) else: print("Using simulated Marlin device.") sio = FakeMarlinSerialDevice() if args.log: print("Writing log file: ", args.log) - chatty = LoggingSerialConnection(sio, args.log) -else: - chatty = sio + sio = LoggingSerialConnection(sio, args.log) if args.errors: print("1 out of %d lines will be corrupted." % args.errors) - noisy = NoisySerialConnection(chatty) - noisy.setErrorRate(1, args.errors) -else: - noisy = chatty + sio = NoisySerialConnection(sio) + sio.setErrorRate(1, args.errors) print() -proto = MarlinSerialProtocol(noisy) +proto = MarlinSerialProtocol(sio) send_gcode_test(args.filename, proto) proto.close() \ No newline at end of file diff --git a/tests/pyMarlin/marlinSerialProtocol.py b/tests/pyMarlin/marlinSerialProtocol.py index 9e3363c78..05d271919 100644 --- a/tests/pyMarlin/marlinSerialProtocol.py +++ b/tests/pyMarlin/marlinSerialProtocol.py @@ -27,6 +27,9 @@ # 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): @@ -36,6 +39,7 @@ # import functools +import re class GCodeHistory: """This class implements a history of GCode commands. Right now, we @@ -71,6 +75,9 @@ class GCodeHistory: 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 @@ -80,6 +87,7 @@ class MarlinSerialProtocol: self.marlinBufSize = 4 self.marlinReserve = 1 self.pendingOk = 0 + self.gotError = False self.history = GCodeHistory() self.restart() @@ -99,6 +107,11 @@ class MarlinSerialProtocol: data = "N%d%s" % (position, cmd) return "N%d%s*%d" % (position, cmd, self._computeChecksum(data)) + def _sendImmediate(self, cmd): + self.serial.write(cmd + '\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 @@ -106,11 +119,9 @@ class MarlinSerialProtocol: further back in the history than that""" while(not self.history.atEnd() and self.marlinBufferCapacity() > 0): pos, cmd = self.history.getNextCommand(); - self.serial.write(cmd + '\n') - self.serial.flush() - self.pendingOk += 1 + self._sendImmediate(cmd) - def _isResend(self, line): + 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 "resend" in line.lower() or "rs" in line: @@ -120,17 +131,26 @@ class MarlinSerialProtocol: if "rs" in line: return int(line.split()[1]) + 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("Error:No Line Number with checksum"): + m = re.search("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 _resendFrom(self, position): """If Marlin requests a resend, we need to backtrack.""" self.history.rewindTo(position) self.pendingOk = 1 - - # When receive a resend request, we must temporarily - # switch to sending commands one at a time, otherwise - # buffering could lead to additional errors, which - # cause Marlin to send more resend requests and - # to us never recovering. - self.resyncCountdown = self.marlinBufSize + self._enterResyncPeriod(); def _flushReadBuffer(self): while self.serial.readline() != b"": @@ -145,24 +165,70 @@ class MarlinSerialProtocol: self.history.append(cmd) self._sendToMarlin() + def _gotOkay(self): + if self.pendingOk > 0: + self.pendingOk -= 1 + + def _enterResyncPeriod(self): + """We define two operational modes. In the normal mode, we send as many + commands as necessary to keep the Marlin queue filled and wait for + an acknowledgement prior to issuing each subsequent command. In the + resync mode, we send only one command at a time, unless there is a + timeout waiting for a response, in which case we proceed to the next + command. The resync mode is engaged at the start of a print or after + an error causing a resend, as at those times the normal mode could + lead to an unrecoverable failure loop or block indefinitely.""" + self.resyncCountdown = self.marlinBufSize + + def _resyncCountdown(self): + """Decrement the resync countdown so we can eventually return to + normal mode""" + if self.resyncCountdown > 0: + self.resyncCountdown -= 1 + + def _inResync(self): + return self.resyncCountdown > 0 + def readLine(self): """This reads data from Marlin. If no data is available '' will be returned after the comm timeout. This *must* be called periodically to keep outgoing data moving from the buffers.""" self._sendToMarlin() line = self.serial.readline() - if self._isResend(line): - # When a resend is requested, handle it. - # Return blank to caller. - position = self._isResend(line) - self._resendFrom(position) + + # An okay means Marlin acknowledged a command. For us, + # this means a slot has been freed in the Marlin buffer. + if line.startswith(b"ok"): + self._gotOkay() + self._resyncCountdown() + + # During the resync period, we treat timeouts as "ok". + # This keeps us from stalling if an "ok" is lost. + if self._inResync() and line == b"": + self._resyncCountdown() + + # 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("Error:"): + self.gotError = True; + elif line == b"" and self.gotError: + self.gotError = False + self._gotOkay() + + # Handle retry related messages. There's no specific + # consistency on how these are indicated, alas. + resendPos = self._isResendRequest(line) or self._isNoLineNumberErr(line) + if resendPos: + 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); line = b"" - elif line.startswith(b"ok"): - self.pendingOk -= 1 - # Decrement the resync countdown so we - # can eventually return to buffered mode - if self.resyncCountdown > 0: - self.resyncCountdown -= 1 return line @@ -172,20 +238,20 @@ class MarlinSerialProtocol: 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""" - if self.resyncCountdown > 0: - # If we are have recently received a resend request, go - # into unbuffered line-by-line mode temporarily. + the non-reserved buffer spots and the number of not yet acknowleged commands. In + resync mode, this always returns 0 if a command has not yet been acknowledged, 1 + otherwise.""" + if self._inResync(): return 0 if self.pendingOk else 1 else: 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. _flushReadBuffer() - self.resyncCountdown = self.marlinBufSize self.history.clear() - self.sendCommand(b"M110") + self._flushReadBuffer() + self._enterResyncPeriod() + self._resetMarlinLineCounter() def close(self): self.serial.close() \ No newline at end of file