├── arduino_plot_screenshot.PNG ├── pylama.ini ├── requirements.txt ├── README.md ├── .gitignore ├── Arduino_Monitor.py └── wx_mpl_dynamic_graph.py /arduino_plot_screenshot.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregpinero/ArduinoPlot/HEAD/arduino_plot_screenshot.PNG -------------------------------------------------------------------------------- /pylama.ini: -------------------------------------------------------------------------------- 1 | [pylama:pyflakes] 2 | builtins = _ 3 | 4 | [pylama:pep8] 5 | max_line_length = 80 6 | 7 | [pylama:pylint] 8 | max_line_length = 80 9 | disable = R -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | backports.functools-lru-cache==1.5 2 | configparser==3.5.0 3 | cycler==0.10.0 4 | matplotlib==2.1.2 5 | mccabe==0.6.1 6 | numpy==1.14.1 7 | pep8==1.7.1 8 | pycodestyle==2.3.1 9 | pydocstyle==2.1.1 10 | pyflakes==1.6.0 11 | pylama==7.4.3 12 | pyparsing==2.2.0 13 | pyserial==3.4 14 | python-dateutil==2.6.1 15 | pytz==2018.3 16 | six==1.11.0 17 | snowballstemmer==1.2.1 18 | subprocess32==3.2.7 19 | wxPython==4.0.1 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arduino Plot 2 | 3 | Python script to plot a numeric data received from a serial port in real time. 4 | 5 | ![Arduino Monitor example screen](arduino_plot_screenshot.PNG) 6 | 7 | ## How to run 8 | 9 | To use, simply run command below in the command line providing serial port to be used and optionally port baud rate and timeout values. 10 | 11 | ````bash 12 | $ ./wx_mpl_dynamic_graph.py [-h] [-b BAUDRATE] [-t TIMEOUT] port 13 | ```` 14 | 15 | For instance, to receive data from `com4` port with baud rate equal to 9600 run 16 | 17 | ````bash 18 | $ ./wx_mpl_dynamic_graph.py com4 --baudrate 9600 19 | ```` 20 | 21 | To display help menu run the command below. 22 | ````bash 23 | $ ./wx_mpl_dynamic_graph.py --help 24 | ```` 25 | 26 | **Note:** Make sure you have your Arduino IDE closed, or it will block other programs like this one from using the serial port. 27 | 28 | ## Requirements 29 | 30 | Install required [`wxPython Project Phoenix`](https://github.com/wxWidgets/Phoenix) system dependencies and than Python packages from `requirements.txt` file: 31 | 32 | ````bash 33 | $ pip install -r requirements.txt 34 | ```` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | .static_storage/ 57 | .media/ 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | -------------------------------------------------------------------------------- /Arduino_Monitor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Listen to serial, return most recent numeric values 3 | Lots of help from here: 4 | http://stackoverflow.com/questions/1093598/pyserial-how-to-read-last-line-sent-from-serial-device 5 | """ 6 | from threading import Thread 7 | import time 8 | import serial 9 | 10 | last_received = '' 11 | 12 | 13 | def receiving(serial_port): 14 | global last_received 15 | buffer = '' 16 | while True: 17 | buffer += serial_port.read_all() 18 | if '\n' in buffer: 19 | lines = buffer.split('\n') # Guaranteed to have at least 2 entries 20 | last_received = lines[-2] 21 | # If the Arduino sends lots of empty lines, you'll lose the last 22 | # filled line, so you could make the above statement conditional 23 | # like so: if lines[-2]: last_received = lines[-2] 24 | buffer = lines[-1] 25 | 26 | 27 | class SerialData(object): 28 | 29 | def __init__(self, **kwargs): 30 | try: 31 | self.serial_port = serial.Serial(**kwargs) 32 | except serial.serialutil.SerialException: 33 | # no serial connection 34 | self.serial_port = None 35 | else: 36 | Thread(target=receiving, args=(self.serial_port,)).start() 37 | 38 | def next(self): 39 | if self.serial_port is None: 40 | # return anything so we can test when Arduino isn't connected 41 | return 100 42 | # return a float value or try a few times until we get one 43 | for i in range(40): 44 | raw_line = last_received 45 | try: 46 | return float(raw_line.strip()) 47 | except ValueError: 48 | print 'bogus data', raw_line 49 | time.sleep(.005) 50 | return 0. 51 | 52 | def __del__(self): 53 | if self.serial_port is not None: 54 | self.serial_port.close() 55 | 56 | 57 | if __name__ == '__main__': 58 | s = SerialData('com4') 59 | for i in range(500): 60 | time.sleep(.015) 61 | print s.next() 62 | -------------------------------------------------------------------------------- /wx_mpl_dynamic_graph.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | This demo demonstrates how to draw a dynamic matplotlib plot in a wxPython 5 | application. 6 | 7 | It allows "live" plotting as well as manual zooming to specific regions. 8 | 9 | Both X and Y axes allow "auto" or "manual" settings. For Y, auto mode sets the 10 | scaling of the graph to see all the data points. For X, auto mode makes the 11 | graph "follow" the data. Set it X min to manual 0 to always see the whole data 12 | from the beginning. 13 | 14 | Note: press Enter in the 'manual' text box to make a new value affect the plot. 15 | """ 16 | 17 | import argparse 18 | import os 19 | import wx 20 | import numpy as np 21 | 22 | import matplotlib 23 | from matplotlib.figure import Figure 24 | 25 | from Arduino_Monitor import SerialData 26 | 27 | # The recommended way to use wx with mpl is with the WXAgg backend. 28 | matplotlib.use('WXAgg') 29 | 30 | # Those import have to be after setting matplotlib backend. 31 | from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigCanvas # noqa 32 | import matplotlib.pyplot as plt # noqa 33 | 34 | 35 | REFRESH_INTERVAL_MS = 90 36 | DPI = 100 37 | 38 | 39 | class BoundControlBox(wx.Panel): 40 | """ 41 | A static box with a couple of radio buttons and a text box. Allows to 42 | switch between an automatic mode and a manual mode with an associated value. 43 | """ 44 | def __init__(self, parent, label, initial_value): 45 | wx.Panel.__init__(self, parent) 46 | 47 | self._value = initial_value 48 | 49 | box = wx.StaticBox(self, label=label) 50 | sizer = wx.StaticBoxSizer(box, wx.VERTICAL) 51 | 52 | self.auto_radio_button = wx.RadioButton( 53 | self, label="Auto", style=wx.RB_GROUP 54 | ) 55 | 56 | self.manual_radio_button = wx.RadioButton( 57 | self, label="Manual" 58 | ) 59 | 60 | self.textbox = wx.TextCtrl( 61 | self, 62 | size=(35, -1), 63 | value=str(self.value), 64 | style=wx.TE_PROCESS_ENTER 65 | ) 66 | 67 | self.Bind( 68 | wx.EVT_UPDATE_UI, 69 | self.on_radio_button_checked, 70 | self.textbox 71 | ) 72 | 73 | self.Bind( 74 | wx.EVT_TEXT_ENTER, 75 | self.on_text_enter, 76 | self.textbox 77 | ) 78 | 79 | manual_box = wx.BoxSizer(wx.HORIZONTAL) 80 | manual_box.Add( 81 | self.manual_radio_button, 82 | flag=wx.ALIGN_CENTER_VERTICAL 83 | ) 84 | manual_box.Add(self.textbox, flag=wx.ALIGN_CENTER_VERTICAL) 85 | 86 | sizer.Add(self.auto_radio_button, 0, wx.ALL, 10) 87 | sizer.Add(manual_box, 0, wx.ALL, 10) 88 | 89 | self.SetSizer(sizer) 90 | sizer.Fit(self) 91 | 92 | @property 93 | def value(self): 94 | return self._value 95 | 96 | def on_radio_button_checked(self, event): 97 | self.textbox.Enable(not self.is_auto()) 98 | 99 | def on_text_enter(self, event): 100 | self._value = self.textbox.GetValue() 101 | 102 | def is_auto(self): 103 | return self.auto_radio_button.GetValue() 104 | 105 | 106 | class GraphFrame(wx.Frame): 107 | """The main frame of the application.""" 108 | 109 | title = 'Demo: dynamic matplotlib graph' 110 | 111 | def __init__(self, data_source): 112 | wx.Frame.__init__(self, None, -1, self.title) 113 | 114 | self.data_source = data_source 115 | self.data = [self.data_source.next()] 116 | self.paused = False 117 | 118 | self.create_menu() 119 | self.create_status_bar() 120 | self.create_main_panel() 121 | 122 | self.redraw_timer = wx.Timer(self) 123 | self.Bind(wx.EVT_TIMER, self.on_plot_redraw, self.redraw_timer) 124 | self.redraw_timer.Start(REFRESH_INTERVAL_MS) 125 | 126 | def create_menu(self): 127 | self.menu_bar = wx.MenuBar() 128 | menu = wx.Menu() 129 | 130 | save_plot_entry = menu.Append( 131 | id=-1, 132 | item="&Save plot\tCtrl-S", 133 | helpString="Save plot to file" 134 | ) 135 | self.Bind(wx.EVT_MENU, self.on_plot_save, save_plot_entry) 136 | 137 | menu.AppendSeparator() 138 | 139 | exit_entry = menu.Append( 140 | id=-1, 141 | item="E&xit\tCtrl-X", 142 | helpString="Exit" 143 | ) 144 | self.Bind(wx.EVT_MENU, self.on_exit, exit_entry) 145 | 146 | self.menu_bar.Append(menu, "&File") 147 | self.SetMenuBar(self.menu_bar) 148 | 149 | def create_main_panel(self): 150 | self.panel = wx.Panel(self) 151 | 152 | self.plot_initialize() 153 | self.canvas = FigCanvas(self.panel, -1, self.figure) 154 | 155 | self.xmin_control_box = BoundControlBox(self.panel, "X min", 0) 156 | self.xmax_control_box = BoundControlBox(self.panel, "X max", 50) 157 | self.ymin_control_box = BoundControlBox(self.panel, "Y min", 0) 158 | self.ymax_control_box = BoundControlBox(self.panel, "Y max", 100) 159 | 160 | self.pause_button = wx.Button(self.panel, -1, "Pause") 161 | self.Bind(wx.EVT_BUTTON, self.on_pause_button_click, self.pause_button) 162 | self.Bind( 163 | wx.EVT_UPDATE_UI, 164 | self.on_pause_button_update, 165 | self.pause_button 166 | ) 167 | 168 | self.grid_visibility_check_box = wx.CheckBox( 169 | self.panel, -1, 170 | "Show Grid", 171 | style=wx.ALIGN_RIGHT 172 | ) 173 | self.Bind( 174 | wx.EVT_CHECKBOX, 175 | self.on_grid_visibility_control_box_toggle, 176 | self.grid_visibility_check_box 177 | ) 178 | self.grid_visibility_check_box.SetValue(True) 179 | 180 | self.xlabels_visibility_check_box = wx.CheckBox( 181 | self.panel, -1, 182 | "Show X labels", 183 | style=wx.ALIGN_RIGHT 184 | ) 185 | self.Bind( 186 | wx.EVT_CHECKBOX, 187 | self.on_xlabels_visibility_check_box_toggle, 188 | self.xlabels_visibility_check_box 189 | ) 190 | self.xlabels_visibility_check_box.SetValue(True) 191 | 192 | self.hbox1 = wx.BoxSizer(wx.HORIZONTAL) 193 | self.hbox1.Add( 194 | self.pause_button, 195 | border=5, 196 | flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL 197 | ) 198 | self.hbox1.AddSpacer(20) 199 | self.hbox1.Add( 200 | self.grid_visibility_check_box, 201 | border=5, 202 | flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL 203 | ) 204 | self.hbox1.AddSpacer(10) 205 | self.hbox1.Add( 206 | self.xlabels_visibility_check_box, 207 | border=5, 208 | flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL 209 | ) 210 | 211 | self.hbox2 = wx.BoxSizer(wx.HORIZONTAL) 212 | self.hbox2.Add(self.xmin_control_box, border=5, flag=wx.ALL) 213 | self.hbox2.Add(self.xmax_control_box, border=5, flag=wx.ALL) 214 | self.hbox2.AddSpacer(24) 215 | self.hbox2.Add(self.ymin_control_box, border=5, flag=wx.ALL) 216 | self.hbox2.Add(self.ymax_control_box, border=5, flag=wx.ALL) 217 | 218 | self.vbox = wx.BoxSizer(wx.VERTICAL) 219 | self.vbox.Add(self.canvas, 1, flag=wx.LEFT | wx.TOP | wx.GROW) 220 | self.vbox.Add(self.hbox1, 0, flag=wx.ALIGN_LEFT | wx.TOP) 221 | self.vbox.Add(self.hbox2, 0, flag=wx.ALIGN_LEFT | wx.TOP) 222 | 223 | self.panel.SetSizer(self.vbox) 224 | self.vbox.Fit(self) 225 | 226 | def create_status_bar(self): 227 | self.status_bar = self.CreateStatusBar() 228 | 229 | def plot_initialize(self): 230 | 231 | self.figure = Figure((3.0, 3.0), dpi=DPI) 232 | 233 | self.axes = self.figure.add_subplot(111) 234 | self.axes.set_facecolor('black') 235 | self.axes.set_title('Arduino Serial Data', size=12) 236 | self.axes.grid(color='grey') 237 | 238 | plt.setp(self.axes.get_xticklabels(), fontsize=8) 239 | plt.setp(self.axes.get_yticklabels(), fontsize=8) 240 | 241 | # Plot the data and save the reference to the plotted line 242 | self.plot_data = self.axes.plot( 243 | self.data, linewidth=1, color=(1, 1, 0), 244 | )[0] 245 | 246 | def get_plot_xrange(self): 247 | """ 248 | Return minimal and maximal values of plot -xaxis range to be displayed. 249 | 250 | Values of *x_min* and *x_max* by default are determined to show sliding 251 | window of last 50 elements of data set and they can be manually set. 252 | """ 253 | x_max = max(len(self.data), 50) if self.xmax_control_box.is_auto() \ 254 | else int(self.xmax_control_box.value) 255 | 256 | x_min = x_max - 50 if self.xmin_control_box.is_auto() \ 257 | else int(self.xmin_control_box.value) 258 | 259 | return x_min, x_max 260 | 261 | def get_plot_yrange(self): 262 | """ 263 | Return minimal and maximal values of plot y-axis range to be displayed. 264 | 265 | Values of *y_min* and *y_max* are determined by finding minimal and 266 | maximal values of the data set and adding minimal necessary margin. 267 | 268 | """ 269 | y_min = round(min(self.data)) - 1 if self.ymin_control_box.is_auto() \ 270 | else int(self.ymin_control_box.value) 271 | 272 | y_max = round(max(self.data)) + 1 if self.ymax_control_box.is_auto() \ 273 | else int(self.ymax_control_box.value) 274 | 275 | return y_min, y_max 276 | 277 | def draw_plot(self): 278 | """Redraw the plot.""" 279 | 280 | x_min, x_max = self.get_plot_xrange() 281 | y_min, y_max = self.get_plot_yrange() 282 | 283 | self.axes.set_xbound(lower=x_min, upper=x_max) 284 | self.axes.set_ybound(lower=y_min, upper=y_max) 285 | 286 | self.axes.grid(self.grid_visibility_check_box.IsChecked()) 287 | 288 | # Set x-axis labels visibility 289 | plt.setp( 290 | self.axes.get_xticklabels(), 291 | visible=self.xlabels_visibility_check_box.IsChecked() 292 | ) 293 | 294 | self.plot_data.set_xdata(np.arange(len(self.data))) 295 | self.plot_data.set_ydata(np.array(self.data)) 296 | 297 | self.canvas.draw() 298 | 299 | def on_pause_button_click(self, event): 300 | self.paused = not self.paused 301 | 302 | def on_pause_button_update(self, event): 303 | label = "Resume" if self.paused else "Pause" 304 | self.pause_button.SetLabel(label) 305 | 306 | def on_grid_visibility_control_box_toggle(self, event): 307 | self.draw_plot() 308 | 309 | def on_xlabels_visibility_check_box_toggle(self, event): 310 | self.draw_plot() 311 | 312 | def on_plot_save(self, event): 313 | file_choices = "PNG (*.png)|*.png" 314 | 315 | dlg = wx.FileDialog( 316 | self, 317 | message="Save plot as...", 318 | defaultDir=os.getcwd(), 319 | defaultFile="plot.png", 320 | wildcard=file_choices, 321 | style=wx.FD_SAVE 322 | ) 323 | 324 | if dlg.ShowModal() == wx.ID_OK: 325 | path = dlg.GetPath() 326 | self.canvas.print_figure(path, dpi=DPI) 327 | self.flash_status_message("Saved to {}".format(path)) 328 | 329 | def on_plot_redraw(self, event): 330 | """Get new value from data source if necessary and redraw the plot.""" 331 | if not self.paused: 332 | self.data.append(self.data_source.next()) 333 | 334 | self.draw_plot() 335 | 336 | def on_exit(self, event): 337 | self.Destroy() 338 | 339 | def flash_status_message(self, message, display_time=1500): 340 | self.status_bar.SetStatusText(message) 341 | self.message_timer = wx.Timer(self) 342 | self.Bind( 343 | wx.EVT_TIMER, 344 | self.on_flash_status_off, 345 | self.message_timer 346 | ) 347 | self.message_timer.Start(display_time, oneShot=True) 348 | 349 | def on_flash_status_off(self, event): 350 | self.status_bar.SetStatusText('') 351 | 352 | 353 | def parse_script_args(): 354 | parser = argparse.ArgumentParser() 355 | 356 | parser.add_argument("port", help="serial port to be used") 357 | parser.add_argument("-b", "--baudrate", type=int, help="port baud rate") 358 | parser.add_argument("-t", "--timeout", type=float, 359 | help="port timeout value") 360 | 361 | args = parser.parse_args() 362 | 363 | return {key: val for key, val in vars(args).iteritems() if val is not None} 364 | 365 | 366 | if __name__ == "__main__": 367 | 368 | kwargs = parse_script_args() 369 | data_source = SerialData(**kwargs) 370 | 371 | app = wx.App() 372 | app.frame = GraphFrame(data_source) 373 | app.frame.Show() 374 | app.MainLoop() 375 | --------------------------------------------------------------------------------