import glob import json import os import shutil import platform import sys import wx import wx.aui import time import pcbnew import textwrap import threading import subprocess import configparser import re import urllib.request import urllib.parse import tempfile from pathlib import Path freerouting_jre_temp_folder = os.path.join(tempfile.gettempdir(), "freerouting", "jre") def detect_os_architecture(): os_name = platform.system().lower() architecture = platform.machine().lower() # Windows 64-bit if (architecture == "amd64"): architecture = "x64" # Windows 32-bit if (architecture == "i386"): architecture = "x86" # Linux 64-bit if (architecture == "x86_64"): architecture = "x64" # macOS 64-bit if (os_name == "darwin"): os_name = "mac" return os_name, architecture def check_latest_jre_version(os_name, architecture): latest_jre_info_request = urllib.request.Request( # Docs at https://api.adoptium.net/q/swagger-ui/ f"https://api.adoptium.net/v3/assets/latest/21/hotspot?image_type=jre&os={os_name}&architecture={architecture}", headers={"User-Agent": ""} # The server rejects requests with the default UA ) jre_version_info = json.loads(urllib.request.urlopen(latest_jre_info_request).read())[0] latest_jre_version = jre_version_info["version"]["semver"] jre_url = jre_version_info['binary']["package"]["link"] return latest_jre_version, jre_url def get_local_java_executable_path(os_name): # Find the latest Java JRE 21 in the temp folder (that we installed earlier) java_exe_path = os.path.join(freerouting_jre_temp_folder, f"jdk-21.*.*+*-jre", "bin", "java") if os_name == "windows": java_exe_path += ".exe" java_found_exes = sorted( [p for p in filter(lambda p: os.path.isfile(p), glob.glob(java_exe_path)) if re.search(r"jdk-21\.(\d+)\.(\d+)(\.\d+)?\+(\d+)-jre", p)], reverse=True, key=lambda p: re.search(r"jdk-21\.(\d+)\.(\d+)(\.\d+)?\+(\d+)-jre", p).groups() if re.search(r"jdk-21\.(\d+)\.(\d+)(\.\d+)?\+(\d+)-jre", p) else () ) if len(java_found_exes) >= 1: java_exe_path = java_found_exes[0] print(f"You already have a downloaded JRE ({java_exe_path}), we are going to use that.") else: java_exe_path = "" # We don't have a downloaded JRE, so we need to check the Homebrew Java path on macOS and the $JAVA_HOME environment variable if java_exe_path == "": # The Homebrew Java path on macOS and use it if the Java version is 21 or higher if os_name == "mac": java_exe_path = "/opt/homebrew/opt/openjdk/bin/java" javaVersion = get_java_version(java_exe_path) javaMajorVersion = int(javaVersion.split(".")[0]) if (javaMajorVersion < 21): java_exe_path = "" # Check $JAVA_HOME environment variable and use it if it's set and the Java version is 21 or higher if java_exe_path == "": java_exe_path = os.path.join(os.environ.get("JAVA_HOME", ""), "bin", "java") javaVersion = get_java_version(java_exe_path) javaMajorVersion = int(javaVersion.split(".")[0]) if (javaMajorVersion < 21): java_exe_path = "" return java_exe_path # Remove java offending characters def search_n_strip(s): s = re.sub('[Ωµ]', '', s) return s # # Freerouting round trip invocation: # * export board.dsn file from pcbnew # * auto route by invoking freerouting.jar # * import generated board.ses file into pcbnew # class FreeroutingPlugin(pcbnew.ActionPlugin): # init in place of constructor def defaults(self): self.host = "KiCad" self.here_path, self.filename = os.path.split(os.path.abspath(__file__)) self.name = "Freerouting" self.category = "PCB auto routing" self.description = "Freerouting for PCB auto routing" self.show_toolbar_button = True self.icon_file_name = os.path.join(self.here_path, 'icon_24x24.png') # Controls KiCAD session file imports (works only in KiCAD nigthly or 6) self.SPECCTRA=True # setup execution context def update_module_command(self): # Run freerouting with input (-de) and output (-do) file definition self.module_command = [self.java_path, "-jar", self.module_path, "-de", self.module_input, "-do", self.module_output, "-host", self.host] # setup execution context def prepare(self): self.board = pcbnew.GetBoard() self.dirpath, self.board_name = os.path.split(self.board.GetFileName()) self.path_tuple = os.path.splitext(os.path.abspath(self.board.GetFileName())) self.board_prefix = self.path_tuple[0] config = configparser.ConfigParser() config_path = os.path.join(self.here_path, 'plugin.ini') config.read(config_path) os_name, architecture = detect_os_architecture() self.java_path = config['java']['path'] local_java_exe_path = get_local_java_executable_path(os_name) if (os.path.exists(local_java_exe_path)): self.java_path = local_java_exe_path self.module_file = config['artifact']['location'] self.module_path = os.path.join(self.here_path, self.module_file) # Convert dirpath to Path object self.dirpath = Path(self.dirpath) # Set temp filename using pathlib self.module_input = self.dirpath / 'freerouting.dsn' self.temp_input = self.dirpath / 'temp-freerouting.dsn' self.module_output = self.dirpath / 'freerouting.ses' self.module_rules = self.dirpath / 'freerouting.rules' # Remove previous temp files try: os.remove(self.temp_input) except: pass try: os.remove(self.module_output) except: pass try: os.remove(self.module_rules) except: pass # Create DSN file and remove java offending characters if not self.RunExport() : raise Exception("Failed to generate DSN file!") self.bFirstLine = True self.bEatNextLine = False fw = open(self.module_input, "w") fr = open(self.temp_input , "r", encoding="utf-8") for l in fr: if self.bFirstLine: fw.writelines('(pcb ' + self.module_input.name + '\n') self.bFirstLine = False elif self.bEatNextLine: self.bEatNextLine = l.rstrip()[-2:]!="))" print(l) print(self.bEatNextLine) # Optional: remove one or both copper-pours before run freerouting #elif l[:28] == " (plane GND (polygon F.Cu": # self.bEatNextLine = True #elif l[:28] == " (plane GND (polygon B.Cu": # self.bEatNextLine = True else: fw.writelines(search_n_strip(l)) fr.close() fw.close() self.update_module_command() # export board.dsn file from pcbnew def RunExport(self): if self.SPECCTRA: ok = pcbnew.ExportSpecctraDSN(self.temp_input) if ok and os.path.isfile(self.temp_input): return True else: wx_show_error(""" Failed to invoke: * pcbnew.ExportSpecctraDSN """) return False else: return True # auto route by invoking freerouting.jar def RunRouter(self): # Check if the freerouting temp folder exists, if not create it if not os.path.exists(freerouting_jre_temp_folder): os.makedirs(freerouting_jre_temp_folder) # Check if Java is installed and if it's version 21 or higher javaVersion = get_java_version(self.java_path) javaMajorVersion = int(javaVersion.split(".")[0]) javaInstallNow = wx.ID_NO if (javaMajorVersion == 0): # No Java installation found javaInstallationWarningMessage = """ Java JRE version 21 or higher is required, but no Java installation was found.{flatpakNote} Would you like to install it now? (This can take up to a few minutes.) """ flatpakNote = " If you believe that you have a working Java installation, double-check if you installed KiCad Flatpak. If you did that could be a reason why we can't access the Java runtime as plugins run in a very limited environment." # Replace the {flatpakNote} string with the flatpakNote string if the operating system is Linux os_name, architecture = detect_os_architecture() if (os_name == "linux"): javaInstallationWarningMessage = javaInstallationWarningMessage.format(flatpakNote=flatpakNote) else: javaInstallationWarningMessage = javaInstallationWarningMessage.format(flatpakNote="") # Ask the user if they want to install Java javaInstallNow = wx_show_warning(javaInstallationWarningMessage) # If the user doesn't want to install Java, return False if (javaInstallNow != wx.ID_YES): return False else: if (javaMajorVersion < 21): javaInstallNow = wx_show_warning(""" Java JRE version 21 or higher is required, but you have Java version {0} installed. Would you like to install a newer one now? (This can take up to a few minutes.) """.format(javaVersion)) if (javaInstallNow != wx.ID_YES): return False if (javaInstallNow == wx.ID_YES): # If the user wants to install Java, clean up the previous JRE installations in the temp folder first for file in os.listdir(freerouting_jre_temp_folder): if file.startswith("jdk-") and file.endswith("-jre"): file_path = os.path.join(freerouting_jre_temp_folder, file) if os.path.isdir(file_path): shutil.rmtree(file_path) else: os.remove(file_path) # Install Java JRE 21 self.java_path = install_java_jre_21() javaVersion = get_java_version(self.java_path) javaMajorVersion = int(javaVersion.split(".")[0]) if javaMajorVersion < 21: wx_show_error(""" Java JRE installation failed, so we can't run Freerouting at the moment. You can download the latest Java JRE from https://adoptium.net/temurin/releases and install it manually. KiCad must be restarted after the installation. """) return False dialog = ProcessDialog(None, """ Complete or Terminate Freerouting: * to complete, close Java window * to terminate, press Terminate here """) def on_complete(): wx_safe_invoke(dialog.terminate) self.update_module_command() invoker = ProcessThread(self.module_command, on_complete) dialog.Show() # dialog first invoker.start() # run java process result = dialog.ShowModal() # block pcbnew here dialog.Destroy() try: if result == dialog.result_button: # return via terminate button invoker.terminate() return False elif result == dialog.result_terminate: # return via dialog.terminate() if invoker.has_ok(): return True else: invoker.show_error() return False else: return False # should not happen finally: invoker.join(10) # prevent thread resource leak # import generated board.ses file into pcbnew def RunImport(self): if self.SPECCTRA: ok = pcbnew.ImportSpecctraSES(self.module_output) if ok and os.path.isfile(self.module_output): os.remove(self.module_input) os.remove(self.module_output) return True else: wx_show_error(""" Failed to invoke: * pcbnew.ImportSpecctraSES """) return False else: return True # invoke chain of dependent methods def RunSteps(self): self.prepare() if not self.RunRouter() : return # Remove temp DSN file os.remove(self.temp_input) wx_safe_invoke(self.RunImport) # kicad plugin action entry def Run(self): if self.SPECCTRA: if has_pcbnew_api(): self.RunSteps() else: wx_show_error(""" Missing required python API: * pcbnew.ExportSpecctraDSN * pcbnew.ImportSpecctraSES --- Try development nightly build: * http://kicad-pcb.org/download/ """) else: self.RunSteps() # provision gui-thread-safe execution context # https://git.launchpad.net/kicad/tree/pcbnew/python/kicad_pyshell/__init__.py#n89 if 'phoenix' in wx.PlatformInfo: if not wx.GetApp(): theApp = wx.App() else: theApp = wx.GetApp() # run functon inside gui-thread-safe context, requires wx.App on phoenix def wx_safe_invoke(function, *args, **kwargs): wx.CallAfter(function, *args, **kwargs) # verify required pcbnew api is present def has_pcbnew_api(): return hasattr(pcbnew, 'ExportSpecctraDSN') and hasattr(pcbnew, 'ImportSpecctraSES') # message dialog style wx_caption = "KiCad Freerouting Plugin" # display warning text with a question to the user def wx_show_warning(text): message = textwrap.dedent(text) style = wx.YES_NO | wx.ICON_WARNING dialog = wx.MessageDialog(None, message=message, caption=wx_caption, style=style) return dialog.ShowModal() # display error text to the user def wx_show_error(text): message = textwrap.dedent(text) style = wx.OK | wx.ICON_ERROR dialog = wx.MessageDialog(None, message=message, caption=wx_caption, style=style) dialog.ShowModal() dialog.Destroy() # check the installed java version def get_java_version(javaPath): try: javaInfo = subprocess.check_output(javaPath + ' -version', shell=True, stderr=subprocess.STDOUT) javaVersions = [re.search(r'([0-9\._]+)', v).group(1).replace('"', '') for v in javaInfo.decode().splitlines()] for v in javaVersions: if v.split(".")[0].isdigit(): return v except: pass return "0.0.0.0" def download_progress_hook(count, block_size, total_size): percent = count * block_size * 100 // total_size #sys.stdout.write(f"\rDownloading: {percent}%") #sys.stdout.flush() def download_with_progress_bar(url): # Return temp filename return urllib.request.urlretrieve(url, reporthook=download_progress_hook)[0] def install_java_jre_21(): # Get platform information and the appropriate URL os_name, architecture = detect_os_architecture() print(f"Operating System: {os_name}") print(f"Architecture: {architecture}") local_java_exe = get_local_java_executable_path(os_name) try: jre_version, jre_url = check_latest_jre_version(os_name, architecture) except Exception: print("Couldn't connect to the server") # Find all matching JRE 21 jre_version = "21.*.*+*" jre_url = None return local_java_exe java_exe_path = os.path.join(freerouting_jre_temp_folder, f"jdk-{jre_version}-jre", "bin", "java") if os_name == "windows": java_exe_path += ".exe" if (local_java_exe >= java_exe_path): print(f"You already have the latest Java JRE ({jre_version})") return java_exe_path if jre_url is None: raise FileNotFoundError("Couldn't find a downloaded JRE") # Double-check if the temp folder exists if not os.path.exists(freerouting_jre_temp_folder): os.makedirs(freerouting_jre_temp_folder) # Download the Java JRE print("Downloading Java JRE from " + jre_url) file_name = download_with_progress_bar(jre_url) print() # Unzip the downloaded file print("Extracting the downloaded file...") unzip_command = f"tar -xf {file_name} -C {freerouting_jre_temp_folder}" os.system(unzip_command) # Remove the downloaded zip file os.remove(file_name) java_exe_path = get_local_java_executable_path(os_name) # Verify the installation #java_version_command = f"{java_exe_path} -version" #result = subprocess.check_output(java_version_command, shell=True, stderr=subprocess.STDOUT) #print("Installed Java version:", result) return java_exe_path # prompt user to cancel pending action; allow to cancel programmatically class ProcessDialog (wx.Dialog): def __init__(self, parent, text): message = textwrap.dedent(text) self.result_button = wx.NewId() self.result_terminate = wx.NewId() wx.Dialog.__init__ (self, parent, id=wx.ID_ANY, title=wx_caption, pos=wx.DefaultPosition, size=wx.Size(-1, -1), style=wx.CAPTION) self.SetSizeHints(wx.DefaultSize, wx.DefaultSize) sizer = wx.BoxSizer(wx.VERTICAL) self.text = wx.StaticText(self, wx.ID_ANY, message, wx.DefaultPosition, wx.DefaultSize, 0) self.text.Wrap(-1) sizer.Add(self.text, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.ALL, 10) self.line = wx.StaticLine(self, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.LI_HORIZONTAL) sizer.Add(self.line, 0, wx.EXPAND | wx.ALL, 5) self.bttn = wx.Button(self, wx.ID_ANY, "Terminate", wx.DefaultPosition, wx.DefaultSize, 0) self.bttn.SetDefault() sizer.Add(self.bttn, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.ALL, 5) self.SetSizer(sizer) self.Layout() sizer.Fit(self) self.Centre(wx.BOTH) self.bttn.Bind(wx.EVT_BUTTON, self.bttn_on_click) def __del__(self): pass def bttn_on_click(self, event): self.EndModal(self.result_button) def terminate(self): self.EndModal(self.result_terminate) # cancelable external process invoker with completion notification class ProcessThread(threading.Thread): def __init__(self, command, on_complete=None): self.command = command self.on_complete = on_complete threading.Thread.__init__(self) self.setDaemon(True) # thread runner def run(self): try: self.process = subprocess.Popen(self.command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) self.stdout, self.stderr = self.process.communicate() except Exception as error: self.error = error finally: if self.on_complete is not None: self.on_complete() def has_ok(self): return self.has_process() and self.process.returncode == 0 def has_code(self): return self.has_process() and self.process.returncode != 0 def has_error(self): return hasattr(self, "error") def has_process(self): return hasattr(self, "process") def terminate(self): if self.has_process(): self.process.kill() else: pass def show_error(self): command = " ".join(self.command) if self.has_error() : wx_show_error(""" Process failure: --- command: %s --- error: %s""" % (command, str(self.error))) elif self.has_code(): wx_show_error(""" Program failure: --- command: %s --- exit code: %d --- stdout --- %s --- stderr --- %s """ % (command, self.process.returncode, self.stdout, self.stderr)) else: pass # register plugin with kicad backend FreeroutingPlugin().register()