diff --git a/platform.json b/platform.json index 0622c715a..2b951f37b 100644 --- a/platform.json +++ b/platform.json @@ -95,10 +95,11 @@ "package-version": "5.0.1", "version": "https://github.com/pioarduino/registry/releases/download/0.0.1/esptoolpy-v5.0.1.zip" }, - "tl-install": { + "tool-esp_install": { "type": "tool", "optional": false, "owner": "pioarduino", + "package-version": "5.1.0", "version": "https://github.com/pioarduino/esp_install/releases/download/v5.1.0/esp_install-v5.1.0.zip" }, "contrib-piohome": { diff --git a/platform.py b/platform.py index 39eccbae5..8d6e73b7d 100644 --- a/platform.py +++ b/platform.py @@ -33,6 +33,7 @@ SUBPROCESS_TIMEOUT = 300 DEFAULT_DEBUG_SPEED = "5000" DEFAULT_APP_OFFSET = "0x10000" +tl_install_name = "tool-esp_install" ARDUINO_ESP32_PACKAGE_URL = "https://raw.githubusercontent.com/espressif/arduino-esp32/master/package/package_esp32_index.template.json" # MCUs that support ESP-builtin debug @@ -109,6 +110,15 @@ def wrapper(*args, **kwargs): return wrapper +@safe_file_operation +def safe_remove_file(path: str) -> bool: + """Safely remove a file with error handling.""" + if os.path.exists(path) and os.path.isfile(path): + os.remove(path) + logger.debug(f"File removed: {path}") + return True + + @safe_file_operation def safe_remove_directory(path: str) -> bool: """Safely remove directories with error handling.""" @@ -141,6 +151,15 @@ def safe_copy_file(src: str, dst: str) -> bool: return True +@safe_file_operation +def safe_copy_directory(src: str, dst: str) -> bool: + """Safely copy directories with error handling.""" + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copytree(src, dst, dirs_exist_ok=True) + logger.debug(f"Directory copied: {src} -> {dst}") + return True + + class Espressif32Platform(PlatformBase): """ESP32 platform implementation for PlatformIO with optimized toolchain management.""" @@ -159,6 +178,151 @@ def packages_dir(self) -> str: self._packages_dir = config.get("platformio", "packages_dir") return self._packages_dir + def _check_tl_install_version(self) -> bool: + """ + Check if tool-esp_install is installed in the correct version. + Install the correct version only if version differs. + + Returns: + bool: True if correct version is available, False on error + """ + + # Get required version from platform.json + required_version = self.packages.get(tl_install_name, {}).get("version") + if not required_version: + logger.debug(f"No version check required for {tl_install_name}") + return True + + # Check if tool is already installed + tl_install_path = os.path.join(self.packages_dir, tl_install_name) + package_json_path = os.path.join(tl_install_path, "package.json") + + if not os.path.exists(package_json_path): + logger.info(f"{tl_install_name} not installed, installing version {required_version}") + return self._install_tl_install(required_version) + + # Read installed version + try: + with open(package_json_path, 'r', encoding='utf-8') as f: + package_data = json.load(f) + + installed_version = package_data.get("version") + if not installed_version: + logger.warning(f"Installed version for {tl_install_name} unknown, installing {required_version}") + return self._install_tl_install(required_version) + + # IMPORTANT: Compare versions correctly + if self._compare_tl_install_versions(installed_version, required_version): + logger.debug(f"{tl_install_name} version {installed_version} is already correctly installed") + # IMPORTANT: Set package as available, but do NOT reinstall + self.packages[tl_install_name]["optional"] = True + return True + else: + logger.info( + f"Version mismatch for {tl_install_name}: " + f"installed={installed_version}, required={required_version}, installing correct version" + ) + return self._install_tl_install(required_version) + + except (json.JSONDecodeError, FileNotFoundError) as e: + logger.error(f"Error reading package data for {tl_install_name}: {e}") + return self._install_tl_install(required_version) + + def _compare_tl_install_versions(self, installed: str, required: str) -> bool: + """ + Compare installed and required version of tool-esp_install. + + Args: + installed: Currently installed version string + required: Required version string from platform.json + + Returns: + bool: True if versions match, False otherwise + """ + # For URL-based versions: Extract version string from URL + installed_clean = self._extract_version_from_url(installed) + required_clean = self._extract_version_from_url(required) + + logger.debug(f"Version comparison: installed='{installed_clean}' vs required='{required_clean}'") + + return installed_clean == required_clean + + def _extract_version_from_url(self, version_string: str) -> str: + """ + Extract version information from URL or return version directly. + + Args: + version_string: Version string or URL containing version + + Returns: + str: Extracted version string + """ + if version_string.startswith(('http://', 'https://')): + # Extract version from URL like: .../v5.1.0/esp_install-v5.1.0.zip + import re + version_match = re.search(r'v(\d+\.\d+\.\d+)', version_string) + if version_match: + return version_match.group(1) # Returns "5.1.0" + else: + # Fallback: Use entire URL + return version_string + else: + # Direct version number + return version_string.strip() + + def _install_tl_install(self, version: str) -> bool: + """ + Install tool-esp_install ONLY when necessary + and handles backwards compability for tl-install. + + Args: + version: Version string or URL to install + + Returns: + bool: True if installation successful, False otherwise + """ + tl_install_path = os.path.join(self.packages_dir, tl_install_name) + old_tl_install_path = os.path.join(self.packages_dir, "tl-install") + + try: + old_tl_install_exists = os.path.exists(old_tl_install_path) + if old_tl_install_exists: + # remove outdated tl-install + safe_remove_directory(old_tl_install_path) + + if os.path.exists(tl_install_path): + logger.info(f"Removing old {tl_install_name} installation") + safe_remove_directory(tl_install_path) + + logger.info(f"Installing {tl_install_name} version {version}") + self.packages[tl_install_name]["optional"] = False + self.packages[tl_install_name]["version"] = version + pm.install(version) + # Ensure backward compability by removing pio install status indicator + tl_piopm_path = os.path.join(tl_install_path, ".piopm") + safe_remove_file(tl_piopm_path) + + if os.path.exists(os.path.join(tl_install_path, "package.json")): + logger.info(f"{tl_install_name} successfully installed and verified") + self.packages[tl_install_name]["optional"] = True + + # Handle old tl-install to keep backwards compability + if old_tl_install_exists: + # Copy tool-esp_install content to tl-install location + if safe_copy_directory(tl_install_path, old_tl_install_path): + logger.info(f"Content copied from {tl_install_name} to old tl-install location") + else: + logger.warning("Failed to copy content to old tl-install location") + return True + else: + logger.error(f"{tl_install_name} installation failed - package.json not found") + return False + + except Exception as e: + logger.error(f"Error installing {tl_install_name}: {e}") + return False + + def _get_tool_paths(self, tool_name: str) -> Dict[str, str]: """Get centralized path calculation for tools with caching.""" if tool_name not in self._tools_cache: @@ -182,7 +346,7 @@ def _get_tool_paths(self, tool_name: str) -> Dict[str, str]: 'tools_json_path': os.path.join(tool_path, "tools.json"), 'piopm_path': os.path.join(tool_path, ".piopm"), 'idf_tools_path': os.path.join( - self.packages_dir, "tl-install", "tools", "idf_tools.py" + self.packages_dir, tl_install_name, "tools", "idf_tools.py" ) } return self._tools_cache[tool_name] @@ -341,7 +505,7 @@ def _handle_existing_tool( return self.install_tool(tool_name, retry_count + 1) def _configure_arduino_framework(self, frameworks: List[str]) -> None: - """Configure Arduino framework""" + """Configure Arduino framework dependencies.""" if "arduino" not in frameworks: return @@ -423,12 +587,28 @@ def _configure_mcu_toolchains( self.install_tool("tool-openocd-esp32") def _configure_installer(self) -> None: - """Configure the ESP-IDF tools installer.""" + """Configure the ESP-IDF tools installer with proper version checking.""" + + # Check version - installs only when needed + if not self._check_tl_install_version(): + logger.error("Error during tool-esp_install version check / installation") + return + + # Remove pio install marker to avoid issues when switching versions + old_tl_piopm_path = os.path.join(self.packages_dir, "tl-install", ".piopm") + if os.path.exists(old_tl_piopm_path): + safe_remove_file(old_tl_piopm_path) + + # Check if idf_tools.py is available installer_path = os.path.join( - self.packages_dir, "tl-install", "tools", "idf_tools.py" + self.packages_dir, tl_install_name, "tools", "idf_tools.py" ) + if os.path.exists(installer_path): - self.packages["tl-install"]["optional"] = True + logger.debug(f"{tl_install_name} is available and ready") + self.packages[tl_install_name]["optional"] = True + else: + logger.warning(f"idf_tools.py not found in {installer_path}") def _install_esptool_package(self) -> None: """Install esptool package required for all builds.""" @@ -463,7 +643,7 @@ def _ensure_mklittlefs_version(self) -> None: os.remove(piopm_path) logger.info(f"Incompatible mklittlefs version {version} removed (required: 3.x)") except (json.JSONDecodeError, KeyError) as e: - logger.error(f"Error reading mklittlefs package data: {e}") + logger.error(f"Error reading mklittlefs package {e}") def _setup_mklittlefs_for_download(self) -> None: """Setup mklittlefs for download functionality with version 4.x."""