Skip to content

Commit f73b9a9

Browse files
rwalton-armPatater
authored andcommitted
Implement serial terminal
1 parent 0865b38 commit f73b9a9

File tree

5 files changed

+366
-0
lines changed

5 files changed

+366
-0
lines changed

news/20201111104222.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Implement sterm command.

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"pyudev; platform_system=='Linux'",
4949
"typing-extensions",
5050
"Jinja2",
51+
"pyserial",
5152
],
5253
license="Apache 2.0",
5354
long_description_content_type="text/markdown",

src/mbed_tools/sterm/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#
2+
# Copyright (C) 2020 Arm Mbed. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
"""Package containing sterm functionality."""

src/mbed_tools/sterm/terminal.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
#
2+
# Copyright (C) 2020 Arm Mbed. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
"""Serial terminal implementation based on pyserial.tools.miniterm.
6+
7+
The Mbed serial terminal makes the following modifications to the default Miniterm.
8+
* Custom help menu text
9+
* Constrained set of menu keys
10+
* CTRL-H to show help
11+
* CTRL-B sends serial break to the target
12+
13+
To start the terminal clients should call the "run" function, this is the entry point to the module.
14+
"""
15+
from typing import Any
16+
17+
from serial import Serial
18+
from serial.tools.miniterm import Miniterm
19+
20+
21+
def run(port: str, baud: int, echo: bool = True) -> None:
22+
"""Run the serial terminal.
23+
24+
This function is blocking as it waits for the terminal thread to finish executing before returning.
25+
26+
Args:
27+
port: The serial port to open a terminal on.
28+
baud: Serial baud rate.
29+
echo: Echo user input back to the console.
30+
"""
31+
term = SerialTerminal(Serial(port=port, baudrate=str(baud)), echo=echo)
32+
term.start()
33+
34+
try:
35+
term.join(True)
36+
except KeyboardInterrupt:
37+
pass
38+
finally:
39+
term.join()
40+
term.close()
41+
42+
43+
class SerialTerminal(Miniterm):
44+
"""An implementation of Miniterm that implements the additional Mbed terminal functionality.
45+
46+
Overrides the `writer` method to implement modified menu key handling behaviour.
47+
Overrides the Miniterm::get_help_text method to return the Mbed custom help text.
48+
Adds a `reset` method so users can send a reset signal to the device.
49+
"""
50+
51+
def __init__(self, *args: Any, **kwargs: Any) -> None:
52+
"""Set the rx/tx encoding and special characters."""
53+
super().__init__(*args, **kwargs)
54+
self.exit_character = CTRL_C
55+
self.menu_character = CTRL_T
56+
self.reset_character = CTRL_B
57+
self.help_character = CTRL_H
58+
self.set_rx_encoding("UTF-8")
59+
self.set_tx_encoding("UTF-8")
60+
61+
def reset(self) -> None:
62+
"""Send a reset signal."""
63+
self.serial.sendBreak()
64+
65+
def get_help_text(self) -> str:
66+
"""Return the text displayed when the user requests help."""
67+
return HELP_TEXT
68+
69+
def writer(self) -> None:
70+
"""Implements terminal behaviour."""
71+
menu_active = False
72+
while self.alive:
73+
try:
74+
input_key = self.console.getkey()
75+
except KeyboardInterrupt:
76+
input_key = self.exit_character
77+
78+
if (menu_active and input_key in VALID_MENU_KEYS) or (input_key == self.help_character):
79+
self.handle_menu_key(input_key)
80+
menu_active = False
81+
82+
elif input_key == self.menu_character:
83+
menu_active = True
84+
85+
elif input_key == self.reset_character:
86+
self.reset()
87+
88+
elif input_key == self.exit_character:
89+
self.stop()
90+
break
91+
92+
else:
93+
self._write_transformed_char(input_key)
94+
95+
if self.echo:
96+
self._echo_transformed_char(input_key)
97+
98+
def _write_transformed_char(self, text: str) -> None:
99+
for transformation in self.tx_transformations:
100+
text = transformation.tx(text)
101+
102+
self.serial.write(self.tx_encoder.encode(text))
103+
104+
def _echo_transformed_char(self, text: str) -> None:
105+
for transformation in self.tx_transformations:
106+
text = transformation.echo(text)
107+
108+
self.console.write(text)
109+
110+
111+
CTRL_B = "\x02"
112+
CTRL_C = "\x03"
113+
CTRL_H = "\x08"
114+
CTRL_T = "\x14"
115+
VALID_MENU_KEYS = ("p", "b", "\t", "\x01", "\x04", "\x05", "\x06", "\x0c", CTRL_C, CTRL_T)
116+
HELP_TEXT = """--- Mbed Serial Terminal
117+
--- Based on miniterm from pySerial
118+
---
119+
--- Ctrl+b Send Break (reset target)
120+
--- Ctrl+c Exit terminal
121+
--- Ctrl+h Help
122+
--- Ctrl+t Menu escape key, followed by:
123+
--- p Change COM port
124+
--- b Change baudrate
125+
--- Tab Show detailed terminal info
126+
--- Ctrl+a Change encoding (default UTF-8)
127+
--- Ctrl+f Edit filters
128+
--- Ctrl+e Toggle local echo
129+
--- Ctrl+l Toggle EOL
130+
--- Ctrl+r Toggle RTS
131+
--- Ctrl+d Toggle DTR
132+
--- Ctrl+c Send control character to remote
133+
--- Ctrl+t Send control character to remote
134+
"""

tests/sterm/test_terminal.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#
2+
# Copyright (C) 2020 Arm Mbed. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
from unittest import mock
6+
7+
import sys
8+
9+
import pytest
10+
11+
from mbed_tools.sterm import terminal
12+
13+
14+
@pytest.fixture
15+
def mock_serial():
16+
with mock.patch("mbed_tools.sterm.terminal.Serial") as serial:
17+
yield serial
18+
19+
20+
@pytest.fixture
21+
def mock_console():
22+
with mock.patch("serial.tools.miniterm.Console") as console:
23+
yield console
24+
25+
26+
@pytest.fixture
27+
def mock_sterm():
28+
with mock.patch("mbed_tools.sterm.terminal.SerialTerminal") as sterm:
29+
yield sterm
30+
31+
32+
def test_initialises_serial_port(mock_sterm, mock_serial):
33+
port = "tty.1111"
34+
baud = 9600
35+
36+
terminal.run(port, baud)
37+
38+
mock_serial.assert_called_once_with(port=port, baudrate=str(baud))
39+
40+
41+
def test_initialises_sterm(mock_sterm, mock_serial):
42+
port = "tty.1111"
43+
baud = "9600"
44+
45+
terminal.run(port, baud)
46+
47+
mock_sterm.assert_called_once_with(mock_serial(), echo=True)
48+
49+
50+
def test_starts_sterm_thread(mock_sterm, mock_serial):
51+
terminal.run("tty.122", 9600)
52+
53+
mock_sterm().start.assert_called_once()
54+
55+
56+
def test_joins_tx_and_rx_threads(mock_sterm, mock_serial):
57+
terminal.run("tty.122", 9600)
58+
59+
mock_sterm().join.assert_any_call(True)
60+
61+
62+
def test_joins_tx_thread_after_keyboard_interrupt(mock_sterm, mock_serial):
63+
mock_sterm().join.side_effect = (KeyboardInterrupt(), None)
64+
65+
terminal.run("tty.122", 9600)
66+
67+
mock_sterm().join.assert_called_with()
68+
69+
70+
def test_closes_sterm(mock_sterm, mock_serial):
71+
terminal.run("tty.122", 9600)
72+
73+
mock_sterm().close.assert_called_once()
74+
75+
76+
def test_closes_sterm_after_exception(mock_sterm, mock_serial):
77+
mock_sterm().join.side_effect = (Exception(), None)
78+
with pytest.raises(Exception):
79+
terminal.run("tty.122", 9600)
80+
81+
mock_sterm().close.assert_called_once()
82+
83+
84+
def test_closes_sterm_after_keyboard_interrupt(mock_sterm, mock_serial):
85+
mock_sterm().join.side_effect = (KeyboardInterrupt(), None)
86+
terminal.run("tty.122", 9600)
87+
88+
mock_sterm().close.assert_called_once()
89+
90+
91+
def test_sets_terminal_special_chars(mock_serial, mock_console):
92+
term = terminal.SerialTerminal(mock_serial())
93+
94+
assert term.exit_character == terminal.CTRL_C
95+
assert term.menu_character == terminal.CTRL_T
96+
assert term.reset_character == terminal.CTRL_B
97+
assert term.help_character == terminal.CTRL_H
98+
99+
100+
def test_sets_terminal_rx_and_tx_encoding_to_utf8(mock_serial, mock_console):
101+
term = terminal.SerialTerminal(mock_serial())
102+
103+
assert term.input_encoding == "UTF-8"
104+
assert term.output_encoding == "UTF-8"
105+
106+
107+
def test_stops_terminal_when_ctrl_c_received(mock_serial, mock_console):
108+
term = terminal.SerialTerminal(mock_serial())
109+
term.alive = True
110+
mock_console().getkey.return_value = terminal.CTRL_C
111+
112+
term.writer()
113+
114+
assert term.alive is False
115+
116+
117+
def test_stops_terminal_on_keyboard_interrupt(mock_serial, mock_console):
118+
term = terminal.SerialTerminal(mock_serial())
119+
term.alive = True
120+
mock_console().getkey.side_effect = KeyboardInterrupt()
121+
122+
term.writer()
123+
124+
assert term.alive is False
125+
126+
127+
@pytest.mark.parametrize("menu_key", terminal.VALID_MENU_KEYS)
128+
def test_handles_valid_menu_key(menu_key, mock_serial, mock_console):
129+
term = terminal.SerialTerminal(mock_serial())
130+
term.handle_menu_key = mock.Mock()
131+
term.alive = True
132+
mock_console().getkey.side_effect = (terminal.CTRL_T, menu_key, terminal.CTRL_C)
133+
134+
term.writer()
135+
136+
term.handle_menu_key.assert_called_once_with(menu_key)
137+
138+
139+
INVALID_MENU_KEYS = tuple(set(chr(i) for i in range(0, 127)) - set(terminal.VALID_MENU_KEYS) - set([terminal.CTRL_H]))
140+
141+
142+
@pytest.mark.parametrize("menu_key", INVALID_MENU_KEYS)
143+
def test_ignores_invalid_menu_key(menu_key, mock_serial, mock_console):
144+
term = terminal.SerialTerminal(mock_serial())
145+
term.handle_menu_key = mock.Mock()
146+
term.alive = True
147+
mock_console().getkey.side_effect = (terminal.CTRL_T, menu_key)
148+
149+
with pytest.raises(StopIteration):
150+
term.writer()
151+
152+
term.handle_menu_key.assert_not_called()
153+
154+
155+
def test_reset_sends_serial_break(mock_serial, mock_console):
156+
term = terminal.SerialTerminal(mock_serial())
157+
158+
term.reset()
159+
160+
mock_serial().sendBreak.assert_called_once()
161+
162+
163+
def test_ctrl_b_sends_reset_to_serial_port(mock_serial, mock_console):
164+
term = terminal.SerialTerminal(mock_serial())
165+
term.alive = True
166+
mock_console().getkey.side_effect = (terminal.CTRL_B,)
167+
168+
with pytest.raises(StopIteration):
169+
term.writer()
170+
171+
mock_serial().sendBreak.assert_called_once()
172+
173+
174+
def test_ctrl_h_prints_help_text(mock_serial, mock_console):
175+
sys.stderr.write = mock.Mock()
176+
term = terminal.SerialTerminal(mock_serial())
177+
term.alive = True
178+
mock_console().getkey.side_effect = (terminal.CTRL_H,)
179+
180+
with pytest.raises(StopIteration):
181+
term.writer()
182+
183+
sys.stderr.write.assert_called_once_with(term.get_help_text())
184+
185+
186+
def test_help_text_is_correct(mock_serial, mock_console):
187+
term = terminal.SerialTerminal(mock_serial())
188+
189+
assert term.get_help_text() == terminal.HELP_TEXT
190+
191+
192+
def test_writes_normal_char_to_serial_output(mock_serial, mock_console):
193+
term = terminal.SerialTerminal(mock_serial())
194+
term.alive = True
195+
normal_char = "h"
196+
mock_console().getkey.side_effect = (normal_char,)
197+
198+
with pytest.raises(StopIteration):
199+
term.writer()
200+
201+
mock_serial().write.assert_called_once_with(term.tx_encoder.encode(normal_char))
202+
203+
204+
def test_echo_to_console_is_default_disabled(mock_serial, mock_console):
205+
term = terminal.SerialTerminal(mock_serial())
206+
term.alive = True
207+
normal_char = "h"
208+
mock_console().getkey.side_effect = (normal_char,)
209+
210+
with pytest.raises(StopIteration):
211+
term.writer()
212+
213+
mock_console().write.assert_not_called()
214+
215+
216+
def test_echo_to_console_can_be_enabled(mock_serial, mock_console):
217+
term = terminal.SerialTerminal(mock_serial(), echo=True)
218+
term.alive = True
219+
normal_char = "h"
220+
mock_console().getkey.side_effect = (normal_char,)
221+
222+
with pytest.raises(StopIteration):
223+
term.writer()
224+
225+
mock_console().write.assert_called_once_with(normal_char)

0 commit comments

Comments
 (0)