1#!/usr/local/bin/python3.8
2#
3# A simple terminal application with wxPython.
4#
5# (C) 2001-2020 Chris Liechti <cliechti@gmx.net>
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