|
3 | 3 | import wx.aui |
4 | 4 | import time |
5 | 5 | import pcbnew |
| 6 | +import textwrap |
6 | 7 | import threading |
7 | 8 | import subprocess |
8 | 9 | import configparser |
9 | 10 |
|
| 11 | + |
10 | 12 | # |
11 | 13 | # FreeRouting round trip invocation: |
12 | 14 | # * export board.dsn file from pcbnew |
@@ -36,46 +38,244 @@ def prepare(self): |
36 | 38 | config.read(config_path) |
37 | 39 |
|
38 | 40 | self.java_path = config['java']['path'] |
39 | | - self.module_timeout = float(config['module']['timeout']) |
40 | 41 |
|
41 | 42 | self.module_file = config['artifact']['location'] |
42 | 43 | self.module_path = os.path.join(self.here_path, self.module_file) |
43 | 44 |
|
44 | | - self.module_input = self.board_prefix + '.dsn' |
45 | | - self.module_output = self.board_prefix + '.ses' |
| 45 | + self.module_input = self.board_prefix + '.' + config['module']['input_ext'] |
| 46 | + self.module_output = self.board_prefix + '.' + config['module']['output_ext'] |
| 47 | + |
| 48 | + self.module_command = [self.java_path, "-jar", self.module_path, "-de", self.module_input, "-s"] |
46 | 49 |
|
47 | 50 | if os.path.isfile(self.module_input): |
48 | 51 | os.remove(self.module_input) |
49 | 52 | if os.path.isfile(self.module_output): |
50 | 53 | os.remove(self.module_output) |
| 54 | + |
| 55 | + # export board.dsn file from pcbnew |
| 56 | + def RunExport(self): |
| 57 | + ok = pcbnew.ExportSpecctraDSN(self.module_input) |
| 58 | + if ok and os.path.isfile(self.module_input): |
| 59 | + return True |
| 60 | + else: |
| 61 | + wx_show_error(""" |
| 62 | + Failed to invoke: |
| 63 | + * pcbnew.ExportSpecctraDSN |
| 64 | + """) |
| 65 | + return False |
| 66 | + |
| 67 | + # auto route by invoking FreeRouting.jar |
| 68 | + def RunRouter(self): |
| 69 | + |
| 70 | + dialog = ProcessDialog(None, """ |
| 71 | + Complete or Terminate FreeRouting: |
| 72 | + * to complete, close Java window |
| 73 | + * to terminate, press Terminate here |
| 74 | + """) |
51 | 75 |
|
52 | | - # run inside gui-thread-safe context |
53 | | - def invoke(self, runner): |
54 | | - wx.CallAfter(runner) |
| 76 | + def on_complete(): |
| 77 | + wx_safe_invoke(dialog.terminate) |
55 | 78 |
|
56 | | - def RunExport(self): |
57 | | - pcbnew.ExportSpecctraDSN(self.module_input) |
| 79 | + invoker = ProcessThread(self.module_command, on_complete) |
58 | 80 |
|
59 | | - def RunRouter(self): |
60 | | - command = [self.java_path, "-jar", self.module_path, "-de", self.module_input, "-s"] |
61 | | - process = subprocess.Popen(command) |
62 | | - process.wait(self.module_timeout) |
| 81 | + dialog.Show() # dialog first |
| 82 | + invoker.start() # run java process |
| 83 | + result = dialog.ShowModal() # block pcbnew here |
| 84 | + dialog.Destroy() |
| 85 | + |
| 86 | + try: |
| 87 | + if result == dialog.result_button: # return via terminate button |
| 88 | + invoker.terminate() |
| 89 | + return False |
| 90 | + elif result == dialog.result_terminate: # return via dialog.terminate() |
| 91 | + if invoker.has_ok(): |
| 92 | + return True |
| 93 | + else: |
| 94 | + invoker.show_error() |
| 95 | + return False |
| 96 | + else: |
| 97 | + return False # should not happen |
| 98 | + finally: |
| 99 | + invoker.join(10) # prevent thread resource leak |
63 | 100 |
|
| 101 | + # import generated board.ses file into pcbnew |
64 | 102 | def RunImport(self): |
65 | | - pcbnew.ImportSpecctraSES(self.module_output) |
| 103 | + ok = pcbnew.ImportSpecctraSES(self.module_output) |
| 104 | + if ok and os.path.isfile(self.module_output): |
| 105 | + return True |
| 106 | + else: |
| 107 | + wx_show_error(""" |
| 108 | + Failed to invoke: |
| 109 | + * pcbnew.ImportSpecctraSES |
| 110 | + """) |
| 111 | + return False |
| 112 | + |
| 113 | + # invoke chain of dependent methods |
| 114 | + def RunSteps(self): |
| 115 | + self.prepare() |
| 116 | + if not self.RunExport() : |
| 117 | + return |
| 118 | + if not self.RunRouter() : |
| 119 | + return |
| 120 | + wx_safe_invoke(self.RunImport) |
66 | 121 |
|
| 122 | + # kicad plugin action entry |
67 | 123 | def Run(self): |
68 | | - self.prepare() |
69 | | - self.invoke(self.RunExport) |
70 | | - self.invoke(self.RunRouter) |
71 | | - self.invoke(self.RunImport) |
| 124 | + if has_pcbnew_api(): |
| 125 | + self.RunSteps() |
| 126 | + else: |
| 127 | + wx_show_error(""" |
| 128 | + Missing required python API: |
| 129 | + * pcbnew.ExportSpecctraDSN |
| 130 | + * pcbnew.ImportSpecctraSES |
| 131 | + --- |
| 132 | + Try development nightly build: |
| 133 | + * http://kicad-pcb.org/download/ |
| 134 | + """) |
72 | 135 |
|
73 | 136 |
|
74 | 137 | # provision gui-thread-safe execution context |
75 | | -if not wx.GetApp(): |
76 | | - theApp = wx.App() |
77 | | -else: |
78 | | - theApp = wx.GetApp() |
| 138 | +# https://git.launchpad.net/kicad/tree/pcbnew/python/kicad_pyshell/__init__.py#n89 |
| 139 | +if 'phoenix' in wx.PlatformInfo: |
| 140 | + if not wx.GetApp(): |
| 141 | + theApp = wx.App() |
| 142 | + else: |
| 143 | + theApp = wx.GetApp() |
| 144 | + |
| 145 | + |
| 146 | +# run functon inside gui-thread-safe context, requires wx.App on phoenix |
| 147 | +def wx_safe_invoke(function, *args, **kwargs): |
| 148 | + wx.CallAfter(function, *args, **kwargs) |
| 149 | + |
| 150 | + |
| 151 | +# verify required pcbnew api is present |
| 152 | +def has_pcbnew_api(): |
| 153 | + return hasattr(pcbnew, 'ExportSpecctraDSN') and hasattr(pcbnew, 'ImportSpecctraSES') |
| 154 | + |
| 155 | + |
| 156 | +# message dialog style |
| 157 | +wx_caption = "KiCad FreeRouting Plugin" |
| 158 | + |
| 159 | + |
| 160 | +# display error text to the user |
| 161 | +def wx_show_error(text): |
| 162 | + message = textwrap.dedent(text) |
| 163 | + style = wx.OK | wx.ICON_ERROR |
| 164 | + dialog = wx.MessageDialog(None, message=message, caption=wx_caption, style=style) |
| 165 | + dialog.ShowModal() |
| 166 | + dialog.Destroy() |
| 167 | + |
| 168 | + |
| 169 | +# prompt user to cancel pending action; allow to cancel programmatically |
| 170 | +class ProcessDialog (wx.Dialog): |
| 171 | + |
| 172 | + def __init__(self, parent, text): |
| 173 | + |
| 174 | + message = textwrap.dedent(text) |
| 175 | + |
| 176 | + self.result_button = wx.NewId() |
| 177 | + self.result_terminate = wx.NewId() |
| 178 | + |
| 179 | + wx.Dialog.__init__ (self, parent, id=wx.ID_ANY, title=wx_caption, pos=wx.DefaultPosition, size=wx.Size(-1, -1), style=wx.CAPTION) |
| 180 | + |
| 181 | + self.SetSizeHints(wx.DefaultSize, wx.DefaultSize) |
| 182 | + |
| 183 | + sizer = wx.BoxSizer(wx.VERTICAL) |
| 184 | + |
| 185 | + self.text = wx.StaticText(self, wx.ID_ANY, message, wx.DefaultPosition, wx.DefaultSize, 0) |
| 186 | + self.text.Wrap(-1) |
| 187 | + sizer.Add(self.text, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.ALL, 10) |
| 188 | + |
| 189 | + self.line = wx.StaticLine(self, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.LI_HORIZONTAL) |
| 190 | + sizer.Add(self.line, 0, wx.EXPAND | wx.ALL, 5) |
| 191 | + |
| 192 | + self.bttn = wx.Button(self, wx.ID_ANY, "Terminate", wx.DefaultPosition, wx.DefaultSize, 0) |
| 193 | + self.bttn.SetDefault() |
| 194 | + sizer.Add(self.bttn, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.ALL, 5) |
| 195 | + |
| 196 | + self.SetSizer(sizer) |
| 197 | + self.Layout() |
| 198 | + sizer.Fit(self) |
| 199 | + |
| 200 | + self.Centre(wx.BOTH) |
| 201 | + |
| 202 | + self.bttn.Bind(wx.EVT_BUTTON, self.bttn_on_click) |
| 203 | + |
| 204 | + def __del__(self): |
| 205 | + pass |
| 206 | + |
| 207 | + def bttn_on_click(self, event): |
| 208 | + self.EndModal(self.result_button) |
| 209 | + |
| 210 | + def terminate(self): |
| 211 | + self.EndModal(self.result_terminate) |
| 212 | + |
| 213 | + |
| 214 | +# cancelable external process invoker with completion notification |
| 215 | +class ProcessThread(threading.Thread): |
| 216 | + |
| 217 | + def __init__(self, command, on_complete=None): |
| 218 | + self.command = command |
| 219 | + self.on_complete = on_complete |
| 220 | + threading.Thread.__init__(self) |
| 221 | + self.setDaemon(True) |
| 222 | + |
| 223 | + # thread runner |
| 224 | + def run(self): |
| 225 | + try: |
| 226 | + self.process = subprocess.Popen(self.command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| 227 | + self.stdout, self.stderr = self.process.communicate() |
| 228 | + except Exception as error: |
| 229 | + self.error = error |
| 230 | + finally: |
| 231 | + if self.on_complete is not None: |
| 232 | + self.on_complete() |
| 233 | + |
| 234 | + def has_ok(self): |
| 235 | + return self.has_process() and self.process.returncode == 0 |
| 236 | + |
| 237 | + def has_code(self): |
| 238 | + return self.has_process() and self.process.returncode != 0 |
| 239 | + |
| 240 | + def has_error(self): |
| 241 | + return hasattr(self, "error") |
| 242 | + |
| 243 | + def has_process(self): |
| 244 | + return hasattr(self, "process") |
| 245 | + |
| 246 | + def terminate(self): |
| 247 | + if self.has_process(): |
| 248 | + self.process.kill() |
| 249 | + else: |
| 250 | + pass |
| 251 | + |
| 252 | + def show_error(self): |
| 253 | + command = " ".join(self.command) |
| 254 | + if self.has_error() : |
| 255 | + wx_show_error(""" |
| 256 | + Process failure: |
| 257 | + --- |
| 258 | + command: |
| 259 | + %s |
| 260 | + --- |
| 261 | + error: |
| 262 | + %s""" % (command, str(self.error))) |
| 263 | + elif self.has_code(): |
| 264 | + wx_show_error(""" |
| 265 | + Program failure: |
| 266 | + --- |
| 267 | + command: |
| 268 | + %s |
| 269 | + --- |
| 270 | + exit code: %d |
| 271 | + --- stdout --- |
| 272 | + %s |
| 273 | + --- stderr --- |
| 274 | + %s |
| 275 | + """ % (command, self.process.returncode, self.stdout, self.stderr)) |
| 276 | + else: |
| 277 | + pass |
| 278 | + |
79 | 279 |
|
80 | 280 | # register plugin with kicad backend |
81 | 281 | FreeRoutingPlugin().register() |
0 commit comments