Skip to content

Commit

Permalink
Standalone mode - Run the plugin outside of KiCad
Browse files Browse the repository at this point in the history
Enables debugging of the plugin UI with PyCharm/vscode

- see __main__.py for instructions on how to launch
- Wrap KiCad calls with stubs

Co-authored-by: Chris Morgan <cmorgan@gmail.com>
  • Loading branch information
2 people authored and chmorgan committed Apr 3, 2024
1 parent a441c61 commit 8a73340
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 26 deletions.
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,74 @@ This plugin makes use of a lot of icons from the excellent [Material Design Icon

Make sure you make use of pre-commit hooks in order to format everything nicely with `black`
In the near future I'll add `ruff` / `pylint` and possibly other pre-commit-hooks that enforce nice and clean code style.

## Standalone mode

Allows the plugin UI to be started without KiCAD, enabling debugging with an IDE like pycharm / vscode.

Standalone mode is under development.

### Limitations

- All board / footprint / value data are hardcoded stubs, see standalone_impl.py

### How to use

To use the plugin in standlone mode you'll need to identify three pieces of information specific to your Kicad version, plugin path, and OS.

#### Python

The <i><b>{KiCad python}</b></i> should be used, this can be found at different locations depending on your system:

| OS | Kicad python |
|---|---|
|Mac| /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3|
|Linux| TBD|
|Windows | TBD|

#### Working directory

The <i><b>{working directory}</b></i> should be your plugins directory, ie:

|OS | Working dir|
|Mac| ~/Documents/KiCad/8.0/scripting/plugins/|
|Linux|TBD|
|Windows|TBD|

#### Plugin folder name

The <i><b>{kicad-jlcpcb-tools folder name}</b></i> should be the name of the kicad-jlcpcb-tools folder.

- For Kicad managed plugins this may be like

> com_github_bouni_kicad-jlcpcb-tools
- If you are developing kicad-jlcpcb-tools this is the folder you cloned the kicad-jlcpcb-tools as.

#### Command line

- Change to the working directory as noted above
- Run the python interpreter with the <i><b>{kicad-jlcpcb-tools folder name}</b></i> folder as a module.

For example:

```sh
cd {working directory}
{kicad_python} -m {kicad-jlcpcb-tools folder name}
```

For example on Mac:

```sh
/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3 -m kicad-jlcpcb-tools
```

#### IDE

- Configure the command line to be '{kicad_python} -m {kicad-jlcpcb-tools folder name}'
- Set the working directory to {working directory}

If using PyCharm or Jetbrains IDEs, set the interpreter to Kicad's python, <i><b>{Kicad python}</b></i> and under 'run configuration' select Python.

Click on 'script path' and change instead to 'module name',
entering the name of the kicad-jlcpcb-tools folder, <i><b>{kicad-jlcpcb-tools folder name}</b></i>.
3 changes: 2 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Init file for pligin."""
from .plugin import JLCPCBPlugin

JLCPCBPlugin().register()
if __name__ != "__main__":
JLCPCBPlugin().register()
19 changes: 19 additions & 0 deletions __main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Entry point for running the plugin in standalone mode."""

import wx

from . import standalone_impl
from .mainwindow import JLCPCBTools

if __name__ == "__main__":
print("starting jlcpcbtools standalone mode...") # noqa: T201

# See README.md for how to use this

app = wx.App(None)

dialog = JLCPCBTools(None, kicad_provider=standalone_impl.KicadStub())
dialog.Center()
dialog.Show()

app.MainLoop()
5 changes: 2 additions & 3 deletions fabrication.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
F_Mask,
F_Paste,
F_SilkS,
GetBoard,
Refresh,
ToMM,
)
Expand All @@ -42,10 +41,10 @@
class Fabrication:
"""Contains all functionality to generate the JLCPCB production files."""

def __init__(self, parent):
def __init__(self, parent, board):
self.parent = parent
self.logger = logging.getLogger(__name__)
self.board = GetBoard()
self.board = board
self.corrections = []
self.path, self.filename = os.path.split(self.board.GetFileName())
self.create_folders()
Expand Down
38 changes: 22 additions & 16 deletions mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import sys
import time

import pcbnew
import pcbnew as kicad_pcbnew
import wx # pylint: disable=import-error
from wx import adv # pylint: disable=import-error
import wx.dataview # pylint: disable=import-error
Expand Down Expand Up @@ -65,11 +65,17 @@
ID_CONTEXT_MENU_ADD_ROT_BY_PACKAGE = wx.NewIdRef()
ID_CONTEXT_MENU_ADD_ROT_BY_NAME = wx.NewIdRef()

class KicadProvider:
"""KiCad implementation of the provider, see standalone_impl.py for the stub version."""

def get_pcbnew(self):
"""Get the pcbnew instance."""
return kicad_pcbnew

class JLCPCBTools(wx.Dialog):
"""Main Windows class for this plugin."""
"""JLCPCBTools main dialog."""

def __init__(self, parent):
def __init__(self, parent, kicad_provider=KicadProvider()):
while not wx.GetApp():
time.sleep(1)
wx.Dialog.__init__(
Expand All @@ -81,12 +87,13 @@ def __init__(self, parent):
size=wx.Size(1300, 800),
style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.MAXIMIZE_BOX,
)
self.KicadBuildVersion = pcbnew.GetBuildVersion()
self.pcbnew = kicad_provider.get_pcbnew()
self.KicadBuildVersion = self.pcbnew.GetBuildVersion()
self.window = wx.GetTopLevelParent(self)
self.SetSize(HighResWxSize(self.window, wx.Size(1300, 800)))
self.scale_factor = GetScaleFactor(self.window)
self.project_path = os.path.split(pcbnew.GetBoard().GetFileName())[0]
self.board_name = os.path.split(pcbnew.GetBoard().GetFileName())[1]
self.project_path = os.path.split(self.pcbnew.GetBoard().GetFileName())[0]
self.board_name = os.path.split(self.pcbnew.GetBoard().GetFileName())[1]
self.schematic_name = f"{self.board_name.split('.')[0]}.kicad_sch"
self.hide_bom_parts = False
self.hide_pos_parts = False
Expand Down Expand Up @@ -517,13 +524,13 @@ def init_library(self):

def init_store(self):
"""Initialize the store of part assignments."""
self.store = Store(self, self.project_path)
self.store = Store(self, self.project_path, self.pcbnew.GetBoard())
if self.library.state == LibraryState.INITIALIZED:
self.populate_footprint_list()

def init_fabrication(self):
"""Initialize the fabrication."""
self.fabrication = Fabrication(self)
self.fabrication = Fabrication(self, self.pcbnew.GetBoard())

def reset_gauge(self, *_):
"""Initialize the gauge."""
Expand Down Expand Up @@ -574,8 +581,7 @@ def populate_footprint_list(self, *_):
numbers = []
parts = []
for part in self.store.read_all():
board = pcbnew.GetBoard()
fp = board.FindFootprintByReference(part[0])
fp = self.pcbnew.GetBoard().FindFootprintByReference(part[0])
if part[3] and part[3] not in numbers:
numbers.append(part[3])
part.insert(4, "")
Expand Down Expand Up @@ -699,7 +705,7 @@ def OnFootprintSelected(self, *_):
)

# clear the present selections
selection = pcbnew.GetCurrentSelection()
selection = self.pcbnew.GetCurrentSelection()
for selected in selection:
selected.ClearSelected()

Expand All @@ -708,12 +714,12 @@ def OnFootprintSelected(self, *_):
for item in self.footprint_list.GetSelections():
row = self.footprint_list.ItemToRow(item)
ref = self.footprint_list.GetTextValue(row, 0)
fp = pcbnew.GetBoard().FindFootprintByReference(ref)
fp = self.pcbnew.GetBoard().FindFootprintByReference(ref)

fp.SetSelected()

# cause pcbnew to refresh the board with the changes to the selected footprint(s)
pcbnew.Refresh()
self.pcbnew.Refresh()

def enable_all_buttons(self, state):
"""Control state of all the buttons."""
Expand Down Expand Up @@ -747,7 +753,7 @@ def toggle_bom_pos(self, *_):
row = self.footprint_list.ItemToRow(item)
selected_rows.append(row)
ref = self.footprint_list.GetTextValue(row, 0)
board = pcbnew.GetBoard()
board = self.pcbnew.GetBoard()
fp = board.FindFootprintByReference(ref)
bom = toggle_exclude_from_bom(fp)
pos = toggle_exclude_from_pos(fp)
Expand All @@ -767,7 +773,7 @@ def toggle_bom(self, *_):
row = self.footprint_list.ItemToRow(item)
selected_rows.append(row)
ref = self.footprint_list.GetTextValue(row, 0)
board = pcbnew.GetBoard()
board = self.pcbnew.GetBoard()
fp = board.FindFootprintByReference(ref)
bom = toggle_exclude_from_bom(fp)
self.store.set_bom(ref, bom)
Expand All @@ -782,7 +788,7 @@ def toggle_pos(self, *_):
row = self.footprint_list.ItemToRow(item)
selected_rows.append(row)
ref = self.footprint_list.GetTextValue(row, 0)
board = pcbnew.GetBoard()
board = self.pcbnew.GetBoard()
fp = board.FindFootprintByReference(ref)
pos = toggle_exclude_from_pos(fp)
self.store.set_pos(ref, pos)
Expand Down
99 changes: 99 additions & 0 deletions standalone_impl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Stubs for standalone usage of the plugin."""

class LIB_ID_Stub:
"""Implementation of pcbnew.LIB_ID."""

def __init__(self, item_name):
self.item_name = item_name

def GetLibItemName(self) -> str:
"""Item name."""
return self.item_name


class Footprint_Stub:
"""Implementation of pcbnew.Footprint."""

def __init__(self, reference, value, fpid):
self.reference = reference
self.value = value
self.fpid = fpid

def GetReference(self) -> str:
"""Retrieve the reference designator string."""
return self.reference

def GetValue(self) -> str:
"""Value string."""
return self.value

def GetFPID(self) -> LIB_ID_Stub:
"""Footprint LIB_ID."""
return self.fpid

def GetProperties(self) -> dict:
"""Properties."""
return {}

def GetAttributes(self) -> int:
"""Attributes."""
return 0

def GetLayer(self) -> int:
"""Layer number."""
# TODO: maybe this is defined in a python module we can import and reuse here?
return 3 # F_Cu, see https://docs.kicad.org/doxygen/layer__ids_8h.html#ae0ad6e574332a997f501d1b091c3f53f

def SetSelected(self):
"""Select this item."""


class BoardStub:
"""Implementation of pcbnew.Board."""

def __init__(self):
self.footprints = []
self.footprints.append(Footprint_Stub("R1", "100", LIB_ID_Stub("resistors")))

def GetFileName(self):
"""Board filename."""
return "fake_test_board.kicad_pcb"

def GetFootprints(self):
"""Footprint list."""
return self.footprints

def FindFootprintByReference(self, reference):
"""Get a list of footprints that match a reference."""
return Footprint_Stub(reference, "stub", 100)

class PcbnewStub:
"""Stub implementation of pcbnew."""

def __init__(self):
self.board = BoardStub()

def GetBoard(self):
"""Get the board."""
return self.board

def GetBuildVersion(self):
"""Get the kicad build version."""
return "8.0.1"

def GetCurrentSelection(self):
"""Get the currently selected board items."""
return []

def Refresh(self):
"""Redraw the screen."""

class KicadStub:
"""Stub implementation of Kicad."""

def __init__(self):
self.pcbnew = PcbnewStub()

def get_pcbnew(self):
"""Get the pcbnew stub."""
return self.pcbnew
10 changes: 4 additions & 6 deletions store.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
from pathlib import Path
import sqlite3

from pcbnew import GetBoard # pylint: disable=import-error

from .helpers import (
get_exclude_from_bom,
get_exclude_from_pos,
Expand All @@ -21,10 +19,11 @@
class Store:
"""A storage class to get data from a sqlite database and write it back."""

def __init__(self, parent, project_path):
def __init__(self, parent, project_path, board):
self.logger = logging.getLogger(__name__)
self.parent = parent
self.project_path = project_path
self.board = board
self.datadir = os.path.join(self.project_path, "jlcpcb")
self.dbfile = os.path.join(self.datadir, "project.db")
self.order_by = "reference"
Expand Down Expand Up @@ -184,8 +183,7 @@ def set_lcsc(self, ref, value):

def update_from_board(self):
"""Read all footprints from the board and insert them into the database if they do not exist."""
board = GetBoard()
for fp in get_valid_footprints(board):
for fp in get_valid_footprints(self.board):
part = [
fp.GetReference(),
fp.GetValue(),
Expand Down Expand Up @@ -241,7 +239,7 @@ def update_from_board(self):

def clean_database(self):
"""Delete all parts from the database that are no longer present on the board."""
refs = [f"'{fp.GetReference()}'" for fp in get_valid_footprints(GetBoard())]
refs = [f"'{fp.GetReference()}'" for fp in get_valid_footprints(self.board)]
with contextlib.closing(sqlite3.connect(self.dbfile)) as con, con as cur:
cur.execute(
f"DELETE FROM part_info WHERE reference NOT IN ({','.join(refs)})"
Expand Down

0 comments on commit 8a73340

Please sign in to comment.