ok-serial-terminal 0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ok_serial_terminal-0.1/PKG-INFO +72 -0
- ok_serial_terminal-0.1/README.md +58 -0
- ok_serial_terminal-0.1/ok_serial_terminal/__init__.py +9 -0
- ok_serial_terminal-0.1/ok_serial_terminal/async_stdio.py +107 -0
- ok_serial_terminal-0.1/ok_serial_terminal/chunker.py +121 -0
- ok_serial_terminal-0.1/ok_serial_terminal/decorator.py +243 -0
- ok_serial_terminal-0.1/ok_serial_terminal/keyboard.py +188 -0
- ok_serial_terminal-0.1/ok_serial_terminal/main.py +438 -0
- ok_serial_terminal-0.1/ok_serial_terminal/mode_tracker.py +445 -0
- ok_serial_terminal-0.1/ok_serial_terminal/timeout_math.py +20 -0
- ok_serial_terminal-0.1/pyproject.toml +44 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: ok-serial-terminal
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: Interactive serial port terminal (based on ok-serial)
|
|
5
|
+
Author: Dan Egnor
|
|
6
|
+
Author-email: Dan Egnor <egnor@ofb.net>
|
|
7
|
+
Requires-Dist: click>=8.3.1
|
|
8
|
+
Requires-Dist: ok-logging-setup>=0.17
|
|
9
|
+
Requires-Dist: ok-serial>=0.4
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Project-URL: Homepage, https://github.com/egnor/ok-serial-terminal#readme
|
|
12
|
+
Project-URL: Repository, https://github.com/egnor/ok-serial-terminal.git
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# OK serial terminal ๐ใกใใกใใก๐ป
|
|
16
|
+
|
|
17
|
+
An interactive [serial port](https://en.wikipedia.org/wiki/Serial_port) terminal, built on [ok-serial](https://github.com/egnor/ok-py-serial#readme).
|
|
18
|
+
|
|
19
|
+
Think twice before using this! Consider something more established:
|
|
20
|
+
|
|
21
|
+
- [tio](https://github.com/tio/tio) - not Python, but a great serial terminal utility
|
|
22
|
+
- [picocom](https://github.com/npat-efault/picocom) - the classic minimal serial terminal
|
|
23
|
+
- [screen](https://www.gnu.org/software/screen/) - the terminal multiplexer is also a serial terminal
|
|
24
|
+
- [minicom](https://salsa.debian.org/minicom-team/minicom) - if you're nostalgic for the DOS era
|
|
25
|
+
- [pyserial's miniterm](https://pyserial.readthedocs.io/en/latest/tools.html#module-serial.tools.miniterm) - `python -m serial.tools.miniterm`, already installed if you have pyserial
|
|
26
|
+
|
|
27
|
+
## Installation and Usage
|
|
28
|
+
|
|
29
|
+
Install the Python package, which installs the `okterm` utility:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install ok-serial-terminal
|
|
33
|
+
# or 'uv add ok-serial-terminal', 'uv tool install ok-serial-terminal', etc.
|
|
34
|
+
okterm <port> [baud]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
OR, skip the package install and run it directly with [uvx](https://docs.astral.sh/uv/guides/tools/) or [pipx](https://pipx.pypa.io/stable/):
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
uvx ok-serial-terminal <port> [baud]
|
|
41
|
+
# or `pipx run ok-serial-terminal <port> [baud]`
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The baud rate defaults to 115200 if omitted. The port is an [ok-serial match expression](https://github.com/egnor/ok-py-serial#port-matching), so `okterm RP2040`, `okterm 2e8a:0005`, and `okterm /dev/ttyACM0` all work. Run [`okserial`](https://github.com/egnor/ok-py-serial#readme) (or `uvx ok-py-serial`) to list visible ports and their attributes.
|
|
45
|
+
|
|
46
|
+
On a terminal (unless `--plain` is given), `okterm` decorates the display with connection status, control signal state, and an indicator for unechoed typed characters. In this mode, ctrl-`]` opens a menu and ctrl-`\` quits.
|
|
47
|
+
|
|
48
|
+
In plain mode (I/O redirected or `--plain` given), data is pass-through and ^C quits.
|
|
49
|
+
|
|
50
|
+
See `okterm --help` for more options (locking mode, etc).
|
|
51
|
+
|
|
52
|
+
## Socat for testing and profit
|
|
53
|
+
|
|
54
|
+
On Unix-ish systems, [socat](http://www.dest-unreach.org/socat/) is handy for connecting serial-port apps (`okterm` or otherwise) to non-serial endpoints (like a Unix program or a TCP socket). Install it with your favorite package manager (eg. `sudo apt install socat`), and run something like this in one window:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
socat pty,raw,echo=0,link=socat.tmp exec:$SHELL,pty,stderr,setsid,ctty
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The first socat argument `pty,raw,echo=0,link=socat.tmp` allocates a pseudoterminal (pty) that looks like a serial port, and creates a `./socat.tmp` symlink to the device. The `,raw,echo=0` suppresses default pty echo behavior to avoid the shell looping on its own output.
|
|
61
|
+
|
|
62
|
+
The second socat argument starts a shell on its own pty, but this could be any socat endpoint (`exec:cat`, `tcp:localhost:8000`, etc).
|
|
63
|
+
|
|
64
|
+
Socat will then shuffle data between the two points. Try this in another window (in the same directory):
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
okterm socat.tmp
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
You should get a terminal connected to the pty socat allocated; hit enter and you should see a shell prompt.
|
|
71
|
+
|
|
72
|
+
(None of this is `okterm`-specific โ as far as [ok-serial](https://github.com/egnor/ok-py-serial#readme) is concerned `./socat.tmp` is just another serial port, so `ok_serial.SerialConnection(match="socat.tmp", baud=115200)` works the same way from your own code.)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# OK serial terminal ๐ใกใใกใใก๐ป
|
|
2
|
+
|
|
3
|
+
An interactive [serial port](https://en.wikipedia.org/wiki/Serial_port) terminal, built on [ok-serial](https://github.com/egnor/ok-py-serial#readme).
|
|
4
|
+
|
|
5
|
+
Think twice before using this! Consider something more established:
|
|
6
|
+
|
|
7
|
+
- [tio](https://github.com/tio/tio) - not Python, but a great serial terminal utility
|
|
8
|
+
- [picocom](https://github.com/npat-efault/picocom) - the classic minimal serial terminal
|
|
9
|
+
- [screen](https://www.gnu.org/software/screen/) - the terminal multiplexer is also a serial terminal
|
|
10
|
+
- [minicom](https://salsa.debian.org/minicom-team/minicom) - if you're nostalgic for the DOS era
|
|
11
|
+
- [pyserial's miniterm](https://pyserial.readthedocs.io/en/latest/tools.html#module-serial.tools.miniterm) - `python -m serial.tools.miniterm`, already installed if you have pyserial
|
|
12
|
+
|
|
13
|
+
## Installation and Usage
|
|
14
|
+
|
|
15
|
+
Install the Python package, which installs the `okterm` utility:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pip install ok-serial-terminal
|
|
19
|
+
# or 'uv add ok-serial-terminal', 'uv tool install ok-serial-terminal', etc.
|
|
20
|
+
okterm <port> [baud]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
OR, skip the package install and run it directly with [uvx](https://docs.astral.sh/uv/guides/tools/) or [pipx](https://pipx.pypa.io/stable/):
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uvx ok-serial-terminal <port> [baud]
|
|
27
|
+
# or `pipx run ok-serial-terminal <port> [baud]`
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The baud rate defaults to 115200 if omitted. The port is an [ok-serial match expression](https://github.com/egnor/ok-py-serial#port-matching), so `okterm RP2040`, `okterm 2e8a:0005`, and `okterm /dev/ttyACM0` all work. Run [`okserial`](https://github.com/egnor/ok-py-serial#readme) (or `uvx ok-py-serial`) to list visible ports and their attributes.
|
|
31
|
+
|
|
32
|
+
On a terminal (unless `--plain` is given), `okterm` decorates the display with connection status, control signal state, and an indicator for unechoed typed characters. In this mode, ctrl-`]` opens a menu and ctrl-`\` quits.
|
|
33
|
+
|
|
34
|
+
In plain mode (I/O redirected or `--plain` given), data is pass-through and ^C quits.
|
|
35
|
+
|
|
36
|
+
See `okterm --help` for more options (locking mode, etc).
|
|
37
|
+
|
|
38
|
+
## Socat for testing and profit
|
|
39
|
+
|
|
40
|
+
On Unix-ish systems, [socat](http://www.dest-unreach.org/socat/) is handy for connecting serial-port apps (`okterm` or otherwise) to non-serial endpoints (like a Unix program or a TCP socket). Install it with your favorite package manager (eg. `sudo apt install socat`), and run something like this in one window:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
socat pty,raw,echo=0,link=socat.tmp exec:$SHELL,pty,stderr,setsid,ctty
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The first socat argument `pty,raw,echo=0,link=socat.tmp` allocates a pseudoterminal (pty) that looks like a serial port, and creates a `./socat.tmp` symlink to the device. The `,raw,echo=0` suppresses default pty echo behavior to avoid the shell looping on its own output.
|
|
47
|
+
|
|
48
|
+
The second socat argument starts a shell on its own pty, but this could be any socat endpoint (`exec:cat`, `tcp:localhost:8000`, etc).
|
|
49
|
+
|
|
50
|
+
Socat will then shuffle data between the two points. Try this in another window (in the same directory):
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
okterm socat.tmp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You should get a terminal connected to the pty socat allocated; hit enter and you should see a shell prompt.
|
|
57
|
+
|
|
58
|
+
(None of this is `okterm`-specific โ as far as [ok-serial](https://github.com/egnor/ok-py-serial#readme) is concerned `./socat.tmp` is just another serial port, so `ok_serial.SerialConnection(match="socat.tmp", baud=115200)` works the same way from your own code.)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
An interactive serial port terminal built on
|
|
3
|
+
[ok-serial](https://github.com/egnor/ok-py-serial#readme).
|
|
4
|
+
This package is a CLI utility (`okterm`), not a library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib.metadata
|
|
8
|
+
|
|
9
|
+
__version__ = importlib.metadata.version(__package__)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import select
|
|
6
|
+
import termios
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Approach: Both reads and writes *attempt* to use the event loop; if
|
|
12
|
+
# loop.add_reader/writer rejects the fd (file, /dev/null, etc), proceed anyway.
|
|
13
|
+
#
|
|
14
|
+
# Other approaches considered
|
|
15
|
+
# - asyncio.streams.StreamReader/Writer: don't work on files, /dev/null, etc
|
|
16
|
+
# - asyncio.run_in_executor (or similar threading): cancellation is difficult
|
|
17
|
+
# - O_NONBLOCK: breaks other users of the file (eg. stderr writes to same tty)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsyncReader:
|
|
21
|
+
"""Wraps an OS-level I/O stream with an async read() function.
|
|
22
|
+
Designed for stdio: pipes, files, ttys/ptys, and /dev/null.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, stream: typing.IO) -> None:
|
|
26
|
+
self._fd = stream.fileno()
|
|
27
|
+
self._lock = asyncio.Lock()
|
|
28
|
+
self._loop = asyncio.get_running_loop()
|
|
29
|
+
self._pollable = True
|
|
30
|
+
|
|
31
|
+
async def read(self, size: int) -> bytes:
|
|
32
|
+
async with self._lock:
|
|
33
|
+
while True:
|
|
34
|
+
if self._pollable:
|
|
35
|
+
future = self._loop.create_future()
|
|
36
|
+
try:
|
|
37
|
+
self._loop.add_reader(self._fd, future.set_result, None)
|
|
38
|
+
await future
|
|
39
|
+
except OSError:
|
|
40
|
+
log.debug("FD %d isn't pollable (reading)", self._fd)
|
|
41
|
+
self._pollable = False
|
|
42
|
+
finally:
|
|
43
|
+
self._loop.remove_reader(self._fd)
|
|
44
|
+
|
|
45
|
+
with contextlib.suppress(BlockingIOError):
|
|
46
|
+
return os.read(self._fd, size)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AsyncWriter:
|
|
50
|
+
"""Wraps an OS-level I/O stream with an async write() function.
|
|
51
|
+
Designed for stdio: pipes, files, ttys/ptys, and /dev/null.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, stream: typing.IO) -> None:
|
|
55
|
+
self._fd = stream.fileno()
|
|
56
|
+
self._lock = asyncio.Lock()
|
|
57
|
+
self._loop = asyncio.get_running_loop()
|
|
58
|
+
self._pollable = True
|
|
59
|
+
|
|
60
|
+
async def write(self, data: bytes) -> None:
|
|
61
|
+
view = memoryview(data)
|
|
62
|
+
async with self._lock:
|
|
63
|
+
while view:
|
|
64
|
+
if self._pollable:
|
|
65
|
+
future = self._loop.create_future()
|
|
66
|
+
try:
|
|
67
|
+
self._loop.add_writer(self._fd, future.set_result, None)
|
|
68
|
+
await future
|
|
69
|
+
except OSError:
|
|
70
|
+
log.debug("FD %d isn't pollable (writing)", self._fd)
|
|
71
|
+
self._pollable = False
|
|
72
|
+
finally:
|
|
73
|
+
self._loop.remove_writer(self._fd)
|
|
74
|
+
|
|
75
|
+
# cap write size to bound blocking (the fd is not O_NONBLOCK);
|
|
76
|
+
# for pipes/FIFOs, writable poll guarantees PIPE_BUF space
|
|
77
|
+
# TODO: for ttys/ptys, reopen it by name and use O_NONBLOCK?
|
|
78
|
+
# TODO: for sockets (eg. systemd logging), use a small write?
|
|
79
|
+
with contextlib.suppress(BlockingIOError):
|
|
80
|
+
view = view[os.write(self._fd, view[: select.PIPE_BUF]) :]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@contextlib.contextmanager
|
|
84
|
+
def raw_tty_context(fd: typing.Literal[0, 1, 2]) -> typing.Iterator[bool]:
|
|
85
|
+
"""Returns a context manager that, on entry, if the stdio fd (0, 1, 2)
|
|
86
|
+
is a terminal, sets it to raw mode and restores original mode on exit."""
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
old_attr = termios.tcgetattr(fd)
|
|
90
|
+
except termios.error:
|
|
91
|
+
logging.debug("FD %d is not a terminal, skipping raw mode", fd)
|
|
92
|
+
yield False # not a tty
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
if fd == 0:
|
|
96
|
+
raw_cc = [int(i == termios.VMIN) for i in range(len(old_attr[6]))]
|
|
97
|
+
raw_attr = [0, old_attr[1], 0, 0, *old_attr[4:6], raw_cc]
|
|
98
|
+
else:
|
|
99
|
+
raw_attr = [old_attr[0], 0, *old_attr[2:]]
|
|
100
|
+
|
|
101
|
+
logging.debug("Setting tty fd=%d to raw mode", fd)
|
|
102
|
+
try:
|
|
103
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, raw_attr)
|
|
104
|
+
yield True # is a tty
|
|
105
|
+
finally:
|
|
106
|
+
logging.debug("Restoring tty fd=%d to original mode", fd)
|
|
107
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_attr)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from threading import TIMEOUT_MAX
|
|
3
|
+
|
|
4
|
+
# TODO: maybe optimize TerminalChunker (and de-chunking); start with a
|
|
5
|
+
# chunker-focused profiling pass. Colorized `xxd` output (SGR codes every few
|
|
6
|
+
# bytes -> ~4-byte chunks) is a good stress test. Ideas: single-pass finditer
|
|
7
|
+
# instead of per-chunk match() calls, batch runs of small text/escape chunks,
|
|
8
|
+
# and accumulate chunk output into a bytearray rather than b"".join() of
|
|
9
|
+
# millions of pieces
|
|
10
|
+
|
|
11
|
+
_CHUNK_RX = re.compile(
|
|
12
|
+
# group 1: well-formed UTF-8 code points -- what str.decode() accepts
|
|
13
|
+
# Grammar: https://datatracker.ietf.org/doc/html/rfc3629#section-4
|
|
14
|
+
b"((?:"
|
|
15
|
+
b"[\x20-\x7e]|" # printable ASCII (other 1-byte are controls, group 5)
|
|
16
|
+
b"[\xc2-\xdf][\x80-\xbf]|" # 2-byte
|
|
17
|
+
# 3-byte, no overlong, no UTF-16 surrogates (U+D800..U+DFFF)
|
|
18
|
+
b"\xe0[\xa0-\xbf][\x80-\xbf]|[\xe1-\xec][\x80-\xbf]{2}|"
|
|
19
|
+
b"\xed[\x80-\x9f][\x80-\xbf]|[\xee-\xef][\x80-\xbf]{2}|"
|
|
20
|
+
# 4-byte, no overlong, no code points > U+10FFFF
|
|
21
|
+
b"\xf0[\x90-\xbf][\x80-\xbf]{2}|[\xf1-\xf3][\x80-\xbf]{3}|"
|
|
22
|
+
b"\xf4[\x80-\x8f][\x80-\xbf]{2}"
|
|
23
|
+
b")+)|"
|
|
24
|
+
# group 2: incomplete-but-valid UTF-8 prefix at end of data
|
|
25
|
+
b"("
|
|
26
|
+
b"[\xc2-\xf4]|"
|
|
27
|
+
b"\xe0[\xa0-\xbf]|[\xe1-\xec][\x80-\xbf]|"
|
|
28
|
+
b"\xed[\x80-\x9f]|[\xee-\xef][\x80-\xbf]|"
|
|
29
|
+
b"\xf0[\x90-\xbf][\x80-\xbf]?|[\xf1-\xf3][\x80-\xbf]{1,2}|"
|
|
30
|
+
b"\xf4[\x80-\x8f][\x80-\xbf]?"
|
|
31
|
+
b")\\Z|"
|
|
32
|
+
# group 3: one complete VTxxx control sequence
|
|
33
|
+
# https://vt100.net/emu/dec_ansi_parser
|
|
34
|
+
b"("
|
|
35
|
+
b"(?:\x1b\\[|\x9b)[\x20-\x3f]*[\x40-\x7e]|" # CSI
|
|
36
|
+
b"(?:\x1b[\x50\x58\\]-\x5f]|[\x90\x98\x9d-\x9f])" # DCS/SOS/OSC/PM/APC
|
|
37
|
+
b"[\x20-\x7f]*(?:\x07|\x9c|\x1b\\\\)|" # ...end DCS/SOS/OSC/PM/APC
|
|
38
|
+
b"(?:\x1b[\x4e\x4f]|[\x8e\x8f])[\x20-\x7e]|" # SS2/SS3 + char
|
|
39
|
+
b"\x1b[\x20-\x2f]+[\x30-\x7e]|" # ESC + intermediates + final (charset)
|
|
40
|
+
b"\x1b[\x30-\x4d\x51-\x57\x59\x5a\x60-\x7e]" # ESC-char controls
|
|
41
|
+
b")|"
|
|
42
|
+
# group 4: *partial* VTxxx control sequence at end of data
|
|
43
|
+
b"("
|
|
44
|
+
b"\x1b\\Z|" # ESC by itself
|
|
45
|
+
b"(?:\x1b[\x4e\x4f]|[\x8e\x8f])\\Z|" # SS2/SS3 awaiting char
|
|
46
|
+
b"\x1b[\x20-\x2f]+\\Z|" # ESC + intermediates awaiting final
|
|
47
|
+
b"(?:\x1b\\[|\x9b)[\x20-\x3f]*\\Z|" # CSI
|
|
48
|
+
b"(?:\x1b[\x50\x58\\]-\x5f]|[\x90\x98\x9d-\x9f])[\x20-\x7f]*\x1b?\\Z"
|
|
49
|
+
b")|"
|
|
50
|
+
# group 5: any other byte (control char, invalid, etc)
|
|
51
|
+
b"([\x00-\xff])"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_CHUNK_TIMEOUT = 0.1 # seconds to pause before giving up on partial data
|
|
55
|
+
|
|
56
|
+
_VALID_TEXT_RX = re.compile("[^\x00-\x1f]+") # non-control text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TerminalChunker:
|
|
60
|
+
"""Breaks VTxxx data into output characters and control sequences.
|
|
61
|
+
|
|
62
|
+
Output attributes:
|
|
63
|
+
- chunks: received escape codes (bytes) or text (str); caller removes
|
|
64
|
+
- data_deadline: when to call add_data(b"", now) if nothing received
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self.chunks: list[str | bytes] = []
|
|
69
|
+
self.data_deadline = TIMEOUT_MAX
|
|
70
|
+
self._buffer = bytearray()
|
|
71
|
+
|
|
72
|
+
def add_data(self, data: bytes, data_time: float) -> None:
|
|
73
|
+
"""Accepts terminal data to be chunked:
|
|
74
|
+
- data: bytes to process; use b"" if nothing received
|
|
75
|
+
- data_time: data timestamp in seconds (arbitrary epoch)
|
|
76
|
+
Appends output to .chunks and updates .data_deadline.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
if data:
|
|
80
|
+
self.data_deadline = data_time + _CHUNK_TIMEOUT
|
|
81
|
+
self._buffer.extend(data)
|
|
82
|
+
self._process_buffer()
|
|
83
|
+
|
|
84
|
+
while self.data_deadline and data_time > self.data_deadline:
|
|
85
|
+
self.chunks.append(bytes(self._buffer[:1]))
|
|
86
|
+
del self._buffer[:1]
|
|
87
|
+
self._process_buffer()
|
|
88
|
+
|
|
89
|
+
def _process_buffer(self) -> None:
|
|
90
|
+
pos = 0
|
|
91
|
+
while pos < len(self._buffer):
|
|
92
|
+
match = _CHUNK_RX.match(self._buffer, pos)
|
|
93
|
+
assert match, self._buffer[pos:]
|
|
94
|
+
chars, char_part, esc, esc_part, other = match.groups()
|
|
95
|
+
if chars:
|
|
96
|
+
self.chunks.append(chars.decode()) # regexp enforces validity
|
|
97
|
+
pos += len(chars)
|
|
98
|
+
elif esc:
|
|
99
|
+
self.chunks.append(esc)
|
|
100
|
+
pos += len(esc)
|
|
101
|
+
elif other:
|
|
102
|
+
self.chunks.append(other)
|
|
103
|
+
assert len(other) == 1, other
|
|
104
|
+
pos += 1
|
|
105
|
+
else:
|
|
106
|
+
assert self._buffer[pos:] in (char_part, esc_part)
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
del self._buffer[:pos]
|
|
110
|
+
if not self._buffer:
|
|
111
|
+
self.data_deadline = TIMEOUT_MAX
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def chunk_to_bytes(chunk: str | bytes):
|
|
115
|
+
"""Returns the data-stream bytes for a TerminalChunker-type chunk."""
|
|
116
|
+
assert isinstance(chunk, (str, bytes)), chunk
|
|
117
|
+
if isinstance(chunk, bytes):
|
|
118
|
+
return chunk
|
|
119
|
+
else:
|
|
120
|
+
assert _VALID_TEXT_RX.fullmatch(chunk), chunk
|
|
121
|
+
return chunk.encode()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from ok_serial_terminal.mode_tracker import TerminalModeTracker
|
|
5
|
+
|
|
6
|
+
QUERY_PASSTHRU_TIMEOUT = 1.0 # seconds
|
|
7
|
+
CURSOR_QUERY_RX = re.compile(b"(?:\x1b\\[|\x9b)6n")
|
|
8
|
+
CURSOR_REPLY_RX = re.compile(b"(?:\x1b\\[|\x9b)(\\d+);(\\d+)R")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TerminalDecorator:
|
|
12
|
+
"""Modifies terminal output to show extra text around the cursor (for
|
|
13
|
+
status messages, alerts, etc) without disrupting base rendering too much.
|
|
14
|
+
Does not perform I/O directly, but processes chunks (per TerminalChunker)
|
|
15
|
+
on their way to/from the terminal, via these properties:
|
|
16
|
+
|
|
17
|
+
Input *queues* (caller should append, culled by .update() as processed):
|
|
18
|
+
- .add_base (chunk list) - base terminal data from serial port
|
|
19
|
+
- .add_above (chunk lists) - message lines to insert above the cursor and
|
|
20
|
+
leave in place (eg. important status messages/logs)
|
|
21
|
+
- .add_from_terminal (chunk list) - input chunks received from the terminal
|
|
22
|
+
|
|
23
|
+
Input *values* (caller should set/update, .update() observes changes):
|
|
24
|
+
- .set_right - message (chunk list) to show immediately after the cursor,
|
|
25
|
+
moving with the cursor until removed or replaced
|
|
26
|
+
- .set_below - message lines (chunk lists) to insert below the cursor,
|
|
27
|
+
moving with the cursor until removed or replaced
|
|
28
|
+
|
|
29
|
+
*Output* queues (appended by .update(), caller should cull once handled):
|
|
30
|
+
- .out_to_terminal (chunk list) - to send directly to the terminal
|
|
31
|
+
- .out_from_terminal (chunk list) - filtered terminal input to handle
|
|
32
|
+
|
|
33
|
+
"Decorations" (.add_above/.set_below lines, .set_right) can include
|
|
34
|
+
SGR-type directives (starting from reset each time) but must be a single
|
|
35
|
+
line without cursor shenanigans. Auto-wrap is disabled so they will cut off.
|
|
36
|
+
|
|
37
|
+
Caveats: base rendering isn't disrupted "too much", but...
|
|
38
|
+
- adding decorations above/below moves lines around and can change the row
|
|
39
|
+
- adding and removing decorations to the right can erase existing content
|
|
40
|
+
- decorations get disrupted if base content switches primary/alt screens
|
|
41
|
+
- if the cursor is outside the scrolling margins, line display is glitchy
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self.add_base: list[bytes | str] = []
|
|
46
|
+
self.add_above: list[list[bytes | str]] = []
|
|
47
|
+
self.set_right: list[bytes | str] = []
|
|
48
|
+
self.set_below: list[list[bytes | str]] = []
|
|
49
|
+
self.out_to_terminal: list[bytes | str] = []
|
|
50
|
+
|
|
51
|
+
self.add_from_terminal: list[bytes | str] = []
|
|
52
|
+
self.out_from_terminal: list[bytes | str] = []
|
|
53
|
+
self.pending_query_time: float | None = None # if query is outstanding
|
|
54
|
+
|
|
55
|
+
# terminal mode tracking: the mode set by base content, the mode
|
|
56
|
+
# to use for decorations, and what the terminal is actually doing
|
|
57
|
+
self._base_mode = TerminalModeTracker()
|
|
58
|
+
self._active_mode = self._base_mode
|
|
59
|
+
|
|
60
|
+
# cursor tracking: the base cursor column, and cursor excursion status
|
|
61
|
+
# (between cols, the cursor *row* remains aligned with the base cursor)
|
|
62
|
+
self._base_col: int | Literal["unknown", "querying"] = 1
|
|
63
|
+
self._cursor_pos: Literal["base", "roam"] = "base"
|
|
64
|
+
self._query_passthru: list[float] = [] # expiration times
|
|
65
|
+
|
|
66
|
+
# currently displayed right/below decorations for comparison
|
|
67
|
+
# (above decorations are inserted and left in place forever)
|
|
68
|
+
self._now_below: list[list[bytes | str]] = []
|
|
69
|
+
self._now_right: list[bytes | str] = []
|
|
70
|
+
|
|
71
|
+
def update(self, time: float) -> None:
|
|
72
|
+
"""Processes input properties and updates output properties.
|
|
73
|
+
- time: clock time in seconds (with any consistent epoch)
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
# process input from terminal; match against pending passthru queries,
|
|
77
|
+
# then our own. (note, this assumes no passthru once in "querying")
|
|
78
|
+
for chunk in self.add_from_terminal:
|
|
79
|
+
if isinstance(chunk, bytes) and (m := CURSOR_REPLY_RX.match(chunk)):
|
|
80
|
+
if self._query_passthru:
|
|
81
|
+
del self._query_passthru[:1]
|
|
82
|
+
elif self.pending_query_time is not None:
|
|
83
|
+
self.pending_query_time = None
|
|
84
|
+
if self._base_col == "querying":
|
|
85
|
+
self._base_col = int(m.group(2))
|
|
86
|
+
continue # we issued the query; consume the result
|
|
87
|
+
self.out_from_terminal.append(chunk)
|
|
88
|
+
self.add_from_terminal.clear()
|
|
89
|
+
|
|
90
|
+
# expire pending passthru queries if we never saw a response
|
|
91
|
+
while self._query_passthru and time > self._query_passthru[0]:
|
|
92
|
+
del self._query_passthru[0]
|
|
93
|
+
|
|
94
|
+
# strategize - trim decorations right/below of cursor if:
|
|
95
|
+
# - base content is pending *and* reachable after trimming right/below
|
|
96
|
+
# - OR right/below decoration content changed and needs updating
|
|
97
|
+
if self.add_base and (
|
|
98
|
+
isinstance(self._base_col, int)
|
|
99
|
+
or (self._can_move_cursor_to_base() and not self._now_below)
|
|
100
|
+
):
|
|
101
|
+
clear_right, keep_below = bool(self._now_right), 0
|
|
102
|
+
else:
|
|
103
|
+
clear_right, keep_below = (self.set_right != self._now_right), 0
|
|
104
|
+
while (
|
|
105
|
+
keep_below < len(self.set_below)
|
|
106
|
+
and keep_below < len(self._now_below)
|
|
107
|
+
and self.set_below[keep_below] == self._now_below[keep_below]
|
|
108
|
+
):
|
|
109
|
+
keep_below += 1
|
|
110
|
+
|
|
111
|
+
# clear right of cursor if requested and possible
|
|
112
|
+
if clear_right and self._can_move_cursor_to_base():
|
|
113
|
+
self._switch_terminal_mode(self._new_decoration_mode())
|
|
114
|
+
self._move_cursor_to_base()
|
|
115
|
+
self._emit(b"\x1b[K") # caveat: leaves a hole right of cursor
|
|
116
|
+
self._now_right.clear()
|
|
117
|
+
|
|
118
|
+
# delete below decoration rows if requested
|
|
119
|
+
if del_below := len(self._now_below) - keep_below:
|
|
120
|
+
assert del_below > 0, (self._now_below, keep_below)
|
|
121
|
+
self._switch_terminal_mode(self._new_decoration_mode())
|
|
122
|
+
self._prepare_cursor_to_roam(time) # deleting rows moves left
|
|
123
|
+
self._emit(
|
|
124
|
+
b"\x1b[%dB" % (keep_below + 1), # move down
|
|
125
|
+
b"\x1b[%dM" % del_below, # delete rows
|
|
126
|
+
b"\x1b[%dA" % (keep_below + 1), # move back up
|
|
127
|
+
)
|
|
128
|
+
del self._now_below[-del_below:]
|
|
129
|
+
|
|
130
|
+
# add base content if provided, reachable, and clear of decorations
|
|
131
|
+
if self.add_base and (
|
|
132
|
+
self._can_move_cursor_to_base()
|
|
133
|
+
and not (self._now_right or self._now_below)
|
|
134
|
+
):
|
|
135
|
+
assert self._base_col != "querying", self._base_col
|
|
136
|
+
self._move_cursor_to_base()
|
|
137
|
+
self._switch_terminal_mode(self._base_mode)
|
|
138
|
+
self._emit(*self.add_base)
|
|
139
|
+
for chunk in self.add_base:
|
|
140
|
+
self._base_col = 1 if chunk == b"\n" else "unknown"
|
|
141
|
+
if isinstance(chunk, bytes) and CURSOR_QUERY_RX.match(chunk):
|
|
142
|
+
self._query_passthru.append(time + QUERY_PASSTHRU_TIMEOUT)
|
|
143
|
+
self.add_base.clear()
|
|
144
|
+
|
|
145
|
+
# add/replace right decoration if provided and reachable
|
|
146
|
+
if self.set_right and (
|
|
147
|
+
self._can_move_cursor_to_base() and not self._now_right
|
|
148
|
+
):
|
|
149
|
+
self._switch_terminal_mode(self._new_decoration_mode())
|
|
150
|
+
self._move_cursor_to_base()
|
|
151
|
+
self._prepare_cursor_to_roam(time) # adding content moves cursor
|
|
152
|
+
self._emit(*self.set_right)
|
|
153
|
+
self._now_right[:] = self.set_right
|
|
154
|
+
|
|
155
|
+
# insert lines above if requested
|
|
156
|
+
if self.add_above:
|
|
157
|
+
self._switch_terminal_mode(self._new_decoration_mode())
|
|
158
|
+
self._prepare_cursor_to_roam(time) # adding content moves cursor
|
|
159
|
+
self._emit(
|
|
160
|
+
*[b"\n"] * len(self.add_above), # scroll down to make room
|
|
161
|
+
b"\x1b[%dA" % len(self.add_above), # move back up
|
|
162
|
+
b"\x1b[%dL" % len(self.add_above), # insert rows
|
|
163
|
+
)
|
|
164
|
+
self._emit(b"\r", *self.add_above[0], b"\n")
|
|
165
|
+
for next_line in self.add_above[1:]:
|
|
166
|
+
self._switch_terminal_mode(self._new_decoration_mode())
|
|
167
|
+
self._emit(b"\r", *next_line, b"\n") # ends at base row
|
|
168
|
+
self.add_above.clear()
|
|
169
|
+
|
|
170
|
+
# insert lines below if requested
|
|
171
|
+
assert len(self.set_below) >= len(self._now_below)
|
|
172
|
+
if len(self.set_below) > len(self._now_below):
|
|
173
|
+
skip_lines = len(self._now_below)
|
|
174
|
+
assert self.set_below[:skip_lines] == self._now_below
|
|
175
|
+
self._switch_terminal_mode(self._new_decoration_mode())
|
|
176
|
+
self._prepare_cursor_to_roam(time) # adding content moves cursor
|
|
177
|
+
self._emit(*([b"\n"] * skip_lines))
|
|
178
|
+
for next_line in self.set_below[skip_lines:]:
|
|
179
|
+
self._switch_terminal_mode(self._new_decoration_mode())
|
|
180
|
+
self._emit(b"\r", b"\n", *next_line)
|
|
181
|
+
self._now_below.append(next_line[:])
|
|
182
|
+
self._emit(b"\x1b[%dA" % len(self.set_below)) # ends at base row
|
|
183
|
+
|
|
184
|
+
if self._can_move_cursor_to_base():
|
|
185
|
+
self._move_cursor_to_base() # leave the cursor there if possible
|
|
186
|
+
|
|
187
|
+
def reset(self) -> None:
|
|
188
|
+
"""Adds cleanup to .out_to_terminal (*without* an update cycle):
|
|
189
|
+
- resets terminal mode to default state
|
|
190
|
+
- clears the screen after & below the cursor
|
|
191
|
+
- moves to a new line if we're not positioned at start of line
|
|
192
|
+
- (for possible restart) updates mode tracking
|
|
193
|
+
- (for possible restart) clears right & below decoration setting
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
self._base_mode = TerminalModeTracker() # reset to default state
|
|
197
|
+
self._switch_terminal_mode(self._base_mode)
|
|
198
|
+
self._emit(b"\x1b7", b"\x1b[r", b"\x1b8") # reset DECSTBM margins
|
|
199
|
+
self._emit(b"\x1b[J") # clear from cursor to end of display
|
|
200
|
+
if (self._cursor_pos, self._base_col) != ("base", 1):
|
|
201
|
+
self._cursor_pos, self._base_col = "base", 1 # in case of restart
|
|
202
|
+
self._emit(b"\r", b"\n") # newline to move past the base line
|
|
203
|
+
self.set_right.clear()
|
|
204
|
+
self.set_below.clear()
|
|
205
|
+
|
|
206
|
+
def _can_move_cursor_to_base(self) -> bool:
|
|
207
|
+
return self._cursor_pos == "base" or isinstance(self._base_col, int)
|
|
208
|
+
|
|
209
|
+
def _move_cursor_to_base(self) -> None:
|
|
210
|
+
assert self._can_move_cursor_to_base()
|
|
211
|
+
if self._cursor_pos != "base":
|
|
212
|
+
assert isinstance(self._base_col, int), self._base_col
|
|
213
|
+
self.out_to_terminal.append(b"\x1b[%dG" % self._base_col)
|
|
214
|
+
self._cursor_pos = "base"
|
|
215
|
+
|
|
216
|
+
def _prepare_cursor_to_roam(self, time: float) -> None:
|
|
217
|
+
if (self._cursor_pos, self._base_col) == ("base", "unknown"):
|
|
218
|
+
self.out_to_terminal.append(b"\x1b[6n")
|
|
219
|
+
self.pending_query_time = time
|
|
220
|
+
self._base_col = "querying"
|
|
221
|
+
self._cursor_pos = "roam"
|
|
222
|
+
|
|
223
|
+
def _switch_terminal_mode(self, mode: TerminalModeTracker) -> None:
|
|
224
|
+
if mode is not self._active_mode:
|
|
225
|
+
mode_chunks = mode.mode_chunks(base=self._active_mode)
|
|
226
|
+
self.out_to_terminal.extend(mode_chunks)
|
|
227
|
+
self._active_mode = mode
|
|
228
|
+
|
|
229
|
+
def _new_decoration_mode(self) -> TerminalModeTracker:
|
|
230
|
+
mode = self._base_mode.copy()
|
|
231
|
+
mode.add_chunk(b"\x0f") # use G0
|
|
232
|
+
mode.add_chunk(b"\x1b(B") # G0 = US-ASCII
|
|
233
|
+
mode.add_chunk(b'\x1b[0"q') # character protection off
|
|
234
|
+
mode.add_chunk(b"\x1b[m") # reset SGR
|
|
235
|
+
mode.add_chunk(b"\x1b[4l") # reset IRM - no insert mode
|
|
236
|
+
mode.add_chunk(b"\x1b[20l") # reset LNM - normal newline mode
|
|
237
|
+
mode.add_chunk(b"\x1b[?7l") # reset DECAWM - do not wrap at EOL
|
|
238
|
+
return mode
|
|
239
|
+
|
|
240
|
+
def _emit(self, *chunks: bytes | str) -> None:
|
|
241
|
+
self.out_to_terminal.extend(chunks)
|
|
242
|
+
for chunk in chunks:
|
|
243
|
+
self._active_mode.add_chunk(chunk)
|