diff --git a/.gitignore b/.gitignore index c0d9831..ff2a2b9 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,13 @@ target/ # Record of pcbnew module location kigadgets/.path_to_pcbnew_module + +# Virtual environments +venv/ +.venv/ + +# OSx things +.DS_Store + +# IDEs +.vscode/ diff --git a/greeting-server.py b/greeting-server.py new file mode 100644 index 0000000..f5223c7 --- /dev/null +++ b/greeting-server.py @@ -0,0 +1,9 @@ +# saved as greeting-server.py +import Pyro5.api +import pathlib + +daemon = Pyro5.api.Daemon() # make a Pyro daemon +uri = daemon.register(Pyro5.api.expose(pathlib.Path)) # register the greeting maker as a Pyro object + +print("Ready. Object uri =", uri) # print the uri so we can use it in the client later +daemon.requestLoop() # start the event loop of the server to wait for calls diff --git a/kigadgets/__init__.py b/kigadgets/__init__.py index 1563a0b..639a6f1 100644 --- a/kigadgets/__init__.py +++ b/kigadgets/__init__.py @@ -8,6 +8,12 @@ from kigadgets.environment import get_pcbnew_module from kigadgets.util import notify, query_user, kireload from kigadgets.exceptions import put_import_warning_on_kicad +import logging +from pathlib import Path + +logging.basicConfig(level=logging.DEBUG, handlers=[logging.FileHandler(Path("~/kigadgets.log").expanduser())]) + +log = logging.getLogger(__name__) # Find SWIG pcbnew try: diff --git a/kigadgets/board.py b/kigadgets/board.py index cf8ed77..a61f292 100644 --- a/kigadgets/board.py +++ b/kigadgets/board.py @@ -1,15 +1,27 @@ -from kigadgets import pcbnew_bare as pcbnew -from kigadgets import units, SWIGtype, instanceof +import logging import tempfile +from typing import Iterable, Optional, Union + +from Pyro5.api import expose -from kigadgets.drawing import wrap_drawing, Segment, Circle, Arc, TextPCB +from kigadgets import SWIGtype, instanceof +from kigadgets import pcbnew_bare as pcbnew +from kigadgets import units +from kigadgets.drawing import Arc, Circle, Segment, TextPCB, wrap_drawing from kigadgets.module import Footprint from kigadgets.track import Track +from kigadgets.util import register_return, register_yielded from kigadgets.via import Via from kigadgets.zone import Zone +log = logging.getLogger(__name__) + + +COPPER_TYPES = Union[Footprint, Track, Via, Zone] +DRAWING_TYPES = Union[Arc, Circle, Segment, TextPCB] -class _FootprintList(object): +@expose +class FootprintList(object): """Internal class to represent `Board.footprints`""" def __init__(self, board): self._board = board @@ -34,27 +46,32 @@ def __len__(self): return len(self._board._obj.GetFootprints()) +@expose class Board(object): def __init__(self, wrap=None): """Board object""" + log.debug("Creating board from: %s", wrap) if wrap: self._obj = wrap else: self._obj = pcbnew.BOARD() + log.debug("Board object created: %s", self._obj) - self._fplist = _FootprintList(self) + self._fplist = FootprintList(self) self._removed_elements = [] @property - def native_obj(self): + def native_obj(self) -> "pcbnew.BOARD": return self._obj - @staticmethod - def wrap(instance): + @classmethod + @register_return + def wrap(cls, instance: "pcbnew.BOARD") -> "Board": """Wraps a C++/old api `BOARD` object, and returns a `Board`.""" return Board(wrap=instance) - def add(self, obj): + @register_return + def add(self, obj: COPPER_TYPES) -> COPPER_TYPES: """Adds an object to the Board. Tracks, Drawings, Modules, etc... @@ -63,11 +80,13 @@ def add(self, obj): return obj @property - def footprints(self): + @register_return + def footprints(self) -> Iterable[Footprint]: """Provides an iterator over the board Footprint objects.""" return self._fplist - def footprint_by_ref(self, ref): + @register_return + def footprint_by_ref(self, ref) -> Optional[Footprint]: """Returns the footprint that has the reference `ref`. Returns `None` if there is no such footprint.""" found = self._obj.FindFootprintByReference(ref) @@ -75,16 +94,19 @@ def footprint_by_ref(self, ref): return Footprint.wrap(found) @property - def modules(self): + @register_return + def modules(self) -> Iterable[Footprint]: """Alias footprint to module""" return self.footprints - def module_by_ref(self, ref): + @register_return + def module_by_ref(self, ref) -> Optional[Footprint]: """Alias footprint to module""" return self.footprintByRef(ref) @property - def vias(self): + @register_yielded + def vias(self) -> Iterable[Via]: """An iterator over via objects""" for t in self._obj.GetTracks(): if type(t) == SWIGtype.Via: @@ -93,7 +115,8 @@ def vias(self): continue @property - def tracks(self): + @register_yielded + def tracks(self) -> Iterable[Track]: """An iterator over track objects""" for t in self._obj.GetTracks(): if type(t) == SWIGtype.Track: @@ -102,7 +125,8 @@ def tracks(self): continue @property - def zones(self): + @register_yielded + def zones(self) -> Iterable[Zone]: """ An iterator over zone objects Implementation note: The iterator breaks if zones are removed during the iteration, so it is put in a list first, then yielded from that list. @@ -118,13 +142,15 @@ def zones(self): yield tt @property - def drawings(self): + @register_yielded + def drawings(self) -> Iterable[DRAWING_TYPES]: all_drawings = [] for drawing in self._obj.GetDrawings(): yield wrap_drawing(drawing) @property - def items(self): + @register_yielded + def items(self) -> Iterable[Union[COPPER_TYPES, DRAWING_TYPES]]: ''' Everything on the board ''' for item in self.modules: yield item @@ -137,13 +163,15 @@ def items(self): for item in self.drawings: yield item - @staticmethod - def from_editor(): + @classmethod + @register_return + def from_editor(cls): """Provides the board object from the editor.""" return Board.wrap(pcbnew.GetBoard()) - @staticmethod - def load(filename): + @classmethod + @register_return + def load(cls, filename): """Loads a board file.""" return Board.wrap(pcbnew.LoadBoard(filename)) @@ -156,6 +184,7 @@ def save(self, filename=None): filename = self._obj.GetFileName() self._obj.Save(filename) + @register_return def copy(self): native = self._obj.Clone() if native is None: # Clone not implemented in v7 @@ -166,11 +195,12 @@ def copy(self): # TODO: add setter for Board.filename. For now, use brd.save(filename) @property - def filename(self): + def filename(self) -> str: """Name of the board file.""" + log.debug("repr(Board:self._obj): %s", self._obj) return self._obj.GetFileName() - def geohash(self): + def geohash(self) -> int: ''' Geometric hash ''' item_hashes = [] for item in self.items: @@ -181,29 +211,32 @@ def geohash(self): item_hashes.sort() return hash(tuple(item_hashes)) - def add_footprint(self, ref, pos=(0, 0)): + @register_return + def add_footprint(self, ref, pos=(0, 0)) -> Footprint: """Create new module on the board""" return Footprint(ref, pos, board=self) - def add_module(self, ref, pos=(0, 0)): + @register_return + def add_module(self, ref, pos=(0, 0)) -> Footprint: """Same as add_footprint""" return Footprint(ref, pos, board=self) @property - def default_width(self, width=None): + def default_width(self, width=None) -> float: b = self._obj return ( float(b.GetDesignSettings().GetCurrentTrackWidth()) / units.DEFAULT_UNIT_IUS) - def add_track_segment(self, start, end, layer='F.Cu', width=None): + @register_return + def add_track_segment(self, start, end, layer='F.Cu', width=None) -> Track: """Create a track segment.""" track = Track(start, end, layer, width or self.default_width, board=self) self._obj.Add(track.native_obj) return track - def get_layer_id(self, name): + def get_layer_id(self, name) -> int: lid = self._obj.GetLayerID(name) if lid == -1: # Try to recover from silkscreen rename @@ -219,10 +252,11 @@ def get_layer_id(self, name): raise ValueError('Layer {} not found in this board'.format(name)) return lid - def get_layer_name(self, layer_id): + def get_layer_name(self, layer_id) -> str: return self._obj.GetLayerName(layer_id) - def add_track(self, coords, layer='F.Cu', width=None): + @register_return + def add_track(self, coords, layer='F.Cu', width=None) -> Track: """Create a track polyline. Create track segments from each coordinate to the next. @@ -232,19 +266,20 @@ def add_track(self, coords, layer='F.Cu', width=None): layer=layer, width=width) @property - def default_via_size(self): + def default_via_size(self) -> float: return (float(self._obj.GetDesignSettings().GetCurrentViaSize()) / units.DEFAULT_UNIT_IUS) @property - def default_via_drill(self): + def default_via_drill(self) -> float: via_drill = self._obj.GetDesignSettings().GetCurrentViaDrill() if via_drill > 0: return (float(via_drill) / units.DEFAULT_UNIT_IUS) else: return 0.2 - def add_via(self, coord, size=None, drill=None, layer_pair=None): + @register_return + def add_via(self, coord, size=None, drill=None, layer_pair=None) -> Via: """Create a via on the board. :param coord: Position of the via. @@ -258,7 +293,8 @@ def add_via(self, coord, size=None, drill=None, layer_pair=None): Via(coord, size or self.default_via_size, drill or self.default_via_drill, layer_pair, board=self)) - def add_line(self, start, end, layer='F.SilkS', width=0.15): + @register_return + def add_line(self, start, end, layer='F.SilkS', width=0.15) -> Segment: """Create a graphic line on the board""" return self.add( Segment(start, end, layer, width, board=self)) @@ -268,19 +304,22 @@ def add_polyline(self, coords, layer='F.SilkS', width=0.15): for n in range(len(coords) - 1): self.add_line(coords[n], coords[n + 1], layer=layer, width=width) - def add_circle(self, center, radius, layer='F.SilkS', width=0.15): + @register_return + def add_circle(self, center, radius, layer='F.SilkS', width=0.15) -> Circle: """Create a graphic circle on the board""" return self.add( Circle(center, radius, layer, width, board=self)) + @register_return def add_arc(self, center, radius, start_angle, stop_angle, - layer='F.SilkS', width=0.15): + layer='F.SilkS', width=0.15) -> Arc: """Create a graphic arc on the board""" return self.add( Arc(center, radius, start_angle, stop_angle, layer, width, board=self)) - def add_text(self, position, text, layer='F.SilkS', size=1.0, thickness=0.15): + @register_return + def add_text(self, position, text, layer='F.SilkS', size=1.0, thickness=0.15) -> TextPCB: return self.add( TextPCB(position, text, layer, size, thickness, board=self)) @@ -303,7 +342,8 @@ def deselect_all(self): self._obj.ClearSelected() @property - def selected_items(self): + @register_yielded + def selected_items(self) -> Iterable[Union[COPPER_TYPES, DRAWING_TYPES]]: ''' This useful for duck typing in the interactive terminal Suppose you want to set some drill radii. Iterating everything would cause attribute errors, so it is easier to just select the vias you want, then use this method for convenience. diff --git a/kigadgets/drawing.py b/kigadgets/drawing.py index 5063edb..30d067b 100644 --- a/kigadgets/drawing.py +++ b/kigadgets/drawing.py @@ -1,11 +1,17 @@ -from kigadgets import pcbnew_bare as pcbnew - import cmath import math +from typing import Iterable, Optional, Union + +from Pyro5.api import expose -from kigadgets import units, Size, SWIGtype, SWIG_version, Point, instanceof +from kigadgets import Point, Size, SWIG_version, SWIGtype, instanceof +from kigadgets import pcbnew_bare as pcbnew +from kigadgets import units +from kigadgets.item import (BoardItem, HasLayer, HasPosition, HasWidth, + Selectable, TextEsque) from kigadgets.layer import get_board_layer_id -from kigadgets.item import HasLayer, Selectable, HasPosition, HasWidth, BoardItem, TextEsque +from kigadgets.util import register_return, register_yielded + class ShapeType(): Segment = pcbnew.S_SEGMENT @@ -48,11 +54,13 @@ def wrap_drawing(instance): raise TypeError('Unrecognized shape type on layer {}'.format(layer)) +@expose class Drawing(HasLayer, HasPosition, HasWidth, Selectable, BoardItem): ''' Base class of shape drawings, not including text ''' _wraps_native_cls = SWIGtype.Shape +@expose class Segment(Drawing): def __init__(self, start, end, layer='F.SilkS', width=0.15, board=None): line = SWIGtype.Shape(board and board.native_obj) @@ -89,6 +97,7 @@ def geohash(self): return mine + super().geohash() +@expose class Circle(Drawing): def __init__(self, center, radius, layer='F.SilkS', width=0.15, board=None): @@ -108,7 +117,8 @@ def __init__(self, center, radius, layer='F.SilkS', width=0.15, circle.SetArcStart(start_coord) @property - def center(self): + @register_return + def center(self) -> Point: return Point.wrap(self._obj.GetCenter()) @center.setter @@ -148,6 +158,7 @@ def geohash(self): # --- Logic for Arc changed a lot in version 6, so there are two classes +@expose class Arc_v5(Drawing): def __init__(self, center, radius, start_angle, stop_angle, layer='F.SilkS', width=0.15, board=None): diff --git a/kigadgets/point.py b/kigadgets/point.py index a0e7c0f..9518537 100644 --- a/kigadgets/point.py +++ b/kigadgets/point.py @@ -2,8 +2,12 @@ import kigadgets from kigadgets import units, SWIGtype +from typing import Iterable, Optional, Union +from Pyro5.api import expose +from kigadgets.util import register_return, register_yielded +@expose class Point(units.BaseUnitTuple): def __init__(self, x, y): diff --git a/kigadgets/remote.py b/kigadgets/remote.py new file mode 100644 index 0000000..5fb51ee --- /dev/null +++ b/kigadgets/remote.py @@ -0,0 +1,45 @@ +""" +Start a server to remote control KiCAD. +""" + +import logging +import threading + +import Pyro5.server +from Pyro5.api import expose, serve + +# from kigadgets import pcbnew_bare as pcbnew + +from .board import Board + +log = logging.getLogger(__name__) + + +@expose +class Pcbnew: + @staticmethod + def refresh(): + """Refresh the board.""" + import pcbnew + pcbnew.Refresh() + + +def _run_server(): + log.info("Starting Pyro5 server") + try: + serve( + { + Pcbnew: "kigadgets.Pcbnew", + Board: "kigadgets.Board", + } + ) + except Exception as ex: + log.exception("Pyro5 server failed with exception: %s", ex) + raise + + +def start_server() -> threading.Thread: + """Start a pryo5 server to remote control KiCAD.""" + # NOTE: this seems to immediately kill KiCAD if + # run as a daemon thread + return threading.Thread(target=_run_server).start() diff --git a/kigadgets/util.py b/kigadgets/util.py index 4acd46a..91e0c66 100644 --- a/kigadgets/util.py +++ b/kigadgets/util.py @@ -6,6 +6,9 @@ def run(self): import action_script # Only runs the first time during this instance of pcbnew, even if file changed kireload(action_script) # Forces reimport, rerunning, and any updates to source ''' +from functools import wraps +from Pyro5.errors import DaemonError + try: from importlib import reload as kireload except ImportError: @@ -65,3 +68,38 @@ def query_user(prompt=None, default=''): if sg != wx.ID_OK: return None return dialog.GetValue() + + +def _do_register(daemon, result): + """Registers the result in the Pyro daemon + if it's not already there.""" + if pyro_id := getattr(result, "_pyroId", None): + if daemon.objectsById[pyro_id] is not result: + daemon.register(result, force=True) + else: + daemon.register(result) + + +def register_return(method): + """Decorator to register the return value + of a method in the Pyro daemon.""" + @wraps(method) + def wrapper(self, *args, **kwargs): + daemon = self._pyroDaemon + result = method(self, *args, **kwargs) + _do_register(daemon, result) + return result + + return wrapper + + +def register_yielded(method): + """Decorator to register the return value + of a method in the Pyro daemon.""" + @wraps(method) + def wrapper(self, *args, **kwargs): + daemon = self._pyroDaemon + for result in method(self, *args, **kwargs): + _do_register(daemon, result) + yield result + return wrapper diff --git a/kigadgets/via.py b/kigadgets/via.py index d21d9e8..f0c3943 100644 --- a/kigadgets/via.py +++ b/kigadgets/via.py @@ -1,3 +1,6 @@ +from kigadgets.util import register_return +from Pyro5.api import expose + from kigadgets import pcbnew_bare as pcbnew from kigadgets import SWIGtype, SWIG_version, Point, DEFAULT_UNIT_IUS @@ -16,6 +19,7 @@ class ViaType(): Blind = pcbnew.VIA_BLIND_BURIED +@expose class Via(HasPosition, HasConnection, Selectable, BoardItem): ''' Careful setting top_layer, then getting top_layer may return different values if the new top_layer is below the existing bottom layer diff --git a/scratchpad.py b/scratchpad.py new file mode 100644 index 0000000..55ccf2f --- /dev/null +++ b/scratchpad.py @@ -0,0 +1,27 @@ +#%% +import Pyro5.api +import Pyro5.errors + +from typing import TYPE_CHECKING +import Pyro5.api + + +if TYPE_CHECKING: + from kigadgets.board import Board + + +uri = "PYRONAME:kigadgets.Board" + +board = Pyro5.api.Proxy(uri) + +# %% +# The fun things! + +b: "Board" = board.from_editor() +for i in range(10): + b.add_via((50, 50 + i * 5)) + +#%% + +with Pyro5.api.Proxy("PYRONAME:kigadgets.Pcbnew") as pcbnew: + pcbnew.refresh()