├── .gitignore ├── LICENSE ├── SerpentSerialTool.dmg.zip ├── SerpentSerialTool.spec ├── icons ├── serpent.icns └── serpent.png ├── periodics.py ├── readme.md ├── requirements.txt ├── screenshots ├── plotter.gif └── serialtool.gif ├── serpent.py ├── serpent_plt.py └── tkinterplot.py /.gitignore: -------------------------------------------------------------------------------- 1 | /.DS_Store 2 | /.venv 3 | /.vscode 4 | /build 5 | /dist 6 | /__pycache__ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Christopher Xu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SerpentSerialTool.dmg.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qwertpas/SerpentSerialTool/b5fdebc11b836e92346b531a22197ce306fa39dc/SerpentSerialTool.dmg.zip -------------------------------------------------------------------------------- /SerpentSerialTool.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | block_cipher = None 5 | 6 | 7 | a = Analysis( 8 | ['serpent.py'], 9 | pathex=[], 10 | binaries=[], 11 | datas=[('icons/serpent.png', '.')], 12 | hiddenimports=[], 13 | hookspath=[], 14 | hooksconfig={}, 15 | runtime_hooks=[], 16 | excludes=[], 17 | win_no_prefer_redirects=False, 18 | win_private_assemblies=False, 19 | cipher=block_cipher, 20 | noarchive=False, 21 | ) 22 | pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) 23 | 24 | exe = EXE( 25 | pyz, 26 | a.scripts, 27 | [], 28 | exclude_binaries=True, 29 | name='SerpentSerialTool', 30 | debug=False, 31 | bootloader_ignore_signals=False, 32 | strip=False, 33 | upx=True, 34 | console=False, 35 | disable_windowed_traceback=False, 36 | argv_emulation=False, 37 | target_arch=None, 38 | codesign_identity=None, 39 | entitlements_file=None, 40 | icon=['icons/serpent.icns'], 41 | ) 42 | coll = COLLECT( 43 | exe, 44 | a.binaries, 45 | a.zipfiles, 46 | a.datas, 47 | strip=False, 48 | upx=True, 49 | upx_exclude=[], 50 | name='SerpentSerialTool', 51 | ) 52 | app = BUNDLE( 53 | coll, 54 | name='SerpentSerialTool.app', 55 | icon='icons/serpent.icns', 56 | bundle_identifier=None, 57 | ) 58 | -------------------------------------------------------------------------------- /icons/serpent.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qwertpas/SerpentSerialTool/b5fdebc11b836e92346b531a22197ce306fa39dc/icons/serpent.icns -------------------------------------------------------------------------------- /icons/serpent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qwertpas/SerpentSerialTool/b5fdebc11b836e92346b531a22197ce306fa39dc/icons/serpent.png -------------------------------------------------------------------------------- /periodics.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | 4 | class PeriodicSleeper(threading.Thread): 5 | def __init__(self, task_function, period): 6 | super().__init__() 7 | self.daemon = True #exit this thread when the main one terminates 8 | self.task_function = task_function 9 | self.period = period 10 | self.i = 0 11 | self.t0 = time.time() 12 | self.running = True 13 | self.start() 14 | 15 | def sleep(self): 16 | self.i += 1 17 | delta = self.t0 + self.period * self.i - time.time() 18 | 19 | if delta > 0: 20 | time.sleep(delta) 21 | 22 | def run(self): 23 | self.running = True 24 | while self.running: 25 | self.task_function() 26 | self.sleep() 27 | 28 | def stop(self): 29 | self.running = False 30 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Serpent Serial Tool 2 | 3 | A simple app to log and plot data from a serial or COM port. 4 | 5 | ![Serpent Serial Tool](screenshots/serialtool.gif) 6 | 7 | Like the Arduino serial monitor and serial plotter, but with a few more features: 8 | * Easily change port 9 | * Detection for end of message to keep values aligned 10 | * Option to send keystrokes immediately without pressing enter 11 | * Text monitor, plot, and send data simultaneously 12 | * Zoom in and out with Cmd + and Cmd - 13 | * Plot scaling 14 | * Looks nicer 15 | 16 | Vertical and horizontal scaling: 17 | ![Serpent Plotter](screenshots/plotter.gif) 18 | 19 | 20 | ## Usage 21 | For MacOS, I've packaged the app into SerpentSerialTool.dmg.zip. After opening the .dmg, a new volume should appear in the filesystem containing SerpentSerialTool.app, which you can drag into your Applications folder. 22 | 23 | With pyinstaller, the app can be compiled into an executable for your own system (tested on MacOS and Windows 10): 24 | ``` 25 | pip install -r requirements.txt 26 | 27 | pyinstaller SerpentSerialTool.spec 28 | ``` 29 | or 30 | ``` 31 | pip install -r requirements.txt 32 | 33 | pyinstaller --clean --windowed --icon icons/serpent.icns --add-data "icons/serpent.png:." --noconfirm --name SerpentSerialTool serpent.py 34 | ``` 35 | `--clean` clears any existing temporary build files 36 | 37 | `--windowed` generates an application file on MacOS 38 | 39 | `--icon ` sets the application icon 40 | 41 | `--add-data ":."` includes a file or directory 42 | 43 | ## Print syntax 44 | If you are using this app to monitor variables from another device, you can use the following format to make sure the values align when in autoscroll mode: 45 | * Each line represents a single variable, consisting of a label and a floating point number, separated by a colon. The line should be terminated with `"\n"`. 46 | * After printing all the variables, indicate the end of the message with `"\t"`. 47 | 48 | Example Arduino code: 49 | ``` 50 | void loop(){ 51 | Serial.println("value1: 3.1415"); 52 | Serial.println("value2: 2.7182"); 53 | Serial.print("\t"); 54 | } 55 | ``` 56 | This format is required for the plotter to parse the variables properly, but is not necessary if you need only need to receive serial data as text. 57 | 58 | 59 | ## Dependencies: 60 | * Python 3.x 61 | * Tcl/Tk (should come with Python 3) 62 | * numpy 63 | * pyserial 64 | 65 | ## Connections 66 | This app was built to debug the [Ø32 controller](https://github.com/qwertpas/O32controller), a miniature brushless DC motor controller. It is part of the Pintobotics project: https://pintobotics.substack.com/ 67 | 68 | Hackaday project: https://hackaday.io/project/191983-serpent-serial-tool -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | altgraph==0.17.3 2 | macholib==1.16.2 3 | numpy==1.25.1 4 | pyinstaller==5.13.0 5 | pyinstaller-hooks-contrib==2023.5 6 | pyserial==3.5 7 | -------------------------------------------------------------------------------- /screenshots/plotter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qwertpas/SerpentSerialTool/b5fdebc11b836e92346b531a22197ce306fa39dc/screenshots/plotter.gif -------------------------------------------------------------------------------- /screenshots/serialtool.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qwertpas/SerpentSerialTool/b5fdebc11b836e92346b531a22197ce306fa39dc/screenshots/serialtool.gif -------------------------------------------------------------------------------- /serpent.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter.messagebox import showinfo 3 | import serial.tools.list_ports 4 | import threading 5 | import time 6 | # from serpent_plt import PlotWindow 7 | from tkinterplot import Plot 8 | from periodics import PeriodicSleeper 9 | from tkinter import filedialog 10 | import datetime 11 | 12 | 13 | 14 | root = tk.Tk() 15 | root.title("Serpent Serial Tool") 16 | 17 | # Top level frame that stacks everything vertically 18 | mainframe = tk.Frame(root) 19 | mainframe.pack(fill=tk.BOTH, expand=True) 20 | 21 | 22 | portframe = tk.Frame(mainframe) 23 | portframe.pack() 24 | 25 | # label for the port dropdown 26 | portlabel = tk.Label(portframe, text = "Port:") 27 | portlabel.pack(side=tk.LEFT) 28 | 29 | def scan_serial_ports(): 30 | ports = serial.tools.list_ports.comports() 31 | port_names = [port.device for port in ports] 32 | return port_names 33 | 34 | def refresh_ports(): 35 | ports = serial.tools.list_ports.comports() 36 | port_names = [port.device for port in ports] 37 | menu = port_dropdown['menu'] 38 | menu.delete(0, 'end') 39 | for port_name in port_names: 40 | menu.add_command(label=port_name, command=tk._setit(port_var, port_name)) 41 | if port_names: 42 | port_var.set(port_names[-1]) 43 | 44 | # Create a dropdown menu for serial ports 45 | ports = scan_serial_ports() 46 | print(ports) 47 | port_var = tk.StringVar() 48 | port_dropdown = tk.OptionMenu(portframe, port_var, *ports) 49 | port_dropdown.pack(side=tk.RIGHT) 50 | 51 | refresh_ports() 52 | 53 | 54 | port_button = tk.Button(portframe, text="Refresh", command=refresh_ports) 55 | port_button.pack(side=tk.RIGHT, before=port_dropdown) 56 | 57 | baudframe = tk.Frame(mainframe) 58 | baudframe.pack() 59 | # label for the baudrate dropdown 60 | baudlabel = tk.Label(baudframe, text = "Baudrate:") 61 | baudlabel.pack(side=tk.LEFT) 62 | 63 | 64 | # Create a dropdown menu for serial ports 65 | baudrates = [9600, 115200, 1000000] 66 | baudrate_var = tk.IntVar() 67 | baudrate_var.set(baudrates[0]) 68 | baud_dropdown = tk.OptionMenu(baudframe, baudrate_var, *baudrates) 69 | baud_dropdown.pack(side=tk.RIGHT) 70 | 71 | 72 | serial_on = False 73 | serial_thread = None 74 | ser = None 75 | 76 | messagebuffer = "" 77 | plot_handler = None 78 | 79 | def start_serial(): 80 | port = port_var.get() 81 | print(f"Connecting to {port} at {baudrate_var.get()} baud") 82 | 83 | global ser 84 | if port: 85 | try: 86 | ser = serial.Serial(port, baudrate=baudrate_var.get(), timeout=1) 87 | ser.read_all() 88 | except Exception as e: 89 | print(e) 90 | showinfo("Error", f"Failed to open serial port: {port}") 91 | return 92 | else: 93 | showinfo("Error", "Please select a port.") 94 | return 95 | 96 | message = "" 97 | delimiter = '\n' 98 | global messagebuffer 99 | 100 | count = 0 101 | while serial_on: 102 | messagecount = 0 103 | starttime = time.time() 104 | if ser.in_waiting > 0: 105 | try: 106 | uarttext = ser.read_all().decode('utf-8', errors='replace') 107 | except Exception as e: 108 | print(e) 109 | continue 110 | 111 | if(delimiter != '\t'): 112 | if(uarttext.find('\t') != -1): 113 | delimiter = '\t' 114 | 115 | ending=0 116 | while(uarttext): 117 | ending = uarttext.find(delimiter) 118 | if(ending == -1): 119 | break 120 | 121 | message += uarttext[0:ending] 122 | add_text(message) 123 | messagebuffer = message 124 | 125 | messagecount += 1 126 | 127 | message = "" #clear message 128 | uarttext = uarttext[ending+len(delimiter):] #front of buffer used up 129 | 130 | message = uarttext #whatver is left over 131 | 132 | # time.sleep(0.033) 133 | # time.sleep(0.010) 134 | # print("messages per second: ", messagecount / (time.time() - starttime)) 135 | count+=1 136 | 137 | ser.close() 138 | 139 | def send_to_plot(): 140 | global messagebuffer 141 | if(plotwindow): 142 | plotwindow.set(messagebuffer) 143 | 144 | 145 | def toggle_serial(): 146 | global serial_on, serial_thread, plot_handler 147 | serial_on = not serial_on 148 | if serial_on: #turning on 149 | 150 | serial_thread = threading.Thread(target=start_serial, daemon=True) 151 | serial_thread.start() 152 | 153 | plot_handler = PeriodicSleeper(send_to_plot, 0.01) 154 | 155 | runbutton.config(text="Pause") 156 | else: #turning off 157 | # if(serial_thread): 158 | # serial_thread.join() 159 | plot_handler.stop() 160 | if plotwindow: 161 | plotwindow.pause() 162 | runbutton.config(text="Run") 163 | 164 | 165 | runbutton = tk.Button(baudframe, text="Run", command=toggle_serial) 166 | runbutton.pack(side=tk.RIGHT, before=baud_dropdown) 167 | 168 | optionframe = tk.Frame(mainframe) 169 | optionframe.pack() 170 | 171 | options_mb = tk.Menubutton(optionframe, text="Options") 172 | options_mb.menu = tk.Menu(options_mb) 173 | options_mb["menu"] = options_mb.menu 174 | 175 | autoscroll = tk.IntVar(value=1) 176 | autosend = tk.IntVar(value=0) 177 | 178 | options_mb.menu.add_checkbutton(label="Autoscroll", variable=autoscroll) 179 | options_mb.menu.add_checkbutton(label="Send immediately", variable=autosend) 180 | options_mb.pack(side=tk.LEFT) 181 | 182 | plotwindow = None 183 | def open_plot_w_root(): 184 | global plotwindow 185 | try: 186 | plotwindow.lift() 187 | except: 188 | window=tk.Toplevel(root) 189 | plotwindow = Plot(window) 190 | 191 | button = tk.Button(optionframe, text="Open Plot", command=open_plot_w_root) 192 | button.pack(side=tk.RIGHT) 193 | 194 | def clear_text_display(): 195 | text_display.configure(state=tk.NORMAL) 196 | text_display.delete(1.0, tk.END) 197 | text_display.configure(state=tk.DISABLED) 198 | clear_button = tk.Button(optionframe, text="Clear", command=clear_text_display) 199 | clear_button.pack(side=tk.RIGHT) 200 | 201 | 202 | def save_to_file(): 203 | text_content = text_display.get(1.0, tk.END) 204 | default_filename = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S.txt") 205 | file_path = filedialog.asksaveasfilename(defaultextension=".txt", filetypes=[("Text files", "*.txt")], initialfile=default_filename) 206 | if file_path: 207 | with open(file_path, "w") as file: 208 | file.write(text_content) 209 | save_button = tk.Button(optionframe, text="Save", command=save_to_file) 210 | save_button.pack(side=tk.RIGHT) 211 | 212 | 213 | 214 | 215 | 216 | consoleframe = tk.Frame(mainframe) 217 | consoleframe.pack(fill=tk.BOTH, expand=True) 218 | 219 | # Create a scrollbar 220 | scrollbar = tk.Scrollbar(consoleframe) 221 | scrollbar.pack(side=tk.RIGHT, fill=tk.Y) 222 | 223 | 224 | # Create a text display widget 225 | text_display = tk.Text(consoleframe, font=('Arial',25), yscrollcommand=scrollbar.set, height=10, width=20) 226 | text_display.configure(state=tk.DISABLED) 227 | text_display.pack(fill=tk.BOTH, expand=True) 228 | 229 | text_display.config(font=("Courier", 20)) 230 | 231 | # Configure the scrollbar to work with the text display 232 | scrollbar.config(command=text_display.yview) 233 | 234 | 235 | 236 | entryframe = tk.Frame(mainframe) 237 | entryframe.pack() 238 | 239 | # label for the send textentry 240 | entrylabel = tk.Label(entryframe, text = "Send:") 241 | entrylabel.pack(side=tk.LEFT) 242 | 243 | def add_text(text): 244 | if(serial_on): 245 | text_display.configure(state=tk.NORMAL) 246 | text_display.insert(tk.END, text + '\n') 247 | text_display.configure(state=tk.DISABLED) 248 | if(autoscroll.get()): 249 | text_display.see(tk.END) 250 | 251 | def send_data(): 252 | text = entry.get() 253 | if text: 254 | global ser 255 | if(ser is not None): 256 | if(ser.is_open): 257 | ser.write(text.encode()) 258 | entry.delete(0, tk.END) 259 | # add_text(text) 260 | 261 | # Create a text entry widget 262 | entry = tk.Entry(entryframe) 263 | # Bind the Enter key to add_text function 264 | entry.bind("", lambda event: send_data()) 265 | 266 | def entry_keypress(event): 267 | if autosend.get() == 1: 268 | key = event.char 269 | entry.delete(0, tk.END) 270 | entry.insert(0, key) 271 | send_data() 272 | 273 | fontsize=20 274 | def console_keypress(event): 275 | if(event.state == 8 or event.state == 4): #command or control is pressed at the same time 276 | global fontsize 277 | if(event.char == '='): 278 | fontsize += 2 279 | text_display.config(font=("Courier", fontsize)) 280 | print('up') 281 | elif(event.char == '-'): 282 | fontsize -= 2 283 | text_display.config(font=("Courier", fontsize)) 284 | print('down') 285 | 286 | 287 | 288 | # Bind the KeyPress event to the on_keypress function 289 | entry.bind("", entry_keypress) 290 | text_display.bind("", console_keypress) 291 | 292 | entry.pack(side=tk.RIGHT, fill=tk.X, expand=True) 293 | 294 | 295 | # img = tk.Image("photo", file="serpent.png") 296 | # root.tk.call('wm','iconphoto', root._w, img) 297 | 298 | 299 | def on_closing(): 300 | root.destroy() 301 | exit() 302 | 303 | root.protocol("WM_DELETE_WINDOW", on_closing) 304 | 305 | root.mainloop() -------------------------------------------------------------------------------- /serpent_plt.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import filedialog 3 | import matplotlib.pyplot as plt 4 | from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg 5 | import csv 6 | import numpy as np 7 | import time 8 | 9 | from blitting import BlitManager 10 | 11 | 12 | class PlotWindowPlt: 13 | def __init__(self, parent): 14 | self.parent = parent 15 | self.window = tk.Toplevel(parent) 16 | self.window.title("Serpent Plotter") 17 | 18 | # Create a Matplotlib figure and axis 19 | self.fig, self.ax = plt.subplots(figsize=(4,4)) 20 | 21 | # Create a Matplotlib canvas within the frame 22 | canvas = FigureCanvasTkAgg(self.fig, master=self.window) 23 | canvas.get_tk_widget().pack() 24 | 25 | self.optionframe = tk.Frame(self.window) 26 | self.optionframe.pack(fill=tk.BOTH, expand=True) 27 | 28 | 29 | # Create a text entry for adjusting x and y limits 30 | limits_frame = tk.Frame(self.window) 31 | limits_frame.pack(pady=10) 32 | x_limit_entry = tk.Entry(limits_frame, width=10) 33 | x_limit_entry.pack(side=tk.LEFT, padx=5) 34 | y_limit_entry = tk.Entry(limits_frame, width=10) 35 | y_limit_entry.pack(side=tk.LEFT, padx=5) 36 | 37 | def update_plot_limits(self=self): 38 | try: 39 | x_limit = float(x_limit_entry.get()) 40 | y_limit = float(y_limit_entry.get()) 41 | self.ax.set_xlim(0, x_limit) 42 | self.ax.set_ylim(0, y_limit) 43 | canvas.draw() 44 | except Exception as e: 45 | print(e) 46 | 47 | # Create a button to update plot limits 48 | update_button = tk.Button(limits_frame, text="Update Limits", command=update_plot_limits) 49 | update_button.pack(pady=10) 50 | 51 | 52 | def save_data_as_csv(self=self): 53 | file_path = filedialog.asksaveasfilename(defaultextension=".csv", filetypes=[("CSV Files", "*.csv")]) 54 | if file_path: 55 | with open(file_path, "w", newline="") as file: 56 | writer = csv.writer(file) 57 | 58 | # Write the labels as the header row 59 | writer.writerow(self.labels) 60 | 61 | # Write the data rows 62 | writer.writerows(self.data.T) 63 | 64 | # Create a button to browse for save location and save data as CSV 65 | save_button = tk.Button(self.window, text="Save CSV", command=save_data_as_csv) 66 | save_button.pack(pady=10) 67 | 68 | self.historylen = 100 69 | self.labels = ["series0"] 70 | self.data = np.zeros([len(self.labels), self.historylen]) #history of data points 71 | (ln,) = self.ax.plot(np.zeros(self.historylen), 'o-', label=self.labels[0], markersize=1) 72 | self.lines = [ln] 73 | self.bm = None 74 | self.ax.set_xlim(0, 100) 75 | self.ax.set_ylim(0, 800) 76 | 77 | 78 | 79 | def lift(self): 80 | self.window.lift() #brings this window above other windows so you can see it 81 | 82 | def message_to_dict(message): 83 | data_dict = {} 84 | lines = message.strip().split("\n") 85 | for line in lines: 86 | try: 87 | parts = line.split(":") 88 | if len(parts) != 2: #ignore lines that don't have exactly one colon 89 | continue 90 | key, value = parts 91 | key = key.strip() 92 | value = float(value.strip().split(" ")[0]) #only use the first "word" and ignore spaces 93 | data_dict[key] = value 94 | except Exception as e: 95 | print(e) 96 | return data_dict 97 | 98 | 99 | 100 | def plot_message(self, message:str): 101 | 102 | 103 | new_data = PlotWindowPlt.message_to_dict(message) 104 | 105 | if(len(new_data) != len(self.labels)): 106 | 107 | #add any series not in the data yet 108 | for key in new_data: 109 | if key not in self.labels: 110 | self.labels.append(key) 111 | self.data = np.append(self.data, [np.zeros(self.historylen)], axis=0) 112 | (ln,) = self.ax.plot(np.zeros(self.historylen), label=key) 113 | # (ln,) = self.ax.plot(np.zeros(self.historylen), 'o-', label=key, markersize=1) 114 | self.lines.append(ln) 115 | 116 | #remove any series that aren't active 117 | to_delete = [] 118 | for i in range(len(self.labels)): 119 | if self.labels[i] not in new_data: 120 | to_delete.append(i) 121 | for i in to_delete: 122 | self.data = np.delete(self.data, i, axis=0) 123 | self.labels.pop(i) 124 | self.lines.pop(i).remove() 125 | 126 | self.ax.legend() 127 | self.bm = BlitManager(self.fig.canvas, self.lines) 128 | 129 | 130 | self.data = np.roll(self.data, shift=-1, axis=1) 131 | 132 | i = 0 133 | for key in new_data: 134 | self.data[i][-1] = new_data[key] 135 | i += 1 136 | 137 | for i in range(len(self.data)): 138 | # self.ax.plot(self.data[i], label=self.labels[i]) 139 | self.lines[i].set_ydata(self.data[i]) 140 | self.bm.update() 141 | # self.fig.update() 142 | 143 | 144 | 145 | 146 | 147 | if __name__ == "__main__": 148 | 149 | root = tk.Tk() 150 | serpent_plt = PlotWindow(root) 151 | 152 | for i in range(1000): 153 | serpent_plt.plot_message(f"m_angle : {i} dsdsdw\n temp: 66 \n \t") 154 | time.sleep(0.01) 155 | 156 | root.mainloop() 157 | 158 | -------------------------------------------------------------------------------- /tkinterplot.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import tkinter as tk 3 | import numpy as np 4 | from numpy import log10, ceil, floor 5 | from colorsys import hsv_to_rgb 6 | from periodics import PeriodicSleeper 7 | 8 | class Plot(tk.Frame): 9 | def __init__(self, parent): 10 | self.parent = parent 11 | 12 | self.historylen = 100 13 | self.labels = [] 14 | self.data = np.array([]) 15 | self.scales = np.array([]) 16 | self.saved_scales = {} #dict for history of labels and scales, so scale is preserved if data is spotty 17 | self.hues = [] 18 | self.w = parent.winfo_width() 19 | self.h = parent.winfo_height() 20 | self.epsilon = 1e-9 #minimum floating value to display 21 | self.max = self.epsilon 22 | self.min = -self.epsilon 23 | 24 | self.message = "" #buffer that some external loop updates, then the plotter displays it periodically 25 | 26 | self.textoffset = np.array([-10, 0]) #xy offset for the labels 27 | self.temp_tags = [] #strings of y axis mark tags that get redrawn on resize 28 | self.scale_frames = [] 29 | self.scale_entries = [] 30 | 31 | self.paused = False 32 | 33 | self.setup_graphics(parent) 34 | self.plotloop() 35 | 36 | def setup_graphics(self, parent): 37 | tk.Frame.__init__(self, parent) 38 | 39 | parent.wm_title("Serpent Plotter") 40 | parent.wm_geometry("800x400") 41 | self.canvas = tk.Canvas(self, background="gray12") 42 | self.canvas.bind("", self.on_resize) 43 | 44 | self.yaxis_frame = tk.Frame(parent) 45 | self.yaxis_frame.pack() 46 | 47 | self.side_frame = tk.LabelFrame(parent,text='Scale',padx=5, pady=5) 48 | self.side_frame.pack(side=tk.RIGHT, fill='both') 49 | 50 | history_frame = tk.Frame(self.side_frame) 51 | history_frame.pack(side=tk.BOTTOM) 52 | 53 | historylabel = tk.Label(history_frame, text="# pts:") 54 | historylabel.pack() 55 | 56 | historyentry = tk.Entry(history_frame, width=5) 57 | historyentry.insert(0, f"{self.historylen}") 58 | historyentry.pack(side=tk.BOTTOM, before=historylabel) 59 | historyentry.bind("", lambda event: self.set_history(historyentry)) 60 | 61 | self.canvas.pack(expand=True, fill=tk.BOTH) 62 | self.pack(expand=True, fill=tk.BOTH) 63 | 64 | 65 | def pause(self): 66 | self.paused = True 67 | 68 | def disp(self, val): 69 | val=(val-self.min)/(self.max-self.min) #map any value to 0 to +1 70 | pad = 20 71 | return (self.h - 2*pad) * (1-val) + pad 72 | 73 | def find_nice_range(xmin, xmax): 74 | diff = xmax-xmin 75 | n = ceil(log10(diff / 5) - 1) 76 | s = diff / 10**(n+1) 77 | if s <= 1: 78 | s = 1 79 | elif s <= 2: 80 | s = 2 81 | else: 82 | s = 5 83 | step = s*10**n 84 | bot = floor(xmin/step)*step 85 | try: 86 | return np.arange(bot, xmax+step, step) 87 | except Exception as e: 88 | print(e) 89 | print(xmin, xmax, bot, xmax+step, step) 90 | 91 | 92 | 93 | def on_resize(self, event=None): 94 | self.w = self.winfo_width() 95 | self.h = self.winfo_height() 96 | 97 | for temp_tag in self.temp_tags: 98 | self.canvas.delete(temp_tag) # Delete line item from the canvas 99 | 100 | for y in Plot.find_nice_range(self.min, self.max): 101 | if abs(y) < self.epsilon: 102 | y = 0 103 | marktag = f"_M{y}" 104 | gridtag = f"_G{y}" 105 | self.temp_tags.append(marktag) 106 | self.temp_tags.append(gridtag) 107 | h = int(self.disp(y)) 108 | 109 | if y == 0: 110 | self.canvas.create_line(0, self.disp(y), self.w, self.disp(y), tag=gridtag, fill="#AAAAAA") 111 | else: 112 | self.canvas.create_line(0, self.disp(y), self.w, self.disp(y), tag=gridtag, fill="#454545") 113 | self.canvas.create_text(10, h, anchor='w', text=f"{y:0.4g}", tag=marktag) 114 | 115 | # self.data = np.zeros_like(self.data) 116 | self.draw() 117 | 118 | def str_to_data(message:str): 119 | new_labels = [] 120 | new_data = [] 121 | lines = message.strip().split("\n") 122 | for line in lines: 123 | try: 124 | parts = line.split(":") 125 | if len(parts) != 2: #ignore lines that don't have exactly one colon 126 | continue 127 | label, value = parts 128 | label = label.strip() 129 | if label in new_labels: 130 | continue #is a duplicate entry in the same message 131 | value = float(value.strip().split(" ")[0]) #only use the first "word" and ignore spaces 132 | new_labels.append(label) 133 | new_data.append(value) 134 | except Exception as e: 135 | print(e) 136 | return new_labels, new_data 137 | 138 | def hue_to_hex(hue:float): 139 | rgb = hsv_to_rgb(hue % 1, s=0.5, v=0.8) 140 | hex_code = '#{:02x}{:02x}{:02x}'.format( 141 | int(rgb[0] * 255), 142 | int(rgb[1] * 255), 143 | int(rgb[2] * 255) 144 | ) 145 | return hex_code 146 | 147 | def set(self, message): 148 | self.message = message 149 | self.paused = False 150 | 151 | def set_history(self, entry): 152 | try: 153 | value = int(entry.get()) 154 | if value >= 2: 155 | if value < self.historylen: 156 | self.data.resize((len(self.data), value)) 157 | else: 158 | #pad the front with zeros 159 | self.data = np.concatenate([np.zeros((len(self.data), value - self.historylen)), self.data], axis=1) 160 | self.historylen = value 161 | except Exception as e: 162 | print(e) 163 | 164 | def rescale(self, entry, label): 165 | for scaleframe in self.scale_frames: 166 | label, entry = scaleframe.winfo_children() 167 | label = label['text'] 168 | text = entry.get() 169 | try: 170 | value = float(text) 171 | i = self.labels.index(label) 172 | self.scales[i] = value 173 | self.saved_scales[label] = value 174 | print(f"saved scales: {self.saved_scales}") 175 | except Exception as e: 176 | print(e) 177 | 178 | 179 | def plotloop(self): 180 | ''' 181 | - delete any inactive series by removing the label, data, and canvas line 182 | - if there is a new series, add it to the label, data, and canvas line 183 | - recalculate and apply evenly spaced hues to existing lines 184 | ''' 185 | new_labels, new_data = Plot.str_to_data(self.message) 186 | 187 | if len(new_labels) > 0: 188 | 189 | #remove any data and lines that aren't active 190 | to_delete = [] #indexes of labels to delete 191 | for i in range(len(self.labels)): 192 | if self.labels[i] not in new_labels: 193 | self.canvas.delete(f"{self.labels[i]}L") #delete the plotline 194 | self.canvas.delete(f"{self.labels[i]}T") #delete the drawn text 195 | to_delete.append(i) 196 | if to_delete: 197 | for i in sorted(to_delete, reverse=True): 198 | del_label = self.labels.pop(i) 199 | self.scale_frames[i].destroy() 200 | self.scale_frames.pop(i) 201 | print(f"removed series: {del_label}") 202 | 203 | self.data = np.delete(self.data, to_delete, axis=0) 204 | self.scales = np.delete(self.scales, to_delete, axis=0) 205 | 206 | 207 | #add lines that don't exist yet 208 | added_new = False 209 | for new_label in new_labels: 210 | if new_label not in self.labels: 211 | self.labels.append(new_label) 212 | if len(self.data) > 0: 213 | self.data = np.append(self.data, [np.zeros(self.historylen)], axis=0) 214 | self.scales = np.append(self.scales, 1) 215 | else: 216 | self.data = np.array([np.zeros(self.historylen)]) 217 | self.scales = np.array([1.]) 218 | if new_label in self.saved_scales: #restore the last used scale for this label 219 | self.scales[-1] = self.saved_scales[new_label] 220 | 221 | self.canvas.create_line(0,0,0,0, tag=f"{new_label}L", width=2) 222 | self.canvas.create_text(0, 0, anchor="e", tag=f"{new_label}T", text=new_label) 223 | 224 | scale_frame = tk.Frame(self.side_frame) 225 | scale_frame.pack() 226 | self.scale_frames.append(scale_frame) 227 | 228 | scalelabel = tk.Label(scale_frame, text=new_label) 229 | scalelabel.pack() 230 | 231 | scaleentry = tk.Entry(scale_frame, width=5) 232 | scaleentry.insert(0, f"{self.scales[-1]:g}") 233 | scaleentry.pack(side=tk.RIGHT, before=scalelabel) 234 | scaleentry.bind("", lambda event: self.rescale(scaleentry, scalelabel['text'])) 235 | # self.scale_entries.append(scaleentry) 236 | 237 | added_new = True 238 | print(f"added new series: {new_label}") 239 | 240 | #update line colors if any lines were added 241 | if added_new: 242 | m = len(self.labels) 243 | hues = np.linspace(0, 1, m + 1) #last item is 1, which is the same hue as 0 so it is unused 244 | for i in range(m): 245 | self.canvas.itemconfig(f"{self.labels[i]}L", fill=Plot.hue_to_hex(hues[i])) 246 | self.canvas.itemconfig(f"{self.labels[i]}T", fill=Plot.hue_to_hex(hues[i])) 247 | 248 | # print(f"concating {[self.data, np.array([new_data])[:,np.newaxis]]}") 249 | self.data = np.concatenate([self.data[:,1:], np.array(new_data)[:,np.newaxis]], axis=1) #combine shifted old data and new data 250 | self.after_idle(self.draw) 251 | 252 | self.after(33, self.plotloop) 253 | 254 | 255 | def draw(self): 256 | m = len(self.labels) 257 | n = self.historylen 258 | 259 | if m == 0 or self.paused: 260 | return 261 | 262 | data_scaled = self.data * self.scales.reshape((-1,1)) 263 | 264 | do_resize = False 265 | max_new = np.max(data_scaled) 266 | if abs(max_new - self.max) > 1e-6: 267 | self.max = max_new 268 | do_resize = True 269 | min_new = np.min(data_scaled) 270 | if abs(min_new - self.min) > 1e-6: 271 | self.min = min_new 272 | do_resize = True 273 | if abs(self.max - self.min) < self.epsilon: #if max-min=0, scale is infinite 274 | self.max += self.epsilon 275 | self.min -= self.epsilon 276 | if do_resize: 277 | self.on_resize() 278 | 279 | disp_data = self.disp(data_scaled) 280 | 281 | #canvas.coords() takes in a flattened input: x1,y1,x2,y2,... 282 | #next 3 lines adds a column vectors to every other index of the data 2D array which are the x values. 283 | xvals_row = np.linspace(start=0, stop=self.w, num=n) 284 | xvals_arr = np.tile(xvals_row, (len(self.labels), 1)) 285 | 286 | points = np.dstack([xvals_arr, disp_data]).reshape(m, 2*n) 287 | 288 | for i in range(len(self.labels)): 289 | self.canvas.coords(f"{self.labels[i]}L", points[i].tolist()) 290 | self.canvas.coords(f"{self.labels[i]}T", (points[i][-2:] + self.textoffset).tolist()) 291 | 292 | count = 0 293 | def main(): 294 | np.set_printoptions(precision=2, suppress=True) 295 | 296 | root = tk.Tk() 297 | plot = Plot(root) 298 | 299 | import time 300 | 301 | def senddata(): 302 | global count 303 | count += 1 304 | 305 | period = 100 306 | 307 | message = "" 308 | for i in range((int)(count / period)+1): 309 | if(i > 4): 310 | break 311 | message = message + f"y{i}: {np.sin(time.time()+i)} \n" 312 | 313 | # if count > 5*period: 314 | # count = 0 315 | 316 | plot.set(message) 317 | 318 | PeriodicSleeper(senddata, 0.01) 319 | 320 | root.mainloop() 321 | return 0 322 | 323 | if __name__ == '__main__': 324 | sys.exit(main()) --------------------------------------------------------------------------------