From 52c826e37338a0f0d92286c24038e17f52a15f60 Mon Sep 17 00:00:00 2001 From: Brendan Haines Date: Thu, 23 Mar 2023 18:32:52 -0600 Subject: [PATCH] add assembly outputs to script --- plugins/bom_csv_combined.py | 115 ++++ plugins/bom_csv_grouped_extra.py | 115 ++++ plugins/kicad_netlist_reader.py | 904 +++++++++++++++++++++++++++ plugins/kicad_utils.py | 28 + templates/jlcpcb_template/release.py | 33 +- 5 files changed, 1194 insertions(+), 1 deletion(-) create mode 100644 plugins/bom_csv_combined.py create mode 100644 plugins/bom_csv_grouped_extra.py create mode 100644 plugins/kicad_netlist_reader.py create mode 100644 plugins/kicad_utils.py diff --git a/plugins/bom_csv_combined.py b/plugins/bom_csv_combined.py new file mode 100644 index 0000000..fbf4475 --- /dev/null +++ b/plugins/bom_csv_combined.py @@ -0,0 +1,115 @@ +# +# Example python script to generate a BOM from a KiCad generic netlist +# +# Example: Sorted and Grouped CSV BOM +# + +""" + @package + Output: CSV (comma-separated) + Grouped By: Value, Footprint, DNP, specified extra fields + Sorted By: Reference + Fields: #, Reference, Qty, Value, Footprint, DNP, specified extra fields + + Outputs components grouped by Value, Footprint, and specified extra fields. + Extra fields can be passed as command line arguments at the end, one field per argument. + + Command line: + python "pathToFile/bom_csv_grouped_extra.py" "%I" "%O.csv" "Extra_Field1" "Extra_Field2" +""" + +import csv +import sys + +# Import the KiCad python helper module and the csv formatter +import kicad_netlist_reader +import kicad_utils + +# Get extra fields from the command line +extra_fields = sys.argv[3:] + + +def myEqu(self, other): + """myEqu is a more advanced equivalence function for components which is + used by component grouping. Normal operation is to group components based + on their Value and Footprint. + + In this example of a more advanced equivalency operator we also compare the + Footprint, Value and all extra fields passed from the command line. If + these fields are not used in some parts they will simply be ignored (they + will match as both will be empty strings). + + """ + result = True + if self.getValue() != other.getValue(): + result = False + elif self.getFootprint() != other.getFootprint(): + result = False + elif self.getDNP() != other.getDNP(): + result = False + else: + for field_name in extra_fields: + if self.getField(field_name) != other.getField(field_name): + result = False + + return result + + +# Override the component equivalence operator - it is important to do this +# before loading the netlist, otherwise all components will have the original +# equivalency operator. +kicad_netlist_reader.comp.__eq__ = myEqu + +# Generate an instance of a generic netlist, and load the netlist tree from +# the command line option. If the file doesn't exist, execution will stop +net = kicad_netlist_reader.netlist(sys.argv[1]) + +# Open a file to write to, if the file cannot be opened output to stdout +# instead +try: + f = kicad_utils.open_file_writeUTF8(sys.argv[2], "w") +except IOError: + e = "Can't open output file for writing: " + sys.argv[2] + print(__file__, ":", e, sys.stderr) + f = sys.stdout + +# Create a new csv writer object to use as the output formatter +out = csv.writer(f, lineterminator="\n", delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL) + +# Output a CSV header +out.writerow(["#", "Reference", "Qty", "Value", "Footprint", "DNP"] + extra_fields) + +# Get all of the components in groups of matching parts + values +# (see kicad_netlist_reader.py) +grouped = net.groupComponents() + +# Output all of the component information +index = 1 +for group in grouped: + refs = "" + + # Add the reference of every component in the group and keep a reference + # to the component so that the other data can be filled in once per group + for component in group: + refs += component.getRef() + ", " + c = component + + # Remove trailing comma + refs = refs[:-2] + + # Fill in the component groups common data + row = [] + row.append(index) + row.append(refs) + row.append(len(group)) + row.append(c.getValue()) + row.append(c.getFootprint()) + row.append(c.getDNPString()) + + # Add the values of extra fields + for field_name in extra_fields: + row.append(c.getField(field_name)) + + out.writerow(row) + + index += 1 diff --git a/plugins/bom_csv_grouped_extra.py b/plugins/bom_csv_grouped_extra.py new file mode 100644 index 0000000..fbf4475 --- /dev/null +++ b/plugins/bom_csv_grouped_extra.py @@ -0,0 +1,115 @@ +# +# Example python script to generate a BOM from a KiCad generic netlist +# +# Example: Sorted and Grouped CSV BOM +# + +""" + @package + Output: CSV (comma-separated) + Grouped By: Value, Footprint, DNP, specified extra fields + Sorted By: Reference + Fields: #, Reference, Qty, Value, Footprint, DNP, specified extra fields + + Outputs components grouped by Value, Footprint, and specified extra fields. + Extra fields can be passed as command line arguments at the end, one field per argument. + + Command line: + python "pathToFile/bom_csv_grouped_extra.py" "%I" "%O.csv" "Extra_Field1" "Extra_Field2" +""" + +import csv +import sys + +# Import the KiCad python helper module and the csv formatter +import kicad_netlist_reader +import kicad_utils + +# Get extra fields from the command line +extra_fields = sys.argv[3:] + + +def myEqu(self, other): + """myEqu is a more advanced equivalence function for components which is + used by component grouping. Normal operation is to group components based + on their Value and Footprint. + + In this example of a more advanced equivalency operator we also compare the + Footprint, Value and all extra fields passed from the command line. If + these fields are not used in some parts they will simply be ignored (they + will match as both will be empty strings). + + """ + result = True + if self.getValue() != other.getValue(): + result = False + elif self.getFootprint() != other.getFootprint(): + result = False + elif self.getDNP() != other.getDNP(): + result = False + else: + for field_name in extra_fields: + if self.getField(field_name) != other.getField(field_name): + result = False + + return result + + +# Override the component equivalence operator - it is important to do this +# before loading the netlist, otherwise all components will have the original +# equivalency operator. +kicad_netlist_reader.comp.__eq__ = myEqu + +# Generate an instance of a generic netlist, and load the netlist tree from +# the command line option. If the file doesn't exist, execution will stop +net = kicad_netlist_reader.netlist(sys.argv[1]) + +# Open a file to write to, if the file cannot be opened output to stdout +# instead +try: + f = kicad_utils.open_file_writeUTF8(sys.argv[2], "w") +except IOError: + e = "Can't open output file for writing: " + sys.argv[2] + print(__file__, ":", e, sys.stderr) + f = sys.stdout + +# Create a new csv writer object to use as the output formatter +out = csv.writer(f, lineterminator="\n", delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL) + +# Output a CSV header +out.writerow(["#", "Reference", "Qty", "Value", "Footprint", "DNP"] + extra_fields) + +# Get all of the components in groups of matching parts + values +# (see kicad_netlist_reader.py) +grouped = net.groupComponents() + +# Output all of the component information +index = 1 +for group in grouped: + refs = "" + + # Add the reference of every component in the group and keep a reference + # to the component so that the other data can be filled in once per group + for component in group: + refs += component.getRef() + ", " + c = component + + # Remove trailing comma + refs = refs[:-2] + + # Fill in the component groups common data + row = [] + row.append(index) + row.append(refs) + row.append(len(group)) + row.append(c.getValue()) + row.append(c.getFootprint()) + row.append(c.getDNPString()) + + # Add the values of extra fields + for field_name in extra_fields: + row.append(c.getField(field_name)) + + out.writerow(row) + + index += 1 diff --git a/plugins/kicad_netlist_reader.py b/plugins/kicad_netlist_reader.py new file mode 100644 index 0000000..9a66b10 --- /dev/null +++ b/plugins/kicad_netlist_reader.py @@ -0,0 +1,904 @@ +# +# KiCad python module for interpreting generic netlists which can be used +# to generate Bills of materials, etc. +# +# Remember these files use UTF8 encoding +# +# No string formatting is used on purpose as the only string formatting that +# is current compatible with python 2.4+ to 3.0+ is the '%' method, and that +# is due to be deprecated in 3.0+ soon +# + +""" + @package + Helper module for interpreting generic netlist and build custom + bom generators or netlists in foreign format +""" + + +from __future__ import print_function + +import pdb +import re +import string +import sys +import xml.sax as sax + +# --------------------------------------------------------------------- + +# excluded_fields is a list of regular expressions. If any one matches a field +# from either a component or a libpart, then that will not be included as a +# column in the BOM. Otherwise all columns from all used libparts and components +# will be unionized and will appear. Some fields are impossible to blacklist, such +# as Ref, Value, Footprint, and Datasheet. Additionally Qty and Item are supplied +# unconditionally as columns, and may not be removed. +excluded_fields = [ + #'Price@1000' +] + + +# You may exclude components from the BOM by either: +# +# 1) adding a custom field named "Installed" to your components and filling it +# with a value of "NU" (Normally Uninstalled). +# See netlist.getInterestingComponents(), or +# +# 2) blacklisting it in any of the three following lists: + + +# regular expressions which match component 'Reference' fields of components that +# are to be excluded from the BOM. +excluded_references = [ + # 'TP[0-9]+' # all test points +] + + +# regular expressions which match component 'Value' fields of components that +# are to be excluded from the BOM. +excluded_values = [ + # 'MOUNTHOLE', + # 'SCOPETEST', + # 'MOUNT_HOLE', + # 'SOLDER_BRIDGE.*' +] + + +# regular expressions which match component 'Footprint' fields of components that +# are to be excluded from the BOM. +excluded_footprints = [ + #'MOUNTHOLE' +] + +# -------------------------------------------------------------------- + + +class xmlElement: + """xml element which can represent all nodes of the netlist tree. It can be + used to easily generate various output formats by propagating format + requests to children recursively. + """ + + def __init__(self, name, parent=None): + self.name = name + self.attributes = {} + self.parent = parent + self.chars = "" + self.children = [] + + def __str__(self): + """String representation of this netlist element""" + return self.name + "[" + self.chars + "]" + " attr_count:" + str(len(self.attributes)) + + def formatXML(self, nestLevel=0, amChild=False): + """Return this element formatted as XML + + Keywords: + nestLevel -- increases by one for each level of nesting. + amChild -- If set to True, the start of document is not returned. + + """ + s = "" + + indent = "" + for i in range(nestLevel): + indent += " " + + if not amChild: + s = '\n' + + s += indent + "<" + self.name + for a in self.attributes: + s += " " + a + '="' + self.attributes[a] + '"' + + if (len(self.chars) == 0) and (len(self.children) == 0): + s += "/>" + else: + s += ">" + self.chars + + for c in self.children: + s += "\n" + s += c.formatXML(nestLevel + 1, True) + + if len(self.children) > 0: + s += "\n" + indent + + if (len(self.children) > 0) or (len(self.chars) > 0): + s += "" + + return s + + def formatHTML(self, amChild=False): + """Return this element formatted as HTML + + Keywords: + amChild -- If set to True, the start of document is not returned + + """ + s = "" + + if not amChild: + s = """ + + + + + + + + """ + + s += "\n" + + for c in self.children: + s += c.formatHTML(True) + + if not amChild: + s += """
" + self.name + "
" + self.chars + "
    " + for a in self.attributes: + s += "
  • " + a + " = " + self.attributes[a] + "
  • " + + s += "
+ + """ + + return s + + def addAttribute(self, attr, value): + """Add an attribute to this element""" + if type(value) != str: + value = value.encode("utf-8") + self.attributes[attr] = value + + def setAttribute(self, attr, value): + """Set an attributes value - in fact does the same thing as add + attribute + + """ + self.attributes[attr] = value + + def setChars(self, chars): + """Set the characters for this element""" + self.chars = chars + + def addChars(self, chars): + """Add characters (textual value) to this element""" + self.chars += chars + + def addChild(self, child): + """Add a child element to this element""" + self.children.append(child) + return self.children[len(self.children) - 1] + + def getParent(self): + """Get the parent of this element (Could be None)""" + return self.parent + + def getChild(self, name): + """Returns the first child element named 'name' + + Keywords: + name -- The name of the child element to return""" + for child in self.children: + if child.name == name: + return child + return None + + def getChildren(self, name=None): + if name: + # return _all_ children named "name" + ret = [] + for child in self.children: + if child.name == name: + ret.append(child) + return ret + else: + return self.children + + def get(self, elemName, attribute="", attrmatch=""): + """Return the text data for either an attribute or an xmlElement""" + if self.name == elemName: + if attribute != "": + try: + if attrmatch != "": + if self.attributes[attribute] == attrmatch: + ret = self.chars + if type(ret) != str: + ret = ret.encode("utf-8") + return ret + else: + ret = self.attributes[attribute] + if type(ret) != str: + ret = ret.encode("utf-8") + return ret + except AttributeError: + ret = "" + if type(ret) != str: + ret = ret.encode("utf-8") + return ret + else: + ret = self.chars + if type(ret) != str: + ret = ret.encode("utf-8") + return ret + + for child in self.children: + ret = child.get(elemName, attribute, attrmatch) + if ret != "": + if type(ret) != str: + ret = ret.encode("utf-8") + return ret + + ret = "" + if type(ret) != str: + ret = ret.encode("utf-8") + return ret + + +class libpart: + """Class for a library part, aka 'libpart' in the xml netlist file. + (Components in eeschema are instantiated from library parts.) + This part class is implemented by wrapping an xmlElement with accessors. + This xmlElement instance is held in field 'element'. + """ + + def __init__(self, xml_element): + # + self.element = xml_element + + # def __str__(self): + # simply print the xmlElement associated with this part + # return str(self.element) + + def getLibName(self): + return self.element.get("libpart", "lib") + + def getPartName(self): + return self.element.get("libpart", "part") + + def getDescription(self): + return self.element.get("description") + + def getField(self, name): + return self.element.get("field", "name", name) + + def getFieldNames(self): + """Return a list of field names in play for this libpart.""" + fieldNames = [] + fields = self.element.getChild("fields") + if fields: + for f in fields.getChildren(): + fieldNames.append(f.get("field", "name")) + return fieldNames + + def getPinList(self): + """Return a list of pins in play for this libpart.""" + pinList = [] + pins = self.element.getChild("pins") + if pins: + for f in pins.getChildren(): + pinList.append(f) + return pinList + + def getDatasheet(self): + return self.getField("Datasheet") + + def getFootprint(self): + return self.getField("Footprint") + + def getAliases(self): + """Return a list of aliases or None""" + aliases = self.element.getChild("aliases") + if aliases: + ret = [] + children = aliases.getChildren() + # grab the text out of each child: + for child in children: + ret.append(child.get("alias")) + return ret + return None + + +class comp: + """Class for a component, aka 'comp' in the xml netlist file. + This component class is implemented by wrapping an xmlElement instance + with accessors. The xmlElement is held in field 'element'. + """ + + def __init__(self, xml_element): + self.element = xml_element + self.libpart = None + + # Set to true when this component is included in a component group + self.grouped = False + + def __eq__(self, other): + """Equivalency operator, remember this can be easily overloaded + 2 components are equivalent ( i.e. can be grouped + if they have same value and same footprint and are both set to be populated + + Override the component equivalence operator must be done before + loading the netlist, otherwise all components will have the original + equivalency operator. + + You have to define a comparison module (for instance named myEqu) + and add the line; + kicad_netlist_reader.comp.__eq__ = myEqu + in your bom generator script before calling the netliste reader by something like: + net = kicad_netlist_reader.netlist(sys.argv[1]) + """ + result = False + if self.getValue() == other.getValue(): + if self.getFootprint() == other.getFootprint(): + if self.getRef().rstrip(string.digits) == other.getRef().rstrip(string.digits): + if self.getDNP() == other.getDNP(): + result = True + return result + + def setLibPart(self, part): + self.libpart = part + + def getLibPart(self): + return self.libpart + + def getPartName(self): + return self.element.get("libsource", "part") + + def getLibName(self): + return self.element.get("libsource", "lib") + + def setValue(self, value): + """Set the value of this component""" + v = self.element.getChild("value") + if v: + v.setChars(value) + + def getValue(self): + return self.element.get("value") + + def getField(self, name, aLibraryToo=True): + """ + Return the value of a field named name. The component is first + checked for the field, and then the components library part is checked + for the field. If the field doesn't exist in either, an empty string is + returned + + Keywords: + name -- The name of the field to return the value for + aLibraryToo -- look in the libpart's fields for the same name if not found + in component itself + """ + + field = self.element.get("field", "name", name) + if field == "" and aLibraryToo and self.libpart: + field = self.libpart.getField(name) + return field + + def getFieldNames(self): + """Return a list of field names in play for this component. Mandatory + fields are not included, and they are: Value, Footprint, Datasheet, Ref. + The netlist format only includes fields with non-empty values. So if a field + is empty, it will not be present in the returned list. + """ + fieldNames = [] + fields = self.element.getChild("fields") + if fields: + for f in fields.getChildren(): + fieldNames.append(f.get("field", "name")) + return fieldNames + + def getRef(self): + return self.element.get("comp", "ref") + + """ + Return true if the component has the DNP property set + """ + + def getDNP(self): + for child in self.element.getChildren("property"): + try: + if child.attributes["name"] == "dnp": + return True + except KeyError: + continue + + return False + + """ + Return 'DNP' if the component has the DNP property set + """ + + def getDNPString(self): + if self.getDNP(): + return "DNP" + + return "" + + """ + Return true if the component has the exclude from BOM property set + """ + + def getExcludeFromBOM(self): + for child in self.element.getChildren("property"): + try: + if child.attributes["name"] == "exclude_from_bom": + return True + except KeyError: + continue + + return False + + """ + Return true if the component has the exclude from Board property set + """ + + def getExcludeFromBoard(self): + for child in self.element.getChildren("property"): + try: + if child.attributes["name"] == "exclude_from_board": + return True + except KeyError: + continue + + return False + + """ + return the footprint name. if empty and aLibraryToo = True, return the + footprint name from libary + """ + + def getFootprint(self, aLibraryToo=True): + ret = self.element.get("footprint") + + if ret == "" and aLibraryToo and self.libpart: + ret = self.libpart.getFootprint() + + return ret + + """ + return the datasheet name. if empty and aLibraryToo = True, return the + datasheet name from libary + """ + + def getDatasheet(self, aLibraryToo=True): + ret = self.element.get("datasheet") + if ret == "" and aLibraryToo and self.libpart: + ret = self.libpart.getDatasheet() + return ret + + def getTimestamp(self): + """ + Kicad 5 uses tstamp keyword for time stamp (8 digits) as UUID + Kicad 6 uses tstamps keyword for UUID and a multi unit symbol has more than one UUID + (UUIDs are separated by spaces) + """ + ret = self.element.get("tstamp") + if ret == "": + ret = self.element.get("tstamps") + return ret + + def getDescription(self): + return self.element.get("libsource", "description") + + """ + return the netname of the pin aPinNum in netlist aNetlist + if aSkipEmptyNet = True, net having only one pin will return a empty name + """ + + def getPinNetname(self, aPinNum, aNetlist, aSkipEmptyNet): + ref = self.getRef() + + for net in aNetlist.nets: + net_name = net.get("net", "name") + + item_cnt = 1 + netitems = net.children + + for node in netitems: + curr_item_ref = node.get("node", "ref") + + if curr_item_ref == ref: + curr_pin = node.get("node", "pin") + + if aPinNum == curr_pin: + if aSkipEmptyNet: # ensure at least 2 pins are in net + pin_count = 0 + + for curr_node in netitems: + pin_count += 1 + + if pin_count > 1: + return net_name + + return "" + else: + return net_name + + return "?" + + +class netlist: + """Kicad generic netlist class. Generally loaded from a kicad generic + netlist file. Includes several helper functions to ease BOM creating + scripts + + """ + + def __init__(self, fname=""): + """Initialiser for the genericNetlist class + + Keywords: + fname -- The name of the generic netlist file to open (Optional) + + """ + self.design = None + self.components = [] + self.libparts = [] + self.libraries = [] + self.nets = [] + + # The entire tree is loaded into self.tree + self.tree = [] + + self._curr_element = None + + # component blacklist regexs, made from excluded_* above. + self.excluded_references = [] + self.excluded_values = [] + self.excluded_footprints = [] + + if fname != "": + self.load(fname) + + def addChars(self, content): + """Add characters to the current element""" + self._curr_element.addChars(content) + + def addElement(self, name): + """Add a new kicad generic element to the list""" + if self._curr_element is None: + self.tree = xmlElement(name) + self._curr_element = self.tree + else: + self._curr_element = self._curr_element.addChild(xmlElement(name, self._curr_element)) + + # If this element is a component, add it to the components list + if self._curr_element.name == "comp": + self.components.append(comp(self._curr_element)) + + # Assign the design element + if self._curr_element.name == "design": + self.design = self._curr_element + + # If this element is a library part, add it to the parts list + if self._curr_element.name == "libpart": + self.libparts.append(libpart(self._curr_element)) + + # If this element is a net, add it to the nets list + if self._curr_element.name == "net": + self.nets.append(self._curr_element) + + # If this element is a library, add it to the libraries list + if self._curr_element.name == "library": + self.libraries.append(self._curr_element) + + return self._curr_element + + def endDocument(self): + """Called when the netlist document has been fully parsed""" + # When the document is complete, the library parts must be linked to + # the components as they are separate in the tree so as not to + # duplicate library part information for every component + for c in self.components: + for p in self.libparts: + if p.getLibName() == c.getLibName(): + if p.getPartName() == c.getPartName(): + c.setLibPart(p) + break + else: + aliases = p.getAliases() + if aliases and self.aliasMatch(c.getPartName(), aliases): + c.setLibPart(p) + break + + if not c.getLibPart(): + print("missing libpart for ref:", c.getRef(), c.getPartName(), c.getLibName()) + + def aliasMatch(self, partName, aliasList): + for alias in aliasList: + if partName == alias: + return True + return False + + def endElement(self): + """End the current element and switch to its parent""" + self._curr_element = self._curr_element.getParent() + + def getDate(self): + """Return the date + time string generated by the tree creation tool""" + return self.design.get("date") + + def getSource(self): + """Return the source string for the design""" + return self.design.get("source") + + def getTool(self): + """Return the tool string which was used to create the netlist tree""" + return self.design.get("tool") + + def getNets(self): + """Return the nets""" + return self.nets + + def gatherComponentFieldUnion(self, components=None): + """Gather the complete 'set' of unique component fields, fields found in any component.""" + if not components: + components = self.components + + s = set() + for c in components: + s.update(c.getFieldNames()) + + # omit anything matching any regex in excluded_fields + ret = set() + for field in s: + exclude = False + for rex in excluded_fields: + if re.match(rex, field): + exclude = True + break + if not exclude: + ret.add(field) + + return ret # this is a python 'set' + + def gatherLibPartFieldUnion(self): + """Gather the complete 'set' of part fields, fields found in any part.""" + s = set() + for p in self.libparts: + s.update(p.getFieldNames()) + + # omit anything matching any regex in excluded_fields + ret = set() + for field in s: + exclude = False + for rex in excluded_fields: + if re.match(rex, field): + exclude = True + break + if not exclude: + ret.add(field) + + return ret # this is a python 'set' + + def getInterestingComponents(self, excludeBOM=False, excludeBoard=False, DNP=False): + """Return a subset of all components, those that should show up in the BOM. + Omit those that should not, by consulting the blacklists: + excluded_values, excluded_refs, and excluded_footprints, which hold one + or more regular expressions. If any of the regular expressions match + the corresponding field's value in a component, then the component is excluded. + """ + + # pre-compile all the regex expressions: + del self.excluded_references[:] + del self.excluded_values[:] + del self.excluded_footprints[:] + + for rex in excluded_references: + self.excluded_references.append(re.compile(rex)) + + for rex in excluded_values: + self.excluded_values.append(re.compile(rex)) + + for rex in excluded_footprints: + self.excluded_footprints.append(re.compile(rex)) + + # the subset of components to return, considered as "interesting". + ret = [] + + # run each component thru a series of tests, if it passes all, then add it + # to the interesting list 'ret'. + for c in self.components: + exclude = False + if not exclude: + for refs in self.excluded_references: + if refs.match(c.getRef()): + exclude = True + break + if not exclude: + for vals in self.excluded_values: + if vals.match(c.getValue()): + exclude = True + break + if not exclude: + for mods in self.excluded_footprints: + if mods.match(c.getFootprint()): + exclude = True + break + + if excludeBOM and c.getExcludeFromBOM(): + exclude = True + + if excludeBoard and c.getExcludeFromBoard(): + exclude = True + + if DNP and c.getDNP(): + exclude = True + + if not exclude: + ret.append(c) + + # The key to sort the components in the BOM + # This sorts using a natural sorting order (e.g. 100 after 99), and if it wasn't used + # the normal sort would place 100 before 99 since it only would look at the first digit. + def sortKey(str): + return [int(t) if t.isdigit() else t.lower() for t in re.split("(\d+)", str)] + + ret.sort(key=lambda g: sortKey(g.getRef())) + + return ret + + def groupComponents(self, components=None): + """Return a list of component lists. Components are grouped together + when the value, library and part identifiers match. + + Keywords: + components -- is a list of components, typically an interesting subset + of all components, or None. If None, then all components are looked at. + """ + if not components: + components = self.components + + groups = [] + + # Make sure to start off will all components ungrouped to begin with + for c in components: + c.grouped = False + + # Group components based on the value, library and part identifiers + for c in components: + if c.grouped == False: + c.grouped = True + newgroup = [] + newgroup.append(c) + + # Check every other ungrouped component against this component + # and add to the group as necessary + for ci in components: + if ci.grouped == False and ci == c: + newgroup.append(ci) + ci.grouped = True + + # Add the new component group to the groups list + groups.append(newgroup) + + # The key to sort the components in the BOM + # This sorts using a natural sorting order (e.g. 100 after 99), and if it wasn't used + # the normal sort would place 100 before 99 since it only would look at the first digit. + def sortKey(str): + return [int(t) if t.isdigit() else t.lower() for t in re.split("(\d+)", str)] + + for g in groups: + # g = sorted(g, key=lambda g: sortKey(g.getRef())) + g.sort(key=lambda g: sortKey(g.getRef())) + + # Finally, sort the groups to order the references alphabetically + groups.sort(key=lambda group: sortKey(group[0].getRef())) + + return groups + + def getGroupField(self, group, field): + """Return the whatever is known about the given field by consulting each + component in the group. If any of them know something about the property/field, + then return that first non-blank value. + """ + for c in group: + ret = c.getField(field, False) + if ret != "": + return ret + + libpart = group[0].getLibPart() + if not libpart: + return "" + + return libpart.getField(field) + + def getGroupFootprint(self, group): + """Return the whatever is known about the Footprint by consulting each + component in the group. If any of them know something about the Footprint, + then return that first non-blank value. + """ + for c in group: + ret = c.getFootprint() + if ret != "": + return ret + return group[0].getLibPart().getFootprint() + + def getGroupDatasheet(self, group): + """Return the whatever is known about the Datasheet by consulting each + component in the group. If any of them know something about the Datasheet, + then return that first non-blank value. + """ + for c in group: + ret = c.getDatasheet() + if ret != "": + return ret + + if len(group) > 0: + return group[0].getLibPart().getDatasheet() + else: + print("NULL!") + return "" + + def formatXML(self): + """Return the whole netlist formatted in XML""" + return self.tree.formatXML() + + def formatHTML(self): + """Return the whole netlist formatted in HTML""" + return self.tree.formatHTML() + + def load(self, fname): + """Load a kicad generic netlist + + Keywords: + fname -- The name of the generic netlist file to open + + """ + try: + self._reader = sax.make_parser() + self._reader.setContentHandler(_gNetReader(self)) + self._reader.parse(fname) + except IOError as e: + print(__file__, ":", e, file=sys.stderr) + sys.exit(-1) + + +class _gNetReader(sax.handler.ContentHandler): + """SAX kicad generic netlist content handler - passes most of the work back + to the 'netlist' class which builds a complete tree in RAM for the design + + """ + + def __init__(self, aParent): + self.parent = aParent + + def startElement(self, name, attrs): + """Start of a new XML element event""" + element = self.parent.addElement(name) + + for name in attrs.getNames(): + element.addAttribute(name, attrs.getValue(name)) + + def endElement(self, name): + self.parent.endElement() + + def characters(self, content): + # Ignore erroneous white space - ignoreableWhitespace does not get rid + # of the need for this! + if not content.isspace(): + self.parent.addChars(content) + + def endDocument(self): + """End of the XML document event""" + self.parent.endDocument() diff --git a/plugins/kicad_utils.py b/plugins/kicad_utils.py new file mode 100644 index 0000000..08c1025 --- /dev/null +++ b/plugins/kicad_utils.py @@ -0,0 +1,28 @@ +# +# KiCad python module for some helper functions +# + +import os + + +def open_file_write(path, mode): + """Open "path" for writing, creating any parent directories as needed.""" + dir_path = os.path.dirname(path) + + if not os.path.isdir(dir_path): + os.makedirs(dir_path) + + return open(path, mode) + + +def open_file_writeUTF8(path, mode): + """ + Open "path" for writing, creating any parent directories as needed. + Use it only for text files. Force text encoding in UTF-8. + """ + dir_path = os.path.dirname(path) + + if not os.path.isdir(dir_path): + os.makedirs(dir_path) + + return open(path, mode, encoding="utf-8") diff --git a/templates/jlcpcb_template/release.py b/templates/jlcpcb_template/release.py index ae902dc..23f82d3 100644 --- a/templates/jlcpcb_template/release.py +++ b/templates/jlcpcb_template/release.py @@ -115,6 +115,8 @@ with zipfile.ZipFile(dir_gerber.with_suffix(".zip"), "w") as z: z.write(f, f.name) # %% Assembly files + +# Assembly Drawing subprocess.run( [ "kicad-cli", @@ -131,6 +133,26 @@ subprocess.run( check=True, ) +# Position Files +subprocess.run( + [ + "kicad-cli", + "pcb", + "export", + "pos", + str(root_project.with_suffix(".kicad_pcb")), + "-o", + str(dir_out / f"{project_name}.pos"), + "--format", + "ascii", + "--side", + "both", + ], + check=True, +) + + +# BOM subprocess.run( [ "kicad-cli", @@ -143,8 +165,17 @@ subprocess.run( ], check=True, ) -# TODO: post-process XML BOM into something more useful or skip XML altogether and just make an interactive HTML BOM +subprocess.run( + [ + "python3", + "/home/brendan/Documents/projects/bhht/common_libraries/plugins/bom_csv_grouped_extra.py", # TODO: move this + str(dir_out / f"{project_name}_bom.xml"), + str(dir_out / f"{project_name}_bom.csv"), + ], + check=True, +) +# # Interactive BOM # subprocess.run( # [ # "python3",