diff --git a/README.md b/README.md index df352c7..848a692 100644 --- a/README.md +++ b/README.md @@ -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 {KiCad python} 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 {working directory} should be your plugins directory, ie: + +|OS | Working dir| +|Mac| ~/Documents/KiCad/8.0/scripting/plugins/| +|Linux|TBD| +|Windows|TBD| + +#### Plugin folder name + +The {kicad-jlcpcb-tools folder name} 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 {kicad-jlcpcb-tools folder name} 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, {Kicad python} 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, {kicad-jlcpcb-tools folder name}. diff --git a/__init__.py b/__init__.py index 1bb9f3d..b572809 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,5 @@ """Init file for pligin.""" from .plugin import JLCPCBPlugin -JLCPCBPlugin().register() +if __name__ != "__main__": + JLCPCBPlugin().register() diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..f8bbed9 --- /dev/null +++ b/__main__.py @@ -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() diff --git a/fabrication.py b/fabrication.py index 2502d95..66c152d 100644 --- a/fabrication.py +++ b/fabrication.py @@ -23,7 +23,6 @@ F_Mask, F_Paste, F_SilkS, - GetBoard, Refresh, ToMM, ) @@ -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() diff --git a/mainwindow.py b/mainwindow.py index af8de81..f6a56a1 100644 --- a/mainwindow.py +++ b/mainwindow.py @@ -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 @@ -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__( @@ -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 @@ -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.""" @@ -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, "") @@ -699,7 +705,7 @@ def OnFootprintSelected(self, *_): ) # clear the present selections - selection = pcbnew.GetCurrentSelection() + selection = self.pcbnew.GetCurrentSelection() for selected in selection: selected.ClearSelected() @@ -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.""" @@ -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) @@ -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) @@ -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) diff --git a/standalone_impl.py b/standalone_impl.py new file mode 100644 index 0000000..56e1bbf --- /dev/null +++ b/standalone_impl.py @@ -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 diff --git a/store.py b/store.py index 7bbb68e..5a9d946 100644 --- a/store.py +++ b/store.py @@ -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, @@ -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" @@ -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(), @@ -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)})"