diff --git a/.gitignore b/.gitignore index e7442793..0eb3160d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # no python virtual environment .venv* +venv/ # no compiled python or caches **/__pycache__ diff --git a/firmware/mpflash.db b/firmware/mpflash.db index a31b9a64..3c4d315b 100644 Binary files a/firmware/mpflash.db and b/firmware/mpflash.db differ diff --git a/mpflash/bootloader/activate.py b/mpflash/bootloader/activate.py index b7298c8e..7c139b64 100644 --- a/mpflash/bootloader/activate.py +++ b/mpflash/bootloader/activate.py @@ -16,6 +16,7 @@ "samd": [BootloaderMethod.TOUCH_1200, BootloaderMethod.MPY, BootloaderMethod.MANUAL], "esp32": [BootloaderMethod.NONE], "esp8266": [BootloaderMethod.NONE], + "psoc6": [BootloaderMethod.NONE], # PSoC6 uses OpenOCD directly, no bootloader activation needed } diff --git a/mpflash/common.py b/mpflash/common.py index 49668374..2a5995b9 100644 --- a/mpflash/common.py +++ b/mpflash/common.py @@ -18,6 +18,7 @@ "esp8266": [".bin"], "rp2": [".uf2"], "samd": [".uf2"], + "psoc6": [".hex", ".elf", ".bin"], # PSoC6 support via OpenOCD # below this not yet implemented / tested "mimxrt": [".hex"], "nrf": [".uf2"], diff --git a/mpflash/flash/__init__.py b/mpflash/flash/__init__.py index ecbb5c1a..3598c306 100644 --- a/mpflash/flash/__init__.py +++ b/mpflash/flash/__init__.py @@ -6,6 +6,7 @@ from mpflash.errors import MPFlashError from .esp import flash_esp +from .psoc6 import flash_psoc6 from .stm32 import flash_stm32 from .uf2 import flash_uf2 from .worklist import FlashTaskList @@ -76,6 +77,9 @@ def flash_mcu( elif mcu.port in ["esp32", "esp8266"]: # bootloader is handled by esptool for esp32/esp8266 updated = flash_esp(mcu, fw_file=fw_file, erase=erase, **kwargs) + elif mcu.port in ["psoc6"]: + # PSoC6 flashing via OpenOCD - no separate bootloader activation needed + updated = flash_psoc6(mcu, fw_file=fw_file, erase=erase) else: raise MPFlashError(f"Don't (yet) know how to flash {mcu.port}-{mcu.board} on {mcu.serialport}") except Exception as e: diff --git a/mpflash/flash/psoc6.py b/mpflash/flash/psoc6.py new file mode 100644 index 00000000..aecd2e55 --- /dev/null +++ b/mpflash/flash/psoc6.py @@ -0,0 +1,180 @@ +"""Flash PSoC6 boards using OpenOCD + +PSoC6 (Infineon/Cypress) boards typically require OpenOCD for programming via SWD/JTAG. +This module provides flashing support for PSoC6 boards using OpenOCD with the KitProg3 +interface which is common on Infineon development boards. + +Supported firmware formats: +- .hex files: Intel HEX format +- .elf files: ELF executable format +- .bin files: Raw binary format (flashed at 0x10000000) + +Requirements: +- OpenOCD must be installed and available in PATH +- KitProg3 or compatible SWD/JTAG interface +- Appropriate OpenOCD configuration files for PSoC6 +""" + +import subprocess +import platform +from pathlib import Path +from typing import Optional + +from loguru import logger as log + +from mpflash.mpremoteboard import MPRemoteBoard + + +def flash_psoc6( + mcu: MPRemoteBoard, + fw_file: Path, + *, + erase: bool = True, +) -> Optional[MPRemoteBoard]: + """ + Flash PSoC6 microcontroller using OpenOCD. + + Args: + mcu (MPRemoteBoard): The remote board to flash. + fw_file (Path): The path to the firmware file (.hex, .elf, or .bin). + erase (bool, optional): Whether to erase the memory before flashing. Defaults to True. + + Returns: + Optional[MPRemoteBoard]: The flashed remote board if successful, None otherwise. + """ + log.info(f"Flashing PSoC6 {mcu.board} on {mcu.serialport} with {fw_file}") + + if not fw_file.exists(): + log.error(f"Firmware file {fw_file} not found") + return None + + if fw_file.suffix not in [".hex", ".elf", ".bin"]: + log.error(f"Unsupported firmware file format: {fw_file.suffix}") + return None + + # Check if OpenOCD is available + if not _check_openocd_available(): + log.error("OpenOCD not found. Please install OpenOCD to flash PSoC6 boards.") + return None + + try: + success = _flash_with_openocd(mcu, fw_file, erase=erase) + if success: + log.success(f"Successfully flashed {mcu.board} on {mcu.serialport}") + return mcu + else: + log.error(f"Failed to flash {mcu.board} on {mcu.serialport}") + return None + except Exception as e: + log.error(f"Error flashing PSoC6 board: {e}") + return None + + +def _check_openocd_available() -> bool: + """Check if OpenOCD is available in the system.""" + try: + result = subprocess.run( + ["openocd", "--version"], + capture_output=True, + text=True, + timeout=10 + ) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + +def _flash_with_openocd( + mcu: MPRemoteBoard, + fw_file: Path, + erase: bool = True +) -> bool: + """ + Flash firmware using OpenOCD. + + Args: + mcu: The MicroPython remote board + fw_file: Path to firmware file + erase: Whether to erase before flashing + + Returns: + bool: True if successful, False otherwise + """ + # Build OpenOCD command + cmd = ["openocd"] + + # Add interface configuration - default to KitProg3 which is common for PSoC6 dev boards + cmd.extend(["-f", "interface/kitprog3.cfg"]) + + # Add target configuration - PSoC6 family + cmd.extend(["-f", "target/psoc6.cfg"]) + + # Build the flash commands + flash_commands = [] + + if erase: + flash_commands.append("psoc6 mass_erase 0") + + # Determine the programming command based on file type + if fw_file.suffix == ".hex": + flash_commands.append(f"program {fw_file} verify reset") + elif fw_file.suffix == ".elf": + flash_commands.append(f"program {fw_file} verify reset") + elif fw_file.suffix == ".bin": + # For .bin files, we need to specify the address (typically 0x10000000 for PSoC6) + flash_commands.append(f"program {fw_file} 0x10000000 verify reset") + + flash_commands.append("exit") + + # Add the commands to OpenOCD + for cmd_str in flash_commands: + cmd.extend(["-c", cmd_str]) + + log.debug(f"Running OpenOCD command: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60 # OpenOCD can take some time + ) + + if result.returncode == 0: + log.debug("OpenOCD flashing successful") + log.trace(f"OpenOCD stdout: {result.stdout}") + return True + else: + log.error(f"OpenOCD failed with return code {result.returncode}") + log.debug(f"OpenOCD stderr: {result.stderr}") + log.debug(f"OpenOCD stdout: {result.stdout}") + return False + + except subprocess.TimeoutExpired: + log.error("OpenOCD command timed out") + return False + except Exception as e: + log.error(f"Error running OpenOCD: {e}") + return False + + +def _get_psoc6_board_config(board: str) -> str: + """ + Get the appropriate OpenOCD configuration for a specific PSoC6 board. + + Args: + board: Board identifier + + Returns: + str: OpenOCD target configuration file name + """ + # Map board names to OpenOCD configurations + board_configs = { + "CY8CPROTO-062-4343W": "psoc6.cfg", + "CY8CKIT-062-BLE": "psoc6.cfg", + "CY8CKIT-062-WIFI-BT": "psoc6.cfg", + "CY8CPROTO-063-BLE": "psoc6.cfg", + # Add more board-specific configurations as needed + } + + return board_configs.get(board.upper(), "psoc6.cfg") # Default to generic PSoC6 config \ No newline at end of file diff --git a/tests/flash/test_flash_psoc6.py b/tests/flash/test_flash_psoc6.py new file mode 100644 index 00000000..cc032406 --- /dev/null +++ b/tests/flash/test_flash_psoc6.py @@ -0,0 +1,195 @@ +"""Tests for PSoC6 flashing functionality.""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock +import subprocess + +from mpflash.flash.psoc6 import flash_psoc6, _check_openocd_available, _flash_with_openocd +from mpflash.mpremoteboard import MPRemoteBoard + + +@pytest.fixture +def mock_mcu(): + """Create a mock MPRemoteBoard for PSoC6.""" + mcu = Mock(spec=MPRemoteBoard) + mcu.board = "CY8CPROTO-062-4343W" + mcu.serialport = "/dev/ttyACM0" + mcu.port = "psoc6" + return mcu + + +@pytest.fixture +def mock_firmware_file(tmp_path): + """Create a mock firmware file.""" + fw_file = tmp_path / "firmware.hex" + fw_file.write_text("mock firmware content") + return fw_file + + +class TestPSoC6Flash: + """Test PSoC6 flashing functionality.""" + + @patch('mpflash.flash.psoc6._check_openocd_available') + @patch('mpflash.flash.psoc6._flash_with_openocd') + def test_flash_psoc6_success(self, mock_flash, mock_openocd_check, mock_mcu, mock_firmware_file): + """Test successful PSoC6 flashing.""" + mock_openocd_check.return_value = True + mock_flash.return_value = True + + result = flash_psoc6(mock_mcu, mock_firmware_file) + + assert result == mock_mcu + mock_openocd_check.assert_called_once() + mock_flash.assert_called_once_with(mock_mcu, mock_firmware_file, erase=True) + + @patch('mpflash.flash.psoc6._check_openocd_available') + def test_flash_psoc6_no_openocd(self, mock_openocd_check, mock_mcu, mock_firmware_file): + """Test PSoC6 flashing when OpenOCD is not available.""" + mock_openocd_check.return_value = False + + result = flash_psoc6(mock_mcu, mock_firmware_file) + + assert result is None + mock_openocd_check.assert_called_once() + + def test_flash_psoc6_file_not_found(self, mock_mcu, tmp_path): + """Test PSoC6 flashing when firmware file doesn't exist.""" + nonexistent_file = tmp_path / "nonexistent.hex" + + result = flash_psoc6(mock_mcu, nonexistent_file) + + assert result is None + + def test_flash_psoc6_unsupported_file_format(self, mock_mcu, tmp_path): + """Test PSoC6 flashing with unsupported file format.""" + bad_file = tmp_path / "firmware.txt" + bad_file.write_text("content") + + result = flash_psoc6(mock_mcu, bad_file) + + assert result is None + + @pytest.mark.parametrize("file_ext", [".hex", ".elf", ".bin"]) + @patch('mpflash.flash.psoc6._check_openocd_available') + @patch('mpflash.flash.psoc6._flash_with_openocd') + def test_flash_psoc6_supported_formats(self, mock_flash, mock_openocd_check, + mock_mcu, tmp_path, file_ext): + """Test PSoC6 flashing with supported file formats.""" + mock_openocd_check.return_value = True + mock_flash.return_value = True + + fw_file = tmp_path / f"firmware{file_ext}" + fw_file.write_text("mock content") + + result = flash_psoc6(mock_mcu, fw_file) + + assert result == mock_mcu + + +class TestOpenOCDAvailability: + """Test OpenOCD availability checking.""" + + @patch('subprocess.run') + def test_openocd_available(self, mock_run): + """Test when OpenOCD is available.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_run.return_value = mock_result + + assert _check_openocd_available() is True + mock_run.assert_called_once_with( + ["openocd", "--version"], + capture_output=True, + text=True, + timeout=10 + ) + + @patch('subprocess.run') + def test_openocd_not_available(self, mock_run): + """Test when OpenOCD is not available.""" + mock_run.side_effect = FileNotFoundError() + + assert _check_openocd_available() is False + + @patch('subprocess.run') + def test_openocd_subprocess_error(self, mock_run): + """Test when OpenOCD command fails.""" + mock_result = Mock() + mock_result.returncode = 1 + mock_run.return_value = mock_result + + assert _check_openocd_available() is False + + +class TestOpenOCDFlashing: + """Test OpenOCD flashing functionality.""" + + @patch('subprocess.run') + def test_flash_with_openocd_hex_success(self, mock_run, mock_mcu, tmp_path): + """Test successful flashing with .hex file.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "Success" + mock_result.stderr = "" + mock_run.return_value = mock_result + + fw_file = tmp_path / "firmware.hex" + fw_file.write_text("mock hex content") + + result = _flash_with_openocd(mock_mcu, fw_file, erase=True) + + assert result is True + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert "openocd" in args + assert "-f" in args + assert "interface/kitprog3.cfg" in args + assert "target/psoc6.cfg" in args + + @patch('subprocess.run') + def test_flash_with_openocd_bin_success(self, mock_run, mock_mcu, tmp_path): + """Test successful flashing with .bin file.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "Success" + mock_result.stderr = "" + mock_run.return_value = mock_result + + fw_file = tmp_path / "firmware.bin" + fw_file.write_text("mock bin content") + + result = _flash_with_openocd(mock_mcu, fw_file, erase=False) + + assert result is True + # Check that the command includes the address for .bin files + args = mock_run.call_args[0][0] + assert any("0x10000000" in arg for arg in args) + + @patch('subprocess.run') + def test_flash_with_openocd_failure(self, mock_run, mock_mcu, tmp_path): + """Test OpenOCD flashing failure.""" + mock_result = Mock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "Error" + mock_run.return_value = mock_result + + fw_file = tmp_path / "firmware.hex" + fw_file.write_text("mock content") + + result = _flash_with_openocd(mock_mcu, fw_file) + + assert result is False + + @patch('subprocess.run') + def test_flash_with_openocd_timeout(self, mock_run, mock_mcu, tmp_path): + """Test OpenOCD flashing timeout.""" + mock_run.side_effect = subprocess.TimeoutExpired("openocd", 60) + + fw_file = tmp_path / "firmware.hex" + fw_file.write_text("mock content") + + result = _flash_with_openocd(mock_mcu, fw_file) + + assert result is False \ No newline at end of file