From dfda0c23130b7cde39aa599015be5a5188358c46 Mon Sep 17 00:00:00 2001 From: Walter Smith Date: Fri, 13 Dec 2024 00:31:04 -0800 Subject: [PATCH] Show component values in list (+ other minor conveniences) (#551) * Make footprint table scrollable * Make part details dialog resizable * Default to database priority Defaulting the setting to board priority means that by default all work done will be lost if you forget to click the export button. This is dangerous and confusing, so instead this saves work by default. It would be better to also warn when the dialog is closed without exporting, when the setting is the other way. * Don't erase non-matching board parts If in database priority mode, set the board's part to the database's part rather than erasing it. * Show parameters derived from LCSC description This adds an "LCSC Params" field to the footprint and part selection lists that makes it much easier to double-check the assignments or select the right part. Particularly for passives, LCSC buries the important data (like the resistance of a resistor) in the middle of the description field. This pulls those values out where you can see them. * Add double-click behaviors Double-clicking a footprint brings up part selection. Double-clicking a part in the selector assigns the part. * Improve default search string Don't search for the existing LCSC value -- if I'm searching, that's presumably the only part I don't want! Tweak the search string to make it easier to find passives quickly. * placehoDLer -> placehoLDer (#553) Fix for little typo * ruff auto fix errors * Fix remaning ruff erorrs --------- Co-authored-by: Maxime Vincent Co-authored-by: bouni --- datamodel.py | 6 +- derive_params.py | 171 +++++++++++++++++++++++++++++++++++++++++++++ library.py | 6 +- mainwindow.py | 44 ++++++++---- partdetails.py | 2 +- partselector.py | 15 +++- settings.json | 2 +- standalone_impl.py | 31 ++++++++ store.py | 2 +- 9 files changed, 261 insertions(+), 18 deletions(-) create mode 100644 derive_params.py diff --git a/datamodel.py b/datamodel.py index 99141bf6..bcd56a1b 100644 --- a/datamodel.py +++ b/datamodel.py @@ -25,6 +25,7 @@ def __init__(self, scale_factor): "POS_COL": 7, "ROT_COL": 8, "SIDE_COL": 9, + "PARAMS_COL": 10, } self.bom_pos_icons = [ @@ -74,6 +75,7 @@ def GetColumnType(self, col): # noqa: DC04 "wxDataViewIconText", "string", "wxDataViewIconText", + "string", ) return columntypes[col] @@ -193,7 +195,7 @@ def select_alike(self, item): alike.append(self.ObjectToItem(data)) return alike - def set_lcsc(self, ref, lcsc, type, stock): + def set_lcsc(self, ref, lcsc, type, stock, params): """Set an lcsc number, type and stock for given reference.""" if (index := self.find_index(ref)) is None: return @@ -201,6 +203,7 @@ def set_lcsc(self, ref, lcsc, type, stock): item[self.columns["LCSC_COL"]] = lcsc item[self.columns["TYPE_COL"]] = type item[self.columns["STOCK_COL"]] = stock + item[self.columns["PARAMS_COL"]] = params self.ItemChanged(self.ObjectToItem(item)) def remove_lcsc_number(self, item): @@ -209,6 +212,7 @@ def remove_lcsc_number(self, item): obj[self.columns["LCSC_COL"]] = "" obj[self.columns["TYPE_COL"]] = "" obj[self.columns["STOCK_COL"]] = "" + item[self.columns["PARAMS_COL"]] = "" self.ItemChanged(self.ObjectToItem(obj)) def toggle_bom(self, item): diff --git a/derive_params.py b/derive_params.py new file mode 100644 index 00000000..46f3bcdf --- /dev/null +++ b/derive_params.py @@ -0,0 +1,171 @@ +"""Derive parameters from the part description and package.""" + +# LCSC hides the critical parameters (like resistor values) in the middle of the +# description field, which makes them invisible in the footprint list. This +# makes it very tedious to double-check the part mappings, as the part details +# dialog has to be opened for each one. +# +# This function uses heuristics to extract a human-readable summary of the most +# significant parameters of the parts from the LCSC data so they can be displayed +# separately in the footprint list. + +import logging +import re + +logger = logging.getLogger() +logging.basicConfig(encoding="utf-8", level=logging.DEBUG) + + +def params_for_part(part) -> str: + """Derive parameters from the part description and package.""" + description = part.get("description", "") + category = part.get("category", "") + part_no = part.get("part_no", "") + package = part.get("package", "") + + result = [] + + # These are heuristic regexes to pull out parameters like resistance, + # capacitance, voltage, current, etc. from the description field. As LCSC + # makes random changes to the format of the descriptions, this function will + # need to follow along. + # + # The package size isn't always in the description, but will be added by the + # caller. + + # For passives, focus on generic values like resistance, capacitance, voltage + + if "Resistors" in category: + result.extend(re.findall(r"([.\d]+[mkM]?Ω)", description)) + result.extend(re.findall(r"(±[.\d]+%)", description)) + elif "Capacitors" in category: + result.extend(re.findall(r"([.\d]+[pnmuμ]?F)", description)) + result.extend(re.findall(r"([.\d]+[mkM]?V)", description)) + elif "Inductors" in category: + result.extend(re.findall(r"([.\d]+[nuμm]?H)", description)) + result.extend(re.findall(r"([.\d]+m?A)", description)) + + # For diodes, we may be looking for specific part or generic I/V specs + + elif "Diodes" in category: + if part_no: + result.append(part_no) + result.extend(re.findall(r"(? dict: with contextlib.closing(sqlite3.connect(self.partsdb_file)) as con: con.row_factory = dict_factory # noqa: DC05 cur = con.cursor() - query = """SELECT "LCSC Part" AS lcsc, "Stock" AS stock, "Library Type" AS type FROM parts WHERE parts MATCH :number""" + query = """SELECT "LCSC Part" AS lcsc, "Stock" AS stock, "Library Type" AS type, + "MFR.Part" as part_no, "Description" as description, "Package" as package, + "First Category" as category + FROM parts WHERE parts MATCH :number""" cur.execute(query, {"number": number}) return next((n for n in cur.fetchall() if n["lcsc"] == number), {}) diff --git a/mainwindow.py b/mainwindow.py index 45b6b20d..4b18b295 100644 --- a/mainwindow.py +++ b/mainwindow.py @@ -14,6 +14,7 @@ import wx.dataview as dv # pylint: disable=import-error from .datamodel import PartListDataModel +from .derive_params import params_for_part from .events import ( EVT_ASSIGN_PARTS_EVENT, EVT_LOGBOX_APPEND_EVENT, @@ -360,16 +361,19 @@ def __init__(self, parent, kicad_provider=KicadProvider()): table_sizer = wx.BoxSizer(wx.HORIZONTAL) table_sizer.SetMinSize(HighResWxSize(self.window, wx.Size(-1, 600))) + table_scroller = wx.ScrolledWindow(self, style=wx.HSCROLL | wx.VSCROLL) + table_scroller.SetScrollRate(20, 20) + self.footprint_list = dv.DataViewCtrl( - self, + table_scroller, style=wx.BORDER_THEME | dv.DV_ROW_LINES | dv.DV_VERT_RULES | dv.DV_MULTIPLE, ) reference = self.footprint_list.AppendTextColumn( - "Reference", 0, width=50, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER + "Ref", 0, width=50, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) value = self.footprint_list.AppendTextColumn( - "Value", 1, width=250, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER + "Value", 1, width=150, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) footprint = self.footprint_list.AppendTextColumn( "Footprint", @@ -378,6 +382,9 @@ def __init__(self, parent, kicad_provider=KicadProvider()): mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER, ) + params = self.footprint_list.AppendTextColumn( + "LCSC Params", 10, width=150, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER + ) lcsc = self.footprint_list.AppendTextColumn( "LCSC", 3, width=100, mode=dv.DATAVIEW_CELL_INERT, align=wx.ALIGN_CENTER ) @@ -410,13 +417,20 @@ def __init__(self, parent, kicad_provider=KicadProvider()): pos.SetSortable(False) rotation.SetSortable(True) side.SetSortable(True) + params.SetSortable(True) + + scrolled_sizer = wx.BoxSizer(wx.VERTICAL) + scrolled_sizer.Add(self.footprint_list, 1, wx.EXPAND) + table_scroller.SetSizer(scrolled_sizer) - table_sizer.Add(self.footprint_list, 20, wx.ALL | wx.EXPAND, 5) + table_sizer.Add(table_scroller, 20, wx.ALL | wx.EXPAND, 5) self.footprint_list.Bind( dv.EVT_DATAVIEW_SELECTION_CHANGED, self.OnFootprintSelected ) + self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_ACTIVATED, self.select_part) + self.footprint_list.Bind(dv.EVT_DATAVIEW_ITEM_CONTEXT_MENU, self.OnRightDown) table_sizer.Add(self.right_toolbar, 1, wx.EXPAND, 5) @@ -529,7 +543,8 @@ def assign_parts(self, e): board = self.pcbnew.GetBoard() fp = board.FindFootprintByReference(reference) set_lcsc_value(fp, e.lcsc) - self.partlist_data_model.set_lcsc(reference, e.lcsc, e.type, e.stock) + params = params_for_part(self.library.get_part_details(e.lcsc)) + self.partlist_data_model.set_lcsc(reference, e.lcsc, e.type, e.stock, params) def display_message(self, e): """Dispaly a message with the data from the event.""" @@ -582,6 +597,7 @@ def populate_footprint_list(self, *_): part["exclude_from_pos"], str(self.get_correction(part, corrections)), str(fp.GetLayer()), + params_for_part(details.get(part["lcsc"], {})), ] ) @@ -794,12 +810,14 @@ def select_part(self, *_): selection = {} for item in self.footprint_list.GetSelections(): ref = self.partlist_data_model.get_reference(item) - lcsc = self.partlist_data_model.get_lcsc(item) value = self.partlist_data_model.get_value(item) - if lcsc != "": - selection[ref] = lcsc - else: - selection[ref] = value + footprint = self.partlist_data_model.get_footprint(item) + if ref.startswith("R"): + value += "Ω" + m = re.search(r"_(\d+)_\d+Metric", footprint) + if m: + value += f" {m.group(1)}" + selection[ref] = value PartSelectorDialog(self, selection).ShowModal() def check_order_number(self): @@ -856,9 +874,10 @@ def paste_part_lcsc(self, *_): if (lcsc := self.sanitize_lcsc(text_data.GetText())) != "": for item in self.footprint_list.GetSelections(): details = self.library.get_part_details(lcsc) + params = params_for_part(details) reference = self.partlist_data_model.get_reference(item) self.partlist_data_model.set_lcsc( - reference, lcsc, details["type"], details["stock"] + reference, lcsc, details["type"], details["stock"], params ) self.store.set_lcsc(reference, lcsc) @@ -924,8 +943,9 @@ def search_foot_mapping(self, *_): self.store.set_lcsc(reference, lcsc) self.logger.info("Found %s", lcsc) details = self.library.get_part_details(lcsc) + params = params_for_part(self.library.get_part_details(lcsc)) self.partlist_data_model.set_lcsc( - reference, lcsc, details["type"], details["stock"] + reference, lcsc, details["type"], details["stock"], params ) def sanitize_lcsc(self, lcsc_PN): diff --git a/partdetails.py b/partdetails.py index 265d4526..ba81b555 100644 --- a/partdetails.py +++ b/partdetails.py @@ -23,7 +23,7 @@ def __init__(self, parent, part): title="JLCPCB Part Details", pos=wx.DefaultPosition, size=HighResWxSize(parent.window, wx.Size(1000, 800)), - style=wx.DEFAULT_DIALOG_STYLE | wx.STAY_ON_TOP, + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER | wx.STAY_ON_TOP, ) self.logger = logging.getLogger(__name__) diff --git a/partselector.py b/partselector.py index 521d3fee..b460aa4f 100644 --- a/partselector.py +++ b/partselector.py @@ -6,6 +6,7 @@ import wx # pylint: disable=import-error import wx.dataview # pylint: disable=import-error +from .derive_params import params_for_part # pylint: disable=import-error from .events import AssignPartsEvent, UpdateSetting from .helpers import HighResWxSize, loadBitmapScaled from .partdetails import PartDetailsDialog @@ -442,6 +443,13 @@ def __init__(self, parent, parts): align=wx.ALIGN_LEFT, flags=wx.dataview.DATAVIEW_COL_RESIZABLE, ).GetRenderer().EnableEllipsize(wx.ELLIPSIZE_NONE) + self.part_list.AppendTextColumn( + "Params", + mode=wx.dataview.DATAVIEW_CELL_INERT, + width=int(parent.scale_factor * 150), + align=wx.ALIGN_CENTER, + flags=wx.dataview.DATAVIEW_COL_RESIZABLE, + ).GetRenderer().EnableEllipsize(wx.ELLIPSIZE_NONE) self.part_list.AppendTextColumn( "Stock", mode=wx.dataview.DATAVIEW_CELL_INERT, @@ -481,6 +489,9 @@ def __init__(self, parent, parts): wx.dataview.EVT_DATAVIEW_SELECTION_CHANGED, self.OnPartSelected ) + self.part_list.Bind(wx.EVT_LEFT_DCLICK, self.select_part) + + table_sizer = wx.BoxSizer(wx.HORIZONTAL) table_sizer.SetMinSize(HighResWxSize(parent.window, wx.Size(-1, 400))) table_sizer.Add(self.part_list, 20, wx.ALL | wx.EXPAND, 5) @@ -689,6 +700,8 @@ def populate_part_list(self, parts, search_duration): ) else: item[pricecol] = "Error in price data" + params = params_for_part({"description": item[7], "category": item[9], "package": item[2]}) + item.insert(5, params) self.part_list.AppendItem(item) def select_part(self, *_): @@ -699,7 +712,7 @@ def select_part(self, *_): return selection = self.part_list.GetTextValue(row, 0) type = self.part_list.GetTextValue(row, 4) - stock = self.part_list.GetTextValue(row, 5) + stock = self.part_list.GetTextValue(row, 6) wx.PostEvent( self.parent, AssignPartsEvent( diff --git a/settings.json b/settings.json index 48d13f2a..9958eb70 100644 --- a/settings.json +++ b/settings.json @@ -1 +1 @@ -{"partselector": {"basic": true, "extended": true, "stock": false}, "gerber": {"tented_vias": true, "fill_zones": false, "plot_values": true, "plot_references": true, "lcsc_bom_cpl": true}, "general": {"lcsc_priority": true, "order_number": true}} \ No newline at end of file +{"partselector": {"basic": true, "extended": true, "stock": false}, "gerber": {"tented_vias": true, "fill_zones": false, "plot_values": true, "plot_references": true, "lcsc_bom_cpl": true}, "general": {"lcsc_priority": false, "order_number": true}} diff --git a/standalone_impl.py b/standalone_impl.py index 64e15f20..b3544e36 100644 --- a/standalone_impl.py +++ b/standalone_impl.py @@ -11,6 +11,25 @@ def GetLibItemName(self) -> str: """Item name.""" return self.item_name +class Field_Stub: + """Implementation of pcbnew.Field.""" + + def __init__(self, name, text): + self.name = name + self.text = text + + def GetName(self) -> str: + """Field name.""" + return self.name + + def GetText(self) -> str: + """Field text.""" + return self.text + + def SetVisible(self, visible): + """Set the field visibility.""" + pass + class Footprint_Stub: """Implementation of pcbnew.Footprint.""" @@ -40,6 +59,18 @@ def GetAttributes(self) -> int: """Attributes.""" return 0 + def GetFields(self) -> list: + """Fields.""" + return [] + + def SetField(self, name, text): + """Set a field.""" + pass + + def GetFieldByName(self, name) -> Field_Stub: + """Get a field by name.""" + return Field_Stub(name, "stub") + def GetLayer(self) -> int: """Layer number.""" # TODO: maybe this is defined in a python module we can import and reuse here? diff --git a/store.py b/store.py index 6854324a..565ace4b 100644 --- a/store.py +++ b/store.py @@ -218,7 +218,7 @@ def update_from_board(self): "Part %s is already in the database and has a lcsc value, the value supplied from the board will be ignored.", board_part["reference"], ) - board_part["lcsc"] = None + board_part["lcsc"] = db_part["lcsc"] else: self.logger.debug( "Part %s is already in the database and has a lcsc value, the value supplied from the board will overwrite that in the database.",