diff --git a/.github/workflows/build_and_run_usbh_shell_pytest.yaml b/.github/workflows/build_and_run_usbh_shell_pytest.yaml new file mode 100644 index 0000000000000..58cbbea454d4e --- /dev/null +++ b/.github/workflows/build_and_run_usbh_shell_pytest.yaml @@ -0,0 +1,193 @@ +name: Build and Run + +on: + pull_request: + types: [opened, reopened, synchronize] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + zephyr_ver: ["v0.29.1"] + container: + image: ghcr.io/zephyrproject-rtos/ci:${{ matrix.zephyr_ver }} + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + path: ws/zephyr + fetch-depth: 0 + + - name: Setup Python deps (west + twister requirements) + shell: bash + run: | + python3 -m venv "$GITHUB_WORKSPACE/ws/.venv" + . "$GITHUB_WORKSPACE/ws/.venv/bin/activate" + pip install -U pip wheel + pip install -r "$GITHUB_WORKSPACE/ws/zephyr/scripts/requirements.txt" \ + -r "$GITHUB_WORKSPACE/ws/zephyr/scripts/requirements-build-test.txt" + + - name: West init/update/export (modules + blobs) + shell: bash + run: | + . "$GITHUB_WORKSPACE/ws/.venv/bin/activate" + cd "$GITHUB_WORKSPACE/ws" + west init -l zephyr + west update hal_espressif + west blobs fetch hal_espressif + west zephyr-export + + - name: Locate Zephyr SDK in container + shell: bash + run: | + # Common locations used by Zephyr CI images; pick the first that exists. + for d in /opt/zephyr-sdk* /opt/toolchains/zephyr-sdk* /usr/local/zephyr-sdk*; do + if [ -d "$d" ]; then + echo "Found Zephyr SDK at $d" + echo "ZEPHYR_SDK_INSTALL_DIR=$d" >> $GITHUB_ENV + break + fi + done + + # Debug: show what we set + echo "ZEPHYR_SDK_INSTALL_DIR=${ZEPHYR_SDK_INSTALL_DIR:-}" + ls -la /opt || true + + - name: Twister build-only (ESP32S3) + shell: bash + env: + PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1" + ZEPHYR_TOOLCHAIN_VARIANT: zephyr + run: | + . "$GITHUB_WORKSPACE/ws/.venv/bin/activate" + cd "$GITHUB_WORKSPACE/ws/zephyr" + + west twister -T samples/subsys/usb/shell --list-tests + + west twister \ + -T samples/subsys/usb/shell \ + -p esp32s3_devkitc/esp32s3/procpu \ + -s sample.usbh.shell.hil \ + --build-only \ + -j 1 -v \ + --outdir "$GITHUB_WORKSPACE/twister-out" + + - name: Pack twister-out artifact + shell: bash + run: | + cd "$GITHUB_WORKSPACE" + tar -czf twister-out.tgz twister-out + + - name: Upload artifacts + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: twister-out + path: twister-out.tgz + if-no-files-found: error + + run: + if: startsWith(github.head_ref, 'pr-add-') + runs-on: [self-hosted, docker, esp32s3, usb_host] + strategy: + matrix: + zephyr_ver: ["v0.29.1"] + container: + image: ghcr.io/zephyrproject-rtos/ci:${{ matrix.zephyr_ver }} + options: >- + --volume=/etc/zephyr:/etc/zephyr:ro + --device=/dev/ttyACM0 + needs: [build] + + steps: + - name: Checkout (workspace layout) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + path: ws/zephyr + fetch-depth: 0 + + - name: Python venv + requirements (clean) + shell: bash + run: | + rm -rf "$GITHUB_WORKSPACE/ws/.venv" + python3 -m venv "$GITHUB_WORKSPACE/ws/.venv" + . "$GITHUB_WORKSPACE/ws/.venv/bin/activate" + pip install -U pip wheel esptool pyserial + pip install -r "$GITHUB_WORKSPACE/ws/zephyr/scripts/requirements.txt" \ + -r "$GITHUB_WORKSPACE/ws/zephyr/scripts/requirements-build-test.txt" + + - name: West init/update/export (runner, idempotent) + shell: bash + run: | + . "$GITHUB_WORKSPACE/ws/.venv/bin/activate" + cd "$GITHUB_WORKSPACE/ws" + if [ ! -d .west ]; then + west init -l zephyr + fi + west zephyr-export + + - name: Download twister-out artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: twister-out + path: ${{ github.workspace }} + + - name: Unpack twister-out + shell: bash + run: | + cd "$GITHUB_WORKSPACE" + rm -rf twister-out + tar -xzf twister-out.tgz + + - name: Locate Zephyr SDK in container + shell: bash + run: | + # Common locations used by Zephyr CI images; pick the first that exists. + for d in /opt/zephyr-sdk* /opt/toolchains/zephyr-sdk* /usr/local/zephyr-sdk*; do + if [ -d "$d" ]; then + echo "Found Zephyr SDK at $d" + echo "ZEPHYR_SDK_INSTALL_DIR=$d" >> $GITHUB_ENV + break + fi + done + + # Debug: show what we set + echo "ZEPHYR_SDK_INSTALL_DIR=${ZEPHYR_SDK_INSTALL_DIR:-}" + ls -la /opt || true + + - name: Run on hardware (test-only) + shell: bash + env: + PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1" + ZEPHYR_TOOLCHAIN_VARIANT: zephyr + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE/ws/zephyr" + + . "$GITHUB_WORKSPACE/ws/.venv/bin/activate" + cd "$GITHUB_WORKSPACE/ws/zephyr" + + west twister \ + -T samples/subsys/usb/shell \ + -p esp32s3_devkitc/esp32s3/procpu \ + -s sample.usbh.shell.hil \ + --test-only \ + --device-testing \ + --hardware-map /etc/zephyr/hardware-map-esp32s3.yml \ + -j 1 -vvv \ + --outdir "$GITHUB_WORKSPACE/twister-out" + + - name: Upload Twister reports + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: twister-reports + path: | + twister-out/**/twister_harness.log + twister-out/twister.xml + twister-out/twister_report.xml + twister-out/twister.json diff --git a/samples/subsys/usb/shell/host_prj.conf b/samples/subsys/usb/shell/host_prj.conf new file mode 100644 index 0000000000000..ff93f5c95ea9b --- /dev/null +++ b/samples/subsys/usb/shell/host_prj.conf @@ -0,0 +1,8 @@ +CONFIG_SHELL=y + +CONFIG_USB_HOST_STACK=y +CONFIG_USBH_SHELL=y +CONFIG_UDC_DWC2=n +CONFIG_LOG=y +CONFIG_USBH_LOG_LEVEL_WRN=y +CONFIG_UHC_DRIVER_LOG_LEVEL_WRN=y diff --git a/samples/subsys/usb/shell/pytest/test_usbh_shell.py b/samples/subsys/usb/shell/pytest/test_usbh_shell.py new file mode 100644 index 0000000000000..e9f2cd6cccd66 --- /dev/null +++ b/samples/subsys/usb/shell/pytest/test_usbh_shell.py @@ -0,0 +1,121 @@ +# Copyright (c) 2026 Roman Leonov +# +# SPDX-License-Identifier: Apache-2.0 + +import logging +import re +import time + +from twister_harness import Shell + +logger = logging.getLogger(__name__) +ANSI = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + + +def _contains(lines, needle: str) -> bool: + return any(needle in line for line in lines) + + +def _match(lines, pattern: str) -> bool: + rx = re.compile(pattern) + return any(rx.search(line) for line in lines) + + +def wait_for_device(shell, timeout_s=10.0, poll_s=0.5): + end = time.time() + timeout_s + last_resp = [] + while time.time() < end: + last_resp = shell.exec_command("usbh device list") + # There should be decimal number in the response + if any(re.match(r"^\s*\d+\s*$", dev_number) for dev_number in last_resp): + return last_resp + time.sleep(poll_s) + raise AssertionError(f"No USB device within {timeout_s}s. Last output:\n{last_resp}") + + +def test_usbh_init_enable_and_list(shell: Shell): + # usbh init + logger.info('run "usbh init"') + lines = shell.exec_command("usbh init") + assert _contains(lines, "host: USB host initialized"), f"Unexpected init output:\n{lines}" + + # usbh enable + logger.info('run "usbh enable"') + lines = shell.exec_command("usbh enable") + assert _contains(lines, "host: USB host enabled"), f"Unexpected enable output:\n{lines}" + + # usbh device list -> poll the list and expect at least one device + wait_for_device(shell, timeout_s=5.0, poll_s=0.5) + + logger.info('run "usbh device descriptor device 1"') + lines = shell.exec_command("usbh device descriptor device 1") + + # Remove ANSI symbols from the lines + clean_lines = [ANSI.sub("", symbol) for symbol in lines] + + # This is an error message, might gone, verify the length and type instead + assert _contains(clean_lines, "host: USB device with address 1"), ( + f"Missing device header:\n{clean_lines}" + ) + + # Check a few stable anchors of device descriptor + assert _match(clean_lines, r"^\s*bLength\s+18\s*$"), ( + f"Missing Device bLength 18:\n{clean_lines}" + ) + assert _match(clean_lines, r"^\s*bDescriptorType\s+1\s*$"), ( + f"Missing Device bDescriptorType 1:\n{clean_lines}" + ) + + logger.info('run "usbh device descriptor configuration 1 0"') + lines = shell.exec_command("usbh device descriptor configuration 1 0") + + # Remove ANSI symbols from the lines + clean_lines = [ANSI.sub("", symbol) for symbol in lines] + + # Check a few stable anchors of configuration descriptor + assert _match(clean_lines, r"^\s*bLength\s+9\s*$"), f"Missing Config bLength 9:\n{clean_lines}" + assert _match(clean_lines, r"^\s*bDescriptorType\s+2\s*$"), ( + f"Missing Config bDescriptorType 2:\n{clean_lines}" + ) + + # String 1: Manufacturer + logger.info('run "usbh device descriptor string 1 1 1"') + lines = shell.exec_command("usbh device descriptor string 1 1 1") + assert _match(lines, r"^00000000:"), f"Expected hexdump for string 1, got:\n{lines}" + + # String 2: Product + logger.info('run "usbh device descriptor string 1 1 2"') + lines = shell.exec_command("usbh device descriptor string 1 1 2") + assert _match(lines, r"^00000000:"), f"Expected hexdump for string 2, got:\n{lines}" + + # String 3: Serial + logger.info('run "usbh device descriptor string 1 1 3"') + lines = shell.exec_command("usbh device descriptor string 1 1 3") + assert _match(lines, r"^00000000:"), f"Expected hexdump for string 3, got:\n{lines}" + + # String 4: Product + logger.info('run "usbh device descriptor string 1 1 4"') + lines = shell.exec_command("usbh device descriptor string 1 1 4") + assert _match(lines, r"^00000000:"), f"Expected hexdump for string 4, got:\n{lines}" + + # String 5: Product + logger.info('run "usbh device descriptor string 1 1 5"') + lines = shell.exec_command("usbh device descriptor string 1 1 5") + assert _match(lines, r"^00000000:"), f"Expected hexdump for string 5, got:\n{lines}" + + # String 6: STALL string request + logger.info('run "usbh device descriptor string 1 1 6"') + lines = shell.exec_command("usbh device descriptor string 1 1 6") + assert _contains(lines, "host: Failed to request configuration descriptor"), ( + f"Expected failure message for string 6, got:\n{lines}" + ) + + # String 1: Manufacturer + logger.info('run "usbh device descriptor string 1 1 1"') + lines = shell.exec_command("usbh device descriptor string 1 1 1") + assert _match(lines, r"^00000000:"), f"Expected hexdump for string 1, got:\n{lines}" + + # String 2: Product + logger.info('run "usbh device descriptor string 1 1 2"') + lines = shell.exec_command("usbh device descriptor string 1 1 2") + assert _match(lines, r"^00000000:"), f"Expected hexdump for string 2, got:\n{lines}" diff --git a/samples/subsys/usb/shell/sample.yaml b/samples/subsys/usb/shell/sample.yaml index 898f0b5398124..602a67f66d158 100644 --- a/samples/subsys/usb/shell/sample.yaml +++ b/samples/subsys/usb/shell/sample.yaml @@ -47,3 +47,12 @@ tests: platform_allow: - native_sim/native/64 build_only: true + sample.usbh.shell.hil: + harness: pytest + tags: + - usb + - shield + extra_args: + - CONF_FILE="host_prj.conf" + platform_allow: + - esp32s3_devkitc/esp32s3/procpu