From 42b39ad1df3f38921bce02d16b5d2483ea5ce477 Mon Sep 17 00:00:00 2001 From: Wojciech Olech Date: Wed, 11 Oct 2023 14:48:46 +0200 Subject: [PATCH 01/10] Calldwell: Added missing f-string --- calldwell/rust_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/calldwell/rust_helpers.py b/calldwell/rust_helpers.py index 45816833..15642763 100644 --- a/calldwell/rust_helpers.py +++ b/calldwell/rust_helpers.py @@ -269,7 +269,7 @@ def _initialize_rtt( if not gdb.setup_rtt(rtt_address, RTT_SECTION_SEARCHED_MEMORY_LENGTH, RTT_SECTION_ID): logging.error( f"Could not setup RTT for section @ {rtt_address} " - "(searched {RTT_SECTION_SEARCHED_MEMORY_LENGTH} bytes)", + f"(searched {RTT_SECTION_SEARCHED_MEMORY_LENGTH} bytes)", ) return None From c22810602f379eec2ffa3afdf5f5982c60685d5e Mon Sep 17 00:00:00 2001 From: Wojciech Olech Date: Wed, 11 Oct 2023 14:51:24 +0200 Subject: [PATCH 02/10] Python: Bumped dependencies --- calldwell/pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/calldwell/pyproject.toml b/calldwell/pyproject.toml index 4656f3d9..66bf64f1 100644 --- a/calldwell/pyproject.toml +++ b/calldwell/pyproject.toml @@ -13,9 +13,9 @@ paramiko = "^3.3.1" [tool.poetry.group.dev.dependencies] black = "^23.9.1" -mypy = "^1.5.1" -pylint = "^2.17.5" -ruff = "^0.0.289" +mypy = "^1.6.0" +pylint = "^3.0.1" +ruff = "^0.0.292" types-paramiko = "^3.3.0.0" [tool.black] From 0f0fbe57e6782f7d5936f0286f8cce4f9c3ae445 Mon Sep 17 00:00:00 2001 From: Wojciech Olech Date: Wed, 11 Oct 2023 17:58:01 +0200 Subject: [PATCH 03/10] Python: Ignores few annoying and unnecessary rules --- calldwell/pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/calldwell/pyproject.toml b/calldwell/pyproject.toml index 66bf64f1..ed4f652d 100644 --- a/calldwell/pyproject.toml +++ b/calldwell/pyproject.toml @@ -64,7 +64,9 @@ select = [ "PERF", # perflint "RUF", # ruff-specific rules ] -ignore = [] +ignore = [ + "TD003", # We don't want to link stuff to TODOs +] [tool.ruff.pylint] max-args = 6 @@ -101,6 +103,7 @@ logging-not-lazy, locally-disabled, suppressed-message, import-error, +use-implicit-booleaness-not-comparison-to-zero, ''' From ade473d177ed270904ef7c715b26ce163a214e7b Mon Sep 17 00:00:00 2001 From: Wojciech Olech Date: Wed, 11 Oct 2023 17:58:27 +0200 Subject: [PATCH 04/10] Calldwell: `SSHClient.execute` returns PID of created process --- calldwell/ssh_client.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/calldwell/ssh_client.py b/calldwell/ssh_client.py index 9edd0321..767e91c0 100644 --- a/calldwell/ssh_client.py +++ b/calldwell/ssh_client.py @@ -24,6 +24,7 @@ def __init__(self: SSHClient, host: str, login: str, password: str, port: int = * `password` - user password * `port` - SSH port, default: 22 """ + self._host = host self.client: paramiko.SSHClient = paramiko.SSHClient() self.client.load_system_host_keys() self.client.connect(hostname=host, port=port, username=login, password=password) @@ -37,13 +38,19 @@ class CommandChannels: stdout: ChannelFile stderr: ChannelStderrFile + @property + def host(self: SSHClient) -> str: + """Returns hostname of the client""" + return self._host + def execute( self: SSHClient, command: str, timeout: float | None = None, environment: dict[str, str] | None = None, - ) -> CommandChannels: - """Executes a command on remote, returns `stdin`, `stdout`, `stderr` wrapped in dataclass. + ) -> tuple[int, CommandChannels]: + """Executes a command on remote, returns PID of created process and `stdin`, `stdout`, + `stderr` wrapped in dataclass. # Parameters * `command` - command (and arguments) to be executed, in form of a single string @@ -52,12 +59,17 @@ def execute( * `environment` - additional environment variables for executed program. """ + # This will start a new shell, echo it's PID, and replace it with command. + # That way, we can safely get the PID of executed process before it starts. stdin, stdout, stderr = self.client.exec_command( - command, + f"sh -c 'echo $$; exec {command}'", timeout=timeout, environment=environment, ) - return self.CommandChannels(stdin, stdout, stderr) + + # Consume the PID from output + pid = int(stdout.readline()) + return (pid, self.CommandChannels(stdin, stdout, stderr)) def upload_file_to_remote( self: SSHClient, From d754b568eefeefda93e5860629e29f1835e7c6f0 Mon Sep 17 00:00:00 2001 From: Wojciech Olech Date: Wed, 11 Oct 2023 19:29:17 +0200 Subject: [PATCH 05/10] Calldwell: Added basic UART support --- calldwell/uart.py | 228 +++++++++++++++++++++++++++++++++++++++++++++ calldwell/utils.py | 36 +++++++ 2 files changed, 264 insertions(+) create mode 100644 calldwell/uart.py create mode 100644 calldwell/utils.py diff --git a/calldwell/uart.py b/calldwell/uart.py new file mode 100644 index 00000000..77002ed1 --- /dev/null +++ b/calldwell/uart.py @@ -0,0 +1,228 @@ +"""Module containing classes for UART management. +UART port is configured using `stty` and forwarded to TCP port using `socat`. + +Convenience functions that create remote UART TCP socket and connect to it using +local socket are provided.""" + +from __future__ import annotations + +import select +import socket +import time +from dataclasses import dataclass +from enum import Enum, auto +from typing import TYPE_CHECKING, cast + +from .utils import wait_until_true + +if TYPE_CHECKING: + from .ssh_client import SSHClient + + +class StopBits(Enum): + """Enumeration representing the amount of stop bits""" + + ONE = 1 + TWO = 2 + + +class Parity(Enum): + """Enumeration representing parity bit configuration""" + + NONE = auto() + EVEN = auto() + ODD = auto() + + +@dataclass +class RemoteUARTConfig: + """Remote UART configuration""" + + device_path: str + """Path to UART device on target's filesystem, for example /dev/ttyUSB0""" + port: int + """TCP port for socat's UART connection""" + baudrate: int + data_bits: int = 8 + stop_bits: StopBits = StopBits.ONE + parity: Parity = Parity.NONE + + +class UARTConnectionError(Exception): + """Exception indicating UART connection error""" + + +class BrokenSocketError(Exception): + """Exception indicating an error involving broken TCP socket""" + + def __init__(self: BrokenSocketError, when: str) -> None: + super().__init__(f"Socket connection broken when {when}") + + +class RemoteUARTConnection: + """Remote UART connection""" + + def __init__( + self: RemoteUARTConnection, + ssh_connection: SSHClient, + config: RemoteUARTConfig, + ) -> None: + self._config = config + self._ssh = ssh_connection + + self._socat_pid = 0 + self._uart_socket: socket.socket | None = None + self._is_open = False + + @property + def config(self: RemoteUARTConnection) -> RemoteUARTConfig: + """Returns UART configuration""" + return self._config + + @property + def is_open(self: RemoteUARTConnection) -> bool: + """Returns `True` if port is currently open""" + return self._is_open + + def open_uart(self: RemoteUARTConnection, timeout: float = 3.0) -> bool: + """Opens UART port on remote, and connects to it via socat""" + if self._is_open: + return False + + socket_location = ( + self._ssh.host, + self._config.port, + ) + + self._open_socat_bridge(self._config) + + # Now, socat should listen to connection on port specified in UART config, so we should be + # able to connect it via TCP socket + self._uart_socket = socket.socket() + self._uart_socket.setblocking(False) + + def check_connection_status() -> bool: + # We just created a new socket, so `cast` here is appropriate, as it cannot be None + return cast(socket.socket, self._uart_socket).connect_ex(socket_location) == 0 + + if wait_until_true(check_connection_status, timeout_seconds=timeout) is not None: + self._is_open = True + return True + + return False + + def close_uart(self: RemoteUARTConnection) -> bool: + """Closes socat connection and UART port on remote""" + if not self._is_open or self._uart_socket is None: + return False + + if self._uart_socket is not None: + self._uart_socket.shutdown(socket.SHUT_RDWR) + self._uart_socket.close() + self._uart_socket = None + self._close_socat_bridge() + + self._is_open = False + return True + + def write(self: RemoteUARTConnection, data: bytes, timeout_seconds: float = 5.0) -> int | None: + """Transmits some data to UART""" + if not self._is_open or self._uart_socket is None or timeout_seconds <= 0: + return None + + start_time = time.time() + elapsed_time = 0.0 + sent_bytes_sum = 0 + + while len(data) > 0 and elapsed_time < timeout_seconds: + _, writable, _ = select.select([], [self._uart_socket], [], timeout_seconds) + + if writable: + if (sent := self._uart_socket.send(data)) <= 0: + raise BrokenSocketError(when="trying to send data") + sent_bytes_sum += sent + data = data[sent:] + + elapsed_time = time.time() - start_time + + return sent_bytes_sum + + def read_any( + self: RemoteUARTConnection, + timeout_seconds: float = 5.0, + maximum_length: int = 255, + ) -> bytes | None: + """Reads any available amount of bytes from UART. + Returns new data immediately when available. + Returns `None` on timeout""" + if not self._is_open or self._uart_socket is None or timeout_seconds <= 0: + return None + + received_data = None + + try: + readable, _, _ = select.select([self._uart_socket], [], [], timeout_seconds) + if readable: + received_data = self._uart_socket.recv(maximum_length) + except OSError: + pass + + return received_data + + def _open_socat_bridge( + self: RemoteUARTConnection, + config: RemoteUARTConfig, + ) -> None: + stty_command = " ".join(["stty", *self._generate_stty_arguments(config)]) + socat_command = " ".join(["socat", *self._generate_socat_arguments(config)]) + + # In order to create socat bridge for UART, stty must be executed first. + _, streams = self._ssh.execute(stty_command) + # We can handler stty errors like that, but socat will block permanently if + # we try to read stderr. + if streams.stderr.readable(): + error_log = streams.stderr.readlines() + if len(error_log) != 0: + raise UARTConnectionError(error_log) + + # Store socat's PID for future cleanup + self._socat_pid, streams = self._ssh.execute(socat_command) + + def _close_socat_bridge(self: RemoteUARTConnection) -> None: + if self._socat_pid != 0: + self._ssh.execute(f"kill {self._socat_pid}") + self._socat_pid = 0 + + def __del__(self: RemoteUARTConnection) -> None: + self.close_uart() + + @staticmethod + def _generate_socat_arguments(config: RemoteUARTConfig) -> list[str]: + return [ + f"{config.device_path},b{config.baudrate},rawer,iexten=0,icanon=0,echo=0", + f"TCP-L:{config.port},reuseaddr", + ] + + @staticmethod + def _parity_to_stty_arg(parity: Parity) -> list[str]: + if parity == Parity.EVEN: + return ["parenb", "-parodd"] + + if parity == Parity.ODD: + return ["parenb", "parodd"] + + return ["-parenb"] + + @staticmethod + def _generate_stty_arguments(config: RemoteUARTConfig) -> list[str]: + stop_bits_arg = "-cstopb" if config.stop_bits == StopBits.ONE else "cstopb" + parity_arg = RemoteUARTConnection._parity_to_stty_arg(config.parity) + + return [ + "-F", + config.device_path, + str(config.baudrate), + f"cs{config.data_bits}", + stop_bits_arg, + *parity_arg, + ] diff --git a/calldwell/utils.py b/calldwell/utils.py new file mode 100644 index 00000000..c907a640 --- /dev/null +++ b/calldwell/utils.py @@ -0,0 +1,36 @@ +"""Module with utility functions""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +def wait_until_true( + function: Callable[[], bool], + timeout_seconds: float, + check_delay: float = 0.1, +) -> float | None: + """Executes provided function in a loop until it returns `True`. + + # Parameters + * `function` - Function returning `True` on success. Accepts no arguments. + Usually this would be a lambda or local function. + * `timeout_seconds` - Timeout, in seconds. + * `check_delay` - Amount of time between function calls, 100ms by default. + + # Returns + Time remaining until timeout (in seconds), or `None` if timeout has been hit. + """ + elapsed_seconds = 0.0 + while elapsed_seconds < timeout_seconds: + if not function(): + time.sleep(check_delay) + elapsed_seconds += check_delay + else: + return timeout_seconds - elapsed_seconds + + return None From 03d8b26a6c3366adcf608a630cd2293036a9031e Mon Sep 17 00:00:00 2001 From: Wojciech Olech Date: Mon, 16 Oct 2023 10:35:21 +0200 Subject: [PATCH 06/10] Scripts: Added script for listening to UART traffic --- scripts/listen_to_uart.py | 100 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 scripts/listen_to_uart.py diff --git a/scripts/listen_to_uart.py b/scripts/listen_to_uart.py new file mode 100644 index 00000000..5c6f3d64 --- /dev/null +++ b/scripts/listen_to_uart.py @@ -0,0 +1,100 @@ +"""Script that can be used to listen for incoming UART traffic from target board. +Call without arguments (or with `--help`) to show usage. +""" +import argparse +import logging +import sys + +from calldwell import init_default_logger +from calldwell.ssh_client import SSHClient +from calldwell.uart import RemoteUARTConfig, RemoteUARTConnection + + +def get_user_args() -> argparse.Namespace: + """Fetch user arguments via `argparse`.""" + parser = argparse.ArgumentParser( + description="Helper script that listens to incoming UART traffic from target board, and" + "prints it to stdout", + exit_on_error=True, + ) + + parser.add_argument( + "hostname", + type=str, + help="Target's hostname, for example 192.168.1.1 or localhost", + ) + parser.add_argument("login", type=str, help="SSH login of target machine") + parser.add_argument("password", type=str, help="SSH password of target machine") + parser.add_argument( + "device_path", + type=str, + help="Path to UART device on target machine, for example /dev/ttyUSB0", + ) + parser.add_argument( + "baudrate", + type=int, + choices=[ + 50, + 75, + 110, + 134, + 150, + 200, + 300, + 600, + 1200, + 1800, + 2400, + 4800, + 9600, + 19200, + 38400, + 57600, + 115200, + 230400, + 460800, + 500000, + 576000, + 921600, + 1000000, + 1152000, + 1500000, + 2000000, + 2500000, + 3000000, + 3500000, + 4000000, + ], + help="Target's baudrate, in bits per second. Only standard baudrates are supported.", + ) + + parser.add_argument("--port", type=int, default=19876, help="TCP port for UART connection") + + return parser.parse_args() + + +def main() -> None: + """Main function.""" + args = get_user_args() + ssh = SSHClient(args.hostname, args.login, args.password) + uart_config = RemoteUARTConfig( + device_path=args.device_path, + port=args.port, + baudrate=args.baudrate, + ) + uart = RemoteUARTConnection(ssh, uart_config) + + if uart.open_uart(): + logging.info("UART opened, waiting for data...") + else: + logging.critical("UART connection couldn't be established, quitting...") + sys.exit(1) + + while True: + if (data := uart.read_any()) is not None: + print(data) + + +if __name__ == "__main__": + init_default_logger() + main() From 6915fedae7988751edc0ccefe442102b0ff73213 Mon Sep 17 00:00:00 2001 From: Wojciech Olech Date: Mon, 16 Oct 2023 10:49:29 +0200 Subject: [PATCH 07/10] Python: Disabled `duplicate-code` pylint Due to the fact that it was triggered at almost every opportunity, and this codebase contains a lot of duplications in integration tests (and some other places) that have no reason to be "fixed" --- calldwell/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/calldwell/pyproject.toml b/calldwell/pyproject.toml index ed4f652d..cbc64d36 100644 --- a/calldwell/pyproject.toml +++ b/calldwell/pyproject.toml @@ -104,6 +104,7 @@ locally-disabled, suppressed-message, import-error, use-implicit-booleaness-not-comparison-to-zero, +duplicate-code, ''' From 4684b68cfff7e2b0197b464e4793382781e43b52 Mon Sep 17 00:00:00 2001 From: Wojciech Olech Date: Mon, 16 Oct 2023 21:31:16 +0200 Subject: [PATCH 08/10] Python: Added `option` package --- calldwell/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/calldwell/pyproject.toml b/calldwell/pyproject.toml index cbc64d36..eaf74745 100644 --- a/calldwell/pyproject.toml +++ b/calldwell/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" python = "^3.9" pygdbmi = "^0.11.0.0" paramiko = "^3.3.1" +option = "^2.1.0" [tool.poetry.group.dev.dependencies] black = "^23.9.1" From 94e0fb6d41bbc934aa43c09e63bdbd867006f86e Mon Sep 17 00:00:00 2001 From: Wojciech Olech Date: Mon, 16 Oct 2023 21:31:29 +0200 Subject: [PATCH 09/10] Calldwell: Extended UART support --- calldwell/uart.py | 202 ++++++++++++++++++++++++++++++++++---- scripts/listen_to_uart.py | 2 +- 2 files changed, 185 insertions(+), 19 deletions(-) diff --git a/calldwell/uart.py b/calldwell/uart.py index 77002ed1..6008868e 100644 --- a/calldwell/uart.py +++ b/calldwell/uart.py @@ -13,6 +13,8 @@ from enum import Enum, auto from typing import TYPE_CHECKING, cast +from option import Err, Ok, Result + from .utils import wait_until_true if TYPE_CHECKING: @@ -59,9 +61,21 @@ def __init__(self: BrokenSocketError, when: str) -> None: super().__init__(f"Socket connection broken when {when}") +class UARTError(Enum): + """Enumeration representing possible UART I/O errors.""" + + UART_IS_CLOSED = auto() + INVALID_TIMEOUT = auto() + TIMEOUT_HIT = auto() + INVALID_LENGTH = auto() + + class RemoteUARTConnection: """Remote UART connection""" + DEFAULT_CHUNK_SIZE = 4096 + """Default maximum length of received data.""" + def __init__( self: RemoteUARTConnection, ssh_connection: SSHClient, @@ -73,6 +87,7 @@ def __init__( self._socat_pid = 0 self._uart_socket: socket.socket | None = None self._is_open = False + self._rx_buffer = bytearray() @property def config(self: RemoteUARTConnection) -> RemoteUARTConfig: @@ -85,7 +100,9 @@ def is_open(self: RemoteUARTConnection) -> bool: return self._is_open def open_uart(self: RemoteUARTConnection, timeout: float = 3.0) -> bool: - """Opens UART port on remote, and connects to it via socat""" + """Opens UART port on remote, and connects to it via socat. + Returns `True` if UART connection has been established, `False` if UART is already open or + if connection cannot be established.""" if self._is_open: return False @@ -112,7 +129,8 @@ def check_connection_status() -> bool: return False def close_uart(self: RemoteUARTConnection) -> bool: - """Closes socat connection and UART port on remote""" + """Closes socat connection and UART port on remote. + Returns `True` if connection has been closed, `False` if UART is not open.""" if not self._is_open or self._uart_socket is None: return False @@ -125,10 +143,19 @@ def close_uart(self: RemoteUARTConnection) -> bool: self._is_open = False return True - def write(self: RemoteUARTConnection, data: bytes, timeout_seconds: float = 5.0) -> int | None: - """Transmits some data to UART""" - if not self._is_open or self._uart_socket is None or timeout_seconds <= 0: - return None + def write_bytes( + self: RemoteUARTConnection, + data: bytes, + timeout_seconds: float = 5.0, + ) -> tuple[int, UARTError | None]: + """Transmits some data via UART. + Returns a tuple. First element is always present and indicates how many bytes have been + sent. Second element is present only on error, and indicates what went wrong.""" + if (not self._is_open) or (self._uart_socket is None): + return 0, UARTError.UART_IS_CLOSED + + if timeout_seconds < 0: + return 0, UARTError.INVALID_TIMEOUT start_time = time.time() elapsed_time = 0.0 @@ -145,29 +172,168 @@ def write(self: RemoteUARTConnection, data: bytes, timeout_seconds: float = 5.0) elapsed_time = time.time() - start_time - return sent_bytes_sum + if elapsed_time >= timeout_seconds: + return sent_bytes_sum, UARTError.TIMEOUT_HIT - def read_any( + return sent_bytes_sum, None + + def write_string( self: RemoteUARTConnection, - timeout_seconds: float = 5.0, - maximum_length: int = 255, - ) -> bytes | None: + message: str, + timeout_seconds: float = 3.0, + ) -> tuple[int, UARTError | None]: + """Transmits an UTF-8 string via UART. Will throw an exception on invalid string. + Returns a tuple. First element is always present and indicates how many **bytes** have been + sent. Second element is present only on error, and indicates what went wrong.""" + return self.write_bytes(message.encode("utf-8"), timeout_seconds) + + def read_bytes( + self: RemoteUARTConnection, + timeout_seconds: float = 3.0, + maximum_length: int = DEFAULT_CHUNK_SIZE, + ) -> Result[bytes, UARTError]: """Reads any available amount of bytes from UART. Returns new data immediately when available. - Returns `None` on timeout""" - if not self._is_open or self._uart_socket is None or timeout_seconds <= 0: - return None + Returns `UARTError` on timeout or invalid arguments.""" + if (not self._is_open) or (self._uart_socket is None): + return Err(UARTError.UART_IS_CLOSED) + + if timeout_seconds < 0: + return Err(UARTError.INVALID_TIMEOUT) - received_data = None + if maximum_length <= 0: + return Err(UARTError.INVALID_LENGTH) + + # First, let's try checking if there's any new data from UART in non-blocking fashion. + # We do that by reading any available bytes into internal buffer, as there might be + # some data left from previous operations, which we don't want to discard and should + # return first. + # If that operation returns an error other than TIMEOUT_HIT, we should abort, as it's an + # unexpected error that should be handled by the user. + read_to_buffer_result = self._read_bytes_to_internal_buffer_non_blocking(maximum_length) + if read_to_buffer_result.is_err: + return Err(read_to_buffer_result.unwrap_err()) + + if len(buffered_data := self._take_internal_buffer_content()) > 0: + return Ok(buffered_data) + + # If internal RX buffer is empty, we can safely fetch new data directly from socket. + return self._read_bytes_from_socket(timeout_seconds, maximum_length) + + def read_exact_bytes( + self: RemoteUARTConnection, + length: int, + timeout_seconds: float = 3.0, + ) -> Result[bytes, UARTError]: + """Reads exact amount of bytes from UART. + Returns new data immediately when available. + Returns `Err(UARTError)` on timeout or invalid arguments.""" + read_bytes = bytearray() + + while len(read_bytes) < length: + remaining_bytes = length - len(read_bytes) + read_bytes_result = self.read_bytes(timeout_seconds, remaining_bytes) + if read_bytes_result.is_err: + # Propagate any hard error + return Err(read_bytes_result.unwrap_err()) + + read_bytes.extend(read_bytes_result.unwrap()) + + return Ok(bytes(read_bytes)) + + def read_string( + self: RemoteUARTConnection, + terminator: bytes, + timeout_seconds: float = 3.0, + maximum_length: int = DEFAULT_CHUNK_SIZE, + ) -> Result[str, UARTError]: + """Reads a string terminated by specified byte(s). + Returns a string immediately when available. + Returns `Err(UARTError)` on invalid arguments or timeout. + May throw an exception if received string (decoded after receiving all the bytes) is not a + valid UTF-8 string.""" + # Similarly to `read_bytes`, fetch any data that's immediately available into internal + # buffer, and return if it fails (timeout is silenced in non-blocking function). + buffer_read_result = self._read_bytes_to_internal_buffer_non_blocking(maximum_length) + if buffer_read_result.is_err: + return Err(buffer_read_result.unwrap_err()) + + if (terminator_index := self._rx_buffer.find(terminator)) >= 0: + return Ok(self._take_bytes_out_of_rx_buffer(terminator_index).decode("UTF-8")) + + # If a valid string isn't yet available in the buffer, try to read it again, but in + # blocking mode: + buffer_read_result = self._read_bytes_to_internal_buffer(timeout_seconds, maximum_length) + if buffer_read_result.is_ok and (terminator_index := self._rx_buffer.find(terminator)) >= 0: + return Ok(self._take_bytes_out_of_rx_buffer(terminator_index).decode("UTF-8")) + + # If `read_result` is not an integer, then it's an error which should be propagated. + # Received data will stay in RX buffer until it's taken via `read_bytes` or some other + # function. + return Err(buffer_read_result.unwrap_err()) + + def _take_bytes_out_of_rx_buffer(self: RemoteUARTConnection, amount: int) -> bytes: + extracted_bytes = self._rx_buffer[0:amount] + self._rx_buffer = self._rx_buffer[amount:] + return extracted_bytes + + def _read_bytes_to_internal_buffer_non_blocking( + self: RemoteUARTConnection, + maximum_length: int, + ) -> Result[int, UARTError]: + """Read any available bytes to internal buffer. + If there's no bytes to read, returns `Ok(0)` immediately. + Returns amount of read bytes, or `Err(UARTError)` if UART is not opened.""" + read_result = self._read_bytes_to_internal_buffer(0, maximum_length) + + # Silence timeout, as we don't care about it in non-blocking operation. + if read_result.is_err and read_result.unwrap_err() == UARTError.TIMEOUT_HIT: + return Ok(0) + + return read_result + + def _read_bytes_to_internal_buffer( + self: RemoteUARTConnection, + timeout_seconds: float, + maximum_length: int, + ) -> Result[int, UARTError]: + """Reads any available data from UART into internal RX buffer. + Returns the amount of bytes put in RX buffer, or `Err(UARTError)` in case of invalid + arguments or when timeout is hit.""" + reading_result = self._read_bytes_from_socket(timeout_seconds, maximum_length) + if reading_result.is_ok: + data = reading_result.unwrap() + self._rx_buffer.extend(data) + return Ok(len(data)) + return Err(reading_result.unwrap_err()) + + def _read_bytes_from_socket( + self: RemoteUARTConnection, + timeout_seconds: float, + maximum_length: int, + ) -> Result[bytes, UARTError]: + """Reads any available data from UART and returns it, or returns `Err(UARTError)` in case of + invalid arguments or when timeout is hit.""" + if (not self._is_open) or (self._uart_socket is None): + return Err(UARTError.UART_IS_CLOSED) + + if timeout_seconds < 0: + return Err(UARTError.INVALID_TIMEOUT) try: readable, _, _ = select.select([self._uart_socket], [], [], timeout_seconds) if readable: - received_data = self._uart_socket.recv(maximum_length) - except OSError: + return Ok(bytes(self._uart_socket.recv(maximum_length))) + except OSError: # timeout pass - return received_data + return Err(UARTError.TIMEOUT_HIT) + + def _take_internal_buffer_content(self: RemoteUARTConnection) -> bytes: + """Returns the whole content of internal buffer, and clears it.""" + buffered_data = bytes(self._rx_buffer.copy()) + self._rx_buffer.clear() + return buffered_data def _open_socat_bridge( self: RemoteUARTConnection, diff --git a/scripts/listen_to_uart.py b/scripts/listen_to_uart.py index 5c6f3d64..085ddd8b 100644 --- a/scripts/listen_to_uart.py +++ b/scripts/listen_to_uart.py @@ -91,7 +91,7 @@ def main() -> None: sys.exit(1) while True: - if (data := uart.read_any()) is not None: + if (data := uart.read_bytes()) is not None: print(data) From 080fe696c824c52502e279b048271f4e01b030fa Mon Sep 17 00:00:00 2001 From: Wojciech Olech Date: Tue, 17 Oct 2023 18:04:24 +0200 Subject: [PATCH 10/10] Calldwell: Fixed `RemoteUARTConnection.read_string` --- calldwell/uart.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/calldwell/uart.py b/calldwell/uart.py index 6008868e..e87e9c5c 100644 --- a/calldwell/uart.py +++ b/calldwell/uart.py @@ -259,13 +259,28 @@ def read_string( return Err(buffer_read_result.unwrap_err()) if (terminator_index := self._rx_buffer.find(terminator)) >= 0: - return Ok(self._take_bytes_out_of_rx_buffer(terminator_index).decode("UTF-8")) + return Ok( + self._take_bytes_out_of_rx_buffer(terminator_index + len(terminator)).decode( + "UTF-8", + ), + ) # If a valid string isn't yet available in the buffer, try to read it again, but in - # blocking mode: - buffer_read_result = self._read_bytes_to_internal_buffer(timeout_seconds, maximum_length) - if buffer_read_result.is_ok and (terminator_index := self._rx_buffer.find(terminator)) >= 0: - return Ok(self._take_bytes_out_of_rx_buffer(terminator_index).decode("UTF-8")) + # blocking mode. Repeat until timeout is hit to make sure that data in chunks is + # received correctly. + + while ( + buffer_read_result := self._read_bytes_to_internal_buffer( + timeout_seconds, + maximum_length, + ) + ).is_ok: + if (terminator_index := self._rx_buffer.find(terminator)) >= 0: + return Ok( + self._take_bytes_out_of_rx_buffer(terminator_index + len(terminator)).decode( + "UTF-8", + ), + ) # If `read_result` is not an integer, then it's an error which should be propagated. # Received data will stay in RX buffer until it's taken via `read_bytes` or some other @@ -360,6 +375,9 @@ def _close_socat_bridge(self: RemoteUARTConnection) -> None: self._socat_pid = 0 def __del__(self: RemoteUARTConnection) -> None: + # If you get an exception there, fix your destruction order manually. + # It may happen if SSH session is killed before UART. + # Make sure you close UART before closing the SSH session. self.close_uart() @staticmethod