diff --git a/.github/workflows/hil.yml b/.github/workflows/hil.yml index 8aca8f2..deddafc 100644 --- a/.github/workflows/hil.yml +++ b/.github/workflows/hil.yml @@ -46,18 +46,29 @@ jobs: run: apt-get update && apt-get -y install curl musl-tools pkg-config - name: Install toolchain - run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - name: Build espflash run: $HOME/.cargo/bin/cargo build --release working-directory: espflash + - name: Build xtask + run: $HOME/.cargo/bin/cargo build --release --locked + working-directory: xtask + - uses: actions/upload-artifact@v4 with: name: espflash path: target/release/espflash if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: xtask + path: target/release/xtask + if-no-files-found: error + run-target: if: github.repository_owner == 'esp-rs' name: ${{ matrix.board.mcu }}${{ matrix.board.freq }} @@ -95,44 +106,17 @@ jobs: name: espflash path: espflash_app + - uses: actions/download-artifact@v4 + with: + name: xtask + path: xtask_app + - name: Set up espflash binary run: | chmod +x espflash_app/espflash echo "$PWD/espflash_app" >> "$GITHUB_PATH" + chmod +x xtask_app/xtask + echo "$PWD/xtask_app" >> "$GITHUB_PATH" - - name: board-info test - run: timeout 10 bash espflash/tests/scripts/board-info.sh - - - name: flash test - run: timeout 80 bash espflash/tests/scripts/flash.sh ${{ matrix.board.mcu }} - - - name: monitor test - run: timeout 10 bash espflash/tests/scripts/monitor.sh - - - name: erase-flash test - run: timeout 60 bash espflash/tests/scripts/erase-flash.sh - - - name: save-image/write-bin test - run: | - timeout 180 bash espflash/tests/scripts/save-image_write-bin.sh ${{ matrix.board.mcu }} - - - name: erase-region test - run: timeout 30 bash espflash/tests/scripts/erase-region.sh - - - name: hold-in-reset test - run: timeout 10 bash espflash/tests/scripts/hold-in-reset.sh - - - name: reset test - run: timeout 10 bash espflash/tests/scripts/reset.sh - - - name: checksum-md5 test - run: timeout 40 bash espflash/tests/scripts/checksum-md5.sh - - - name: list-ports test - run: timeout 10 bash espflash/tests/scripts/list-ports.sh - - - name: write-bin test - run: timeout 20 bash espflash/tests/scripts/write-bin.sh - - - name: read-flash test - run: timeout 60 bash espflash/tests/scripts/read-flash.sh + - name: Run all tests + run: xtask run-tests --chip ${{ matrix.board.mcu }} -t 60 --no-build \ No newline at end of file diff --git a/.gitignore b/.gitignore index 84dcbf5..64f2b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Generated during tests +*.bin + # Generated by Cargo # will have compiled files and executables debug/ diff --git a/espflash/tests/scripts/board-info.sh b/espflash/tests/scripts/board-info.sh deleted file mode 100755 index c28b11d..0000000 --- a/espflash/tests/scripts/board-info.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash - -# Run the command and capture output and exit code -result=$(espflash board-info) -exit_code=$? -echo "$result" - -# Extract chip type -chip_type=$(awk -F': *' '/Chip type:/ {print $2}' <<< "$result" | awk '{print $1}') - -if [[ "$chip_type" == "esp32" ]]; then - # ESP32 doesn't support get_security_info - [[ $exit_code -eq 0 && "$result" =~ "Security features: None" ]] || { - echo "Expected Security features: None" - exit 1 - } -else - # Non-ESP32 should contain required info - [[ $exit_code -eq 0 && "$result" =~ "Security Information:" && "$result" =~ "Flags" ]] || { - echo "Expected 'Security Information:' and 'Flags' in output but did not find them" - exit 1 - } -fi diff --git a/espflash/tests/scripts/checksum-md5.sh b/espflash/tests/scripts/checksum-md5.sh deleted file mode 100644 index 0218c56..0000000 --- a/espflash/tests/scripts/checksum-md5.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -result=$(espflash erase-flash 2>&1) -echo "$result" -if [[ ! $result =~ "Flash has been erased!" ]]; then - exit 1 -fi -result=$(espflash checksum-md5 0x1000 0x100 2>&1) -echo "$result" -if [[ ! $result =~ "0x827f263ef9fb63d05499d14fcef32f60" ]]; then - exit 1 -fi diff --git a/espflash/tests/scripts/erase-flash.sh b/espflash/tests/scripts/erase-flash.sh deleted file mode 100644 index 7a43826..0000000 --- a/espflash/tests/scripts/erase-flash.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -result=$(espflash erase-flash 2>&1) -echo "$result" -if [[ ! $result =~ "Flash has been erased!" ]]; then - exit 1 -fi -result=$(espflash read-flash 0 0x4000 flash_content.bin 2>&1) -echo "$result" -if [[ ! $result =~ "Flash content successfully read and written to" ]]; then - exit 1 -fi -echo "Checking if flash is empty" -if hexdump -v -e '/1 "%02x"' "flash_content.bin" | grep -qv '^ff*$'; then - exit 1 -fi -echo "Flash is empty!" diff --git a/espflash/tests/scripts/erase-region.sh b/espflash/tests/scripts/erase-region.sh deleted file mode 100755 index 88c8185..0000000 --- a/espflash/tests/scripts/erase-region.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash - -# Function to check expected failure for unaligned erase arguments -check_unaligned_erase() { - local address=$1 - local size=$2 - result=$(espflash erase-region "$address" "$size" 2>&1) - echo "$result" - - if [[ $result =~ "Invalid `address`" ]]; then - echo "Unaligned erase correctly rejected: address=$address, size=$size" - else - echo "Test failed: unaligned erase was not rejected!" - exit 1 - fi -} - -# Unaligned address (not a multiple of 4096) -check_unaligned_erase 0x1001 0x1000 - -# Unaligned size (not a multiple of 4096) -check_unaligned_erase 0x1000 0x1001 - -# Both address and size unaligned -check_unaligned_erase 0x1003 0x1005 - -# Valid erase - should succeed -result=$(espflash erase-region 0x1000 0x1000 2>&1) -echo "$result" -if [[ ! $result =~ "Erasing region at" ]]; then - exit 1 -fi -result=$(espflash read-flash 0x1000 0x2000 flash_content.bin 2>&1) -echo "$result" -if [[ ! $result =~ "Flash content successfully read and written to" ]]; then - echo "Failed to read flash contents" - exit 1 -fi -# Check first 0x1000 bytes are FF -if head -c 4096 flash_content.bin | hexdump -v -e '/1 "%02x"' | grep -qv '^ff*$'; then - echo "First 0x1000 bytes should be empty (FF)" - exit 1 -fi -# Check next 0x1000 bytes contain some non-FF bytes -if ! tail -c 4096 flash_content.bin | hexdump -v -e '/1 "%02x"' | grep -q '[0-e]'; then - echo "Next 0x1000 bytes should contain some non-FF bytes" - exit 1 -fi -echo "Flash contents verified!" diff --git a/espflash/tests/scripts/flash.sh b/espflash/tests/scripts/flash.sh deleted file mode 100644 index 64321ab..0000000 --- a/espflash/tests/scripts/flash.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bash -app="espflash/tests/data/$1" -part_table="espflash/tests/data/partitions.csv" - -# espflash should not flash a partition table that is too big -result=$(timeout 15s espflash flash --no-skip --monitor --non-interactive $app --flash-size 2mb --partition-table $part_table 2>&1) -echo "$result" -if [[ $result =~ "Flashing has completed!" ]]; then - echo "Flashing should have failed!" - exit 1 -fi -if [[ $result =~ "espflash::partition_table::does_not_fit" ]]; then - echo "Flashing failed as expected!" -else - echo "Flashing has failed but not with the expected error!" - exit 1 -fi - -if [[ "$1" == "esp32c6" ]]; then - # With manual log-format - app_defmt="${app}_defmt" - result=$(timeout 15s espflash flash --no-skip --monitor --non-interactive $app_defmt --log-format defmt 2>&1) - echo "$result" - if [[ ! $result =~ "Flashing has completed!" ]]; then - echo "Flashing failed!" - exit 1 - fi - if ! echo "$result" | grep -q "Hello world!"; then - echo "Monitoring failed!" - exit 1 - fi - - # With auto-detected log-format - result=$(timeout 15s espflash flash --no-skip --monitor --non-interactive $app_defmt 2>&1) - echo "$result" - if [[ ! $result =~ "Flashing has completed!" ]]; then - echo "Flashing failed!" - exit 1 - fi - if ! echo "$result" | grep -q "Hello world!"; then - echo "Monitoring failed!" - exit 1 - fi - - # Backtrace test - app_backtrace="${app}_backtrace" - - result=$(timeout 10s espflash flash --no-skip --monitor --non-interactive $app_backtrace 2>&1) - echo "$result" - if [[ ! $result =~ "Flashing has completed!" ]]; then - echo "Flashing failed!" - exit 1 - fi - expected_strings=( - "0x420012c8" - "main" - "esp32c6_backtrace/src/bin/main.rs:" - "0x42001280" - "hal_main" - ) - for expected in "${expected_strings[@]}"; do - if ! echo "$result" | grep -q "$expected"; then - echo "Monitoring failed! Expected '$expected' not found in output." - exit 1 - fi - done -fi - -result=$(timeout 15s espflash flash --no-skip --monitor --non-interactive $app 2>&1) -echo "$result" -if [[ ! $result =~ "Flashing has completed!" ]]; then - echo "Flashing failed!" - exit 1 -fi -if ! echo "$result" | grep -q "Hello world!"; then - echo "Monitoring failed!" - exit 1 -fi - -# Test with a higher baud rate -result=$(timeout 15s espflash flash --no-skip --monitor --non-interactive --baud 921600 $app 2>&1 | tr -d '\0') -echo "$result" -if [[ ! $result =~ "Flashing has completed!" ]]; then - echo "Flashing failed!" - exit 1 -fi -if ! echo "$result" | grep -q "Hello world!"; then - echo "Monitoring failed!" - exit 1 -fi diff --git a/espflash/tests/scripts/hold-in-reset.sh b/espflash/tests/scripts/hold-in-reset.sh deleted file mode 100644 index 2c5d11f..0000000 --- a/espflash/tests/scripts/hold-in-reset.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -result=$(espflash hold-in-reset 2>&1) -echo "$result" -if [[ ! $result =~ "Holding target device in reset" ]]; then - exit 1 -fi diff --git a/espflash/tests/scripts/list-ports.sh b/espflash/tests/scripts/list-ports.sh deleted file mode 100644 index dd89b20..0000000 --- a/espflash/tests/scripts/list-ports.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -result=$(espflash list-ports 2>&1) -echo "$result" -if [[ ! $result =~ "Silicon Labs" ]]; then - exit 1 -fi diff --git a/espflash/tests/scripts/monitor.sh b/espflash/tests/scripts/monitor.sh deleted file mode 100644 index f6fbfd7..0000000 --- a/espflash/tests/scripts/monitor.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -echo "Monitoring..." -result=$(timeout 5s espflash monitor --non-interactive || true) -echo "$result" -if ! echo "$result" | grep -q "Hello world!"; then - exit 1 -fi diff --git a/espflash/tests/scripts/read-flash.sh b/espflash/tests/scripts/read-flash.sh deleted file mode 100644 index 9a400a8..0000000 --- a/espflash/tests/scripts/read-flash.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash - -KNOWN_PATTERN=$'\x01\xa0\x02\xB3\x04\xC4\x08\xD5\x10\xE6\x20\xF7\x40\x88\x50\x99' -KNOWN_PATTERN+=$'\x60\xAA\x70\xBB\x80\xCC\x90\xDD\xA0\xEE\xB0\xFF\xC0\x11\xD0\x22' -KNOWN_PATTERN+=$'\xE0\x33\xF0\x44\x05\x55\x15\x66\x25\x77\x35\x88\x45\x99\x55\xAA' -KNOWN_PATTERN+=$'\x65\xBB\x75\xCC\x85\xDD\x95\xEE\xA5\xFF\xB5\x00\xC5\x11\xD5\x22' -KNOWN_PATTERN+=$'\xE5\x33\xF5\x44\x06\x55\x16\x66\x26\x77\x36\x88\x46\x99\x56\xAA' -KNOWN_PATTERN+=$'\x66\xBB\x76\xCC\x86\xDD\x96\xEE\xA6\xFF\xB6\x00\xC6\x11\xD6\x22' - -echo -ne "$KNOWN_PATTERN" > pattern.bin -result=$(espflash write-bin 0x0 pattern.bin 2>&1) -echo "$result" -if [[ ! $result =~ "Binary successfully written to flash!" ]]; then - echo "Failed to write binary to flash" - exit 1 -fi - -lengths=(2 5 10 26 44 86) - -for len in "${lengths[@]}"; do - echo "Testing read-flash with length: $len" - - result=$(espflash read-flash 0 "$len" flash_content.bin 2>&1) - echo "$result" - if [[ ! $result =~ "Flash content successfully read and written to" ]]; then - echo "Failed to read $len bytes from flash" - exit 1 - fi - - EXPECTED=$(echo -ne "$KNOWN_PATTERN" | head -c "$len") - - if ! cmp -s <(echo -ne "$EXPECTED") flash_content.bin; then - echo "Verification failed: content does not match expected for length" - exit 1 - fi - - echo "Testing ROM read-flash with length: $len" - result=$(espflash read-flash --no-stub 0 "$len" flash_content.bin 2>&1) - echo "$result" - - if ! cmp -s <(echo -ne "$EXPECTED") flash_content.bin; then - echo "Verification failed: content does not match expected for length" - exit 1 - fi - - if [[ ! $result =~ "Flash content successfully read and written to" ]]; then - echo "Failed to read $len bytes from flash" - exit 1 - fi - -done - -echo "All read-flash tests passed!" diff --git a/espflash/tests/scripts/reset.sh b/espflash/tests/scripts/reset.sh deleted file mode 100644 index e2fda7d..0000000 --- a/espflash/tests/scripts/reset.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -result=$(espflash reset 2>&1) -echo "$result" -if [[ ! $result =~ "Resetting target device" ]]; then - exit 1 -fi diff --git a/espflash/tests/scripts/save-image_write-bin.sh b/espflash/tests/scripts/save-image_write-bin.sh deleted file mode 100644 index 0e763aa..0000000 --- a/espflash/tests/scripts/save-image_write-bin.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -app="espflash/tests/data/$1" - -# if $1 is esp32c2, create an variable that contains `-x 26mhz` -if [[ $1 == "esp32c2" ]]; then - freq="-x 26mhz" -fi - -result=$(espflash save-image --merge --chip $1 $freq $app app.bin 2>&1) -echo "$result" -if [[ ! $result =~ "Image successfully saved!" ]]; then - exit 1 -fi -echo "Writing binary" -result=$(timeout 90 espflash write-bin --monitor 0x0 app.bin --non-interactive 2>&1) -echo "$result" -if [[ ! $result =~ "Binary successfully written to flash!" ]]; then - exit 1 -fi - -if ! echo "$result" | grep -q "Hello world!"; then - exit 1 -fi - -if [[ $1 == "esp32c6" ]]; then - # Regression test for https://github.com/esp-rs/espflash/issues/741 - app="espflash/tests/data/esp_idf_firmware_c6.elf" - - result=$(espflash save-image --merge --chip esp32c6 $app app.bin 2>&1) - echo "$result" - if [[ ! $result =~ "Image successfully saved!" ]]; then - exit 1 - fi - - echo "Checking that app descriptor is first" - # Read 4 bytes from 0x10020, it needs to be 0xABCD5432 - - app_descriptor=$(xxd -p -s 0x10020 -l 4 app.bin) - if [[ $app_descriptor != "3254cdab" ]]; then - echo "App descriptor magic word is not correct: $app_descriptor" - exit 1 - fi -fi diff --git a/espflash/tests/scripts/write-bin.sh b/espflash/tests/scripts/write-bin.sh deleted file mode 100644 index 7419dea..0000000 --- a/espflash/tests/scripts/write-bin.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -part_table="espflash/tests/data/partitions.csv" - -# https://github.com/esp-rs/espflash/issues/622 reproducer -echo -ne "\x01\xa0" >binary_file.bin -result=$(espflash write-bin 0x0 binary_file.bin 2>&1) -echo "$result" -if [[ ! $result =~ "Binary successfully written to flash!" ]]; then - echo "Failed to write binary" - exit 1 -fi - -result=$(espflash read-flash 0 64 flash_content.bin 2>&1) -echo "$result" -if [[ ! $result =~ "Flash content successfully read and written to" ]]; then - echo "Failed to read flash content" - exit 1 -fi -# Check that the flash_content.bin contains the '01 a0' bytes -if ! grep -q -a -F $'\x01\xa0' flash_content.bin; then - echo "Failed verifying content" - exit 1 -fi diff --git a/xtask/src/efuse_generator.rs b/xtask/src/efuse_generator.rs new file mode 100644 index 0000000..65c75ba --- /dev/null +++ b/xtask/src/efuse_generator.rs @@ -0,0 +1,264 @@ +use std::{ + cmp::Ordering, + collections::{BTreeMap, HashMap}, + ffi::OsStr, + fs::{self, OpenOptions}, + io::{BufWriter, Write}, + path::{Path, PathBuf}, + process::Command, +}; + +use clap::{Args, Parser}; + +type Result = std::result::Result>; + +// ---------------------------------------------------------------------------- +// Command-line Interface + +#[derive(Debug, Parser)] +enum Cli { + /// Generate eFuse field definitions + GenerateEfuseFields(GenerateEfuseFieldsArgs), +} + +#[derive(Debug, Args)] +pub(crate) struct GenerateEfuseFieldsArgs { + /// Local path to the `esptool` repository + esptool_path: PathBuf, +} + +const HEADER: &str = r#" +//! eFuse field definitions for the $CHIP +//! +//! This file was automatically generated, please do not edit it manually! +//! +//! Generated: $DATE +//! Version: $VERSION + +#![allow(unused)] + +use super::EfuseField; + +"#; + +type EfuseFields = HashMap; + +#[derive(Debug, serde::Deserialize)] +struct EfuseYaml { + #[serde(rename = "VER_NO")] + version: String, + #[serde(rename = "EFUSES")] + fields: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +struct EfuseAttrs { + #[serde(rename = "blk")] + block: u32, + word: u32, + len: u32, + start: u32, + #[serde(rename = "desc")] + description: String, +} + +impl PartialOrd for EfuseAttrs { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for EfuseAttrs { + fn cmp(&self, other: &Self) -> Ordering { + match self.block.cmp(&other.block) { + Ordering::Equal => {} + ord => return ord, + } + + match self.word.cmp(&other.word) { + Ordering::Equal => {} + ord => return ord, + } + + self.start.cmp(&other.start) + } +} + +pub(crate) fn generate_efuse_fields(workspace: &Path, args: GenerateEfuseFieldsArgs) -> Result<()> { + let efuse_yaml_path = args + .esptool_path + .join("espefuse") + .join("efuse_defs") + .canonicalize()?; + + let espflash_path = workspace.join("espflash").canonicalize()?; + + let mut efuse_fields = parse_efuse_fields(&efuse_yaml_path)?; + process_efuse_definitions(&mut efuse_fields)?; + generate_efuse_definitions(&espflash_path, efuse_fields)?; + + Command::new("cargo") + .args(["+nightly", "fmt"]) + .current_dir(workspace) + .output()?; + + Ok(()) +} + +fn parse_efuse_fields(efuse_yaml_path: &Path) -> Result { + // TODO: We can probably handle this better, e.g. by defining a `Chip` enum + // which can be iterated over, but for now this is good enough. + const CHIPS: &[&str] = &[ + "esp32", "esp32c2", "esp32c3", "esp32c5", "esp32c6", "esp32h2", "esp32p4", "esp32s2", + "esp32s3", + ]; + + let mut efuse_fields = EfuseFields::new(); + + for result in fs::read_dir(efuse_yaml_path)? { + let path = result?.path(); + if path.extension().is_none_or(|ext| ext != OsStr::new("yaml")) { + continue; + } + + let chip = path.file_stem().unwrap().to_string_lossy().to_string(); + if !CHIPS.contains(&chip.as_str()) { + continue; + } + + let efuse_yaml = fs::read_to_string(&path)?; + let efuse_yaml: EfuseYaml = serde_yaml::from_str(&efuse_yaml)?; + + efuse_fields.insert(chip.to_string(), efuse_yaml); + } + + Ok(efuse_fields) +} + +fn process_efuse_definitions(efuse_fields: &mut EfuseFields) -> Result<()> { + // This is all a special case for the MAC field, which is larger than a single + // word (i.e. 32-bits) in size. To handle this, we just split it up into two + // separate fields, and update the fields' attributes accordingly. + for yaml in (*efuse_fields).values_mut() { + let mac_attrs = yaml.fields.get("MAC").unwrap(); + + let mut mac0_attrs = mac_attrs.clone(); + mac0_attrs.len = 32; + + let mut mac1_attrs = mac_attrs.clone(); + mac1_attrs.start = mac0_attrs.start + 32; + mac1_attrs.word = mac1_attrs.start / 32; + mac1_attrs.len = 16; + + yaml.fields.remove("MAC").unwrap(); + yaml.fields.insert("MAC0".into(), mac0_attrs); + yaml.fields.insert("MAC1".into(), mac1_attrs); + } + + // The ESP32-S2 seems to be missing a reserved byte at the end of BLOCK0 + // (Or, something else weird is going on). + efuse_fields.entry("esp32s2".into()).and_modify(|yaml| { + yaml.fields + .entry("RESERVED_0_162".into()) + .and_modify(|field| field.len = 30); + }); + + Ok(()) +} + +fn generate_efuse_definitions(espflash_path: &Path, efuse_fields: EfuseFields) -> Result<()> { + let targets_efuse_path = espflash_path + .join("src") + .join("target") + .join("efuse") + .canonicalize()?; + + for (chip, yaml) in efuse_fields { + let f = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(targets_efuse_path.join(format!("{chip}.rs")))?; + + let mut writer = BufWriter::new(f); + + write!( + writer, + "{}", + HEADER + .replace("$CHIP", &chip) + .replace( + "$DATE", + &chrono::Utc::now().format("%Y-%m-%d %H:%M").to_string() + ) + .replace("$VERSION", &yaml.version) + .trim_start() + )?; + + generate_efuse_block_sizes(&mut writer, &yaml.fields)?; + generate_efuse_constants(&mut writer, &yaml.fields)?; + } + + Ok(()) +} + +fn generate_efuse_block_sizes( + writer: &mut dyn Write, + fields: &HashMap, +) -> Result<()> { + let mut field_attrs = fields.values().collect::>(); + field_attrs.sort(); + + let block_sizes = field_attrs + .chunk_by(|a, b| a.block == b.block) + .enumerate() + .map(|(block, attrs)| { + let last = attrs.last().unwrap(); + let size_bits = last.start + last.len; + assert!(size_bits % 8 == 0); + + (block, size_bits / 8) + }) + .collect::>(); + + writeln!(writer, "/// Total size in bytes of each block")?; + writeln!( + writer, + "pub(crate) const BLOCK_SIZES: &[u32] = &[{}];\n", + block_sizes + .values() + .map(|v| v.to_string()) + .collect::>() + .join(", ") + )?; + + Ok(()) +} + +fn generate_efuse_constants( + writer: &mut dyn Write, + fields: &HashMap, +) -> Result<()> { + let mut sorted = fields.iter().collect::>(); + sorted.sort_by(|a, b| (a.1).cmp(b.1)); + + for (name, attrs) in sorted { + let EfuseAttrs { + block, + word, + len, + start, + description, + } = attrs; + + let description = description.replace('[', "\\[").replace(']', "\\]"); + + writeln!(writer, "/// {description}")?; + writeln!( + writer, + "pub const {name}: EfuseField = EfuseField::new({block}, {word}, {start}, {len});" + )?; + } + + Ok(()) +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c046198..36788dc 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,16 +1,13 @@ -use std::{ - cmp::Ordering, - collections::{BTreeMap, HashMap}, - ffi::OsStr, - fs::{self, OpenOptions}, - io::{BufWriter, Write}, - path::{Path, PathBuf}, - process::Command, -}; +use std::{env, path::PathBuf}; -use clap::{Args, Parser}; +use clap::Parser; -type Result = std::result::Result>; +// Import modules +mod efuse_generator; +mod test_runner; + +// Type definition for results +pub type Result = std::result::Result>; // ---------------------------------------------------------------------------- // Command-line Interface @@ -18,13 +15,10 @@ type Result = std::result::Result>; #[derive(Debug, Parser)] enum Cli { /// Generate eFuse field definitions - GenerateEfuseFields(GenerateEfuseFieldsArgs), -} + GenerateEfuseFields(efuse_generator::GenerateEfuseFieldsArgs), -#[derive(Debug, Args)] -struct GenerateEfuseFieldsArgs { - /// Local path to the `esptool` repository - esptool_path: PathBuf, + /// Run espflash tests + RunTests(test_runner::RunTestsArgs), } // ---------------------------------------------------------------------------- @@ -35,251 +29,27 @@ fn main() -> Result<()> { .filter_module("xtask", log::LevelFilter::Info) .init(); - // The directory containing the cargo manifest for the 'xtask' package is a - // subdirectory within the cargo workspace: - let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace = workspace.parent().unwrap().canonicalize()?; + // Determine the path to the workspace (i.e. the root of the repository). + // At compile-time we know where the `xtask` crate lives, but that absolute + // path may not exist at runtime once the binary is distributed as an + // artefact and executed on a different machine (e.g. a self-hosted CI + // runner). Therefore we + // 1. Try the compile-time location first. + // 2. Fallback to the current working directory if that fails. + + let workspace_from_build = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("`CARGO_MANIFEST_DIR` should always have a parent") + .to_path_buf(); + + let workspace = if workspace_from_build.exists() { + workspace_from_build.canonicalize()? + } else { + env::current_dir()?.canonicalize()? + }; match Cli::parse() { - Cli::GenerateEfuseFields(args) => generate_efuse_fields(&workspace, args), + Cli::GenerateEfuseFields(args) => efuse_generator::generate_efuse_fields(&workspace, args), + Cli::RunTests(args) => test_runner::run_tests(&workspace, args), } } - -// ---------------------------------------------------------------------------- -// Generate eFuse Fields - -const HEADER: &str = r#" -//! eFuse field definitions for the $CHIP -//! -//! This file was automatically generated, please do not edit it manually! -//! -//! Generated: $DATE -//! Version: $VERSION - -#![allow(unused)] - -use super::EfuseField; - -"#; - -type EfuseFields = HashMap; - -#[derive(Debug, serde::Deserialize)] -struct EfuseYaml { - #[serde(rename = "VER_NO")] - version: String, - #[serde(rename = "EFUSES")] - fields: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] -struct EfuseAttrs { - #[serde(rename = "blk")] - block: u32, - word: u32, - len: u32, - start: u32, - #[serde(rename = "desc")] - description: String, -} - -impl PartialOrd for EfuseAttrs { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for EfuseAttrs { - fn cmp(&self, other: &Self) -> Ordering { - match self.block.cmp(&other.block) { - Ordering::Equal => {} - ord => return ord, - } - - match self.word.cmp(&other.word) { - Ordering::Equal => {} - ord => return ord, - } - - self.start.cmp(&other.start) - } -} - -fn generate_efuse_fields(workspace: &Path, args: GenerateEfuseFieldsArgs) -> Result<()> { - let efuse_yaml_path = args - .esptool_path - .join("espefuse") - .join("efuse_defs") - .canonicalize()?; - - let espflash_path = workspace.join("espflash").canonicalize()?; - - let mut efuse_fields = parse_efuse_fields(&efuse_yaml_path)?; - process_efuse_definitions(&mut efuse_fields)?; - generate_efuse_definitions(&espflash_path, efuse_fields)?; - - Command::new("cargo") - .args(["+nightly", "fmt"]) - .current_dir(workspace) - .output()?; - - Ok(()) -} - -fn parse_efuse_fields(efuse_yaml_path: &Path) -> Result { - // TODO: We can probably handle this better, e.g. by defining a `Chip` enum - // which can be iterated over, but for now this is good enough. - const CHIPS: &[&str] = &[ - "esp32", "esp32c2", "esp32c3", "esp32c5", "esp32c6", "esp32h2", "esp32p4", "esp32s2", - "esp32s3", - ]; - - let mut efuse_fields = EfuseFields::new(); - - for result in fs::read_dir(efuse_yaml_path)? { - let path = result?.path(); - if path.extension().is_none_or(|ext| ext != OsStr::new("yaml")) { - continue; - } - - let chip = path.file_stem().unwrap().to_string_lossy().to_string(); - if !CHIPS.contains(&chip.as_str()) { - continue; - } - - let efuse_yaml = fs::read_to_string(&path)?; - let efuse_yaml: EfuseYaml = serde_yaml::from_str(&efuse_yaml)?; - - efuse_fields.insert(chip.to_string(), efuse_yaml); - } - - Ok(efuse_fields) -} - -fn process_efuse_definitions(efuse_fields: &mut EfuseFields) -> Result<()> { - // This is all a special case for the MAC field, which is larger than a single - // word (i.e. 32-bits) in size. To handle this, we just split it up into two - // separate fields, and update the fields' attributes accordingly. - for yaml in (*efuse_fields).values_mut() { - let mac_attrs = yaml.fields.get("MAC").unwrap(); - - let mut mac0_attrs = mac_attrs.clone(); - mac0_attrs.len = 32; - - let mut mac1_attrs = mac_attrs.clone(); - mac1_attrs.start = mac0_attrs.start + 32; - mac1_attrs.word = mac1_attrs.start / 32; - mac1_attrs.len = 16; - - yaml.fields.remove("MAC").unwrap(); - yaml.fields.insert("MAC0".into(), mac0_attrs); - yaml.fields.insert("MAC1".into(), mac1_attrs); - } - - // The ESP32-S2 seems to be missing a reserved byte at the end of BLOCK0 - // (Or, something else weird is going on). - efuse_fields.entry("esp32s2".into()).and_modify(|yaml| { - yaml.fields - .entry("RESERVED_0_162".into()) - .and_modify(|field| field.len = 30); - }); - - Ok(()) -} - -fn generate_efuse_definitions(espflash_path: &Path, efuse_fields: EfuseFields) -> Result<()> { - let targets_efuse_path = espflash_path - .join("src") - .join("target") - .join("efuse") - .canonicalize()?; - - for (chip, yaml) in efuse_fields { - let f = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(targets_efuse_path.join(format!("{chip}.rs")))?; - - let mut writer = BufWriter::new(f); - - write!( - writer, - "{}", - HEADER - .replace("$CHIP", &chip) - .replace( - "$DATE", - &chrono::Utc::now().format("%Y-%m-%d %H:%M").to_string() - ) - .replace("$VERSION", &yaml.version) - .trim_start() - )?; - - generate_efuse_block_sizes(&mut writer, &yaml.fields)?; - generate_efuse_constants(&mut writer, &yaml.fields)?; - } - - Ok(()) -} - -fn generate_efuse_block_sizes( - writer: &mut dyn Write, - fields: &HashMap, -) -> Result<()> { - let mut field_attrs = fields.values().collect::>(); - field_attrs.sort(); - - let block_sizes = field_attrs - .chunk_by(|a, b| a.block == b.block) - .enumerate() - .map(|(block, attrs)| { - let last = attrs.last().unwrap(); - let size_bits = last.start + last.len; - assert!(size_bits % 8 == 0); - - (block, size_bits / 8) - }) - .collect::>(); - - writeln!(writer, "/// Total size in bytes of each block")?; - writeln!( - writer, - "pub(crate) const BLOCK_SIZES: &[u32] = &[{}];\n", - block_sizes - .values() - .map(|v| v.to_string()) - .collect::>() - .join(", ") - )?; - - Ok(()) -} - -fn generate_efuse_constants( - writer: &mut dyn Write, - fields: &HashMap, -) -> Result<()> { - let mut sorted = fields.iter().collect::>(); - sorted.sort_by(|a, b| (a.1).cmp(b.1)); - - for (name, attrs) in sorted { - let EfuseAttrs { - block, - word, - len, - start, - description, - } = attrs; - - let description = description.replace('[', "\\[").replace(']', "\\]"); - - writeln!(writer, "/// {description}")?; - writeln!( - writer, - "pub const {name}: EfuseField = EfuseField::new({block}, {word}, {start}, {len});" - )?; - } - - Ok(()) -} diff --git a/xtask/src/test_runner.rs b/xtask/src/test_runner.rs new file mode 100644 index 0000000..cffc94b --- /dev/null +++ b/xtask/src/test_runner.rs @@ -0,0 +1,953 @@ +use std::{ + fs, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{ + Arc, + Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use clap::{ArgAction, Args}; +use log::info; + +use crate::Result; + +type SpawnedCommandOutput = ( + Child, + Arc>, + thread::JoinHandle<()>, + thread::JoinHandle<()>, +); +/// Arguments for running tests +#[derive(Debug, Args)] +pub struct RunTestsArgs { + /// Which test to run (or "all" to run all tests) + #[clap(default_value = "all")] + pub test: String, + + /// Chip target + #[clap(short, long)] + pub chip: Option, + + /// Timeout for test commands in seconds + #[clap(short, long, default_value = "15")] + pub timeout: u64, + + /// Whether to build espflash before running tests, true by default + #[arg(long = "no-build", action = ArgAction::SetFalse, default_value_t = true)] + pub build_espflash: bool, +} + +/// A struct to manage and run tests for the espflash +pub struct TestRunner { + /// The workspace directory where the tests are located + pub workspace: PathBuf, + /// The directory containing the test files + pub tests_dir: PathBuf, + /// Timeout for test commands + pub timeout: Duration, + /// Optional chip target for tests + pub chip: Option, + /// Build espflash before running tests + pub build_espflash: bool, +} + +impl TestRunner { + /// Creates a new [TestRunner] instance + pub fn new( + workspace: &Path, + tests_dir: PathBuf, + timeout_secs: u64, + build_espflash: bool, + ) -> Self { + Self { + workspace: workspace.to_path_buf(), + tests_dir, + timeout: Duration::from_secs(timeout_secs), + chip: None, + build_espflash, + } + } + + fn setup_command(&self, cmd: &mut Command) { + cmd.current_dir(&self.workspace) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + } + + fn terminate_process(child: &mut Option<&mut Child>) { + if let Some(child_proc) = child { + let _ = child_proc.kill(); + + // Wait for the process to terminate + if let Some(child_proc) = child { + let _ = child_proc.wait(); + } + } + } + + fn restore_terminal() { + #[cfg(unix)] + { + let _ = Command::new("stty").arg("sane").status(); + } + } + + fn spawn_and_capture_output(cmd: &mut Command) -> Result { + info!("Spawning command: {cmd:?}"); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd.spawn()?; + let stdout = child.stdout.take().expect("Failed to capture stdout"); + let stderr = child.stderr.take().expect("Failed to capture stderr"); + + let output = Arc::new(Mutex::new(String::new())); + let out_clone1 = Arc::clone(&output); + let out_clone2 = Arc::clone(&output); + + let stdout_handle = thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(|line| line.ok()) { + println!("{line}"); + out_clone1.lock().unwrap().push_str(&line); + out_clone1.lock().unwrap().push('\n'); + } + }); + + let stderr_handle = thread::spawn(move || { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(|line| line.ok()) { + println!("{line}"); + out_clone2.lock().unwrap().push_str(&line); + out_clone2.lock().unwrap().push('\n'); + } + }); + + Ok((child, output, stdout_handle, stderr_handle)) + } + + /// Runs a command with a timeout, returning the exit code + pub fn run_command_with_timeout(&self, cmd: &mut Command, timeout: Duration) -> Result { + log::debug!("Running command: {cmd:?}"); + self.setup_command(cmd); + + let mut child = cmd.spawn()?; + let completed = Arc::new(AtomicBool::new(false)); + let child_id = child.id(); + let completed_clone = Arc::clone(&completed); + + let timer = thread::spawn(move || { + let interval = Duration::from_millis(100); + let mut elapsed = Duration::ZERO; + + while elapsed < timeout { + thread::sleep(interval); + elapsed += interval; + if completed_clone.load(Ordering::SeqCst) { + return; + } + } + + log::warn!("Command timed out after {timeout:?}, killing process {child_id}"); + Self::terminate_process(&mut None); + }); + + let status = match child.wait() { + Ok(s) => { + completed.store(true, Ordering::SeqCst); + s + } + Err(e) => { + completed.store(true, Ordering::SeqCst); + thread::sleep(Duration::from_millis(10)); + return Err(format!("Command execution failed: {e}").into()); + } + }; + + let _ = timer.join(); + let exit_code = status.code().unwrap_or(1); + log::debug!("Command exit code: {exit_code}"); + Ok(exit_code) + } + + /// Runs a command for a specified duration, returning whether it terminated + /// naturally + pub fn run_command_for(&self, cmd: &mut Command, duration: Duration) -> Result { + log::debug!("Running command: {cmd:?}"); + let mut child = cmd.spawn()?; + let start_time = Instant::now(); + let mut naturally_terminated = false; + + if let Ok(Some(_)) = child.try_wait() { + naturally_terminated = true; + } else { + thread::sleep(duration); + if let Ok(Some(_)) = child.try_wait() { + naturally_terminated = true; + } + } + + if !naturally_terminated { + log::info!( + "Command ran for {:?}, terminating process {}", + start_time.elapsed(), + child.id() + ); + Self::terminate_process(&mut Some(&mut child)); + } + + Self::restore_terminal(); + log::debug!("Command completed after {:?}", start_time.elapsed()); + + Ok(naturally_terminated) + } + + fn build_espflash(&self) { + let mut cmd = Command::new("cargo"); + + log::info!("Building espflash..."); + cmd.args(["build", "-p", "espflash", "--release", "--"]); + + let status = cmd.status().expect("Failed to build espflash"); + if !status.success() { + panic!("espflash build failed with status: {status}"); + } + } + + fn create_espflash_command(&self, args: &[&str]) -> Command { + let mut cmd = Command::new("cargo"); + + // we need to distinguish between local and CI runs, on CI we are building + // espflash and then copying the binary, so we can use just `espflash` + match self.build_espflash { + true => { + log::info!("Running cargo run..."); + cmd.args(["run", "-p", "espflash", "--release", "--quiet", "--"]); + } + false => { + log::info!("Using system espflash"); + let mut cmd = Command::new("espflash"); + cmd.args(args); + return cmd; + } + } + + cmd.args(args); + + cmd + } + + /// Runs a simple command test, capturing output and checking for expected + /// outputs + pub fn run_simple_command_test( + &self, + args: &[&str], + expected_contains: Option<&[&str]>, + timeout: Duration, + test_name: &str, + ) -> Result<()> { + log::info!("Running {test_name} test"); + let mut cmd = self.create_espflash_command(args); + + if let Some(expected) = expected_contains { + let (mut child, output, h1, h2) = Self::spawn_and_capture_output(&mut cmd)?; + let start_time = Instant::now(); + let mut terminated_naturally = false; + + while start_time.elapsed() < timeout { + if let Ok(Some(_)) = child.try_wait() { + terminated_naturally = true; + break; + } + thread::sleep(Duration::from_millis(100)); + } + + // If still running, kill it + if !terminated_naturally { + let _ = child.kill(); + let _ = child.wait(); + } + + let _ = h1.join(); + let _ = h2.join(); + + let output = output.lock().unwrap(); + for &expected in expected { + if !output.contains(expected) { + Self::restore_terminal(); + return Err(format!("Missing expected output: {expected}").into()); + } + } + + log::info!("{test_name} test passed and output verified"); + } else { + let exit_code = self.run_command_with_timeout(&mut cmd, timeout)?; + if exit_code != 0 { + return Err( + format!("{test_name} test failed: non-zero exit code {exit_code}").into(), + ); + } + + log::info!("{test_name} test passed with exit code 0"); + } + + Ok(()) + } + + /// Runs a timed command test, capturing output and checking for expected + /// outputs after a specified duration + pub fn run_timed_command_test( + &self, + args: &[&str], + expected_contains: Option<&[&str]>, + duration: Duration, + test_name: &str, + ) -> Result<()> { + log::info!("Running {test_name} test"); + let mut cmd = self.create_espflash_command(args); + + if let Some(expected) = expected_contains { + let (mut child, output, h1, h2) = Self::spawn_and_capture_output(&mut cmd)?; + thread::sleep(duration); + let _ = child.kill(); + let _ = child.wait(); + let _ = h1.join(); + let _ = h2.join(); + + let output = output.lock().unwrap(); + for &expected in expected { + if !output.contains(expected) { + Self::restore_terminal(); + return Err(format!("Missing expected output: {expected}").into()); + } + } + + log::info!("{test_name} test passed and output verified"); + } else { + let terminated_naturally = self.run_command_for(&mut cmd, duration)?; + log::info!("{test_name} test completed (terminated naturally: {terminated_naturally})"); + } + + Self::restore_terminal(); + Ok(()) + } + + fn is_flash_empty(&self, file_path: &Path) -> Result { + let flash_data = fs::read(file_path)?; + Ok(flash_data.iter().all(|&b| b == 0xFF)) + } + + fn flash_output_file(&self) -> PathBuf { + self.tests_dir.join("flash_content.bin") + } + + fn contains_sequence(data: &[u8], sequence: &[u8]) -> bool { + if sequence.len() > data.len() { + return false; + } + + for i in 0..=(data.len() - sequence.len()) { + if &data[i..(i + sequence.len())] == sequence { + return true; + } + } + + false + } + + /// Runs all tests in the test suite, optionally overriding the chip target + pub fn run_all_tests(&self, chip_override: Option<&str>) -> Result<()> { + log::info!("Running all tests"); + + let chip = chip_override.or(self.chip.as_deref()).unwrap_or("esp32"); + + self.test_board_info()?; + self.test_flash(Some(chip))?; + self.test_monitor()?; + self.test_erase_flash()?; + self.test_save_image(Some(chip))?; + self.test_erase_region()?; + self.test_hold_in_reset()?; + self.test_reset()?; + self.test_checksum_md5()?; + self.test_list_ports()?; + self.test_write_bin()?; + self.test_read_flash()?; + + log::info!("All tests completed successfully"); + Ok(()) + } + + /// Runs a specific test by name, optionally overriding the chip target + pub fn run_specific_test(&self, test_name: &str, chip_override: Option<&str>) -> Result<()> { + let chip = chip_override.or(self.chip.as_deref()).unwrap_or("esp32"); + + match test_name { + "board-info" => self.test_board_info(), + "flash" => self.test_flash(Some(chip)), + "monitor" => self.test_monitor(), + "erase-flash" => self.test_erase_flash(), + "save-image" => self.test_save_image(Some(chip)), + "erase-region" => self.test_erase_region(), + "hold-in-reset" => self.test_hold_in_reset(), + "reset" => self.test_reset(), + "checksum-md5" => self.test_checksum_md5(), + "list-ports" => self.test_list_ports(), + "write-bin" => self.test_write_bin(), + "read-flash" => self.test_read_flash(), + _ => Err(format!("Unknown test: {test_name}").into()), + } + } + + // Board info test + pub fn test_board_info(&self) -> Result<()> { + self.run_simple_command_test( + &["board-info"], + Some(&["Chip type:"]), + Duration::from_secs(5), + "board-info", + ) + } + + // Flash test + pub fn test_flash(&self, chip: Option<&str>) -> Result<()> { + let chip = chip.unwrap_or_else(|| self.chip.as_deref().unwrap_or("esp32")); + log::info!("Running flash test for chip: {chip}"); + + let app = format!("espflash/tests/data/{chip}"); + let app_backtrace = format!("espflash/tests/data/{chip}_backtrace"); + let part_table = "espflash/tests/data/partitions.csv"; + + // Partition table is too big + self.run_timed_command_test( + &[ + "flash", + "--no-skip", + "--monitor", + "--non-interactive", + &app, + "--flash-size", + "2mb", + "--partition-table", + part_table, + ], + Some(&["espflash::partition_table::does_not_fit"]), + Duration::from_secs(5), + "partition too big", + )?; + + // Additional tests for ESP32-C6 with manual log-format + if chip == "esp32c6" { + // Test with manual log-format and with auto-detected log-format + self.test_flash_with_defmt(&app)?; + // Backtrace test + self.test_backtrace(&app_backtrace)?; + } + + // Test standard flashing + self.run_timed_command_test( + &["flash", "--no-skip", "--monitor", "--non-interactive", &app], + Some(&["Flashing has completed!", "Hello world!"]), + Duration::from_secs(15), + "standard flashing", + )?; + + // Test standard flashing + self.run_timed_command_test( + &[ + "flash", + "--no-skip", + "--monitor", + "--non-interactive", + "--baud", + "921600", + &app, + ], + Some(&["Flashing has completed!", "Hello world!"]), + Duration::from_secs(15), + "standard flashing with high baud rate", + )?; + + Ok(()) + } + + fn test_flash_with_defmt(&self, app: &str) -> Result<()> { + let app_defmt = format!("{app}_defmt"); + + // Test with manual log-format + self.run_timed_command_test( + &[ + "flash", + "--no-skip", + "--monitor", + "--non-interactive", + &app_defmt, + "--log-format", + "defmt", + ], + Some(&["Flashing has completed!", "Hello world!"]), + Duration::from_secs(15), + "defmt manual log-format", + )?; + + // Test with auto-detected log-format + self.run_timed_command_test( + &[ + "flash", + "--no-skip", + "--monitor", + "--non-interactive", + &app_defmt, + ], + Some(&["Flashing has completed!", "Hello world!"]), + Duration::from_secs(15), + "defmt auto-detected log-format", + )?; + + Ok(()) + } + + fn test_backtrace(&self, app_backtrace: &str) -> Result<()> { + // Test flashing with backtrace + self.run_timed_command_test( + &[ + "flash", + "--no-skip", + "--monitor", + "--non-interactive", + app_backtrace, + ], + Some(&[ + "0x420012c8", + "main", + "esp32c6_backtrace/src/bin/main.rs:", + "0x42001280", + "hal_main", + ]), + Duration::from_secs(15), + "backtrace test", + )?; + + Ok(()) + } + + /// Tests listing available ports + pub fn test_list_ports(&self) -> Result<()> { + self.run_simple_command_test( + &["list-ports"], + Some(&["Silicon Labs"]), + Duration::from_secs(5), + "list-ports", + )?; + Ok(()) + } + + /// Tests erasing the flash memory + pub fn test_erase_flash(&self) -> Result<()> { + log::info!("Running erase-flash test"); + let flash_output = self.flash_output_file(); + + self.run_simple_command_test( + &["erase-flash"], + Some(&["Flash has been erased!"]), + Duration::from_secs(40), + "erase-flash", + )?; + + // Read a portion of the flash to verify it's erased + self.run_simple_command_test( + &["read-flash", "0", "0x4000", flash_output.to_str().unwrap()], + Some(&["Flash content successfully read"]), + Duration::from_secs(5), + "read after erase", + )?; + + // Verify the flash is empty (all 0xFF) + if let Ok(is_empty) = self.is_flash_empty(&flash_output) { + if !is_empty { + return Err("Flash is not empty after erase-flash command".into()); + } + } else { + return Err("Failed to check if flash is empty".into()); + } + + log::info!("erase-flash test passed"); + Ok(()) + } + + /// Tests erasing a specific region of the flash memory + pub fn test_erase_region(&self) -> Result<()> { + log::info!("Running erase-region test"); + let flash_output = self.flash_output_file(); + + // Test unaligned address (not multiple of 4096) + let mut cmd = self.create_espflash_command(&["erase-region", "0x1001", "0x1000"]); + let exit_code = self.run_command_with_timeout(&mut cmd, Duration::from_secs(5))?; + if exit_code == 0 { + return Err("Unaligned address erase should have failed but succeeded".into()); + } + + // Test unaligned size (not multiple of 4096) + let mut cmd = self.create_espflash_command(&["erase-region", "0x1000", "0x1001"]); + let exit_code = self.run_command_with_timeout(&mut cmd, Duration::from_secs(5))?; + if exit_code == 0 { + return Err("Unaligned size erase should have failed but succeeded".into()); + } + + // Valid erase - should succeed + self.run_simple_command_test( + &["erase-region", "0x1000", "0x1000"], + Some(&["Erasing region at"]), + Duration::from_secs(5), + "erase-region valid", + )?; + + // Read the region to verify it was erased + self.run_simple_command_test( + &[ + "read-flash", + "0x1000", + "0x2000", + flash_output.to_str().unwrap(), + ], + Some(&["Flash content successfully read"]), + Duration::from_secs(5), + "read after erase-region", + )?; + + // Check flash contents - first part should be erased + if let Ok(flash_data) = fs::read(&flash_output) { + // First 0x1000 bytes should be 0xFF (erased) + let first_part = &flash_data[0..4096]; + if !first_part.iter().all(|&b| b == 0xFF) { + return Err("First 0x1000 bytes should be empty (0xFF)".into()); + } + + // Next 0x1000 bytes should contain some non-FF bytes + let second_part = &flash_data[4096..8192]; + if second_part.iter().all(|&b| b == 0xFF) { + return Err("Next 0x1000 bytes should contain some non-FF bytes".into()); + } + } else { + return Err("Failed to read flash_content.bin file".into()); + } + + log::info!("erase-region test passed"); + Ok(()) + } + + /// Tests reading the flash memory + pub fn test_read_flash(&self) -> Result<()> { + log::info!("Running read-flash test"); + let flash_output = self.flash_output_file(); + let pattern_file = self.tests_dir.join("pattern.bin"); + + // Create a pattern to write to flash + let known_pattern: Vec = vec![ + 0x01, 0xA0, 0x02, 0xB3, 0x04, 0xC4, 0x08, 0xD5, 0x10, 0xE6, 0x20, 0xF7, 0x40, 0x88, + 0x50, 0x99, 0x60, 0xAA, 0x70, 0xBB, 0x80, 0xCC, 0x90, 0xDD, 0xA0, 0xEE, 0xB0, 0xFF, + 0xC0, 0x11, 0xD0, 0x22, + ]; + + // Write the pattern to a file + fs::write(&pattern_file, &known_pattern)?; + + // Write the pattern to the flash + self.run_simple_command_test( + &["write-bin", "0x0", pattern_file.to_str().unwrap()], + Some(&["Binary successfully written to flash!"]), + Duration::from_secs(5), + "write pattern", + )?; + + // Test reading various lengths + for &len in &[2, 5, 10, 26] { + log::info!("Testing read-flash with length: {len}"); + + // Test normal read + self.run_simple_command_test( + &[ + "read-flash", + "0", + &len.to_string(), + flash_output.to_str().unwrap(), + ], + Some(&["Flash content successfully read and written to"]), + Duration::from_secs(5), + &format!("read {len} bytes"), + )?; + + // Verify the read data matches the expected pattern + if let Ok(read_data) = fs::read(&flash_output) { + let expected = &known_pattern[0..len as usize]; + if &read_data[0..len as usize] != expected { + return Err(format!( + "Verification failed for length {len}: content does not match" + ) + .into()); + } + } else { + return Err(format!("Failed to read flash_content.bin for length {len}").into()); + } + + // Test ROM read (--no-stub option) + self.run_simple_command_test( + &[ + "read-flash", + "--no-stub", + "0", + &len.to_string(), + flash_output.to_str().unwrap(), + ], + Some(&["Flash content successfully read and written to"]), + Duration::from_secs(5), + &format!("read {len} bytes with ROM bootloader"), + )?; + + // Verify the ROM read data matches the expected pattern + if let Ok(read_data) = fs::read(&flash_output) { + let expected = &known_pattern[0..len as usize]; + if &read_data[0..len as usize] != expected { + return Err(format!( + "ROM read verification failed for length {len}: content does not match" + ) + .into()); + } + } else { + return Err( + format!("Failed to read flash_content.bin for ROM read length {len}").into(), + ); + } + } + + log::info!("read-flash test passed"); + Ok(()) + } + + /// Tests writing a binary file to the flash memory + pub fn test_write_bin(&self) -> Result<()> { + log::info!("Running write-bin test"); + let flash_content = self.flash_output_file(); + let binary_file = self.tests_dir.join("binary_file.bin"); + + // Create a simple binary with a known pattern (regression test for issue #622) + let test_pattern = [0x01, 0xA0]; + fs::write(&binary_file, test_pattern)?; + + // Write the binary to a specific address + self.run_simple_command_test( + &["write-bin", "0x0", binary_file.to_str().unwrap()], + Some(&["Binary successfully written to flash!"]), + Duration::from_secs(15), + "write-bin to address", + )?; + + // Read the flash to verify + self.run_simple_command_test( + &["read-flash", "0", "64", flash_content.to_str().unwrap()], + Some(&["Flash content successfully read"]), + Duration::from_secs(50), + "read after write-bin", + )?; + + // Verify the flash content contains the test pattern + if let Ok(flash_data) = fs::read(&flash_content) { + if !Self::contains_sequence(&flash_data, &test_pattern) { + return Err("Failed verifying content: test pattern not found in flash".into()); + } + } else { + return Err("Failed to read flash_content.bin file".into()); + } + + log::info!("write-bin test passed"); + Ok(()) + } + + /// Tests saving an image to the flash memory + pub fn test_save_image(&self, chip: Option<&str>) -> Result<()> { + let chip = chip.unwrap_or_else(|| self.chip.as_deref().unwrap_or("esp32")); + log::info!("Running save-image test for chip: {chip}"); + + let app = format!("espflash/tests/data/{chip}"); + let app_bin = self.tests_dir.join("app.bin"); + + // Determine if frequency option is needed + let mut args = vec![ + "save-image", + "--merge", + "--chip", + chip, + &app, + app_bin.to_str().unwrap(), + ]; + + // Add frequency option for esp32c2 + if chip == "esp32c2" { + args.splice(2..2, ["-x", "26mhz"].iter().copied()); + } + + // Save image + self.run_simple_command_test( + &args, + Some(&["Image successfully saved!"]), + self.timeout, + "save-image", + )?; + + // Write the image and monitor + self.run_timed_command_test( + &[ + "write-bin", + "--monitor", + "0x0", + app_bin.to_str().unwrap(), + "--non-interactive", + ], + Some(&["Hello world!"]), + Duration::from_secs(80), + "write-bin and monitor", + )?; + + // Additional regression test for ESP32-C6 + if chip == "esp32c6" { + self.test_esp32c6_regression(&app_bin)?; + } + + log::info!("save-image test passed"); + Ok(()) + } + + /// Tests the ESP32-C6 regression case + fn test_esp32c6_regression(&self, app_bin: &Path) -> Result<()> { + log::info!("Running ESP32-C6 regression test"); + + let app = "espflash/tests/data/esp_idf_firmware_c6.elf"; + + // Save image with ESP32-C6 regression test case + self.run_simple_command_test( + &[ + "save-image", + "--merge", + "--chip", + "esp32c6", + app, + app_bin.to_str().unwrap(), + ], + Some(&["Image successfully saved!"]), + Duration::from_secs(5), + "save-image C6 regression", + )?; + + // Check that app descriptor is in the correct position + if let Ok(bin_data) = fs::read(app_bin) { + if bin_data.len() >= 0x10024 { + let app_descriptor_offset = 0x10020; + // Check for magic word 0xABCD5432 (in little-endian format) + let expected_magic = [0x32, 0x54, 0xCD, 0xAB]; + + if bin_data[app_descriptor_offset..app_descriptor_offset + 4] != expected_magic { + return Err("App descriptor magic word is not correct".into()); + } + } else { + return Err("Binary file is too small to contain app descriptor".into()); + } + } else { + return Err("Failed to read app.bin file".into()); + } + + log::info!("ESP32-C6 regression test passed"); + Ok(()) + } + + /// Tests the MD5 checksum command + pub fn test_checksum_md5(&self) -> Result<()> { + log::info!("Running checksum-md5 test"); + + // First erase the flash + self.run_simple_command_test( + &["erase-flash"], + Some(&["Flash has been erased!"]), + Duration::from_secs(40), + "erase-flash for checksum", + )?; + + // Then check the MD5 checksum of a region + self.run_simple_command_test( + &["checksum-md5", "0x1000", "0x100"], + Some(&["0x827f263ef9fb63d05499d14fcef32f60"]), + Duration::from_secs(5), + "checksum-md5", + )?; + + log::info!("checksum-md5 test passed"); + Ok(()) + } + + /// Tests the monitor command + pub fn test_monitor(&self) -> Result<()> { + self.run_timed_command_test( + &["monitor", "--non-interactive"], + Some(&["Hello world!"]), + Duration::from_secs(5), + "monitor", + )?; + Ok(()) + } + + /// Tests resetting the target device + pub fn test_reset(&self) -> Result<()> { + self.run_simple_command_test( + &["reset"], + Some(&["Resetting target device"]), + Duration::from_secs(5), + "reset", + )?; + Ok(()) + } + + /// Tests holding the target device in reset + pub fn test_hold_in_reset(&self) -> Result<()> { + self.run_simple_command_test( + &["hold-in-reset"], + Some(&["Holding target device in reset"]), + Duration::from_secs(5), + "hold-in-reset", + )?; + Ok(()) + } +} + +/// Runs the tests based on the provided arguments +pub fn run_tests(workspace: &Path, args: RunTestsArgs) -> Result<()> { + log::info!("Running espflash tests"); + + let tests_dir = workspace.join("espflash").join("tests"); + let test_runner = TestRunner::new(workspace, tests_dir, args.timeout, args.build_espflash); + + // Build espflash before running test(s) so we are not "waisting" test's + // duration or timeout + if args.build_espflash { + test_runner.build_espflash(); + } + + match args.test.as_str() { + "all" => { + if let Err(e) = test_runner.run_all_tests(args.chip.as_deref()) { + log::error!("Test suite failed: {e}"); + return Err(e); + } + } + specific_test => { + if let Err(e) = test_runner.run_specific_test(specific_test, args.chip.as_deref()) { + log::error!("Test '{specific_test}' failed: {e}"); + return Err(e); + } + } + } + + Ok(()) +}