my-kicad-user-folder/3rdparty/plugins/app_freerouting_kicad-plugin/plugin.py
2025-03-21 13:28:36 +08:00

599 lines
21 KiB
Python

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()