scripts: twister: Support multiple ports in pytest-twister-harness
Add support for multiple connections (e.g. UART) from one device, enabling communication with second core UARTs. Feature implemented by extracting connection logic from DeviceAdapter to new DeviceConnection class with specialized implementations: - SerialConnection for hardware UART ports - ProcessConnection for native simulation - FifoConnection for QEMU communication Each connection maintains separate log files (handler.log, handler_1.log, etc.) and can be accessed via connection_index parameter in device methods like readline() and write(). This enables testing multi-core applications where different cores communicate through separate UART interfaces. Signed-off-by: Grzegorz Chwierut <grzegorz.chwierut@nordicsemi.no>
This commit is contained in:
committed by
Benjamin Cabé
parent
0d448cc2c1
commit
1b7810f0cb
@@ -310,32 +310,14 @@
|
||||
"I001", # https://docs.astral.sh/ruff/rules/unsorted-imports
|
||||
"UP015", # https://docs.astral.sh/ruff/rules/redundant-open-modes
|
||||
]
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/binary_adapter.py" = [
|
||||
"E501", # https://docs.astral.sh/ruff/rules/line-too-long
|
||||
"SIM103", # https://docs.astral.sh/ruff/rules/needless-bool
|
||||
]
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/device_adapter.py" = [
|
||||
"E501", # https://docs.astral.sh/ruff/rules/line-too-long
|
||||
"I001", # https://docs.astral.sh/ruff/rules/unsorted-imports
|
||||
]
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/factory.py" = [
|
||||
"I001", # https://docs.astral.sh/ruff/rules/unsorted-imports
|
||||
"UP006", # https://docs.astral.sh/ruff/rules/non-pep585-annotation
|
||||
"UP035", # https://docs.astral.sh/ruff/rules/deprecated-import
|
||||
]
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/fifo_handler.py" = [
|
||||
"E501", # https://docs.astral.sh/ruff/rules/line-too-long
|
||||
"SIM115", # https://docs.astral.sh/ruff/rules/open-file-with-context-handler
|
||||
]
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/hardware_adapter.py" = [
|
||||
"E501", # https://docs.astral.sh/ruff/rules/line-too-long
|
||||
"I001", # https://docs.astral.sh/ruff/rules/unsorted-imports
|
||||
"UP024", # https://docs.astral.sh/ruff/rules/os-error-alias
|
||||
]
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/qemu_adapter.py" = [
|
||||
"E501", # https://docs.astral.sh/ruff/rules/line-too-long
|
||||
"I001", # https://docs.astral.sh/ruff/rules/unsorted-imports
|
||||
]
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/fixtures.py" = [
|
||||
"E501", # https://docs.astral.sh/ruff/rules/line-too-long
|
||||
"I001", # https://docs.astral.sh/ruff/rules/unsorted-imports
|
||||
@@ -956,11 +938,6 @@ exclude = [
|
||||
"./scripts/net/enumerate_http_status.py",
|
||||
"./scripts/profiling/stackcollapse.py",
|
||||
"./scripts/pylib/build_helpers/domains.py",
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/binary_adapter.py",
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/device_adapter.py",
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/fifo_handler.py",
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/hardware_adapter.py",
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/device/qemu_adapter.py",
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/fixtures.py",
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/helpers/mcumgr.py",
|
||||
"./scripts/pylib/pytest-twister-harness/src/twister_harness/plugin.py",
|
||||
|
||||
@@ -35,8 +35,9 @@ class BinaryAdapterBase(DeviceAdapter, abc.ABC):
|
||||
def generate_command(self) -> None:
|
||||
"""Generate and set command which will be used during running device."""
|
||||
|
||||
def _flash_and_run(self) -> None:
|
||||
def _device_launch(self) -> None:
|
||||
self._run_subprocess()
|
||||
self.connect()
|
||||
|
||||
def _run_subprocess(self) -> None:
|
||||
if not self.command:
|
||||
@@ -46,6 +47,9 @@ class BinaryAdapterBase(DeviceAdapter, abc.ABC):
|
||||
log_command(logger, 'Running command', self.command, level=logging.DEBUG)
|
||||
try:
|
||||
self._process = subprocess.Popen(self.command, **self.process_kwargs)
|
||||
# Update all ProcessConnection instances with the new process
|
||||
for conn in self.connections:
|
||||
conn.update(process=self._process)
|
||||
except subprocess.SubprocessError as exc:
|
||||
msg = f'Running subprocess failed due to SubprocessError {exc}'
|
||||
logger.error(msg)
|
||||
@@ -59,22 +63,6 @@ class BinaryAdapterBase(DeviceAdapter, abc.ABC):
|
||||
logger.error(msg)
|
||||
raise TwisterHarnessException(msg) from exc
|
||||
|
||||
def _connect_device(self) -> None:
|
||||
"""
|
||||
This method was implemented only to imitate standard connect behavior
|
||||
like in Serial class.
|
||||
"""
|
||||
|
||||
def _disconnect_device(self) -> None:
|
||||
"""
|
||||
This method was implemented only to imitate standard disconnect behavior
|
||||
like in serial connection.
|
||||
"""
|
||||
|
||||
def _close_device(self) -> None:
|
||||
"""Terminate subprocess"""
|
||||
self._stop_subprocess()
|
||||
|
||||
def _stop_subprocess(self) -> None:
|
||||
if self._process is None:
|
||||
# subprocess already stopped
|
||||
@@ -84,30 +72,14 @@ class BinaryAdapterBase(DeviceAdapter, abc.ABC):
|
||||
terminate_process(self._process, self.base_timeout)
|
||||
return_code = self._process.wait(self.base_timeout)
|
||||
self._process = None
|
||||
for conn in self.connections:
|
||||
if hasattr(conn, '_process'):
|
||||
conn._process = None
|
||||
logger.debug('Running subprocess finished with return code %s', return_code)
|
||||
|
||||
def _read_device_output(self) -> bytes:
|
||||
return self._process.stdout.readline()
|
||||
|
||||
def _write_to_device(self, data: bytes) -> None:
|
||||
self._process.stdin.write(data)
|
||||
self._process.stdin.flush()
|
||||
|
||||
def _flush_device_output(self) -> None:
|
||||
if self.is_device_running():
|
||||
self._process.stdout.flush()
|
||||
|
||||
def is_device_running(self) -> bool:
|
||||
return self._device_run.is_set() and self._is_binary_running()
|
||||
|
||||
def _is_binary_running(self) -> bool:
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
"""Return true if device is connected."""
|
||||
return self.is_device_running() and self._device_connected.is_set()
|
||||
def _close_device(self) -> None:
|
||||
"""Terminate subprocess"""
|
||||
self._stop_subprocess()
|
||||
|
||||
def _clear_internal_resources(self) -> None:
|
||||
super()._clear_internal_resources()
|
||||
@@ -131,6 +103,32 @@ class UnitSimulatorAdapter(BinaryAdapterBase):
|
||||
|
||||
|
||||
class CustomSimulatorAdapter(BinaryAdapterBase):
|
||||
"""Simulator adapter to run custom simulator"""
|
||||
|
||||
def generate_command(self) -> None:
|
||||
"""Set command to run."""
|
||||
self.command = [self.west, 'build', '-d', str(self.device_config.app_build_dir), '-t', 'run']
|
||||
self.command = [
|
||||
self.west,
|
||||
'build',
|
||||
'-d',
|
||||
str(self.device_config.app_build_dir),
|
||||
'-t',
|
||||
'run',
|
||||
]
|
||||
|
||||
|
||||
class QemuAdapter(BinaryAdapterBase):
|
||||
"""Simulator adapter to run QEMU"""
|
||||
|
||||
def generate_command(self) -> None:
|
||||
"""Set command to run."""
|
||||
self.command = [
|
||||
self.west,
|
||||
'build',
|
||||
'-d',
|
||||
str(self.device_config.app_build_dir),
|
||||
'-t',
|
||||
'run',
|
||||
]
|
||||
if 'stdin' in self.process_kwargs:
|
||||
self.process_kwargs.pop('stdin')
|
||||
|
||||
@@ -7,19 +7,13 @@ from __future__ import annotations
|
||||
import abc
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from serial import SerialException
|
||||
|
||||
from twister_harness.exceptions import (
|
||||
TwisterHarnessException,
|
||||
TwisterHarnessTimeoutException,
|
||||
)
|
||||
from twister_harness.device.device_connection import DeviceConnection, create_device_connections
|
||||
from twister_harness.exceptions import TwisterHarnessException
|
||||
from twister_harness.twister_harness_config import DeviceConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,24 +26,16 @@ class DeviceAdapter(abc.ABC):
|
||||
it.
|
||||
"""
|
||||
|
||||
_west: str | None = None
|
||||
|
||||
def __init__(self, device_config: DeviceConfig) -> None:
|
||||
"""
|
||||
:param device_config: device configuration
|
||||
"""
|
||||
self.device_config: DeviceConfig = device_config
|
||||
self.base_timeout: float = device_config.base_timeout
|
||||
self._device_read_queue: queue.Queue = queue.Queue()
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._device_run: threading.Event = threading.Event()
|
||||
self._device_connected: threading.Event = threading.Event()
|
||||
self._reader_started: threading.Event = threading.Event()
|
||||
self.command: list[str] = []
|
||||
self._west: str | None = None
|
||||
|
||||
self.handler_log_path: Path = device_config.build_dir / 'handler.log'
|
||||
self._log_files: list[Path] = [self.handler_log_path]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'{self.__class__.__name__}()'
|
||||
self.connections: list[DeviceConnection] = create_device_connections(device_config)
|
||||
self._log_files: list[Path] = [conn.log_path for conn in self.connections]
|
||||
|
||||
@property
|
||||
def env(self) -> dict[str, str]:
|
||||
@@ -57,171 +43,117 @@ class DeviceAdapter(abc.ABC):
|
||||
return env
|
||||
|
||||
def launch(self) -> None:
|
||||
"""
|
||||
Start by closing previously running application (no effect if not
|
||||
needed). Then, flash and run test application. Finally, start an
|
||||
internal reader thread capturing an output from a device.
|
||||
"""Launch the test application on the target device.
|
||||
|
||||
This method performs the complete device initialization sequence:
|
||||
|
||||
- Close any previously running application (cleanup)
|
||||
- Generate the execution command if not already set
|
||||
- Add any extra test arguments to the command
|
||||
- Start reader threads to capture device output
|
||||
- Launch the application (flash for hardware, execute for simulators)
|
||||
|
||||
The launch process varies by device type:
|
||||
|
||||
- Hardware devices: Flash the application and establish serial communication.
|
||||
May connect before or after flashing depending on device configuration.
|
||||
- QEMU/simulators: Start subprocess and establish FIFO/pipe communication
|
||||
- Native simulators: Execute binary and connect via process pipes
|
||||
"""
|
||||
self.close()
|
||||
self._clear_internal_resources()
|
||||
|
||||
if not self.command:
|
||||
self.generate_command()
|
||||
if self.device_config.extra_test_args:
|
||||
self.command.extend(self.device_config.extra_test_args.split())
|
||||
|
||||
if self.device_config.type != 'hardware':
|
||||
self._flash_and_run()
|
||||
self._device_run.set()
|
||||
self._start_reader_thread()
|
||||
self.connect()
|
||||
return
|
||||
|
||||
self._device_run.set()
|
||||
self._start_reader_thread()
|
||||
|
||||
if self.device_config.flash_before:
|
||||
# For hardware devices with shared USB or software USB, connect after flashing.
|
||||
# Retry for up to 10 seconds for USB-CDC based devices to enumerate.
|
||||
self._flash_and_run()
|
||||
self.connect(retry_s = 10)
|
||||
else:
|
||||
# On hardware, flash after connecting to COM port, otherwise some messages
|
||||
# from target can be lost.
|
||||
self.connect()
|
||||
self._flash_and_run()
|
||||
self.start_reader()
|
||||
self._device_launch()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Disconnect, close device and close reader thread."""
|
||||
if not self._device_run.is_set():
|
||||
# device already closed
|
||||
return
|
||||
"""Disconnect, close device and close reader threads."""
|
||||
self.disconnect()
|
||||
self._close_device()
|
||||
self._device_run.clear()
|
||||
self._join_reader_thread()
|
||||
self.stop_reader()
|
||||
self._clear_internal_resources()
|
||||
|
||||
def connect(self, retry_s: int = 0) -> None:
|
||||
"""Connect to device - allow for output gathering."""
|
||||
if self.is_device_connected():
|
||||
logger.debug('Device already connected')
|
||||
return
|
||||
if not self.is_device_running():
|
||||
msg = 'Cannot connect to not working device'
|
||||
logger.error(msg)
|
||||
raise TwisterHarnessException(msg)
|
||||
|
||||
if retry_s > 0:
|
||||
retry_cycles = retry_s * 10
|
||||
for i in range(retry_cycles):
|
||||
try:
|
||||
self._connect_device()
|
||||
break
|
||||
except SerialException:
|
||||
if i == retry_cycles - 1:
|
||||
raise
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
self._connect_device()
|
||||
|
||||
self._device_connected.set()
|
||||
for connection in self.connections:
|
||||
connection.connect()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect device - block output gathering."""
|
||||
if not self.is_device_connected():
|
||||
logger.debug("Device already disconnected")
|
||||
return
|
||||
self._disconnect_device()
|
||||
self._device_connected.clear()
|
||||
for connection in self.connections:
|
||||
connection.disconnect()
|
||||
|
||||
def readline(self, timeout: float | None = None, print_output: bool = True) -> str:
|
||||
"""
|
||||
Read line from device output. If timeout is not provided, then use
|
||||
base_timeout.
|
||||
"""
|
||||
timeout = timeout or self.base_timeout
|
||||
if self.is_device_connected() or not self._device_read_queue.empty():
|
||||
data = self._read_from_queue(timeout)
|
||||
else:
|
||||
msg = 'No connection to the device and no more data to read.'
|
||||
logger.error(msg)
|
||||
raise TwisterHarnessException('No connection to the device and no more data to read.')
|
||||
if print_output:
|
||||
logger.debug('#: %s', data)
|
||||
return data
|
||||
|
||||
def readlines_until(
|
||||
self,
|
||||
regex: str | None = None,
|
||||
num_of_lines: int | None = None,
|
||||
timeout: float | None = None,
|
||||
print_output: bool = True,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Read available output lines produced by device from internal buffer
|
||||
until following conditions:
|
||||
|
||||
1. If regex is provided - read until regex regex is found in read
|
||||
line (or until timeout).
|
||||
2. If num_of_lines is provided - read until number of read lines is
|
||||
equal to num_of_lines (or until timeout).
|
||||
3. If none of above is provided - return immediately lines collected so
|
||||
far in internal buffer.
|
||||
|
||||
If timeout is not provided, then use base_timeout.
|
||||
"""
|
||||
__tracebackhide__ = True # pylint: disable=unused-variable
|
||||
timeout = timeout or self.base_timeout
|
||||
if regex:
|
||||
regex_compiled = re.compile(regex)
|
||||
lines: list[str] = []
|
||||
if regex or num_of_lines:
|
||||
timeout_time: float = time.time() + timeout
|
||||
while time.time() < timeout_time:
|
||||
try:
|
||||
line = self.readline(0.1, print_output)
|
||||
except TwisterHarnessTimeoutException:
|
||||
continue
|
||||
lines.append(line)
|
||||
if regex and regex_compiled.search(line):
|
||||
break
|
||||
if num_of_lines and len(lines) == num_of_lines:
|
||||
break
|
||||
else:
|
||||
if regex is not None:
|
||||
msg = f'Did not find line "{regex}" within {timeout} seconds'
|
||||
else:
|
||||
msg = f'Did not find expected number of lines within {timeout} seconds'
|
||||
logger.error(msg)
|
||||
raise AssertionError(msg)
|
||||
else:
|
||||
lines = self.readlines(print_output)
|
||||
return lines
|
||||
|
||||
def readlines(self, print_output: bool = True) -> list[str]:
|
||||
"""
|
||||
Read all available output lines produced by device from internal buffer.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
while not self._device_read_queue.empty():
|
||||
line = self.readline(0.1, print_output)
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
def clear_buffer(self) -> None:
|
||||
"""
|
||||
Remove all available output produced by device from internal buffer
|
||||
(queue).
|
||||
"""
|
||||
self.readlines(print_output=False)
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
"""Write data bytes to device."""
|
||||
if not self.is_device_connected():
|
||||
msg = 'No connection to the device'
|
||||
def check_connection(self, connection_index: int = 0) -> None:
|
||||
"""Validate that the specified connection index exists."""
|
||||
if connection_index >= len(self.connections):
|
||||
msg = f'Connection index {connection_index} is out of range.'
|
||||
logger.error(msg)
|
||||
raise TwisterHarnessException(msg)
|
||||
self._write_to_device(data)
|
||||
|
||||
def readline(self, connection_index: int = 0, **kwargs) -> str:
|
||||
"""Read a single line from device output.
|
||||
|
||||
:param connection_index: Connection port to read from (0=main UART, 1=second core, etc.)
|
||||
:param kwargs: Additional keyword arguments (timeout, print_output)
|
||||
:returns: Single line of text without trailing newlines
|
||||
"""
|
||||
self.check_connection(connection_index)
|
||||
if kwargs.get('timeout') is None:
|
||||
kwargs['timeout'] = self.base_timeout
|
||||
return self.connections[connection_index].readline(**kwargs)
|
||||
|
||||
def readlines(self, connection_index: int = 0, **kwargs) -> list[str]:
|
||||
"""Read all available output lines produced by device from internal buffer."""
|
||||
self.check_connection(connection_index)
|
||||
return self.connections[connection_index].readlines(**kwargs)
|
||||
|
||||
def readlines_until(self, connection_index: int = 0, **kwargs) -> list[str]:
|
||||
"""Read lines from device output until a specific condition is met.
|
||||
|
||||
This method provides flexible ways to collect device output: wait for a specific
|
||||
pattern, collect a fixed number of lines, or get all currently available lines.
|
||||
|
||||
:param connection_index: Index of the connection/port to read from (default: 0).
|
||||
For hardware devices: 0 = main UART, 1 = second core UART, etc.
|
||||
For QEMU and native: always use 0 (only one connection available)
|
||||
:param kwargs: Keyword arguments passed to underlying connection including:
|
||||
|
||||
- regex - Regular expression pattern to search for
|
||||
- num_of_lines - Exact number of lines to read
|
||||
- timeout - Maximum time in seconds to wait (uses base_timeout if not provided)
|
||||
- print_output - Whether to log each line as it's read (default: True)
|
||||
:returns: List of output lines without trailing newlines
|
||||
:raises AssertionError: If timeout expires before condition is met
|
||||
"""
|
||||
self.check_connection(connection_index)
|
||||
return self.connections[connection_index].readlines_until(**kwargs)
|
||||
|
||||
def clear_buffer(self) -> None:
|
||||
"""Remove all available output produced by device from internal buffer."""
|
||||
for connection in self.connections:
|
||||
connection.clear_buffer()
|
||||
|
||||
def write(self, data: bytes, connection_index: int = 0) -> None:
|
||||
"""Write data bytes to the target device.
|
||||
|
||||
Sends raw bytes to the device through the appropriate communication channel.
|
||||
The underlying transport mechanism varies by device type: Hardware devices
|
||||
write to serial/UART port, QEMU devices write to FIFO queue, and Native
|
||||
simulators write to process stdin pipes.
|
||||
|
||||
:param data: Raw bytes to send to the device
|
||||
:param connection_index: Index of the connection/port to write to (default: 0)
|
||||
"""
|
||||
self.check_connection(connection_index)
|
||||
if not self.connections[connection_index].is_device_connected():
|
||||
msg = f'Cannot write to not connected device on connection index {connection_index}.'
|
||||
logger.error(msg)
|
||||
raise TwisterHarnessException(msg)
|
||||
self.connections[connection_index]._write_to_device(data)
|
||||
|
||||
def initialize_log_files(self, test_name: str = '') -> None:
|
||||
"""
|
||||
@@ -232,46 +164,27 @@ class DeviceAdapter(abc.ABC):
|
||||
with open(log_file_path, 'a+') as log_file:
|
||||
log_file.write(f'\n==== Test {test_name} started at {datetime.now()} ====\n')
|
||||
|
||||
def _start_reader_thread(self) -> None:
|
||||
self._reader_thread = threading.Thread(target=self._handle_device_output, daemon=True)
|
||||
self._reader_thread.start()
|
||||
def start_reader(self) -> None:
|
||||
"""Start internal reader threads for all connections."""
|
||||
if self._reader_started.is_set():
|
||||
# reader already started
|
||||
return
|
||||
self._reader_started.set()
|
||||
for connection in self.connections:
|
||||
connection.start_reader_thread(self._reader_started)
|
||||
|
||||
def _handle_device_output(self) -> None:
|
||||
"""
|
||||
This method is dedicated to run it in separate thread to read output
|
||||
from device and put them into internal queue and save to log file.
|
||||
"""
|
||||
with open(self.handler_log_path, 'a+') as log_file:
|
||||
while self.is_device_running():
|
||||
if self.is_device_connected():
|
||||
output = self._read_device_output().decode(errors='replace').rstrip("\r\n")
|
||||
if output:
|
||||
self._device_read_queue.put(output)
|
||||
log_file.write(f'{output}\n')
|
||||
log_file.flush()
|
||||
else:
|
||||
# ignore output from device
|
||||
self._flush_device_output()
|
||||
time.sleep(0.1)
|
||||
|
||||
def _read_from_queue(self, timeout: float) -> str:
|
||||
"""Read data from internal queue"""
|
||||
try:
|
||||
data: str | object = self._device_read_queue.get(timeout=timeout)
|
||||
except queue.Empty as exc:
|
||||
raise TwisterHarnessTimeoutException(f'Read from device timeout occurred ({timeout}s)') from exc
|
||||
return data
|
||||
|
||||
def _join_reader_thread(self) -> None:
|
||||
if self._reader_thread is not None:
|
||||
self._reader_thread.join(self.base_timeout)
|
||||
self._reader_thread = None
|
||||
def stop_reader(self) -> None:
|
||||
"""Stop internal reader threads for all connections."""
|
||||
if not self._reader_started.is_set():
|
||||
# reader already stopped
|
||||
return
|
||||
self._reader_started.clear()
|
||||
for connection in self.connections:
|
||||
connection.join_reader_thread(self.base_timeout)
|
||||
|
||||
def _clear_internal_resources(self) -> None:
|
||||
self._reader_thread = None
|
||||
self._device_read_queue = queue.Queue()
|
||||
self._device_run.clear()
|
||||
self._device_connected.clear()
|
||||
for connection in self.connections:
|
||||
connection._clear_internal_resources()
|
||||
|
||||
@property
|
||||
def west(self) -> str:
|
||||
@@ -296,42 +209,17 @@ class DeviceAdapter(abc.ABC):
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _flash_and_run(self) -> None:
|
||||
"""Flash and run application on a device."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _connect_device(self) -> None:
|
||||
"""Connect with the device (e.g. via serial port)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _disconnect_device(self) -> None:
|
||||
"""Disconnect from the device (e.g. from serial port)."""
|
||||
def _device_launch(self) -> None:
|
||||
"""Launch the application on the target device."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _close_device(self) -> None:
|
||||
"""Stop application"""
|
||||
"""Stop application on the target device."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _read_device_output(self) -> bytes:
|
||||
"""
|
||||
Read device output directly through serial, subprocess, FIFO, etc.
|
||||
Even if device is not connected, this method has to return something
|
||||
(e.g. empty bytes string). This assumption is made to maintain
|
||||
compatibility between various adapters and their reading technique.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _write_to_device(self, data: bytes) -> None:
|
||||
"""Write to device directly through serial, subprocess, FIFO, etc."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _flush_device_output(self) -> None:
|
||||
"""Flush device connection (serial, subprocess output, FIFO, etc.)"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_device_running(self) -> bool:
|
||||
"""Return true if application is running on device."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_device_connected(self) -> bool:
|
||||
"""Return true if device is connected."""
|
||||
"""
|
||||
Check if the primary device connection is active.
|
||||
|
||||
Added to keep backward compatibility as it is used in fixtures.
|
||||
"""
|
||||
return self.connections and self.connections[0].is_device_connected()
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
# Copyright (c) 2025 Nordic Semiconductor ASA
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import abc
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
if os.name != 'nt':
|
||||
import pty
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import serial
|
||||
|
||||
from twister_harness.device.fifo_handler import FifoHandler
|
||||
from twister_harness.exceptions import TwisterHarnessException, TwisterHarnessTimeoutException
|
||||
from twister_harness.twister_harness_config import DeviceConfig, DeviceSerialConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeviceConnection(abc.ABC):
|
||||
"""
|
||||
Interface for device communication transport layer.
|
||||
Handles the actual connection mechanism (Serial/UART, FIFO, process, etc.)
|
||||
"""
|
||||
|
||||
def __init__(self, log_path: Path, timeout: float) -> None:
|
||||
"""Initialize the device connection"""
|
||||
self.log_path = log_path
|
||||
self.timeout = timeout
|
||||
self._device_read_queue: queue.Queue = queue.Queue()
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
# Prefix for log messages to differentiate between multiple connections
|
||||
self.log_prefix: str = ''
|
||||
|
||||
def start_reader_thread(self, reader_started: threading.Event) -> None:
|
||||
"""Start the internal reader thread for the device connection."""
|
||||
if self._reader_thread is None or not self._reader_thread.is_alive():
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._handle_device_output, args=(reader_started,), daemon=True
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
def join_reader_thread(self, timeout: float) -> None:
|
||||
"""Join the internal reader thread for the device connection."""
|
||||
if self._reader_thread is not None:
|
||||
self._reader_thread.join(timeout)
|
||||
if self._reader_thread.is_alive():
|
||||
logger.warning("Reader thread did not terminate within timeout")
|
||||
self._reader_thread = None
|
||||
|
||||
def update(self, **kwargs) -> None: # noqa: B027
|
||||
"""Update connection parameters based on provided keyword arguments."""
|
||||
|
||||
def _clear_internal_resources(self) -> None:
|
||||
self._reader_thread = None
|
||||
self._device_read_queue = queue.Queue()
|
||||
|
||||
def _handle_device_output(self, reader_started: threading.Event) -> None:
|
||||
"""
|
||||
This method is dedicated to run it in separate thread to read output
|
||||
from device and put them into internal queue and save to log file.
|
||||
"""
|
||||
with open(self.log_path, 'a+', encoding='utf-8', errors='replace') as log_file:
|
||||
while reader_started.is_set():
|
||||
if self.is_device_connected():
|
||||
output = self._read_device_output().decode(errors='replace').rstrip("\r\n")
|
||||
if output:
|
||||
self._device_read_queue.put(output)
|
||||
log_file.write(f'{output}\n')
|
||||
log_file.flush()
|
||||
else:
|
||||
# ignore output from device
|
||||
self._flush_device_output()
|
||||
time.sleep(0.1)
|
||||
|
||||
def _read_from_queue(self, timeout: float) -> str:
|
||||
"""Read data from internal queue"""
|
||||
try:
|
||||
data: str | object = self._device_read_queue.get(timeout=timeout)
|
||||
except queue.Empty as exc:
|
||||
raise TwisterHarnessTimeoutException(
|
||||
f'Read from device timeout occurred ({timeout}s)'
|
||||
) from exc
|
||||
return data
|
||||
|
||||
def readline(self, timeout: float | None = None, print_output: bool = True) -> str:
|
||||
"""
|
||||
Read line from device output. If timeout is not provided, then use
|
||||
base_timeout.
|
||||
"""
|
||||
timeout = timeout or self.timeout
|
||||
if self.is_device_connected() or not self._device_read_queue.empty():
|
||||
data = self._read_from_queue(timeout)
|
||||
else:
|
||||
msg = 'No connection to the device and no more data to read.'
|
||||
logger.error(msg)
|
||||
raise TwisterHarnessException('No connection to the device and no more data to read.')
|
||||
if print_output:
|
||||
logger.debug('%s#: %s', self.log_prefix, data)
|
||||
return data
|
||||
|
||||
def readlines_until(
|
||||
self,
|
||||
regex: str | None = None,
|
||||
num_of_lines: int | None = None,
|
||||
timeout: float | None = None,
|
||||
print_output: bool = True,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Read lines from device output until a specific condition is met.
|
||||
|
||||
This method provides flexible ways to collect device output from your test device:
|
||||
- Wait for a specific pattern/message to appear in the output
|
||||
- Collect a fixed number of lines
|
||||
- Get all currently available lines immediately
|
||||
"""
|
||||
__tracebackhide__ = True # pylint: disable=unused-variable
|
||||
timeout = timeout or self.timeout
|
||||
if regex:
|
||||
regex_compiled = re.compile(regex)
|
||||
lines: list[str] = []
|
||||
if regex or num_of_lines:
|
||||
timeout_time: float = time.time() + timeout
|
||||
while time.time() < timeout_time:
|
||||
try:
|
||||
line = self.readline(0.1, print_output)
|
||||
except TwisterHarnessTimeoutException:
|
||||
continue
|
||||
lines.append(line)
|
||||
if regex and regex_compiled.search(line):
|
||||
break
|
||||
if num_of_lines and len(lines) == num_of_lines:
|
||||
break
|
||||
else:
|
||||
if regex is not None:
|
||||
msg = f'Did not find line "{regex}" within {timeout} seconds'
|
||||
else:
|
||||
msg = f'Did not find expected number of lines within {timeout} seconds'
|
||||
logger.error(msg)
|
||||
raise AssertionError(msg)
|
||||
else:
|
||||
lines = self.readlines(print_output)
|
||||
return lines
|
||||
|
||||
def readlines(self, print_output: bool = True) -> list[str]:
|
||||
"""
|
||||
Read all available output lines produced by device from internal buffer.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
while not self._device_read_queue.empty():
|
||||
line = self.readline(0.1, print_output)
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
def clear_buffer(self) -> None:
|
||||
"""
|
||||
Remove all available output produced by device from internal buffer.
|
||||
"""
|
||||
self.readlines(print_output=False)
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Connect to device - allow for output gathering."""
|
||||
if self.is_device_connected():
|
||||
# Device already connected
|
||||
return
|
||||
self._connect_device()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect device - block output gathering."""
|
||||
if not self.is_device_connected():
|
||||
# Device already disconnected
|
||||
return
|
||||
self._disconnect_device()
|
||||
|
||||
@abc.abstractmethod
|
||||
def _connect_device(self) -> None:
|
||||
"""Connect with the device (e.g. via serial port)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _disconnect_device(self) -> None:
|
||||
"""Disconnect from the device (e.g. from serial port)."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _read_device_output(self) -> bytes:
|
||||
"""
|
||||
Read device output directly through serial, subprocess, FIFO, etc.
|
||||
Even if device is not connected, this method has to return something
|
||||
(e.g. empty bytes string). This assumption is made to maintain
|
||||
compatibility between various adapters and their reading technique.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def is_device_connected(self) -> bool:
|
||||
"""Return true if device is connected."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _write_to_device(self, data: bytes) -> None:
|
||||
"""Write to device directly through serial, subprocess, FIFO, etc."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _flush_device_output(self) -> None:
|
||||
"""Flush device connection (serial, subprocess output, FIFO, etc.)"""
|
||||
|
||||
|
||||
class SerialConnection(DeviceConnection):
|
||||
"""Serial/UART connection implementation for hardware devices"""
|
||||
|
||||
def __init__(self, log_path: Path, timeout: float, serial_config: DeviceSerialConfig) -> None:
|
||||
"""
|
||||
Initialize serial connection.
|
||||
"""
|
||||
super().__init__(log_path, timeout)
|
||||
self.serial_config: DeviceSerialConfig = serial_config
|
||||
self._serial_connection: serial.Serial | None = None
|
||||
self._serial_pty_proc: subprocess.Popen | None = None
|
||||
self._serial_buffer: bytearray = bytearray()
|
||||
|
||||
def _connect_device(self) -> None:
|
||||
if self.is_device_connected():
|
||||
# Device already connected
|
||||
return
|
||||
serial_name = self._open_serial_pty() or self.serial_config.port
|
||||
logger.debug('Opening serial connection for %s', serial_name)
|
||||
try:
|
||||
self._serial_connection = serial.Serial(
|
||||
serial_name,
|
||||
baudrate=self.serial_config.baud,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except serial.SerialException as exc:
|
||||
logger.exception('Cannot open connection: %s', exc)
|
||||
self._close_serial_pty()
|
||||
raise
|
||||
|
||||
self._serial_connection.flush()
|
||||
self._serial_connection.reset_input_buffer()
|
||||
self._serial_connection.reset_output_buffer()
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
return self._serial_connection and self._serial_connection.is_open
|
||||
|
||||
def _open_serial_pty(self) -> str | None:
|
||||
"""Open a pty pair, run process and return tty name"""
|
||||
if not self.serial_config.serial_pty:
|
||||
return None
|
||||
|
||||
try:
|
||||
master, slave = pty.openpty()
|
||||
except NameError as exc:
|
||||
logger.exception('PTY module is not available.')
|
||||
raise exc
|
||||
|
||||
try:
|
||||
self._serial_pty_proc = subprocess.Popen(
|
||||
re.split('[, ]', self.serial_config.serial_pty),
|
||||
stdout=master,
|
||||
stdin=master,
|
||||
stderr=master,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.exception('Failed to run subprocess, error %s', str(exc))
|
||||
raise
|
||||
return os.ttyname(slave)
|
||||
|
||||
def _disconnect_device(self) -> None:
|
||||
if self._serial_connection:
|
||||
serial_name = self._serial_connection.port
|
||||
self._serial_connection.close()
|
||||
self._serial_connection = None
|
||||
logger.debug('Closed serial connection for %s', serial_name)
|
||||
self._close_serial_pty()
|
||||
|
||||
def _close_serial_pty(self) -> None:
|
||||
"""Terminate the process opened for serial pty script"""
|
||||
if self._serial_pty_proc:
|
||||
self._serial_pty_proc.terminate()
|
||||
self._serial_pty_proc.communicate(timeout=self.timeout)
|
||||
logger.debug('Process %s terminated', self.serial_config.serial_pty)
|
||||
self._serial_pty_proc = None
|
||||
|
||||
def _read_device_output(self) -> bytes:
|
||||
try:
|
||||
output = self._readline_serial()
|
||||
except (OSError, TypeError):
|
||||
# serial was probably disconnected
|
||||
output = b''
|
||||
return output
|
||||
|
||||
def _readline_serial(self) -> bytes:
|
||||
"""
|
||||
This method was created to avoid using PySerial built-in readline
|
||||
method which cause blocking reader thread even if there is no data to
|
||||
read. Instead for this, following implementation try to read data only
|
||||
if they are available. Inspiration for this code was taken from this
|
||||
comment:
|
||||
https://github.com/pyserial/pyserial/issues/216#issuecomment-369414522
|
||||
"""
|
||||
line = self._readline_from_serial_buffer()
|
||||
if line is not None:
|
||||
return line
|
||||
while True:
|
||||
if self._serial_connection is None or not self._serial_connection.is_open:
|
||||
return b''
|
||||
elif self._serial_connection.in_waiting == 0:
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
bytes_to_read = max(1, min(2048, self._serial_connection.in_waiting))
|
||||
output = self._serial_connection.read(bytes_to_read)
|
||||
self._serial_buffer.extend(output)
|
||||
line = self._readline_from_serial_buffer()
|
||||
if line is not None:
|
||||
return line
|
||||
|
||||
def _readline_from_serial_buffer(self) -> bytes | None:
|
||||
idx = self._serial_buffer.find(b"\n")
|
||||
if idx >= 0:
|
||||
line = self._serial_buffer[: idx + 1]
|
||||
self._serial_buffer = self._serial_buffer[idx + 1 :]
|
||||
return bytes(line)
|
||||
else:
|
||||
return None
|
||||
|
||||
def _write_to_device(self, data: bytes) -> None:
|
||||
self._serial_connection.write(data)
|
||||
|
||||
def _flush_device_output(self) -> None:
|
||||
if self.is_device_connected():
|
||||
self._serial_connection.flush()
|
||||
self._serial_connection.reset_input_buffer()
|
||||
|
||||
def _clear_internal_resources(self) -> None:
|
||||
super()._clear_internal_resources()
|
||||
self._serial_connection = None
|
||||
self._serial_pty_proc = None
|
||||
self._serial_buffer.clear()
|
||||
|
||||
|
||||
class ProcessConnection(DeviceConnection):
|
||||
"""Process pipe connection implementation for native simulation"""
|
||||
|
||||
def __init__(self, log_path: Path, timeout: float) -> None:
|
||||
"""
|
||||
Initialize serial connection.
|
||||
"""
|
||||
super().__init__(log_path, timeout)
|
||||
self._process: subprocess.Popen | None = None
|
||||
|
||||
def update(self, **kwargs) -> None:
|
||||
"""
|
||||
The process instance is set after connection initialization and must be
|
||||
provided by the binary adapter via this method.
|
||||
"""
|
||||
if 'process' in kwargs:
|
||||
self._process = kwargs['process']
|
||||
|
||||
def _read_device_output(self) -> bytes:
|
||||
return self._process.stdout.readline()
|
||||
|
||||
def _connect_device(self) -> None:
|
||||
"""Connect with the device. Left empty for native simulation."""
|
||||
|
||||
def _disconnect_device(self) -> None:
|
||||
"""Disconnect from the device. Left empty for native simulation."""
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
return self._is_binary_running()
|
||||
|
||||
def _is_binary_running(self) -> bool:
|
||||
return self._process is not None and self._process.poll() is None
|
||||
|
||||
def _write_to_device(self, data: bytes) -> None:
|
||||
if self.is_device_connected():
|
||||
self._process.stdin.write(data)
|
||||
self._process.stdin.flush()
|
||||
|
||||
def _flush_device_output(self) -> None:
|
||||
if self.is_device_connected():
|
||||
self._process.stdout.flush()
|
||||
|
||||
|
||||
class FifoConnection(ProcessConnection):
|
||||
"""FIFO queue connection implementation for QEMU"""
|
||||
|
||||
def __init__(self, log_path: Path, timeout: float, fifo_file: Path) -> None:
|
||||
"""
|
||||
Initialize serial connection.
|
||||
"""
|
||||
super().__init__(log_path, timeout)
|
||||
self._fifo_connection: FifoHandler = FifoHandler(fifo_file, timeout)
|
||||
|
||||
def _connect_device(self) -> None:
|
||||
"""Create fifo connection"""
|
||||
if self.is_device_connected():
|
||||
# Device already connected
|
||||
return
|
||||
if not self._is_binary_running():
|
||||
msg = 'Cannot connect to not working device.'
|
||||
logger.error(msg)
|
||||
raise TwisterHarnessException(msg)
|
||||
self._fifo_connection.initiate_connection()
|
||||
timeout_time: float = time.time() + self.timeout
|
||||
while time.time() < timeout_time and self._is_binary_running():
|
||||
if self._fifo_connection.is_open:
|
||||
# to flush after reconnection
|
||||
self._write_to_device(b'\n')
|
||||
return
|
||||
time.sleep(0.1)
|
||||
msg = 'Cannot establish communication with QEMU device.'
|
||||
logger.error(msg)
|
||||
raise TwisterHarnessException(msg)
|
||||
|
||||
def _disconnect_device(self) -> None:
|
||||
"""Disconnect fifo connection"""
|
||||
if self._fifo_connection.is_open:
|
||||
self._fifo_connection.disconnect()
|
||||
|
||||
def _read_device_output(self) -> bytes:
|
||||
try:
|
||||
output = self._fifo_connection.readline()
|
||||
except (OSError, ValueError):
|
||||
# emulation was probably finished and thus fifo file was closed too
|
||||
output = b''
|
||||
return output
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
return super().is_device_connected() and self._fifo_connection.is_open
|
||||
|
||||
def _write_to_device(self, data: bytes) -> None:
|
||||
if self.is_device_connected():
|
||||
self._fifo_connection.write(data)
|
||||
self._fifo_connection.flush_write()
|
||||
|
||||
def _flush_device_output(self) -> None:
|
||||
if self.is_device_connected():
|
||||
self._fifo_connection.flush_read()
|
||||
|
||||
def _clear_internal_resources(self) -> None:
|
||||
super()._clear_internal_resources()
|
||||
self._fifo_connection.cleanup()
|
||||
|
||||
|
||||
def create_device_connections(device_config: DeviceConfig) -> list[DeviceConnection]:
|
||||
"""Factory method to create device connections based on device configuration."""
|
||||
connections: list[DeviceConnection] = []
|
||||
|
||||
log_path: Path = device_config.build_dir / 'handler.log'
|
||||
timeout = device_config.base_timeout
|
||||
|
||||
if device_config.type == "hardware":
|
||||
for core, serial_config in enumerate(device_config.serial_configs):
|
||||
if core > 0:
|
||||
log_path = device_config.build_dir / f'handler_{core}.log'
|
||||
connection = SerialConnection(log_path, timeout, serial_config)
|
||||
connections.append(connection)
|
||||
elif device_config.type == "qemu":
|
||||
fifo_file = device_config.build_dir / 'qemu-fifo'
|
||||
connection = FifoConnection(log_path, timeout, fifo_file)
|
||||
connections.append(connection)
|
||||
else: # native
|
||||
connection = ProcessConnection(log_path, timeout)
|
||||
connections.append(connection)
|
||||
|
||||
# Update log prefixes only for extra connections (not for the first one)
|
||||
for index, _ in enumerate(connections[1:], start=1):
|
||||
connections[index].log_prefix = f"[{index}]"
|
||||
return connections
|
||||
@@ -9,11 +9,11 @@ from typing import Type
|
||||
|
||||
from twister_harness.device.device_adapter import DeviceAdapter
|
||||
from twister_harness.device.hardware_adapter import HardwareAdapter
|
||||
from twister_harness.device.qemu_adapter import QemuAdapter
|
||||
from twister_harness.device.binary_adapter import (
|
||||
CustomSimulatorAdapter,
|
||||
NativeSimulatorAdapter,
|
||||
UnitSimulatorAdapter,
|
||||
QemuAdapter,
|
||||
)
|
||||
from twister_harness.exceptions import TwisterHarnessException
|
||||
|
||||
|
||||
@@ -44,13 +44,17 @@ class FifoHandler:
|
||||
thread when timeout will expire.
|
||||
"""
|
||||
self._stop_waiting_for_opening.clear()
|
||||
self._fifo_opened.clear()
|
||||
self._make_fifo_file(self._fifo_out_path)
|
||||
self._make_fifo_file(self._fifo_in_path)
|
||||
if self._open_fifo_thread is None:
|
||||
# Only create new threads if they don't exist or are not alive
|
||||
if self._open_fifo_thread is None or not self._open_fifo_thread.is_alive():
|
||||
self._open_fifo_thread = threading.Thread(target=self._open_fifo, daemon=True)
|
||||
self._open_fifo_thread.start()
|
||||
if self._opening_monitor_thread is None:
|
||||
self._opening_monitor_thread = threading.Thread(target=self._opening_monitor, daemon=True)
|
||||
if self._opening_monitor_thread is None or not self._opening_monitor_thread.is_alive():
|
||||
self._opening_monitor_thread = threading.Thread(
|
||||
target=self._opening_monitor, daemon=True
|
||||
)
|
||||
self._opening_monitor_thread.start()
|
||||
|
||||
@staticmethod
|
||||
@@ -90,6 +94,7 @@ class FifoHandler:
|
||||
|
||||
def disconnect(self) -> None:
|
||||
self._stop_waiting_for_opening.set()
|
||||
|
||||
if self._open_fifo_thread and self._open_fifo_thread.is_alive():
|
||||
self._open_fifo_thread.join(timeout=1)
|
||||
self._open_fifo_thread = None
|
||||
@@ -100,9 +105,13 @@ class FifoHandler:
|
||||
|
||||
if self._fifo_out_file:
|
||||
self._fifo_out_file.close()
|
||||
self._fifo_out_file = None
|
||||
if self._fifo_in_file:
|
||||
self._fifo_in_file.close()
|
||||
self._fifo_in_file = None
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up FIFO files when object is destroyed"""
|
||||
if os.path.exists(self._fifo_out_path):
|
||||
os.unlink(self._fifo_out_path)
|
||||
if os.path.exists(self._fifo_in_path):
|
||||
@@ -113,8 +122,10 @@ class FifoHandler:
|
||||
try:
|
||||
return bool(
|
||||
self._fifo_opened.is_set()
|
||||
and self._fifo_in_file is not None and self._fifo_out_file is not None
|
||||
and self._fifo_in_file.fileno() and self._fifo_out_file.fileno()
|
||||
and self._fifo_in_file is not None
|
||||
and self._fifo_out_file is not None
|
||||
and self._fifo_in_file.fileno()
|
||||
and self._fifo_out_file.fileno()
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@@ -5,21 +5,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
if os.name != 'nt':
|
||||
import pty
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import serial
|
||||
from serial import SerialException
|
||||
|
||||
from twister_harness.device.device_adapter import DeviceAdapter
|
||||
from twister_harness.device.utils import log_command, terminate_process
|
||||
from twister_harness.exceptions import (
|
||||
TwisterHarnessException,
|
||||
TwisterHarnessTimeoutException,
|
||||
)
|
||||
from twister_harness.device.utils import log_command, terminate_process
|
||||
from twister_harness.twister_harness_config import DeviceConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -31,9 +28,6 @@ class HardwareAdapter(DeviceAdapter):
|
||||
def __init__(self, device_config: DeviceConfig) -> None:
|
||||
super().__init__(device_config)
|
||||
self._flashing_timeout: float = device_config.flash_timeout
|
||||
self._serial_connection: serial.Serial | None = None
|
||||
self._serial_pty_proc: subprocess.Popen | None = None
|
||||
self._serial_buffer: bytearray = bytearray()
|
||||
|
||||
self.device_log_path: Path = device_config.build_dir / 'device.log'
|
||||
self._log_files.append(self.device_log_path)
|
||||
@@ -59,7 +53,8 @@ class HardwareAdapter(DeviceAdapter):
|
||||
self.west,
|
||||
'flash',
|
||||
'--no-rebuild',
|
||||
'--build-dir', str(self.device_config.build_dir),
|
||||
'--build-dir',
|
||||
str(self.device_config.build_dir),
|
||||
]
|
||||
|
||||
command_extra_args = []
|
||||
@@ -94,7 +89,10 @@ class HardwareAdapter(DeviceAdapter):
|
||||
elif runner in ('nrfjprog', 'nrfutil', 'nrfutil_next'):
|
||||
extra_args.append('--dev-id')
|
||||
extra_args.append(board_id)
|
||||
elif runner == 'openocd' and self.device_config.product in ['STM32 STLink', 'STLINK-V3']:
|
||||
elif runner == 'openocd' and self.device_config.product in [
|
||||
'STM32 STLink',
|
||||
'STLINK-V3',
|
||||
]:
|
||||
extra_args.append('--cmd-pre-init')
|
||||
extra_args.append(f'hla_serial {board_id}')
|
||||
elif runner == 'openocd' and self.device_config.product == 'EDBG CMSIS-DAP':
|
||||
@@ -103,13 +101,38 @@ class HardwareAdapter(DeviceAdapter):
|
||||
elif runner == "openocd" and self.device_config.product == "LPC-LINK2 CMSIS-DAP":
|
||||
extra_args.append("--cmd-pre-init")
|
||||
extra_args.append(f'adapter serial {board_id}')
|
||||
elif runner == 'jlink' or (runner == 'stm32cubeprogrammer' and self.device_config.product != "BOOT-SERIAL"):
|
||||
elif runner == 'jlink' or (
|
||||
runner == 'stm32cubeprogrammer' and self.device_config.product != "BOOT-SERIAL"
|
||||
):
|
||||
base_args.append('--dev-id')
|
||||
base_args.append(board_id)
|
||||
elif runner == 'linkserver':
|
||||
base_args.append(f'--probe={board_id}')
|
||||
return base_args, extra_args
|
||||
|
||||
def _device_launch(self) -> None:
|
||||
"""Flash and run application on a device and connect with serial port."""
|
||||
if self.device_config.flash_before:
|
||||
# For hardware devices with shared USB or software USB, connect after flashing.
|
||||
# Retry for up to 10 seconds for USB-CDC based devices to enumerate.
|
||||
self._flash_and_run()
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
self.connect()
|
||||
break
|
||||
except SerialException:
|
||||
if attempt < 100:
|
||||
attempt += 1
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
# On hardware, flash after connecting to COM port, otherwise some messages
|
||||
# from target can be lost.
|
||||
self.connect()
|
||||
self._flash_and_run()
|
||||
|
||||
def _flash_and_run(self) -> None:
|
||||
"""Flash application on a device."""
|
||||
if not self.command:
|
||||
@@ -126,7 +149,9 @@ class HardwareAdapter(DeviceAdapter):
|
||||
|
||||
process = stdout = None
|
||||
try:
|
||||
process = subprocess.Popen(self.command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=self.env)
|
||||
process = subprocess.Popen(
|
||||
self.command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=self.env
|
||||
)
|
||||
stdout, _ = process.communicate(timeout=self._flashing_timeout)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
process.kill()
|
||||
@@ -151,141 +176,15 @@ class HardwareAdapter(DeviceAdapter):
|
||||
logger.error(msg)
|
||||
raise TwisterHarnessException(msg)
|
||||
|
||||
def _connect_device(self) -> None:
|
||||
serial_name = self._open_serial_pty() or self.device_config.serial
|
||||
logger.debug('Opening serial connection for %s', serial_name)
|
||||
try:
|
||||
self._serial_connection = serial.Serial(
|
||||
serial_name,
|
||||
baudrate=self.device_config.baud,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
timeout=self.base_timeout,
|
||||
)
|
||||
except serial.SerialException as exc:
|
||||
logger.exception('Cannot open connection: %s', exc)
|
||||
self._close_serial_pty()
|
||||
raise
|
||||
|
||||
self._serial_connection.flush()
|
||||
self._serial_connection.reset_input_buffer()
|
||||
self._serial_connection.reset_output_buffer()
|
||||
|
||||
def _open_serial_pty(self) -> str | None:
|
||||
"""Open a pty pair, run process and return tty name"""
|
||||
if not self.device_config.serial_pty:
|
||||
return None
|
||||
|
||||
try:
|
||||
master, slave = pty.openpty()
|
||||
except NameError as exc:
|
||||
logger.exception('PTY module is not available.')
|
||||
raise exc
|
||||
|
||||
try:
|
||||
self._serial_pty_proc = subprocess.Popen(
|
||||
re.split(',| ', self.device_config.serial_pty),
|
||||
stdout=master,
|
||||
stdin=master,
|
||||
stderr=master
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.exception('Failed to run subprocess %s, error %s', self.device_config.serial_pty, str(exc))
|
||||
raise
|
||||
return os.ttyname(slave)
|
||||
|
||||
def _disconnect_device(self) -> None:
|
||||
if self._serial_connection:
|
||||
serial_name = self._serial_connection.port
|
||||
self._serial_connection.close()
|
||||
# self._serial_connection = None
|
||||
logger.debug('Closed serial connection for %s', serial_name)
|
||||
self._close_serial_pty()
|
||||
|
||||
def _close_serial_pty(self) -> None:
|
||||
"""Terminate the process opened for serial pty script"""
|
||||
if self._serial_pty_proc:
|
||||
self._serial_pty_proc.terminate()
|
||||
self._serial_pty_proc.communicate(timeout=self.base_timeout)
|
||||
logger.debug('Process %s terminated', self.device_config.serial_pty)
|
||||
self._serial_pty_proc = None
|
||||
|
||||
def _close_device(self) -> None:
|
||||
if self.device_config.post_script:
|
||||
self._run_custom_script(self.device_config.post_script, self.base_timeout)
|
||||
|
||||
def is_device_running(self) -> bool:
|
||||
return self._device_run.is_set()
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
return bool(
|
||||
self.is_device_running()
|
||||
and self._device_connected.is_set()
|
||||
and self._serial_connection
|
||||
and self._serial_connection.is_open
|
||||
)
|
||||
|
||||
def _read_device_output(self) -> bytes:
|
||||
try:
|
||||
output = self._readline_serial()
|
||||
except (serial.SerialException, TypeError, IOError):
|
||||
# serial was probably disconnected
|
||||
output = b''
|
||||
return output
|
||||
|
||||
def _readline_serial(self) -> bytes:
|
||||
"""
|
||||
This method was created to avoid using PySerial built-in readline
|
||||
method which cause blocking reader thread even if there is no data to
|
||||
read. Instead for this, following implementation try to read data only
|
||||
if they are available. Inspiration for this code was taken from this
|
||||
comment:
|
||||
https://github.com/pyserial/pyserial/issues/216#issuecomment-369414522
|
||||
"""
|
||||
line = self._readline_from_serial_buffer()
|
||||
if line is not None:
|
||||
return line
|
||||
while True:
|
||||
if self._serial_connection is None or not self._serial_connection.is_open:
|
||||
return b''
|
||||
elif self._serial_connection.in_waiting == 0:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
else:
|
||||
bytes_to_read = max(1, min(2048, self._serial_connection.in_waiting))
|
||||
output = self._serial_connection.read(bytes_to_read)
|
||||
self._serial_buffer.extend(output)
|
||||
line = self._readline_from_serial_buffer()
|
||||
if line is not None:
|
||||
return line
|
||||
|
||||
def _readline_from_serial_buffer(self) -> bytes | None:
|
||||
idx = self._serial_buffer.find(b"\n")
|
||||
if idx >= 0:
|
||||
line = self._serial_buffer[:idx+1]
|
||||
self._serial_buffer = self._serial_buffer[idx+1:]
|
||||
return bytes(line)
|
||||
else:
|
||||
return None
|
||||
|
||||
def _write_to_device(self, data: bytes) -> None:
|
||||
self._serial_connection.write(data)
|
||||
|
||||
def _flush_device_output(self) -> None:
|
||||
if self.is_device_connected():
|
||||
self._serial_connection.flush()
|
||||
self._serial_connection.reset_input_buffer()
|
||||
|
||||
def _clear_internal_resources(self) -> None:
|
||||
super()._clear_internal_resources()
|
||||
self._serial_connection = None
|
||||
self._serial_pty_proc = None
|
||||
self._serial_buffer.clear()
|
||||
|
||||
@staticmethod
|
||||
def _run_custom_script(script_path: str | Path, timeout: float) -> None:
|
||||
with subprocess.Popen(str(script_path), stderr=subprocess.PIPE, stdout=subprocess.PIPE) as proc:
|
||||
with subprocess.Popen(
|
||||
str(script_path), stderr=subprocess.PIPE, stdout=subprocess.PIPE
|
||||
) as proc:
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
logger.debug(stdout.decode())
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# Copyright (c) 2023 Nordic Semiconductor ASA
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from twister_harness.device.fifo_handler import FifoHandler
|
||||
from twister_harness.device.binary_adapter import BinaryAdapterBase
|
||||
from twister_harness.exceptions import TwisterHarnessException
|
||||
from twister_harness.twister_harness_config import DeviceConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QemuAdapter(BinaryAdapterBase):
|
||||
def __init__(self, device_config: DeviceConfig) -> None:
|
||||
super().__init__(device_config)
|
||||
qemu_fifo_file_path = self.device_config.build_dir / 'qemu-fifo'
|
||||
self._fifo_connection: FifoHandler = FifoHandler(qemu_fifo_file_path, self.base_timeout)
|
||||
|
||||
def generate_command(self) -> None:
|
||||
"""Set command to run."""
|
||||
self.command = [self.west, 'build', '-d', str(self.device_config.app_build_dir), '-t', 'run']
|
||||
if 'stdin' in self.process_kwargs:
|
||||
self.process_kwargs.pop('stdin')
|
||||
|
||||
def _flash_and_run(self) -> None:
|
||||
super()._flash_and_run()
|
||||
self._create_fifo_connection()
|
||||
|
||||
def _create_fifo_connection(self) -> None:
|
||||
self._fifo_connection.initiate_connection()
|
||||
timeout_time: float = time.time() + self.base_timeout
|
||||
while time.time() < timeout_time and self._is_binary_running():
|
||||
if self._fifo_connection.is_open:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
msg = 'Cannot establish communication with QEMU device.'
|
||||
logger.error(msg)
|
||||
raise TwisterHarnessException(msg)
|
||||
|
||||
def _stop_subprocess(self) -> None:
|
||||
super()._stop_subprocess()
|
||||
self._fifo_connection.disconnect()
|
||||
|
||||
def _read_device_output(self) -> bytes:
|
||||
try:
|
||||
output = self._fifo_connection.readline()
|
||||
except (OSError, ValueError):
|
||||
# emulation was probably finished and thus fifo file was closed too
|
||||
output = b''
|
||||
return output
|
||||
|
||||
def _write_to_device(self, data: bytes) -> None:
|
||||
self._fifo_connection.write(data)
|
||||
self._fifo_connection.flush_write()
|
||||
|
||||
def _flush_device_output(self) -> None:
|
||||
if self.is_device_running():
|
||||
self._fifo_connection.flush_read()
|
||||
|
||||
def is_device_connected(self) -> bool:
|
||||
"""Return true if device is connected."""
|
||||
return bool(super().is_device_connected() and self._fifo_connection.is_open)
|
||||
@@ -97,7 +97,7 @@ def mcumgr(device_object: DeviceAdapter) -> Generator[MCUmgr, None, None]:
|
||||
"""Fixture to create an MCUmgr instance for serial connection."""
|
||||
if not MCUmgr.is_available():
|
||||
pytest.skip('mcumgr not available')
|
||||
yield MCUmgr.create_for_serial(device_object.device_config.serial)
|
||||
yield MCUmgr.create_for_serial(device_object.device_config.serial_configs[0].port)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -15,6 +15,13 @@ import pytest
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceSerialConfig:
|
||||
port: str
|
||||
baud: int = 115200
|
||||
serial_pty: str = ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceConfig:
|
||||
type: str
|
||||
@@ -22,13 +29,11 @@ class DeviceConfig:
|
||||
base_timeout: float = 60.0 # [s]
|
||||
flash_timeout: float = 60.0 # [s]
|
||||
platform: str = ''
|
||||
serial: str = ''
|
||||
baud: int = 115200
|
||||
serial_configs: list[DeviceSerialConfig] = field(default_factory=list)
|
||||
runner: str = ''
|
||||
runner_params: list[str] = field(default_factory=list, repr=False)
|
||||
id: str = ''
|
||||
product: str = ''
|
||||
serial_pty: str = ''
|
||||
flash_before: bool = False
|
||||
west_flash_extra_args: list[str] = field(default_factory=list, repr=False)
|
||||
flash_command: str = ''
|
||||
@@ -68,19 +73,27 @@ class TwisterHarnessConfig:
|
||||
runner_params: list[str] = []
|
||||
if config.option.runner_params:
|
||||
runner_params = [w.strip() for w in config.option.runner_params]
|
||||
serial_configs: list[DeviceSerialConfig] = []
|
||||
if config.option.device_serial:
|
||||
for serial_port in config.option.device_serial:
|
||||
serial_configs.append(
|
||||
DeviceSerialConfig(
|
||||
port=serial_port,
|
||||
baud=config.option.device_serial_baud,
|
||||
serial_pty=config.option.device_serial_pty
|
||||
)
|
||||
)
|
||||
device_from_cli = DeviceConfig(
|
||||
type=config.option.device_type,
|
||||
build_dir=_cast_to_path(config.option.build_dir),
|
||||
base_timeout=config.option.base_timeout,
|
||||
flash_timeout=config.option.flash_timeout,
|
||||
platform=config.option.platform,
|
||||
serial=config.option.device_serial[0] if config.option.device_serial else '',
|
||||
baud=config.option.device_serial_baud,
|
||||
serial_configs=serial_configs,
|
||||
runner=config.option.runner,
|
||||
runner_params=runner_params,
|
||||
id=config.option.device_id,
|
||||
product=config.option.device_product,
|
||||
serial_pty=config.option.device_serial_pty,
|
||||
flash_before=bool(config.option.flash_before),
|
||||
west_flash_extra_args=west_flash_extra_args,
|
||||
flash_command=flash_command,
|
||||
|
||||
Reference in New Issue
Block a user