diff --git a/.github/workflows/treadmill-ci.yml b/.github/workflows/treadmill-ci.yml new file mode 100644 index 0000000..a471f0a --- /dev/null +++ b/.github/workflows/treadmill-ci.yml @@ -0,0 +1,274 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +# This workflow contains all Treadmill-based hardware CI jobs. +# +# Treadmill is a distributed hardware testbed developed within the Tock OS +# project. For more information on Treadmill, have a look at its documentation +# [1] or repository [2]. +# +# This workflow is based on the Treadmill GitHub Actions integration guide [3]. +# In addition, it features the ability to run multiple Treadmill jobs and +# test-execute stages through GitHub Action's job matrices, and uses a GitHub +# environment to allow deployments with access to secrets for select PRs. +# +# [1]: https://book.treadmill.ci/ +# [2]: https://github.com/treadmill-tb/treadmill +# [3]: https://book.treadmill.ci/user-guide/github-actions-integration.html + +name: treadmill-ci +env: + TERM: xterm # Makes tput work in actions output + +# Controls when the action will run. Triggers the workflow on pull request and +# merge group checks: +# +# KEEP IN SYNC WITH `environment:` ATTRIBUTE BELOW: +on: + push: + branches: + - master + # Pull requests from forks will not have access to the required GitHub API + # secrets below, even if they are using an appropriate deployment environment + # and the workflow runs have been approved according to this environment's + # rules. We don't know whether this is a bug on GitHub's end or deliberate. + # Either way, for now we disable this workflow to run on PRs until we have + # an API proxy that securely performs these GitHub API calls (adding runners + # and starting Treadmill jobs with those runner registration tokens), which + # allows this workflow to run without access to repository secrets. + #pull_request: + merge_group: # Run CI for the GitHub merge queue + +permissions: + contents: read + +jobs: + test-prepare: + runs-on: ubuntu-latest + + # Do not run job on forks + if: github.repository == 'tock/tock' + + # This provides access to the secrets required below: + # - for `treadmill-ci`: after approval by certain persons or GH teams + # - for `treadmill-ci-merged`: without approval, on merge queue branches + # and the master branch + # + # KEEP IN SYNC WITH `on:` EVENTS ABOVE: + environment: ${{ github.event_name == 'pull_request' && 'treadmill-ci' || 'treadmill-ci-merged' }} + + outputs: + tml-job-ids: ${{ steps.treadmill-job-launch.outputs.tml-job-ids }} + tml-jobs: ${{ steps.treadmill-job-launch.outputs.tml-jobs }} + + steps: + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Checkout Treadmill repository + uses: actions/checkout@v4 + with: + repository: treadmill-tb/treadmill + # treadmill-tb/treadmill main as of Oct 1, 2024, 3:05 PM EDT + ref: 'c82f4d7ebddd17f8275ba52139e64e04623f30cb' + path: treadmill + + - name: Cache Treadmill CLI compilation artifacts + id: cache-tml-cli + uses: actions/cache@v4 + with: + path: treadmill/target + key: ${{ runner.os }}-tml-cli + + - name: Compile the Treadmill CLI binary + run: | + pushd treadmill + cargo build --package tml-cli + popd + echo "$PWD/treadmill/target/debug" >> "$GITHUB_PATH" + + # - uses: actions/checkout@v4 + # with: + # path: tock + + # - name: Analyze changes and determine types of tests to run + # run: | + # echo "TODO: implement this!" + + - name: Generate a token to register new just-in-time runners + id: generate-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.TREADMILL_GH_APP_CLIENT_ID }} + private-key: ${{ secrets.TREADMILL_GH_APP_PRIVATE_KEY }} + + - name: Create GitHub just-in-time runners and enqueue Treadmill jobs + id: treadmill-job-launch + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + TML_API_TOKEN: ${{ secrets.TREADMILL_API_TOKEN }} + + # Currently, all tests run only on hosts attached to an nRF52840DK + DUT_BOARD: nrf52840dk + + # A Raspberry Pi OS netboot (NBD) image with a GitHub Actions + # self-hosted runner pre-configured. + # + # For the available images see + # https://book.treadmill.ci/treadmillci-deployment/images.html + IMAGE_ID: 1b6900eff30f37b6d012240f63aa77a22e20934e7f6ebf38e25310552dc08378 + + # Limit the supervisors to hosts that are compatible with this + # image. This is a hack until we introduce "image sets" which define + # multiple images for various supervisor hosts, but otherwise behave + # identically: + HOST_TYPE: nbd-netboot + HOST_ARCH: arm64 + run: | + # When we eventually launch tests on multiple hardware platforms in + # parallel, we need to supply different SUB_TEST_IDs here: + SUB_TEST_ID="0" + + # This runner ID uniquely identifies the GitHub Actions runner we're + # registering and allows us to launch test-execute jobs on this exact + # runner (connected to the exact board we want to run tests on). + RUNNER_ID="tml-gh-actions-runner-${GITHUB_REPOSITORY_ID}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${SUB_TEST_ID}" + + # Obtain a new just-in-time runner registration token: + RUNNER_CONFIG_JSON="$(gh api \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/${{ github.repository }}/actions/runners/generate-jitconfig \ + -f "name=$RUNNER_ID" \ + -F "runner_group_id=1" \ + -f "labels[]=$RUNNER_ID" \ + -f "work_folder=_work")" + echo "Generated configuration for runner $(echo "$RUNNER_CONFIG_JSON" | jq -r '.runner.name')" + + # Generate a set of job paramters that includes the GitHub runner + # registration token and a script that shuts down the host once the + # runner has run through successfully (and created a file indicating + # successful job completion, /run/github-actions-shutdown): + TML_JOB_PARAMETERS="{\ + \"gh-actions-runner-encoded-jit-config\": {\ + \"secret\": true, \ + \"value\": \"$(echo "$RUNNER_CONFIG_JSON" | jq -r '.encoded_jit_config')\" \ + }, \ + \"gh-actions-runner-exec-stop-post-sh\": {\ + \"secret\": false, \ + \"value\": \"if [ \\\"\$SERVICE_RESULT\\\" = \\\"success\\\" ] && [ -f /run/github-actions-shutdown ]; then tml-puppet job terminate; fi\" \ + }\ + }" + + echo "Enqueueing treadmill job:" + TML_JOB_ID_JSON="$(tml job enqueue \ + "$IMAGE_ID" \ + --tag-config "board:$DUT_BOARD;host-type:$HOST_TYPE;host-arch:$HOST_ARCH" \ + --parameters "$TML_JOB_PARAMETERS" \ + )" + + TML_JOB_ID="$(echo "$TML_JOB_ID_JSON" | jq -r .job_id)" + echo "Enqueued Treadmill job with ID $TML_JOB_ID" + + # Pass the job IDs and other configuration data into the outputs of + # this step, such that we can run test-execute job instances for each + # Treadmill job we've started: + echo "tml-job-ids=[ \ + \"$TML_JOB_ID\" \ + ]" >> "$GITHUB_OUTPUT" + + echo "tml-jobs={ \ + \"$TML_JOB_ID\": { \ + \"runner-id\": \"$RUNNER_ID\", \ + } \ + }" >> "$GITHUB_OUTPUT" + + test-execute: + needs: test-prepare + + strategy: + matrix: + tml-job-id: ${{ fromJSON(needs.test-prepare.outputs.tml-job-ids) }} + + runs-on: ${{ fromJSON(needs.test-prepare.outputs.tml-jobs)[matrix.tml-job-id].runner-id }} + + steps: + - name: Print Treadmill Job Context and Debug Information + run: | + echo "Treadmill job id: ${{ matrix.tml-job-id }}" + echo "GitHub Actions Runner ID: ${{ fromJSON(needs.test-prepare.outputs.tml-jobs)[matrix.tml-job-id] }}" + echo "===== Parameters: =====" + ls /run/tml/parameters + echo "===== User & group configuration: =====" + echo "whoami: $(whoami)" + echo "groups: $(groups)" + echo "===== Network configration: =====" + ip address + echo "===== Attached USB & serial console devices: =====" + lsusb + ls -lh /dev/ttyAMA* 2>/dev/null || true + ls -lh /dev/ttyACM* 2>/dev/null || true + ls -lh /dev/ttyUSB* 2>/dev/null || true + ls -lh /dev/bus/usb/*/* 2>/dev/null || true + + - name: Disable wget progress output + run: | + echo "verbose = off" >> $HOME/.wgetrc + + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + # Avoid overwriting the RUSTFLAGS environment variable + rustflags: "" + + - name: Install required system packages + run: | + # TODO: currently, the Netboot NBD targets have no access to their + # boot parition (e.g., mounted on /boot/firmware) on a Raspberry Pi OS + # host. This causes certain hooks in response to dpkg / apt commands + # to fail. Thus we ignore errors in these steps until we figure this + # part out. + sudo DEBIAN_FRONTEND=noninteractive apt update || true + sudo DEBIAN_FRONTEND=noninteractive apt install -y \ + git cargo openocd python3 python3-pip python3-serial \ + python3-pexpect gcc-arm-none-eabi libnewlib-arm-none-eabi \ + pkg-config libudev-dev cmake libusb-1.0-0-dev udev make \ + gdb-multiarch gcc-arm-none-eabi build-essential || true + + # Install probe-rs: + curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh \ + | sh + + - name: Create Python virtual environment and install required dependencies + run: | + python3 -m venv ./hwcienv + source ./hwcienv/bin/activate + pip install -r tools/hwci/requirements.txt -c tools/hwci/requirements-frozen.txt + + - name: Run tests + run: | + source ./hwcienv/bin/activate + cd ./tools/hwci + export PYTHONPATH="$PWD:$PYTHONPATH" + python3 core/main.py --board boards/nrf52dk.py --test tests/c_hello.py + + - name: Request shutdown after successful job completion + run: | + sudo touch /run/github-actions-shutdown + + - name: Provide connection information on job failure + if: failure() + run: | + echo "This CI job has failed, we avoid terminating the Treadmill job" + echo "immediately. It will be active until it reaches its timeout." + echo "" + echo "If you added SSH keys to the `job enqueue` command, you can" + echo "open an interactive session to this host. Connection" + echo "information is available here:" + echo "https://book.treadmill.ci/treadmillci-deployment/sites.html" + echo "" + echo "TODO: print host / supervisor ID as part of workflow" + echo "TODO: determine public SSH endpoint automatically and print" + echo "TODO: allow adding SSH keys to running Treadmill jobs" diff --git a/hwci/__init__.py b/hwci/__init__.py new file mode 100644 index 0000000..46da6c4 --- /dev/null +++ b/hwci/__init__.py @@ -0,0 +1,13 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +from .core import main, BoardHarness, TestHarness +from .boards import TockloaderBoard, Nrf52dk, MockBoard +from .tests import ( + OneshotTest, + AnalyzeConsoleTest, + WaitForConsoleMessageTest, + c_hello_test, +) +from .utils import SerialPort, MockSerialPort diff --git a/hwci/boards/__init__.py b/hwci/boards/__init__.py new file mode 100644 index 0000000..11375ca --- /dev/null +++ b/hwci/boards/__init__.py @@ -0,0 +1,7 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +from .tockloader_board import TockloaderBoard +from .nrf52dk import Nrf52dk +from .mock_board import MockBoard diff --git a/hwci/boards/mock_board.py b/hwci/boards/mock_board.py new file mode 100644 index 0000000..bc07846 --- /dev/null +++ b/hwci/boards/mock_board.py @@ -0,0 +1,90 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +import logging +import threading +import time +from core.board_harness import BoardHarness +from utils.serial_port import MockSerialPort + + +class MockBoard(BoardHarness): + def __init__(self): + super().__init__() + self.arch = "cortex-m4" + self.kernel_board_path = "tock/boards/nordic/nrf52840dk" + self.uart_port = self.get_uart_port() + self.uart_baudrate = self.get_uart_baudrate() + self.serial = self.get_serial_port() + self.serial_output_thread = None + self.running = False + + def get_uart_port(self): + # Return a mock serial port identifier + return "MOCK_SERIAL_PORT" + + def get_uart_baudrate(self): + return 115200 # Same as the actual board + + def get_serial_port(self): + return MockSerialPort() # Initialize the mock serial port + + def erase_board(self): + logging.info("Mock erase of the board") + + def flash_kernel(self): + logging.info("Mock flashing of the Tock OS kernel") + + def flash_app(self, app): + logging.info(f"Mock flashing of app: {app}") + # Depending on the app, set up simulated output + if app == "c_hello": + self.simulate_output("Hello World!\r\n") + else: + logging.warning(f"No mock output configured for app: {app}") + + def simulate_output(self, message): + # Start a thread to simulate serial output + def output_thread(): + self.running = True + logging.info("Starting mock serial output thread") + time.sleep(1) + self.serial.write(message.encode()) + self.running = False + logging.info("Mock serial output thread finished") + + self.serial_output_thread = threading.Thread(target=output_thread) + self.serial_output_thread.start() + time.sleep(1) + + def simulate_multi_alarm_output(self): + def output_thread(): + self.running = True + logging.info("Starting mock multi-alarm serial output thread") + start_time = int(time.time()) + while self.running: + current_time = int(time.time()) - start_time + # Simulate alarm 1 firing every 2 seconds + if current_time % 2 == 0: + line = f"1 {current_time} {current_time + 2}\r\n" + self.serial.write(line.encode()) + # Simulate alarm 2 firing every 4 seconds + if current_time % 4 == 0: + line = f"2 {current_time} {current_time + 4}\r\n" + self.serial.write(line.encode()) + time.sleep(1) + if current_time > 10: # Timeout after 10 seconds + self.running = False + logging.info("Mock multi-alarm serial output thread finished") + + self.serial_output_thread = threading.Thread(target=output_thread) + self.serial_output_thread.start() + + def stop(self): + self.running = False + if self.serial_output_thread and self.serial_output_thread.is_alive(): + self.serial_output_thread.join() + + +board = MockBoard() diff --git a/hwci/boards/nrf52dk.py b/hwci/boards/nrf52dk.py new file mode 100644 index 0000000..4d0eafc --- /dev/null +++ b/hwci/boards/nrf52dk.py @@ -0,0 +1,79 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +import os +import subprocess +import logging +from contextlib import contextmanager +import serial.tools.list_ports +from boards.tockloader_board import TockloaderBoard +from utils.serial_port import SerialPort + + +class Nrf52dk(TockloaderBoard): + def __init__(self): + super().__init__() + self.arch = "cortex-m4" + self.kernel_board_path = "tock/boards/nordic/nrf52840dk" + self.uart_port = self.get_uart_port() + self.uart_baudrate = self.get_uart_baudrate() + self.openocd_board = "nrf52dk" + self.board = "nrf52dk" + self.serial = self.get_serial_port() # Set serial attribute + + def get_uart_port(self): + logging.info("Getting list of serial ports") + ports = list(serial.tools.list_ports.comports()) + for port in ports: + if "J-Link" in port.description: + logging.info(f"Found J-Link port: {port.device}") + return port.device + if ports: + logging.info(f"Automatically selected port: {ports[0].device}") + return ports[0].device + else: + logging.error("No serial ports found") + raise Exception("No serial ports found") + + def get_uart_baudrate(self): + return 115200 # Default baudrate for the board + + def get_serial_port(self): + logging.info( + f"Using serial port: {self.uart_port} at baudrate {self.uart_baudrate}" + ) + return SerialPort(self.uart_port, self.uart_baudrate) + + def erase_board(self): + logging.info("Erasing the board") + command = [ + "openocd", + "-c", + "adapter driver jlink; transport select swd; source [find target/nrf52.cfg]; init; nrf52_recover; exit", + ] + subprocess.run(command, check=True) + + def flash_kernel(self): + logging.info("Flashing the Tock OS kernel") + if not os.path.exists("tock"): + logging.info("Cloning Tock repository") + subprocess.run(["git", "clone", "https://github.com/tock/tock"], check=True) + with self.change_directory(self.kernel_board_path): + subprocess.run(["make", "flash-openocd"], check=True) + + # The flash_app method is inherited from TockloaderBoard + + @contextmanager + def change_directory(self, new_dir): + previous_dir = os.getcwd() + os.chdir(new_dir) + logging.info(f"Changed directory to: {os.getcwd()}") + try: + yield + finally: + os.chdir(previous_dir) + logging.info(f"Reverted to directory: {os.getcwd()}") + + +board = Nrf52dk() diff --git a/hwci/boards/tockloader_board.py b/hwci/boards/tockloader_board.py new file mode 100644 index 0000000..c196b05 --- /dev/null +++ b/hwci/boards/tockloader_board.py @@ -0,0 +1,71 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +from core.board_harness import BoardHarness +import os +import subprocess +import logging +from contextlib import contextmanager + + +class TockloaderBoard(BoardHarness): + + def __init__(self): + super().__init__() + self.board = None # Should be set in subclass + self.arch = None # Should be set in subclass + + def flash_app(self, app): + logging.info(f"Flashing app: {app}") + if not os.path.exists("libtock-c"): + logging.info("Cloning libtock-c repository") + subprocess.run( + ["git", "clone", "https://github.com/tock/libtock-c"], check=True + ) + app_dir = os.path.join("libtock-c", "examples", app) + if not os.path.exists(app_dir): + logging.error(f"App directory {app_dir} not found") + raise FileNotFoundError(f"App directory {app_dir} not found") + with self.change_directory(app_dir): + logging.info(f"Building app: {app}") + subprocess.run(["make", f"TOCK_TARGETS={self.arch}"], check=True) + tab_file = f"build/{app}.tab" + if not os.path.exists(tab_file): + logging.error(f"Tab file {tab_file} not found") + raise FileNotFoundError(f"Tab file {tab_file} not found") + logging.info(f"Installing app: {app}") + subprocess.run( + [ + "tockloader", + "install", + "--board", + self.board, + "--openocd", + tab_file, + ], + check=True, + ) + + def get_uart_port(self): + pass + + def get_uart_baudrate(self): + pass + + def erase_board(self): + pass + + def flash_kernel(self): + pass + + @contextmanager + def change_directory(self, new_dir): + previous_dir = os.getcwd() + os.chdir(new_dir) + logging.info(f"Changed directory to: {os.getcwd()}") + try: + yield + finally: + os.chdir(previous_dir) + logging.info(f"Reverted to directory: {os.getcwd()}") diff --git a/hwci/core/__init__.py b/hwci/core/__init__.py new file mode 100644 index 0000000..1b877fe --- /dev/null +++ b/hwci/core/__init__.py @@ -0,0 +1,7 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +from .main import main +from .board_harness import BoardHarness +from .test_harness import TestHarness diff --git a/hwci/core/board_harness.py b/hwci/core/board_harness.py new file mode 100644 index 0000000..446ede5 --- /dev/null +++ b/hwci/core/board_harness.py @@ -0,0 +1,33 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + + +class BoardHarness: + arch = None + kernel_board_path = None + + def __init__(self): + self.serial = None + self.gpio = None + + def get_uart_port(self): + pass + + def get_uart_baudrate(self): + pass + + def get_serial_port(self): + pass + + def get_gpio_interface(self): + pass + + def erase_board(self): + pass + + def flash_kernel(self): + pass + + def flash_app(self, app): + pass diff --git a/hwci/core/main.py b/hwci/core/main.py new file mode 100644 index 0000000..28bc028 --- /dev/null +++ b/hwci/core/main.py @@ -0,0 +1,55 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +import argparse +import logging +import importlib.util +import sys + + +def main(): + parser = argparse.ArgumentParser(description="Run tests on Tock OS") + parser.add_argument("--board", required=True, help="Path to the board module") + parser.add_argument("--test", required=True, help="Path to the test module") + args = parser.parse_args() + + # Set up logging + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + ) + + # 1. Load board module + board_spec = importlib.util.spec_from_file_location("board_module", args.board) + board_module = importlib.util.module_from_spec(board_spec) + board_spec.loader.exec_module(board_module) + if hasattr(board_module, "board"): + board = board_module.board + else: + logging.error("No board class found in the specified board module") + sys.exit(1) + + # 5. Load test module, run test function + test_spec = importlib.util.spec_from_file_location("test_module", args.test) + test_module = importlib.util.module_from_spec(test_spec) + test_spec.loader.exec_module(test_module) + if hasattr(test_module, "test"): + test = test_module.test + else: + logging.error("No test variable found in the specified test module") + sys.exit(1) + + # Run the test + try: + test.test(board) + logging.info("Test completed successfully") + except Exception as e: + logging.exception("An error occurred during test execution") + sys.exit(1) + finally: + board.serial.close() + + +if __name__ == "__main__": + main() diff --git a/hwci/core/test_harness.py b/hwci/core/test_harness.py new file mode 100644 index 0000000..3f0447f --- /dev/null +++ b/hwci/core/test_harness.py @@ -0,0 +1,8 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + + +class TestHarness: + def test(self, board): + pass diff --git a/hwci/requirements-frozen.txt b/hwci/requirements-frozen.txt new file mode 100644 index 0000000..5b197a1 --- /dev/null +++ b/hwci/requirements-frozen.txt @@ -0,0 +1,14 @@ +argcomplete==3.5.1 +colorama==0.4.6 +crcmod==1.7 +pexpect==4.9.0 +prompt-toolkit==3.0.36 +ptyprocess==0.7.0 +pycryptodome==3.21.0 +pyserial==3.5 +questionary==2.0.1 +siphash==0.0.1 +tockloader==1.13.0 +toml==0.10.2 +tqdm==4.66.5 +wcwidth==0.2.13 diff --git a/hwci/requirements.txt b/hwci/requirements.txt new file mode 100644 index 0000000..16398e5 --- /dev/null +++ b/hwci/requirements.txt @@ -0,0 +1,7 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +pexpect +pyserial +tockloader diff --git a/hwci/shell.nix b/hwci/shell.nix new file mode 100644 index 0000000..e361066 --- /dev/null +++ b/hwci/shell.nix @@ -0,0 +1,46 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +{pkgs ? import {}}: let + pythonEnv = pkgs.python311.withPackages (ps: + with ps; [ + pip + setuptools + wheel + ]); +in + pkgs.mkShell { + name = "treadmill-hwci-env"; + buildInputs = [ + pythonEnv + pkgs.autoPatchelfHook + ]; + propagatedBuildInputs = [ + pkgs.stdenv.cc.cc.lib + ]; + + venvDir = "./venv"; + + shellHook = '' + if [ ! -d "$venvDir" ]; then + echo "Creating new venv..." + ${pythonEnv.interpreter} -m venv "$venvDir" + fi + + source "$venvDir/bin/activate" + + if [ ! -f "$venvDir/.requirements_installed" ] || [ requirements.txt -nt "$venvDir/.requirements_installed" ]; then + echo "Installing/updating dependencies..." + pip install -U pip setuptools wheel + pip install -r requirements.txt + pip install -e . + autoPatchelf "$venvDir" + touch "$venvDir/.requirements_installed" + fi + + export PYTHONPATH="$PWD:$PYTHONPATH" + + echo "Virtual environment is ready!" + ''; + } diff --git a/hwci/tests/c_hello.py b/hwci/tests/c_hello.py new file mode 100644 index 0000000..8c96aea --- /dev/null +++ b/hwci/tests/c_hello.py @@ -0,0 +1,7 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +from utils.test_helpers import WaitForConsoleMessageTest + +test = WaitForConsoleMessageTest(["c_hello"], "Hello World!") diff --git a/hwci/utils/__init__.py b/hwci/utils/__init__.py new file mode 100644 index 0000000..56a2aa0 --- /dev/null +++ b/hwci/utils/__init__.py @@ -0,0 +1,5 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +from .serial_port import SerialPort, MockSerialPort diff --git a/hwci/utils/serial_port.py b/hwci/utils/serial_port.py new file mode 100644 index 0000000..cd47066 --- /dev/null +++ b/hwci/utils/serial_port.py @@ -0,0 +1,85 @@ +# Licensed under the Apache License, Version 2.0 or the MIT License. +# SPDX-License-Identifier: Apache-2.0 OR MIT +# Copyright Tock Contributors 2024. + +import serial +from pexpect import fdpexpect +import logging +import queue +import re +import time +import logging + + +class SerialPort: + def __init__(self, port, baudrate=115200): + self.port = port + self.baudrate = baudrate + try: + self.ser = serial.Serial(port, baudrate=baudrate, timeout=1) + self.child = fdpexpect.fdspawn(self.ser.fileno()) + logging.info(f"Opened serial port {port} at baudrate {baudrate}") + except serial.SerialException as e: + logging.error(f"Failed to open serial port {port}: {e}") + raise + + def flush_buffer(self): + self.ser.reset_input_buffer() + self.ser.reset_output_buffer() + logging.info("Flushed serial buffers") + + def expect(self, pattern, timeout=10): + try: + index = self.child.expect(pattern, timeout=timeout) + logging.debug(f"Matched pattern '{pattern}'") + return self.child.after + except fdpexpect.TIMEOUT: + logging.error(f"Timeout waiting for pattern '{pattern}'") + return None + except fdpexpect.EOF: + logging.error("EOF reached while waiting for pattern") + return None + + def close(self): + self.ser.close() + logging.info(f"Closed serial port {self.port}") + + +class MockSerialPort: + def __init__(self): + self.buffer = queue.Queue() + self.accumulated_data = b"" + + def write(self, data): + logging.debug(f"Writing data: {data}") + self.buffer.put(data) + + def expect(self, pattern, timeout=10): + end_time = time.time() + timeout + compiled_pattern = re.compile(pattern.encode()) + while time.time() < end_time: + try: + data = self.buffer.get(timeout=0.1) + logging.debug(f"Received data chunk: {data}") + self.accumulated_data += data + if compiled_pattern.search(self.accumulated_data): + logging.debug(f"Matched pattern '{pattern}'") + return self.accumulated_data + except queue.Empty: + continue + logging.error(f"Timeout waiting for pattern '{pattern}'") + return None + + def flush_buffer(self): + self.accumulated_data = b"" + while not self.buffer.empty(): + self.buffer.get() + + def close(self): + pass + + def reset_input_buffer(self): + self.flush_buffer() + + def reset_output_buffer(self): + pass diff --git a/hwci/utils/test_helpers/__init__.py b/hwci/utils/test_helpers/__init__.py new file mode 100644 index 0000000..b184f3d --- /dev/null +++ b/hwci/utils/test_helpers/__init__.py @@ -0,0 +1,3 @@ +from .oneshot import OneshotTest +from .analyze_console import AnalyzeConsoleTest +from .wait_for_console_message import WaitForConsoleMessageTest diff --git a/hwci/utils/test_helpers/analyze_console.py b/hwci/utils/test_helpers/analyze_console.py new file mode 100644 index 0000000..2055503 --- /dev/null +++ b/hwci/utils/test_helpers/analyze_console.py @@ -0,0 +1,24 @@ +import logging +from utils.test_helpers import OneshotTest + +class AnalyzeConsoleTest(OneshotTest): + def oneshot_test(self, board): + logging.info("Starting AnalyzeConsoleTest") + lines = [] + serial = board.serial + try: + while True: + output = serial.expect(".*\r\n", timeout=5) + if output: + line = output.decode("utf-8", errors="replace").strip() + logging.info(f"SERIAL OUTPUT: {line}") + lines.append(line) + else: + break + self.analyze(lines) + except Exception as e: + logging.error(f"Error during serial communication: {e}") + logging.info("Finished AnalyzeConsoleTest") + + def analyze(self, lines): + pass # To be implemented by subclasses diff --git a/hwci/utils/test_helpers/oneshot.py b/hwci/utils/test_helpers/oneshot.py new file mode 100644 index 0000000..df6d799 --- /dev/null +++ b/hwci/utils/test_helpers/oneshot.py @@ -0,0 +1,19 @@ +import logging +from core.test_harness import TestHarness + +class OneshotTest(TestHarness): + def __init__(self, apps=[]): + self.apps = apps + + def test(self, board): + logging.info("Starting OneshotTest") + board.erase_board() + board.serial.flush_buffer() + board.flash_kernel() + for app in self.apps: + board.flash_app(app) + self.oneshot_test(board) + logging.info("Finished OneshotTest") + + def oneshot_test(self, board): + pass # To be implemented by subclasses diff --git a/hwci/utils/test_helpers/wait_for_console_message.py b/hwci/utils/test_helpers/wait_for_console_message.py new file mode 100644 index 0000000..f6482a9 --- /dev/null +++ b/hwci/utils/test_helpers/wait_for_console_message.py @@ -0,0 +1,15 @@ +import logging +from utils.test_helpers import OneshotTest + +class WaitForConsoleMessageTest(OneshotTest): + def __init__(self, apps, message): + super().__init__(apps) + self.message = message + + def oneshot_test(self, board): + logging.info(f"Waiting for message: '{self.message}'") + output = board.serial.expect(self.message, timeout=10) + if output: + logging.info(f"Received expected message: '{self.message}'") + else: + raise Exception(f"Did not receive expected message: '{self.message}'")