Skip to content

Commit b4e11ea

Browse files
committed
remote/client: Provide an internal console
At present Labgrid uses microcom as its console. This has some limitations: - console output is lost between when the board is reset and microcom connects - txdelay cannot be handled in microcom, meaning that boards may fail to receive expected output - the console may echo a few characters back to the caller in the time between when 'labgrid-client console' is executed and when microcom starts (which causes failures with U-Boot test system) For many use cases, microcom is more than is needed, so provide a simple internal terminal which resolves the above problems. It is enabled by a '-i' option to the 'console' command, as well as an environment variable, so that it can be adjusted without updating a lot of scripts. To exit, press Ctrl-] twice, quickly. Use prompt_toolkit to look after the terminal, since it already knows how to put it into raw mode and how to watch it from the event loop. The keystrokes themselves are read with os.read() rather than prompt_toolkit's read_keys(), since the board needs a transparent byte stream: read_keys() decodes the input and parses escape sequences into key objects, so control characters and escape sequences would no longer reach the board as they were typed. Series-changes: 4 - Get internal console working with qemu - Show a prompt when starting, to indicate it is waiting for the board Series-changes: 5 - Use prompt_toolkit to handle the terminal, rather than driving termios directly (Rouven) - Watch the keyboard from the event loop, instead of polling stdin Signed-off-by: Simon Glass <sjg@chromium.org>
1 parent 53dea3a commit b4e11ea

5 files changed

Lines changed: 219 additions & 31 deletions

File tree

doc/usage.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,3 +839,14 @@ like this:
839839
$ labgrid-client -p example allow sirius/john
840840
841841
To remove the allow it is currently necessary to unlock and lock the place.
842+
843+
Internal console
844+
^^^^^^^^^^^^^^^^
845+
846+
Labgrid uses microcom as its console by default. For situations where this is
847+
not suitable, an internal console is provided. To use this, provide the
848+
``--internal`` flag to the ``labgrid client`` command.
849+
850+
When the internal console is used, the console transitions cleanly between use
851+
within a strategy or driver, and interactive use for the user. The console is
852+
not closed and therefore there is no loss of data.

labgrid/remote/client.py

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,43 +1029,49 @@ def digital_io(self):
10291029
drv.set(False)
10301030

10311031
async def _console(self, place, target, timeout, *, logfile=None, loop=False, listen_only=False):
1032+
from ..protocol import ConsoleProtocol
1033+
10321034
name = self.args.name
1033-
from ..resource import NetworkSerialPort
10341035

1035-
# deactivate console drivers so we are able to connect with microcom
1036-
try:
1037-
con = target.get_active_driver("ConsoleProtocol")
1038-
target.deactivate(con)
1039-
except NoDriverFoundError:
1040-
pass
1036+
if not place.acquired:
1037+
print("place released")
1038+
return 255
10411039

1042-
resource = target.get_resource(NetworkSerialPort, name=name, wait_avail=False)
1040+
if self.args.internal or os.environ.get("LG_CONSOLE") == "internal":
1041+
console = target.get_driver(ConsoleProtocol, name=name)
1042+
returncode = await term.internal(lambda: self.is_allowed(place), console, logfile, listen_only)
1043+
else:
1044+
from ..resource import NetworkSerialPort
10431045

1044-
# async await resources
1045-
timeout = Timeout(timeout)
1046-
while True:
1047-
target.update_resources()
1048-
if resource.avail or (not loop and timeout.expired):
1049-
break
1050-
await asyncio.sleep(0.1)
1046+
# deactivate console drivers so we are able to connect with microcom
1047+
try:
1048+
con = target.get_active_driver("ConsoleProtocol")
1049+
target.deactivate(con)
1050+
except NoDriverFoundError:
1051+
pass
10511052

1052-
# use zero timeout to prevent blocking sleeps
1053-
target.await_resources([resource], timeout=0.0)
1053+
resource = target.get_resource(NetworkSerialPort, name=name, wait_avail=False)
10541054

1055-
if not place.acquired:
1056-
print("place released")
1057-
return 255
1055+
# async await resources
1056+
timeout = Timeout(timeout)
1057+
while True:
1058+
target.update_resources()
1059+
if resource.avail or (not loop and timeout.expired):
1060+
break
1061+
await asyncio.sleep(0.1)
10581062

1059-
host, port = proxymanager.get_host_and_port(resource)
1063+
# use zero timeout to prevent blocking sleeps
1064+
target.await_resources([resource], timeout=0.0)
1065+
host, port = proxymanager.get_host_and_port(resource)
10601066

1061-
# check for valid resources
1062-
assert port is not None, "Port is not set"
1063-
try:
1064-
returncode = await term.external(
1065-
lambda: self.is_allowed(place), host, port, resource, logfile, listen_only
1066-
)
1067-
except FileNotFoundError as e:
1068-
raise ServerError(f"failed to execute remote console command: {e}")
1067+
# check for valid resources
1068+
assert port is not None, "Port is not set"
1069+
try:
1070+
returncode = await term.external(
1071+
lambda: self.is_allowed(place), host, port, resource, logfile, listen_only
1072+
)
1073+
except FileNotFoundError as e:
1074+
raise ServerError(f"failed to execute remote console command: {e}")
10691075

10701076
# Raise an exception if the place was released
10711077
self._check_allowed(place)
@@ -1966,6 +1972,7 @@ def get_parser(auto_doc_mode=False) -> "argparse.ArgumentParser | AutoProgramArg
19661972
subparser.set_defaults(func=ClientSession.digital_io)
19671973

19681974
subparser = subparsers.add_parser("console", aliases=("con",), help="connect to the console")
1975+
subparser.add_argument("-i", "--internal", action="store_true", help="use an internal console instead of microcom")
19691976
subparser.add_argument(
19701977
"-l", "--loop", action="store_true", help="keep trying to connect if the console is unavailable"
19711978
)

labgrid/util/term.py

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
"""Terminal handling, using microcom or telnet"""
1+
"""Terminal handling, using microcom, telnet or an internal function"""
22

33
import asyncio
4+
import collections
5+
import contextlib
46
import logging
7+
import os
58
import sys
69
import shutil
10+
import time
11+
12+
from pexpect import TIMEOUT
13+
from prompt_toolkit.input import create_input
14+
from serial.serialutil import SerialException
715

816
EXIT_CHAR = 0x1d # FS (Ctrl + ])
917

@@ -69,3 +77,159 @@ async def external(check_allowed, host, port, resource, logfile, listen_only):
6977
if p.returncode:
7078
print("connection lost", file=sys.stderr)
7179
return p.returncode
80+
81+
82+
BUF_SIZE = 1024
83+
84+
async def run(check_allowed, cons, log_fd, listen_only, inp=None):
85+
"""Handle the console session, passing data between board and terminal
86+
87+
Args:
88+
check_allowed (lambda): Function to call to make sure the terminal is
89+
still accessible. No args. Returns True if allowed, False if not.
90+
cons (ConsoleProtocol): Console device to read/write
91+
log_fd (file): File to write console output to, or None
92+
listen_only (bool): True to ignore keyboard input
93+
inp (prompt_toolkit Input): Terminal to read keystrokes from, or None
94+
to ignore the keyboard
95+
"""
96+
prev = collections.deque(maxlen=2)
97+
98+
deadline = None
99+
to_cons = b''
100+
next_cons = time.monotonic()
101+
txdelay = cons.txdelay
102+
typed = bytearray()
103+
104+
def read_keys():
105+
"""Collect what the user types, called by the event loop
106+
107+
This reads the bytes as they arrive, rather than using prompt_toolkit's
108+
read_keys(), since the board needs a transparent stream: it must see
109+
escape sequences and control characters exactly as they were typed.
110+
"""
111+
try:
112+
data = os.read(inp.fileno(), BUF_SIZE)
113+
except OSError:
114+
data = b''
115+
if data:
116+
typed.extend(data)
117+
else:
118+
# The terminal is gone (e.g. a closed pipe), so stop watching it,
119+
# else the event loop calls us forever
120+
asyncio.get_running_loop().remove_reader(inp.fileno())
121+
122+
# Show a message to indicate we are waiting for output from the board
123+
msg = 'Terminal ready...press Ctrl-] twice to exit'
124+
sys.stdout.write(msg)
125+
sys.stdout.flush()
126+
erase_msg = '\b' * len(msg) + ' ' * len(msg) + '\b' * len(msg)
127+
have_output = False
128+
129+
# Ask the event loop to tell us when the user types something
130+
watch_keys = inp.attach(read_keys) if inp else contextlib.nullcontext()
131+
132+
with watch_keys:
133+
while True:
134+
activity = bool(to_cons)
135+
try:
136+
data = cons.read(size=BUF_SIZE, timeout=0.001)
137+
if data:
138+
activity = True
139+
if not have_output:
140+
# Erase our message
141+
sys.stdout.write(erase_msg)
142+
sys.stdout.flush()
143+
have_output = True
144+
sys.stdout.buffer.write(data)
145+
sys.stdout.buffer.flush()
146+
if log_fd:
147+
log_fd.write(data)
148+
log_fd.flush()
149+
150+
except TIMEOUT:
151+
pass
152+
153+
except SerialException:
154+
break
155+
156+
if typed:
157+
data = bytes(typed)
158+
typed.clear()
159+
activity = True
160+
if not deadline:
161+
deadline = time.monotonic() + .5 # seconds
162+
prev.extend(data)
163+
count = prev.count(EXIT_CHAR)
164+
if count == 2:
165+
break
166+
167+
to_cons += data
168+
169+
if to_cons and time.monotonic() > next_cons:
170+
cons._write(to_cons[:1])
171+
to_cons = to_cons[1:]
172+
if txdelay:
173+
next_cons += txdelay
174+
175+
if deadline and time.monotonic() > deadline:
176+
prev.clear()
177+
deadline = None
178+
if check_allowed():
179+
break
180+
181+
# Give the event loop a chance to run read_keys()
182+
await asyncio.sleep(0 if activity else .001)
183+
184+
# Blank line to move past any partial output
185+
print()
186+
187+
188+
async def internal(check_allowed, cons, logfile, listen_only):
189+
"""Start an internal terminal session
190+
191+
This talks to the board directly, rather than starting a separate tool.
192+
193+
Args:
194+
check_allowed (lambda): Function to call to make sure the terminal is
195+
still accessible. No args. Returns True if allowed, False if not.
196+
cons (ConsoleProtocol): Console device to read/write
197+
logfile (str): Logfile to write output too, or None
198+
listen_only (bool): True to ignore keyboard input
199+
200+
Return:
201+
int: Result code
202+
"""
203+
returncode = 0
204+
log_fd = None
205+
inp = None
206+
try:
207+
# Watch the keyboard, unless we only care about what the board says
208+
if not listen_only:
209+
inp = create_input()
210+
211+
if logfile:
212+
log_fd = open(logfile, 'wb')
213+
214+
logging.info('Console start:')
215+
216+
# Put the terminal in raw mode, so that keystrokes reach the board
217+
# untouched. As well as the echo and line editing, this turns off
218+
# flow control (so that Ctrl-S and Ctrl-Q get through) and the
219+
# carriage-return translation. It does nothing if stdin is not a
220+
# terminal, such as when input is piped in
221+
raw_mode = inp.raw_mode() if inp else contextlib.nullcontext()
222+
with raw_mode:
223+
await run(check_allowed, cons, log_fd, listen_only, inp)
224+
225+
except OSError as err:
226+
print('error', err)
227+
returncode = 1
228+
229+
finally:
230+
if inp:
231+
inp.close()
232+
if log_fd:
233+
log_fd.close()
234+
235+
return returncode

man/labgrid-client.1

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ connect to the console
233233
.INDENT 3.5
234234
.sp
235235
.EX
236-
usage: labgrid\-client console|con [\-l] [\-o] [\-\-logfile FILE] [name]
236+
usage: labgrid\-client console|con [\-i] [\-l] [\-o] [\-\-logfile FILE] [name]
237237
.EE
238238
.UNINDENT
239239
.UNINDENT
@@ -244,6 +244,11 @@ optional resource name
244244
.UNINDENT
245245
.INDENT 0.0
246246
.TP
247+
.B \-i, \-\-internal
248+
use an internal console instead of microcom
249+
.UNINDENT
250+
.INDENT 0.0
251+
.TP
247252
.B \-l, \-\-loop
248253
keep trying to connect if the console is unavailable
249254
.UNINDENT

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies = [
3939
"protobuf>=5.27.0",
4040
"jinja2>=3.0.2",
4141
"pexpect>=4.8.0",
42+
"prompt-toolkit>=3.0.0",
4243
"pyserial-labgrid>=3.4.0.1",
4344
"pytest>=7.0.0",
4445
"pyudev>=0.24.4",

0 commit comments

Comments
 (0)