1#!/usr/bin/env python 2# 3# A simple terminal application with wxPython. 4# 5# (C) 2001-2020 Chris Liechti <[email protected]> 6# 7# SPDX-License-Identifier: BSD-3-Clause 8 9import codecs 10from serial.tools.miniterm import unichr 11import serial 12import threading 13import wx 14import wx.lib.newevent 15import wxSerialConfigDialog 16 17try: 18 unichr 19except NameError: 20 unichr = chr 21 22# ---------------------------------------------------------------------- 23# Create an own event type, so that GUI updates can be delegated 24# this is required as on some platforms only the main thread can 25# access the GUI without crashing. wxMutexGuiEnter/wxMutexGuiLeave 26# could be used too, but an event is more elegant. 27 28SerialRxEvent, EVT_SERIALRX = wx.lib.newevent.NewEvent() 29SERIALRX = wx.NewEventType() 30 31# ---------------------------------------------------------------------- 32 33ID_CLEAR = wx.NewId() 34ID_SAVEAS = wx.NewId() 35ID_SETTINGS = wx.NewId() 36ID_TERM = wx.NewId() 37ID_EXIT = wx.NewId() 38ID_RTS = wx.NewId() 39ID_DTR = wx.NewId() 40 41NEWLINE_CR = 0 42NEWLINE_LF = 1 43NEWLINE_CRLF = 2 44 45 46class TerminalSetup: 47 """ 48 Placeholder for various terminal settings. Used to pass the 49 options to the TerminalSettingsDialog. 50 """ 51 def __init__(self): 52 self.echo = False 53 self.unprintable = False 54 self.newline = NEWLINE_CRLF 55 56 57class TerminalSettingsDialog(wx.Dialog): 58 """Simple dialog with common terminal settings like echo, newline mode.""" 59 60 def __init__(self, *args, **kwds): 61 self.settings = kwds['settings'] 62 del kwds['settings'] 63 # begin wxGlade: TerminalSettingsDialog.__init__ 64 kwds["style"] = wx.DEFAULT_DIALOG_STYLE 65 wx.Dialog.__init__(self, *args, **kwds) 66 self.checkbox_echo = wx.CheckBox(self, -1, "Local Echo") 67 self.checkbox_unprintable = wx.CheckBox(self, -1, "Show unprintable characters") 68 self.radio_box_newline = wx.RadioBox(self, -1, "Newline Handling", choices=["CR only", "LF only", "CR+LF"], majorDimension=0, style=wx.RA_SPECIFY_ROWS) 69 self.sizer_4_staticbox = wx.StaticBox(self, -1, "Input/Output") 70 self.button_ok = wx.Button(self, wx.ID_OK, "") 71 self.button_cancel = wx.Button(self, wx.ID_CANCEL, "") 72 73 self.__set_properties() 74 self.__do_layout() 75 # end wxGlade 76 self.__attach_events() 77 self.checkbox_echo.SetValue(self.settings.echo) 78 self.checkbox_unprintable.SetValue(self.settings.unprintable) 79 self.radio_box_newline.SetSelection(self.settings.newline) 80 81 def __set_properties(self): 82 # begin wxGlade: TerminalSettingsDialog.__set_properties 83 self.SetTitle("Terminal Settings") 84 self.radio_box_newline.SetSelection(0) 85 self.button_ok.SetDefault() 86 # end wxGlade 87 88 def __do_layout(self): 89 # begin wxGlade: TerminalSettingsDialog.__do_layout 90 sizer_2 = wx.BoxSizer(wx.VERTICAL) 91 sizer_3 = wx.BoxSizer(wx.HORIZONTAL) 92 self.sizer_4_staticbox.Lower() 93 sizer_4 = wx.StaticBoxSizer(self.sizer_4_staticbox, wx.VERTICAL) 94 sizer_4.Add(self.checkbox_echo, 0, wx.ALL, 4) 95 sizer_4.Add(self.checkbox_unprintable, 0, wx.ALL, 4) 96 sizer_4.Add(self.radio_box_newline, 0, 0, 0) 97 sizer_2.Add(sizer_4, 0, wx.EXPAND, 0) 98 sizer_3.Add(self.button_ok, 0, 0, 0) 99 sizer_3.Add(self.button_cancel, 0, 0, 0) 100 sizer_2.Add(sizer_3, 0, wx.ALL | wx.ALIGN_RIGHT, 4) 101 self.SetSizer(sizer_2) 102 sizer_2.Fit(self) 103 self.Layout() 104 # end wxGlade 105 106 def __attach_events(self): 107 self.Bind(wx.EVT_BUTTON, self.OnOK, id=self.button_ok.GetId()) 108 self.Bind(wx.EVT_BUTTON, self.OnCancel, id=self.button_cancel.GetId()) 109 110 def OnOK(self, events): 111 """Update data wil new values and close dialog.""" 112 self.settings.echo = self.checkbox_echo.GetValue() 113 self.settings.unprintable = self.checkbox_unprintable.GetValue() 114 self.settings.newline = self.radio_box_newline.GetSelection() 115 self.EndModal(wx.ID_OK) 116 117 def OnCancel(self, events): 118 """Do not update data but close dialog.""" 119 self.EndModal(wx.ID_CANCEL) 120 121# end of class TerminalSettingsDialog 122 123 124class TerminalFrame(wx.Frame): 125 """Simple terminal program for wxPython""" 126 127 def __init__(self, *args, **kwds): 128 self.serial = serial.Serial() 129 self.serial.timeout = 0.5 # make sure that the alive event can be checked from time to time 130 self.settings = TerminalSetup() # placeholder for the settings 131 self.thread = None 132 self.alive = threading.Event() 133 # begin wxGlade: TerminalFrame.__init__ 134 kwds["style"] = wx.DEFAULT_FRAME_STYLE 135 wx.Frame.__init__(self, *args, **kwds) 136 137 # Menu Bar 138 self.frame_terminal_menubar = wx.MenuBar() 139 wxglade_tmp_menu = wx.Menu() 140 wxglade_tmp_menu.Append(ID_CLEAR, "&Clear", "", wx.ITEM_NORMAL) 141 wxglade_tmp_menu.Append(ID_SAVEAS, "&Save Text As...", "", wx.ITEM_NORMAL) 142 wxglade_tmp_menu.AppendSeparator() 143 wxglade_tmp_menu.Append(ID_TERM, "&Terminal Settings...", "", wx.ITEM_NORMAL) 144 wxglade_tmp_menu.AppendSeparator() 145 wxglade_tmp_menu.Append(ID_EXIT, "&Exit", "", wx.ITEM_NORMAL) 146 self.frame_terminal_menubar.Append(wxglade_tmp_menu, "&File") 147 wxglade_tmp_menu = wx.Menu() 148 wxglade_tmp_menu.Append(ID_RTS, "RTS", "", wx.ITEM_CHECK) 149 wxglade_tmp_menu.Append(ID_DTR, "&DTR", "", wx.ITEM_CHECK) 150 wxglade_tmp_menu.Append(ID_SETTINGS, "&Port Settings...", "", wx.ITEM_NORMAL) 151 self.frame_terminal_menubar.Append(wxglade_tmp_menu, "Serial Port") 152 self.SetMenuBar(self.frame_terminal_menubar) 153 # Menu Bar end 154 self.text_ctrl_output = wx.TextCtrl(self, -1, "", style=wx.TE_MULTILINE | wx.TE_READONLY) 155 156 self.__set_properties() 157 self.__do_layout() 158 159 self.Bind(wx.EVT_MENU, self.OnClear, id=ID_CLEAR) 160 self.Bind(wx.EVT_MENU, self.OnSaveAs, id=ID_SAVEAS) 161 self.Bind(wx.EVT_MENU, self.OnTermSettings, id=ID_TERM) 162 self.Bind(wx.EVT_MENU, self.OnExit, id=ID_EXIT) 163 self.Bind(wx.EVT_MENU, self.OnRTS, id=ID_RTS) 164 self.Bind(wx.EVT_MENU, self.OnDTR, id=ID_DTR) 165 self.Bind(wx.EVT_MENU, self.OnPortSettings, id=ID_SETTINGS) 166 # end wxGlade 167 self.__attach_events() # register events 168 self.OnPortSettings(None) # call setup dialog on startup, opens port 169 if not self.alive.isSet(): 170 self.Close() 171 172 def StartThread(self): 173 """Start the receiver thread""" 174 self.thread = threading.Thread(target=self.ComPortThread) 175 self.thread.setDaemon(1) 176 self.alive.set() 177 self.thread.start() 178 self.serial.rts = True 179 self.serial.dtr = True 180 self.frame_terminal_menubar.Check(ID_RTS, self.serial.rts) 181 self.frame_terminal_menubar.Check(ID_DTR, self.serial.dtr) 182 183 def StopThread(self): 184 """Stop the receiver thread, wait until it's finished.""" 185 if self.thread is not None: 186 self.alive.clear() # clear alive event for thread 187 self.thread.join() # wait until thread has finished 188 self.thread = None 189 190 def __set_properties(self): 191 # begin wxGlade: TerminalFrame.__set_properties 192 self.SetTitle("Serial Terminal") 193 self.SetSize((546, 383)) 194 self.text_ctrl_output.SetFont(wx.Font(9, wx.MODERN, wx.NORMAL, wx.NORMAL, 0, "")) 195 # end wxGlade 196 197 def __do_layout(self): 198 # begin wxGlade: TerminalFrame.__do_layout 199 sizer_1 = wx.BoxSizer(wx.VERTICAL) 200 sizer_1.Add(self.text_ctrl_output, 1, wx.EXPAND, 0) 201 self.SetSizer(sizer_1) 202 self.Layout() 203 # end wxGlade 204 205 def __attach_events(self): 206 # register events at the controls 207 self.Bind(wx.EVT_MENU, self.OnClear, id=ID_CLEAR) 208 self.Bind(wx.EVT_MENU, self.OnSaveAs, id=ID_SAVEAS) 209 self.Bind(wx.EVT_MENU, self.OnExit, id=ID_EXIT) 210 self.Bind(wx.EVT_MENU, self.OnPortSettings, id=ID_SETTINGS) 211 self.Bind(wx.EVT_MENU, self.OnTermSettings, id=ID_TERM) 212 self.text_ctrl_output.Bind(wx.EVT_CHAR, self.OnKey) 213 self.Bind(wx.EVT_CHAR_HOOK, self.OnKey) 214 self.Bind(EVT_SERIALRX, self.OnSerialRead) 215 self.Bind(wx.EVT_CLOSE, self.OnClose) 216 217 def OnExit(self, event): # wxGlade: TerminalFrame.<event_handler> 218 """Menu point Exit""" 219 self.Close() 220 221 def OnClose(self, event): 222 """Called on application shutdown.""" 223 self.StopThread() # stop reader thread 224 self.serial.close() # cleanup 225 self.Destroy() # close windows, exit app 226 227 def OnSaveAs(self, event): # wxGlade: TerminalFrame.<event_handler> 228 """Save contents of output window.""" 229 with wx.FileDialog( 230 None, 231 "Save Text As...", 232 ".", 233 "", 234 "Text File|*.txt|All Files|*", 235 wx.SAVE) as dlg: 236 if dlg.ShowModal() == wx.ID_OK: 237 filename = dlg.GetPath() 238 with codecs.open(filename, 'w', encoding='utf-8') as f: 239 text = self.text_ctrl_output.GetValue().encode("utf-8") 240 f.write(text) 241 242 def OnClear(self, event): # wxGlade: TerminalFrame.<event_handler> 243 """Clear contents of output window.""" 244 self.text_ctrl_output.Clear() 245 246 def OnPortSettings(self, event): # wxGlade: TerminalFrame.<event_handler> 247 """ 248 Show the port settings dialog. The reader thread is stopped for the 249 settings change. 250 """ 251 if event is not None: # will be none when called on startup 252 self.StopThread() 253 self.serial.close() 254 ok = False 255 while not ok: 256 with wxSerialConfigDialog.SerialConfigDialog( 257 self, 258 -1, 259 "", 260 show=wxSerialConfigDialog.SHOW_BAUDRATE | wxSerialConfigDialog.SHOW_FORMAT | wxSerialConfigDialog.SHOW_FLOW, 261 serial=self.serial) as dialog_serial_cfg: 262 dialog_serial_cfg.CenterOnParent() 263 result = dialog_serial_cfg.ShowModal() 264 # open port if not called on startup, open it on startup and OK too 265 if result == wx.ID_OK or event is not None: 266 try: 267 self.serial.open() 268 except serial.SerialException as e: 269 with wx.MessageDialog(self, str(e), "Serial Port Error", wx.OK | wx.ICON_ERROR)as dlg: 270 dlg.ShowModal() 271 else: 272 self.StartThread() 273 self.SetTitle("Serial Terminal on {} [{},{},{},{}{}{}]".format( 274 self.serial.portstr, 275 self.serial.baudrate, 276 self.serial.bytesize, 277 self.serial.parity, 278 self.serial.stopbits, 279 ' RTS/CTS' if self.serial.rtscts else '', 280 ' Xon/Xoff' if self.serial.xonxoff else '', 281 )) 282 ok = True 283 else: 284 # on startup, dialog aborted 285 self.alive.clear() 286 ok = True 287 288 def OnTermSettings(self, event): # wxGlade: TerminalFrame.<event_handler> 289 """\ 290 Menu point Terminal Settings. Show the settings dialog 291 with the current terminal settings. 292 """ 293 with TerminalSettingsDialog(self, -1, "", settings=self.settings) as dialog: 294 dialog.CenterOnParent() 295 dialog.ShowModal() 296 297 def OnKey(self, event): 298 """\ 299 Key event handler. If the key is in the ASCII range, write it to the 300 serial port. Newline handling and local echo is also done here. 301 """ 302 code = event.GetUnicodeKey() 303 # if code < 256: # XXX bug in some versions of wx returning only capital letters 304 # code = event.GetKeyCode() 305 if code == 13: # is it a newline? (check for CR which is the RETURN key) 306 if self.settings.echo: # do echo if needed 307 self.text_ctrl_output.AppendText('\n') 308 if self.settings.newline == NEWLINE_CR: 309 self.serial.write(b'\r') # send CR 310 elif self.settings.newline == NEWLINE_LF: 311 self.serial.write(b'\n') # send LF 312 elif self.settings.newline == NEWLINE_CRLF: 313 self.serial.write(b'\r\n') # send CR+LF 314 else: 315 char = unichr(code) 316 if self.settings.echo: # do echo if needed 317 self.WriteText(char) 318 self.serial.write(char.encode('UTF-8', 'replace')) # send the character 319 event.StopPropagation() 320 321 def WriteText(self, text): 322 if self.settings.unprintable: 323 text = ''.join([c if (c >= ' ' and c != '\x7f') else unichr(0x2400 + ord(c)) for c in text]) 324 self.text_ctrl_output.AppendText(text) 325 326 def OnSerialRead(self, event): 327 """Handle input from the serial port.""" 328 self.WriteText(event.data.decode('UTF-8', 'replace')) 329 330 def ComPortThread(self): 331 """\ 332 Thread that handles the incoming traffic. Does the basic input 333 transformation (newlines) and generates an SerialRxEvent 334 """ 335 while self.alive.isSet(): 336 b = self.serial.read(self.serial.in_waiting or 1) 337 if b: 338 # newline transformation 339 if self.settings.newline == NEWLINE_CR: 340 b = b.replace(b'\r', b'\n') 341 elif self.settings.newline == NEWLINE_LF: 342 pass 343 elif self.settings.newline == NEWLINE_CRLF: 344 b = b.replace(b'\r\n', b'\n') 345 wx.PostEvent(self, SerialRxEvent(data=b)) 346 347 def OnRTS(self, event): # wxGlade: TerminalFrame.<event_handler> 348 self.serial.rts = event.IsChecked() 349 350 def OnDTR(self, event): # wxGlade: TerminalFrame.<event_handler> 351 self.serial.dtr = event.IsChecked() 352 353# end of class TerminalFrame 354 355 356class MyApp(wx.App): 357 def OnInit(self): 358 frame_terminal = TerminalFrame(None, -1, "") 359 self.SetTopWindow(frame_terminal) 360 frame_terminal.Show(True) 361 return 1 362 363# end of class MyApp 364 365if __name__ == "__main__": 366 app = MyApp(0) 367 app.MainLoop() 368