A Python interface to a large-format pen plotter

Code Walkthrough

Lines 1-5 of the program (Listing 1) handle imports [5]. import is Python's command to bring in external libraries. Many libraries are included with Python, but they can also be purchased, written, or downloaded.

Listing 1

Imports

001 import pygame
002 import serial
003 import curses
004 import os
005 import time
006

Line 1 imports the pygame library, which is a low-level graphics library that also includes functions for sound playback, user input from a variety of devices, and other modules designed for game creation. The plotter program uses Pygame to draw previews of HPGL files before plotting them.

The serial library imported on line 2 provides the functions to talk to serial ports. On a Raspberry Pi, this is GPIO pin 14 (transmit) and GPIO pin 15 (receive) or a USB serial dongle. The GPIO port only operates at 3.3V, which is great for electronics projects but difficult for traditional serial equipment. Thus, I use a serial dongle to connect to the plotter.

Line 3 imports the curses library, which provides functions to create a text interface in a terminal or console. This library is also capable of creating interfaces on traditional serial terminals, but this capability is unrelated to the serial communication in use in this program.

The os library (line 4) provides functions relating to the current operating system running the Python interpreter. My program uses the OS library to compile file paths with Linux-appropriate directory separators and to poll the filesystem for directory listings. The time library (line 5) provides delay and timing functions used by the nullPlotter class to simulate a physical plotter.

The plotMenu Class

The plotMenu class (Listing 2; lines 7-222) is the main class of the program and provides the user interface. plotMenu also initiates all of the other classes as needed. In a Python class, __init__ is run each time a class is initialized. Here, I use it to initialize the curses display and general variables for the program.

Listing 2

plotMenu Class

007 class plotMenu:
008    def __init__ ( self ):
009       self.screen = curses.initscr()
010       curses.noecho()
011       curses.cbreak()
012       self.screen.keypad ( True )
013       screenDimensions = self.screen.getmaxyx()
014       self.maxX = screenDimensions [ 1 ]
015       self.maxY = screenDimensions [ 0 ]
016
017       self.hpglFiles = list()
018       self.fileView = None
019       self.fileOffset = 0
020       self.paper = None
021
022    def menu ( self ):
023       while 1:
024          self.screen.clear()
025          self.screen.addstr ( 1 , 1 , "Files Menu" )
026          self.screen.addstr ( 3 , 1 , "1) Open a File" )
027          self.screen.addstr ( 4 , 1 , "2) Shift to center" )
028          self.screen.addstr ( 5 , 1 , "3) Flip Horizontal" )
029          self.screen.addstr ( 6 , 1 , "4) Flip Vertical" )
030          self.screen.addstr ( 7 , 1 , "5) Preview file" )
031
032          self.screen.addstr ( 1 , 20 , "Plotter Menu" )
033          self.screen.addstr ( 3 , 20 , "C) connect to plotter" )
034          self.screen.addstr ( 4 , 20 , "P) plot current fileset" )
035
036          self.screen.addstr ( 1 , 50 , "Paper Information" )
037          if self.paper != None:
038             self.screen.addstr ( 3 , 50 , "    X           Y   " )
039             self.screen.addstr ( 4 , 50 , "Min  {0:8d}     {1:8d}".format \
                                    ( self.paper [ 0 ] , self.paper [ 1 ] ) )
040             self.screen.addstr ( 5 , 50 , "Max  {0:8d}     {1:8d}".format \
                                    ( self.paper [ 2 ] , self.paper [ 3 ] ) )
041          else:
042             self.screen.addstr ( 3 , 50 , "(Plotter Not Connected)" )
043
044          if self.maxY % 2 == 0: y = self.maxY / 2
045          else: y = ( self.maxY - 1 ) / 2
046
047          if self.fileView != None:
048             for i in range ( self.fileOffset , self.fileOffset + \
                                 self.maxY - 3 ):
049                self.screen.addstr ( i - self.fileOffset + 3 , 90 , \
                                       "{0:4d}: {1:s}".format ( i , \
                                       self.hpglFiles [ self.fileView ].\
                                       commands [ i ] ) )
050
051          self.screen.addstr ( y , 1 , "Filename" )
052          self.screen.addstr ( y , 20 , "Commands" )
053          self.screen.addstr ( y , 30 , "Pen" )
054          self.screen.addstr ( y , 50 , "Min X" )
055          self.screen.addstr ( y , 60 , "Min Y" )
056          self.screen.addstr ( y , 70 , "Max X" )
057          self.screen.addstr ( y , 80 , "Max Y" )
058
059          y += 1
060          for index , hpgl in enumerate ( self.hpglFiles ):
061             if index == self.fileView: self.screen.addstr ( y , 0 , ">" )
062             else: self.screen.addstr ( y , 0 , " " )
063
064             self.screen.addstr ( y , 1 , os.path.basename \
                                    ( hpgl.filename ) )
065             self.screen.addstr ( y , 20 , str ( hpgl.commandLength() ) )
066             self.screen.addstr ( y , 30 , str ( hpgl.pen ) )
067             self.screen.addstr ( y , 50 , str ( hpgl.minX ) )
068             self.screen.addstr ( y , 60 , str ( hpgl.minY ) )
069             self.screen.addstr ( y , 70 , str ( hpgl.maxX ) )
070             self.screen.addstr ( y , 80 , str ( hpgl.maxY ) )
071             y += 1
072
073          self.screen.refresh()
074
075          key = self.screen.getch()
076          if key == ord ( "1" ):
077             selection = self.fileBrowser()
078             if selection != None:
079                newFile = HPGL()
080                newFile.openFile ( selection )
081                self.hpglFiles.append ( newFile )
082                self.fileView = len ( self.hpglFiles ) - 1
083          elif key == ord ( "2" ):
084             x , y = self.hpglFiles [ self.fileView ].shiftToCenter()
085             self.screen.addstr ( 35 , 5 , str ( x ) + "," + str ( y ) )
086          elif key == ord ( "3" ):
087             self.screen.addstr ( self.maxY - 1 , 1 , "Working..." )
088             self.screen.refresh()
089             self.hpglFiles [ self.fileView ].flipHorizontal()
090             self.screen.addstr ( self.maxY - 1 , 1 , "          " )
091             self.screen.refresh()
092          elif key == ord ( "4" ):
093             self.screen.addstr ( self.maxY - 1 , 1 , "Working..." )
094             self.screen.refresh()
095             self.hpglFiles [ self.fileView ].flipVertical()
096             self.screen.addstr ( self.maxY - 1 , 1 , "          " )
097             self.screen.refresh()
098          elif key == ord ( "5" ):
099             self.preview()
100          elif key == curses.KEY_PPAGE:
101             self.fileOffset -= self.maxY - 3
102          elif key == curses.KEY_NPAGE:
103             self.fileOffset += self.maxY - 3
104          elif key == curses.KEY_DOWN:
105             self.fileView += 1
106             if self.fileView > len ( self.hpglFiles ) - \
                 1:self.fileView = len ( self.hpglFiles ) - 1
107             self.fileOffset = 0
108          elif key == curses.KEY_UP:
109             self.fileView -= 1
110             if self.fileView < 0: self.fileView = 0
111             self.fileOffset = 0
112          elif key == ord ( "c" ):
113             #self.draftPro = plotter ( "/dev/ttyUSB0" )
114             self.draftPro = nullPlotter ( "" )
115             self.paper = self.draftPro.getPaper()
116          elif key == ord ( "p" ):
117             self.plot()
118
119
120    def fileBrowser ( self ):
121       path = "../plotter"
122       y = 1
123
124       selectedX = 1
125       selectedY = 1
126
127       while 1:
128          screenDirectories = dict()
129          screenFiles = dict()
130
131          dirOffset = 0
132          fileOffset = 0
133
134          self.screen.clear()
135
136          for ( dirpath , dirnames , filenames ) in os.walk ( path ):
137             y = 1
138             x = 0
139             for directory in dirnames [ dirOffset:(dirOffset + self.max\
                                            Y - 3 ) ]:
140                if ( selectedX * 30 ) == x and selectedY == y: \
                        self.screen.addstr ( y , 1 , directory , \
                        curses.A_REVERSE )
141                else: self.screen.addstr ( y , 1 , directory )
142                screenDirectories [ ( y , x ) ] = directory
143                y += 1
144
145             y = 1
146             x = 30
147
148             for filename in filenames [ fileOffset:\
                                           (fileOffset + self.maxY - 3 ) ]:
149                if "hpgl" not in filename: continue
150                if ( selectedX * 30 ) == x and selectedY == y: \
                    self.screen.addstr ( y , x , filename , curses.A_REVERSE )
151                else: self.screen.addstr ( y , x , filename )
152                y += 1
153
154                screenFiles [ ( y , x ) ] = os.path.join ( dirpath , filename )
155                if y == self.maxY - 5:
156                   y = 1
157                   x += 30
158
159             self.screen.refresh()
160             break
161
162          key = self.screen.getch()
163          if key == 13 or key == 10:
164             if ( selectedY + 1 , selectedX * 30 ) in screenFiles:
165                return screenFiles [ ( selectedY + 1 , selectedX * 30 ) ]
166             elif ( selectedY , selectedX * 30 ) in screenDirectories:
167                path += "/" + screenDirectories [ ( selectedY , selectedX * 30 ) ]
168                selectedX = 1
169                selectedY = 1
170          elif key == curses.KEY_RIGHT:
171             selectedX += 1
172          elif key == curses.KEY_LEFT:
173             selectedX -= 1
174          elif key == curses.KEY_DOWN:
175             selectedY += 1
176          elif key == curses.KEY_UP:
177             selectedY -= 1
178          elif key == ord ( "u" ):
179             path += "/.."
180             selectedX = 1
181             selectedY = 1
182          self.screen.addstr ( self.maxY - 1 , 1 , str ( selectedX ) + "," \
                                 + str ( selectedY ) )
183          self.screen.refresh()
184
185    def preview ( self ):
186       pygame.display.init()
187       screen = pygame.display.set_mode ( ( 1680 , 1050 ) )
188       self.hpglFiles [ self.fileView ].visual ( screen )
189       pygame.display.flip()
190
191    def plot ( self ):
192       for fileIndex , hpgl in enumerate ( self.hpglFiles ):
193          for index , cmd in enumerate ( hpgl.cmdPoints ):
194             if cmd.cmd == "PU" or cmd.cmd == "PD":
195                self.draftPro.port.write ( cmd.cmd + str ( cmd.x ) + "," + \
                                             str ( cmd.y ) + ";" )
196             else:
197                self.draftPro.port.write ( cmd.cmd + ";" )
198
199             for i in range ( index , index + self.maxY - 3 ):
200                if i < len ( self.hpglFiles [ fileIndex ].commands ):
201                   if i == index:
202                      self.screen.addstr ( i - index + 3 , 90 , "{0:4d}: \
                                             {1:s}".format ( i , self.\
                                             hpglFiles [ fileIndex ].commands \
                                             [ i ] ) , curses.A_REVERSE )
203                   else:
204                      self.screen.addstr ( i - index + 3 , 90 , "{0:4d}: \
                                             {1:s}".format ( i , self.\
                                             hpglFiles [ fileIndex ].commands \
                                             [ i ] ) )
205
206
207             buffer = self.draftPro.getBuffer()
208             percent = float ( buffer ) / 1024.0
209             chars = int ( ( self.maxX - 3 ) * percent )
210             self.screen.addstr ( self.maxY - 1 , 0 , "#" * chars )
211             self.screen.clrtoeol()
212             self.screen.refresh()
213
214             while buffer < 64:
215                buffer = self.draftPro.getBuffer()
216                percent = float ( buffer ) / 1024
217                chars = int ( ( self.maxX - 3 ) * percent )
218                self.screen.addstr ( self.maxY - 1 , 0 , "#" * chars )
219                self.screen.clrtoeol()
220                self.screen.refresh()
221
222                time.sleep ( .5 )
223

Line 9 shows how to start a curses program: curses.initiscr() creates the curses display, which I store in self.screen. Because I use self to store it, the screen will be accessible to all functions inside the plotMenu class.

Lines 10-12 set curses modes and configuration options. curses.noecho() instructs curses only to display characters when they are explicitly drawn. Keypresses used to navigate a menu or make a selection will not be drawn to the screen. curses.cbreak disables line buffering from the console, allowing curses to receive each keystroke as it occurs. The special characters interrupt, quit, suspend, and flow control are not caught by curses in this mode and retain their normal function.

The self.screen.keypad (True) command asks curses to interpret "special" (non-ASCII) keystrokes.

The menu function – lines 22-88 – is essentially the main loop of the program. It takes care of drawing the display and handling user input. The entire function is an infinite loop (line 23) that is called as the entry point to the class.

Line 24 uses the curses function clear to clear the screen, and lines 25-36 draw the files and plotter menus to the screen. Line 37 checks whether self.paper has any information about the paper currently loaded in the plotter. If so, lines 38-40 draw the paper dimensions to the screen. (Note the use of the inline string function format to align the variables in the string.) Otherwise, line 42 states that there is no information about the paper yet.

Starting at line 44, the program prepares to draw the list of files currently loaded and determines the coordinates of a place halfway down the screen.

The self.fileView on line 47 is the index of the currently selected HPGL file. If it is set, then lines 48 and 49 draw the number of lines that will fit on the screen, with paging tracked by self.fileOffset. Lines 51-57 draw the labels for the file list. Line 59 increments the y (screen row) counter.

Line 60 sets up a loop for all of the currently loaded HPGL files. Finally, lines 64-70 draw the line specified in the file table, and line 71 increments y for the next round of the loop.

Now that all of the drawing is complete, line 73 forces a refresh of the screen. Curses caches all of the screen data in internal structures and does all of the updates at once.

Line 75 uses self.screen.getch() to get a keypress. This is blocking by default but that behavior can be easily changed. Curses supports full blocking, no blocking (getch() returns -1 immediately if no key is currently pressed), or "delayed" mode, wherein a getch() times out after a specified interval and returns -1 or returns immediately with the value of a pressed key. The possible keypress options refer to integers (1, 2, 3, 4, 5) that correspond to specific actions, such as opening a file, shifting the current file to the center of the screen, flipping the current image, or drawing a preview plot on the screen.

Buy this article as PDF

Express-Checkout as PDF
Price $2.95
(incl. VAT)

Buy Raspberry Pi Geek

SINGLE ISSUES
 
SUBSCRIPTIONS
 
TABLET & SMARTPHONE APPS
Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content