3333SUBPROCESS_TIMEOUT = 300
3434DEFAULT_DEBUG_SPEED = "5000"
3535DEFAULT_APP_OFFSET = "0x10000"
36+ tl_install_name = "tool-esp_install"
3637ARDUINO_ESP32_PACKAGE_URL = "https://raw.githubusercontent.com/espressif/arduino-esp32/master/package/package_esp32_index.template.json"
3738
3839# MCUs that support ESP-builtin debug
@@ -109,6 +110,15 @@ def wrapper(*args, **kwargs):
109110 return wrapper
110111
111112
113+ @safe_file_operation
114+ def safe_remove_file (path : str ) -> bool :
115+ """Safely remove a file with error handling."""
116+ if os .path .exists (path ) and os .path .isfile (path ):
117+ os .remove (path )
118+ logger .debug (f"File removed: { path } " )
119+ return True
120+
121+
112122@safe_file_operation
113123def safe_remove_directory (path : str ) -> bool :
114124 """Safely remove directories with error handling."""
@@ -141,6 +151,15 @@ def safe_copy_file(src: str, dst: str) -> bool:
141151 return True
142152
143153
154+ @safe_file_operation
155+ def safe_copy_directory (src : str , dst : str ) -> bool :
156+ """Safely copy directories with error handling."""
157+ os .makedirs (os .path .dirname (dst ), exist_ok = True )
158+ shutil .copytree (src , dst , dirs_exist_ok = True )
159+ logger .debug (f"Directory copied: { src } -> { dst } " )
160+ return True
161+
162+
144163class Espressif32Platform (PlatformBase ):
145164 """ESP32 platform implementation for PlatformIO with optimized toolchain management."""
146165
@@ -159,6 +178,151 @@ def packages_dir(self) -> str:
159178 self ._packages_dir = config .get ("platformio" , "packages_dir" )
160179 return self ._packages_dir
161180
181+ def _check_tl_install_version (self ) -> bool :
182+ """
183+ Check if tool-esp_install is installed in the correct version.
184+ Install the correct version only if version differs.
185+
186+ Returns:
187+ bool: True if correct version is available, False on error
188+ """
189+
190+ # Get required version from platform.json
191+ required_version = self .packages .get (tl_install_name , {}).get ("version" )
192+ if not required_version :
193+ logger .debug (f"No version check required for { tl_install_name } " )
194+ return True
195+
196+ # Check if tool is already installed
197+ tl_install_path = os .path .join (self .packages_dir , tl_install_name )
198+ package_json_path = os .path .join (tl_install_path , "package.json" )
199+
200+ if not os .path .exists (package_json_path ):
201+ logger .info (f"{ tl_install_name } not installed, installing version { required_version } " )
202+ return self ._install_tl_install (required_version )
203+
204+ # Read installed version
205+ try :
206+ with open (package_json_path , 'r' , encoding = 'utf-8' ) as f :
207+ package_data = json .load (f )
208+
209+ installed_version = package_data .get ("version" )
210+ if not installed_version :
211+ logger .warning (f"Installed version for { tl_install_name } unknown, installing { required_version } " )
212+ return self ._install_tl_install (required_version )
213+
214+ # IMPORTANT: Compare versions correctly
215+ if self ._compare_tl_install_versions (installed_version , required_version ):
216+ logger .debug (f"{ tl_install_name } version { installed_version } is already correctly installed" )
217+ # IMPORTANT: Set package as available, but do NOT reinstall
218+ self .packages [tl_install_name ]["optional" ] = True
219+ return True
220+ else :
221+ logger .info (
222+ f"Version mismatch for { tl_install_name } : "
223+ f"installed={ installed_version } , required={ required_version } , installing correct version"
224+ )
225+ return self ._install_tl_install (required_version )
226+
227+ except (json .JSONDecodeError , FileNotFoundError ) as e :
228+ logger .error (f"Error reading package data for { tl_install_name } : { e } " )
229+ return self ._install_tl_install (required_version )
230+
231+ def _compare_tl_install_versions (self , installed : str , required : str ) -> bool :
232+ """
233+ Compare installed and required version of tool-esp_install.
234+
235+ Args:
236+ installed: Currently installed version string
237+ required: Required version string from platform.json
238+
239+ Returns:
240+ bool: True if versions match, False otherwise
241+ """
242+ # For URL-based versions: Extract version string from URL
243+ installed_clean = self ._extract_version_from_url (installed )
244+ required_clean = self ._extract_version_from_url (required )
245+
246+ logger .debug (f"Version comparison: installed='{ installed_clean } ' vs required='{ required_clean } '" )
247+
248+ return installed_clean == required_clean
249+
250+ def _extract_version_from_url (self , version_string : str ) -> str :
251+ """
252+ Extract version information from URL or return version directly.
253+
254+ Args:
255+ version_string: Version string or URL containing version
256+
257+ Returns:
258+ str: Extracted version string
259+ """
260+ if version_string .startswith (('http://' , 'https://' )):
261+ # Extract version from URL like: .../v5.1.0/esp_install-v5.1.0.zip
262+ import re
263+ version_match = re .search (r'v(\d+\.\d+\.\d+)' , version_string )
264+ if version_match :
265+ return version_match .group (1 ) # Returns "5.1.0"
266+ else :
267+ # Fallback: Use entire URL
268+ return version_string
269+ else :
270+ # Direct version number
271+ return version_string .strip ()
272+
273+ def _install_tl_install (self , version : str ) -> bool :
274+ """
275+ Install tool-esp_install ONLY when necessary
276+ and handles backwards compability for tl-install.
277+
278+ Args:
279+ version: Version string or URL to install
280+
281+ Returns:
282+ bool: True if installation successful, False otherwise
283+ """
284+ tl_install_path = os .path .join (self .packages_dir , tl_install_name )
285+ old_tl_install_path = os .path .join (self .packages_dir , "tl-install" )
286+
287+ try :
288+ old_tl_install_exists = os .path .exists (old_tl_install_path )
289+ if old_tl_install_exists :
290+ # remove outdated tl-install
291+ safe_remove_directory (old_tl_install_path )
292+
293+ if os .path .exists (tl_install_path ):
294+ logger .info (f"Removing old { tl_install_name } installation" )
295+ safe_remove_directory (tl_install_path )
296+
297+ logger .info (f"Installing { tl_install_name } version { version } " )
298+ self .packages [tl_install_name ]["optional" ] = False
299+ self .packages [tl_install_name ]["version" ] = version
300+ pm .install (version )
301+ # Ensure backward compability by removing pio install status indicator
302+ tl_piopm_path = os .path .join (tl_install_path , ".piopm" )
303+ safe_remove_file (tl_piopm_path )
304+
305+ if os .path .exists (os .path .join (tl_install_path , "package.json" )):
306+ logger .info (f"{ tl_install_name } successfully installed and verified" )
307+ self .packages [tl_install_name ]["optional" ] = True
308+
309+ # Handle old tl-install to keep backwards compability
310+ if old_tl_install_exists :
311+ # Copy tool-esp_install content to tl-install location
312+ if safe_copy_directory (tl_install_path , old_tl_install_path ):
313+ logger .info (f"Content copied from { tl_install_name } to old tl-install location" )
314+ else :
315+ logger .warning ("Failed to copy content to old tl-install location" )
316+ return True
317+ else :
318+ logger .error (f"{ tl_install_name } installation failed - package.json not found" )
319+ return False
320+
321+ except Exception as e :
322+ logger .error (f"Error installing { tl_install_name } : { e } " )
323+ return False
324+
325+
162326 def _get_tool_paths (self , tool_name : str ) -> Dict [str , str ]:
163327 """Get centralized path calculation for tools with caching."""
164328 if tool_name not in self ._tools_cache :
@@ -182,7 +346,7 @@ def _get_tool_paths(self, tool_name: str) -> Dict[str, str]:
182346 'tools_json_path' : os .path .join (tool_path , "tools.json" ),
183347 'piopm_path' : os .path .join (tool_path , ".piopm" ),
184348 'idf_tools_path' : os .path .join (
185- self .packages_dir , "tl-install" , "tools" , "idf_tools.py"
349+ self .packages_dir , tl_install_name , "tools" , "idf_tools.py"
186350 )
187351 }
188352 return self ._tools_cache [tool_name ]
@@ -341,7 +505,7 @@ def _handle_existing_tool(
341505 return self .install_tool (tool_name , retry_count + 1 )
342506
343507 def _configure_arduino_framework (self , frameworks : List [str ]) -> None :
344- """Configure Arduino framework"""
508+ """Configure Arduino framework dependencies. """
345509 if "arduino" not in frameworks :
346510 return
347511
@@ -423,12 +587,28 @@ def _configure_mcu_toolchains(
423587 self .install_tool ("tool-openocd-esp32" )
424588
425589 def _configure_installer (self ) -> None :
426- """Configure the ESP-IDF tools installer."""
590+ """Configure the ESP-IDF tools installer with proper version checking."""
591+
592+ # Check version - installs only when needed
593+ if not self ._check_tl_install_version ():
594+ logger .error ("Error during tool-esp_install version check / installation" )
595+ return
596+
597+ # Remove pio install marker to avoid issues when switching versions
598+ old_tl_piopm_path = os .path .join (self .packages_dir , "tl-install" , ".piopm" )
599+ if os .path .exists (old_tl_piopm_path ):
600+ safe_remove_file (old_tl_piopm_path )
601+
602+ # Check if idf_tools.py is available
427603 installer_path = os .path .join (
428- self .packages_dir , "tl-install" , "tools" , "idf_tools.py"
604+ self .packages_dir , tl_install_name , "tools" , "idf_tools.py"
429605 )
606+
430607 if os .path .exists (installer_path ):
431- self .packages ["tl-install" ]["optional" ] = True
608+ logger .debug (f"{ tl_install_name } is available and ready" )
609+ self .packages [tl_install_name ]["optional" ] = True
610+ else :
611+ logger .warning (f"idf_tools.py not found in { installer_path } " )
432612
433613 def _install_esptool_package (self ) -> None :
434614 """Install esptool package required for all builds."""
@@ -463,7 +643,7 @@ def _ensure_mklittlefs_version(self) -> None:
463643 os .remove (piopm_path )
464644 logger .info (f"Incompatible mklittlefs version { version } removed (required: 3.x)" )
465645 except (json .JSONDecodeError , KeyError ) as e :
466- logger .error (f"Error reading mklittlefs package data: { e } " )
646+ logger .error (f"Error reading mklittlefs package { e } " )
467647
468648 def _setup_mklittlefs_for_download (self ) -> None :
469649 """Setup mklittlefs for download functionality with version 4.x."""
0 commit comments