add KiBoM

This commit is contained in:
Brendan Haines 2020-02-04 17:33:02 -07:00
parent fa80b9822e
commit b66c02d753
28 changed files with 2948 additions and 0 deletions

18
scripts/KiBoM/.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
__pycache__/
*.py[cod]
*$py.class
*.pyc
*.kicad_mod
.idea/
.env
.venv
env/
venv/
VENV/
ENV/
env.bak/
venv.bak/
.python-version

14
scripts/KiBoM/.travis.yml Normal file
View File

@ -0,0 +1,14 @@
# travis-ci integration for KiBOM
language:
- python
python:
- "2.7"
- "3.7"
before_install:
- sudo apt-get install flake8
script:
- flake8 .

210
scripts/KiBoM/KiBOM_CLI.py Normal file
View File

@ -0,0 +1,210 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@package
KiBOM - Bill of Materials generation for KiCad
Generate BOM in xml, csv, txt, tsv, html or xlsx formats.
- Components are automatically grouped into BoM rows (grouping is configurable)
- Component groups count number of components and list component designators
- Rows are automatically sorted by component reference(s)
- Supports board variants
Extended options are available in the "bom.ini" config file in the PCB directory (this file is auto-generated with default options the first time the script is executed).
"""
from __future__ import print_function
import sys
import os
import argparse
from bomlib.columns import ColumnList
from bomlib.netlist_reader import netlist
from bomlib.bom_writer import WriteBoM
from bomlib.preferences import BomPref
try:
import xlsxwriter # noqa: F401
except:
xlsxwriter_available = False
else:
xlsxwriter_available = True
here = os.path.abspath(os.path.dirname(sys.argv[0]))
sys.path.append(here)
sys.path.append(os.path.join(here, "KiBOM"))
verbose = False
def close(*arg):
print(*arg)
sys.exit(0)
def say(*arg):
# Simple debug message handler
if verbose:
print(*arg)
def isExtensionSupported(filename):
result = False
extensions = [".xml", ".csv", ".txt", ".tsv", ".html"]
if xlsxwriter_available:
extensions.append(".xlsx")
for e in extensions:
if filename.endswith(e):
result = True
break
return result
def writeVariant(variant, subdirectory):
if variant is not None:
pref.pcbConfig = variant.strip().split(',')
print("PCB variant: ", ", ".join(pref.pcbConfig))
# Write preference file back out (first run will generate a file with default preferences)
if not have_cfile:
pref.Write(config_file)
say("Writing preferences file %s" % (config_file,))
# Individual components
components = []
# Component groups
groups = []
# Read out the netlist
net = netlist(input_file, prefs=pref)
# Extract the components
components = net.getInterestingComponents()
# Group the components
groups = net.groupComponents(components)
columns = ColumnList(pref.corder)
# Read out all available fields
for g in groups:
for f in g.fields:
columns.AddColumn(f)
# Don't add 'boards' column if only one board is specified
if pref.boards <= 1:
columns.RemoveColumn(ColumnList.COL_GRP_BUILD_QUANTITY)
say("Removing:", ColumnList.COL_GRP_BUILD_QUANTITY)
# Finally, write the BoM out to file
if write_to_bom:
output_file = args.output
if output_file is None:
output_file = input_file.replace(".xml", ".csv")
output_path, output_name = os.path.split(output_file)
output_name, output_ext = os.path.splitext(output_name)
# KiCad BOM dialog by default passes "%O" without an extension. Append our default
if not isExtensionSupported(output_ext):
output_ext = ".csv"
# Make replacements to custom file_name.
file_name = pref.outputFileName
file_name = file_name.replace("%O", output_name)
file_name = file_name.replace("%v", net.getVersion())
if variant is not None:
file_name = file_name.replace("%V", pref.variantFileNameFormat)
file_name = file_name.replace("%V", variant)
else:
file_name = file_name.replace("%V", "")
if args.subdirectory is not None:
output_path = os.path.join(output_path, args.subdirectory)
if not os.path.exists(os.path.abspath(output_path)):
os.makedirs(os.path.abspath(output_path))
output_file = os.path.join(output_path, file_name + output_ext)
output_file = os.path.abspath(output_file)
say("Output:", output_file)
return WriteBoM(output_file, groups, net, columns.columns, pref)
parser = argparse.ArgumentParser(description="KiBOM Bill of Materials generator script")
parser.add_argument("netlist", help='xml netlist file. Use "%%I" when running from within KiCad')
parser.add_argument("output", default="", help='BoM output file name.\nUse "%%O" when running from within KiCad to use the default output name (csv file).\nFor e.g. HTML output, use "%%O.html"')
parser.add_argument("-n", "--number", help="Number of boards to build (default = 1)", type=int, default=None)
parser.add_argument("-v", "--verbose", help="Enable verbose output", action='count')
parser.add_argument("-r", "--variant", help="Board variant(s), used to determine which components are output to the BoM. Comma-separate for multiple.", type=str, default=None)
parser.add_argument("-d", "--subdirectory", help="Subdirectory within which to store the generated BoM files.", type=str, default=None)
parser.add_argument("--cfg", help="BoM config file (script will try to use 'bom.ini' if not specified here)")
parser.add_argument("-s", "--separator", help="CSV Separator (default ',')", type=str, default=None)
args = parser.parse_args()
input_file = args.netlist
if not input_file.endswith(".xml"):
close("{i} is not a valid xml file".format(i=input_file))
verbose = args.verbose is not None
input_file = os.path.abspath(input_file)
say("Input:", input_file)
# Look for a config file!
# bom.ini by default
ini = os.path.abspath(os.path.join(os.path.dirname(input_file), "bom.ini"))
# Default value
config_file = ini
# User can overwrite with a specific config file
if args.cfg:
config_file = args.cfg
# Read preferences from file. If file does not exists, default preferences will be used
pref = BomPref()
have_cfile = os.path.exists(config_file)
if have_cfile:
pref.Read(config_file)
say("Config:", config_file)
# Pass available modules
pref.xlsxwriter_available = xlsxwriter_available
# Pass various command-line options through
pref.verbose = verbose
if args.number is not None:
pref.boards = args.number
pref.separatorCSV = args.separator
write_to_bom = True
if args.variant is not None:
variants = args.variant.split(';')
else:
variants = [None]
for variant in variants:
result = writeVariant(variant, args)
if not result:
sys.exit(-1)
sys.exit(0)

7
scripts/KiBoM/LICENSE.md Normal file
View File

@ -0,0 +1,7 @@
Copyright (c) 2016 KiBOM
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

439
scripts/KiBoM/README.md Normal file
View File

@ -0,0 +1,439 @@
# KiBoM
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Travis Status](https://api.travis-ci.org/SchrodingersGat/KiBoM.svg?branch=master)](https://travis-ci.org/SchrodingersGat/KiBoM)
Configurable BoM generation tool for KiCad EDA (http://kicad-pcb.org/)
## Description
KiBoM is a configurable BOM (Bill of Materials) generation tool for KiCad EDA. Written in Python, it can be used directly with KiCad software without the need for any external libraries or plugins.
KiBoM intelligently groups components based on multiple factors, and can generate BoM files in multiple output formats.
BoM options are user-configurable in a per-project configuration file.
## Usage
The *KiBOM_CLI* script can be run directly from KiCad or from the command line. For command help, run the script with the *-h* flag e.g.
`python KiBOM_CLI.py -h`
~~~~
usage: KiBOM_CLI.py [-h] [-n NUMBER] [-v] [-r VARIANT] [-d SUBDIRECTORY]
[--cfg CFG] [-s SEPARATOR]
netlist output
KiBOM Bill of Materials generator script
positional arguments:
netlist xml netlist file. Use "%I" when running from within
KiCad
output BoM output file name. Use "%O" when running from
within KiCad to use the default output name (csv
file). For e.g. HTML output, use "%O.html"
optional arguments:
-h, --help show this help message and exit
-n NUMBER, --number NUMBER
Number of boards to build (default = 1)
-v, --verbose Enable verbose output
-r VARIANT, --variant VARIANT
Board variant(s), used to determine which components
are output to the BoM. Comma-separate for multiple.
-d SUBDIRECTORY, --subdirectory SUBDIRECTORY
Subdirectory within which to store the generated BoM
files.
--cfg CFG BoM config file (script will try to use 'bom.ini' if
not specified here)
-s SEPARATOR, --separator SEPARATOR
CSV Separator (default ',')
~~~~
**netlist** The netlist must be provided to the script. When running from KiCad use "%I"
**output** This is the path to the BoM output. When running from KiCad, usage "%O" for the default option
* If a suffix is not specified, CSV output format will be used
* HTML output can be specified within KiCad as: "%O.html" or "%O_BOM.html" (etc)
* XML output can be specified within KiCad as: "%O.xml" (etc)
* XSLX output can be specified within KiCad as: "%O.xlsx" (etc)
**-n --number** Specify number of boards for calculating part quantities
**-v --verbose** Enable extra debugging information
**-r --variant** Specify the PCB *variant(s)*. Support for arbitrary PCB variants allows individual components to be marked as 'fitted' or 'not fitted' in a given variant. You can provide muliple variants comma-separated. You can generate multiple BoMs at once for different variants by using semicolon-separation.
**-d --subdirectory** Specify a subdirectory (from the provided **output** file) into which the boms should be generated.
**--cfg** If provided, this is the BoM config file that will be used. If not provided, options will be loaded from "bom.ini"
**-s --separator** Override the delimiter for CSV or TSV generation
--------
To run from KiCad, simply add the same command line in the *Bill of Materials* script window. e.g. to generate a HTML output:
![alt tag](example/html_ex.png?raw=True "HTML Example")
## Quick Start
Download and unzip the files almost anywhere.
When you start the KiCad schematic editor and choose *Tools>Generate Bill of Materials* expect a *Bill of Material* dialog. Choose the *Add Plugin* button, expect a file chooser dialog. Navigate to where you unzipped the files, select the KiBOM_CLI.py file, and choose the *Open* button. Expect another confirmation dialog and choose *OK*. Expect the *Command Line:* text box to be filled in, and for a description of the plugin to appear in the *Plugin Info* text box. Choose the *Generate* button. Expect some messages in the *Plugin Info* text box, and for a .csv file to exist in your KiCad project directory.
If you want other than .csv format, edit the *Command Line*, for example inserting ".html" after the "%O".
If you want more columns in your BoM, before you generate your BoM, in the schematic editor choose *Preferences>Schematic Editor Options* and create new rows in the *Template Field Names* tab. Then edit your components and fill in the fields. KiBoM will reasonably sum rows in the BoM having the same values in your fields. For example, if you have two components both with Vendor=Digikey and SKU=877-5309 (and value and footprints equal), there will be one row with Quantity "2" and References e.g. "R1, R2."
## Features
### Intelligent Component Grouping
To be useful for ordering components, the BoM output from a KiCad project should be organized into sensible component groups. By default, KiBoM groups components based on the following factors:
* Part name: (e.g. 'R' for resistors, 'C' for capacitors, or longer part names such as 'MAX232') *note: parts such as {'R','r_small'} (which are different symbol representations for the same component) can also be grouped together*
* Value: Components must have the same value to be grouped together
* Footprint: Components must have the same footprint to be grouped together *(this option can be enabled/disabled in the bom.ini configuration file)*
#### Custom Column Grouping
If the user wishes to group components based on additional field values, these can be specified in the preferences (.ini) file
### Intelligent Value Matching
Some component values can be expressed in multiple ways (e.g. "0.1uF" === "100n" for a capacitor). KiBoM matches value strings based on their interpreted numerical value, such that components are grouped together even if their values are expressed differently.
### Field Extraction
In addition to the default KiCad fields which are assigned to each component, KiBoM extracts and custom fields added to the various components.
**Default Fields**
The following default fields are extracted and can be added to the output BoM file:
* `Description` : Part description as per the schematic symbol
* `References` : List of part references included in a particular group
* `Quantity` : Number of components included in a particular group
* `Part` : Part name as per the schematic symbol
* `Part Lib` : Part library for the symbol *(default - not output to BoM file)*
* `Footprint` : Part footprint
* `Footprint Lib` : Part footprint library *(default - not output to BoM file)*
* `Datasheet` : Component datasheet extracted either from user-included data, or from part library
**User Fields**
If any components have custom fields added, these are available to the output BoM file.
### Multiple PCB Configurations
KiBoM allows for arbitrary PCB configurations, which means that the user can specify that individual components will be included or excluded from the BoM in certain circumstances.
The preferences (.ini) file provides the *fit_field* option which designates a particular part field (default = "Config") which the user can specify whether or not a part is to be included.
**DNF Parts**
To specify a part as DNF (do not fit), the *fit_field* field can be set to one of the following values: (case insensitive)
* "dnf"
* "do not fit"
* "nofit"
* "not fitted"
* "dnp"
* "do not place"
* "no stuff"
* "nostuff"
* "noload"
* "do not load"
**Note:**
If the *Value* field for the component contains any of these values, the component will also not be included
**PCB Variants**
To generate a BoM with a custom *Variant*, the --variant flag can be used at the command line to specify which variant is to be used.
If a variant is specified, the value of the *fit_field* field is used to determine if a component will be included in the BoM, as follows:
* If the *fit_field* value is empty / blank then it will be loaded in ALL variants.
* If the *fit_field* begins with a '-' character, if will be excluded from the matching variant.
* If the *fit_field* begins with a '+' character, if will ONLY be included in the matching variant.
Multiple variants can be addressed as the *fit_field* can contain multiple comma-separated values. Multiple BoMs can be generated at once by using semicolon-separated values.
* If you specify multiple variants
- If the *fit_field* contains the variant beginning with a '-' character, it will be excluded irrespective of any other '+' matches.
- If the *fit_field* contains the variant beginning with a '+' and matches any of the given variants, it will be included.
e.g. if we have a PCB with three components that have the following values in the *fit_field* field:
* C1 -> "-production,+test"
* C2 -> "+production,+test"
* R1 -> ""
* R2 -> "-test"
If the script is run with the flag *--variant production* then C2, R1 and R2 will be loaded.
If the script is run without the *--variant production* flag, then R1 and R2 will be loaded.
If the script is run with the flag *--variant test*, then C1, C2 and R1 will be loaded.
If the script is run with the flags *--variant production,test*, then C2 and R1 will be loaded.
If the script is run with the flags *--variant production;test;production,test*, then three separate BoMs will be generated one as though it had been run with *--variant production*, one for *--variant test*, and one for *--variant production,test*.
### Regular Expression Matching
KiBoM features two types of regex matching : "Include" and "Exclude" (each of these are specified within the preferences (bom.ini) file).
If the user wishes to include ONLY parts that match one-of-many regular expressions, these can be specified in REGEX_INCLUDE section of the bom.ini file
If the user wishes to exclude components based on one-of-many regular expressions, these are specified in the REGEX_EXCLUDE section of the bom.ini file
(Refer to the default bom.ini file for examples)
### Multiple File Outputs
Multiple BoM output formats are supported:
* CSV (Comma separated values)
* TSV (Tab separated values)
* TXT (Text file output with tab separated values)
* XML
* HTML
* XLSX (Needs XlsxWriter Python module)
Output file format selection is set by the output filename. e.g. "bom.html" will be written to a HTML file, "bom.csv" will be written to a CSV file.
### Configuration File
BoM generation options can be configured (on a per-project basis) by editing the *bom.ini* file in the PCB project directory. This file is generated the first time that the KiBoM script is run, and allows configuration of the following options.
* `ignore_dnf` : Component groups designated as 'DNF' (do not fit) will be excluded from the BoM output
* `use_alt` : If this option is set, grouped references will be printed in the alternate compressed style eg: R1-R7,R18
* `alt_wrap` : If this option is set to an integer `N`, the references field will wrap after `N` entries are printed
* `number_rows` : Add row numbers to the BoM output
* `group_connectors` : If this option is set, connector comparison based on the 'Value' field is ignored. This allows multiple connectors which are named for their function (e.g. "Power", "ICP" etc) can be grouped together.
* `test_regex` : If this option is set, each component group row is test against a list of (user configurable) regular expressions. If any matches are found, that row is excluded from the output BoM file.
* `merge_blank_field` : If this option is set, blank fields are able to be merged with non-blank fields (and do not count as a 'conflict')
* `fit_field` : This is the name of the part field used to determine if the component is fitted, or not.
* `output_file_name` : A string that allows arbitrary specification of the output file name with field replacements. Fields available:
- `%O` : The base output file name (pulled from kicad, or specified on command line when calling script).
- `%v` : version number.
- `%V` : variant name, note that this will be ammended according to `variant_file_name_format`.
* `variant_file_name_format` : A string that defines the variant file format. This is a unique field as the variant is not always used/specified.
* `make_backup` : If this option is set, a backup of the bom created before generating the new one. The option is a string that allows arbitrary specification of the filename. See `output_file_name` for available fields.
* `number_boards` : Specifies the number of boards to produce, if none is specified on CLI with `-n`.
* `board_variant` : Specifies the name of the PCB variant, if none is specified on CLI with `-r`.
* `hide_headers` : If this option is set, the table/column headers and legends are suppressed in the output file.
* `hide_pcb_info` : If this option is set, PCB information (version, component count, etc) are suppressed in the output file.
* `IGNORE_COLUMNS` : A list of columns can be marked as 'ignore', and will not be output to the BoM file. By default, the *Part_Lib* and *Footprint_Lib* columns are ignored.
* `GROUP_FIELDS` : A list of component fields used to group components together.
* `COMPONENT_ALIASES` : A list of space-separated values which allows multiple schematic symbol visualisations to be consolidated.
* `REGEX_INCLUDE` : A list of regular expressions used to explicitly include components. If there are no regex here, all components pass this test. If there are regex here, then a component must match at least one of them to be included in the BoM.
* `REGEX_EXCLUDE` : If a component matches any of these regular expressions, it will *not* be included in the BoM.
Example configuration file (.ini format) *default values shown*
~~~~
[BOM_OPTIONS]
; General BoM options here
; If 'ignore_dnf' option is set to 1, rows that are not to be fitted on the PCB will not be written to the BoM file
ignore_dnf = 1
; If 'use_alt' option is set to 1, grouped references will be printed in the alternate compressed style eg: R1-R7,R18
use_alt = 0
; If 'alt_wrap' option is set to and integer N, the references field will wrap after N entries are printed
alt_wrap = 0
; If 'number_rows' option is set to 1, each row in the BoM will be prepended with an incrementing row number
number_rows = 1
; If 'group_connectors' option is set to 1, connectors with the same footprints will be grouped together, independent of the name of the connector
group_connectors = 1
; If 'test_regex' option is set to 1, each component group will be tested against a number of regular-expressions (specified, per column, below). If any matches are found, the row is ignored in the output file
test_regex = 1
; If 'merge_blank_fields' option is set to 1, component groups with blank fields will be merged into the most compatible group, where possible
merge_blank_fields = 1
; Specify output file name format, %O is the defined output name, %v is the version, %V is the variant name which will be ammended according to 'variant_file_name_format'.
output_file_name = %O_bom_%v%V
; Specify the variant file name format, this is a unique field as the variant is not always used/specified. When it is unused you will want to strip all of this.
variant_file_name_format = _(%V)
; Field name used to determine if a particular part is to be fitted
fit_field = Config
; Make a backup of the bom before generating the new one, using the following template
make_backup = %O.tmp
; Default number of boards to produce if none given on CLI with -n
number_boards = 1
; Default PCB variant if none given on CLI with -r
board_variant = "default"
; When set to 1, suppresses table/column headers and legends in the output file.
; May be useful for testing purposes.
hide_headers = 0
; When set to 1, PCB information (version, component count, etc) is not shown in the output file.
; Useful for saving space in the HTML output and for ensuring CSV output is machine-parseable.
hide_pcb_info = 0
[IGNORE_COLUMNS]
; Any column heading that appears here will be excluded from the Generated BoM
; Titles are case-insensitive
Part Lib
Footprint Lib
[COLUMN_ORDER]
; Columns will apear in the order they are listed here
; Titles are case-insensitive
Description
Part
Part Lib
References
Value
Footprint
Footprint Lib
Quantity Per PCB
Build Quantity
Datasheet
[GROUP_FIELDS]
; List of fields used for sorting individual components into groups
; Components which match (comparing *all* fields) will be grouped together
; Field names are case-insensitive
Part
Part Lib
Value
Footprint
Footprint Lib
[COMPONENT_ALIASES]
; A series of values which are considered to be equivalent for the part name
; Each line represents a tab-separated list of equivalent component name values
; e.g. 'c c_small cap' will ensure the equivalent capacitor symbols can be grouped together
; Aliases are case-insensitive
c c_small cap capacitor
r r_small res resistor
sw switch
l l_small inductor
zener zenersmall
d diode d_small
[REGEX_INCLUDE]
; A series of regular expressions used to include parts in the BoM
; If there are any regex defined here, only components that match against ANY of them will be included in the BOM
; Column names are case-insensitive
; Format is: "ColumName Regex" (tab-separated)
[REGEX_EXCLUDE]
; A series of regular expressions used to exclude parts from the BoM
; If a component matches ANY of these, it will be excluded from the BoM
; Column names are case-insensitive
; Format is: "ColumName Regex" (tab-separated)
References ^TP[0-9]*
References ^FID
Part mount.*hole
Part solder.*bridge
Part test.*point
Footprint test.*point
Footprint mount.*hole
Footprint fiducial
~~~~
## Example
A simple schematic is shown below. Here a number of resistors, capacitors, and one IC have been added to demonstrate the BoM output capability. Some of the components have custom fields added ('Vendor', 'Rating', 'Notes')
![alt tag](example/schem.png?raw=True "Schematic")
Here, a number of logical groups can be seen:
**R1 R2**
Resistors R1 and R2 have the same value (470 Ohm) even though the value is expressed differently.
Resistors R1 and R2 have the same footprint
**R3 R4**
Resistors R3 and R4 have the same value and the same footprint
**R5**
While R5 has the same value as R3 and R4, it is in a different footprint and thus cannot be placed in the same group.
**C1 C2**
C1 and C2 have the same value and footprint
**C3 C5**
C3 and C5 have the same value and footprint
**C4**
C4 has a different footprint to C3 and C5, and thus is grouped separately
A HTML BoM file is generated as follows:
![alt tag](example/bom.png?raw=True "BoM")
To add the BoM script, the Command Line options should be configured as follows:
* path-to-python-script (KiBOM_CLI.py)
* netlist-file "%I"
* output_path "%O_bom.html" (replace file extension for different output file formats)
Hit the "Generate" button, and the output window should show that the BoM generation was successful.
### HTML Output
The output HTML file is generated as follows:
![alt tag](example/html_ex.png?raw=True "HTML Gen")
![alt tag](example/html.png?raw=True "HTML Output")
Here the components are correctly grouped, with links to datasheets where appropriate, and fields color-coded.
### CSV Output
A CSV file output can be generated simply by changing the file extension
Component,Description,Part,References,Value,Footprint,Quantity,Datasheet,Rating,Vendor,Notes
1,Unpolarized capacitor,C,C1 C2,0.1uF,C_0805,2,,,,
2,Unpolarized capacitor,C,C3 C5,2.2uF,C_0805,2,,,,
3,Unpolarized capacitor,C,C4,2.2uF,C_0603,1,,100V X7R,,
4,"Connector, single row, 01x09",CONN_01X09,P2,Comms,JST_XH_S09B-XH-A_09x2.50mm_Angled,1,,,,
5,"Connector, single row, 01x09",CONN_01X09,P1,Power,JST_XH_S09B-XH-A_09x2.50mm_Angled,1,,,,
6,Resistor,R,R3 R4,100,R_0805,2,,,,
7,Resistor,R,R5,100,R_0603,1,,0.5W 0.5%,,
8,Resistor,R,R1 R2,470R,R_0805,2,,,Digikey,
9,"Dual RS232 driver/receiver, 5V supply, 120kb/s, 0C-70C",MAX232,U1,MAX232,DIP-16_W7.62mm,1 (DNF),http://www.ti.com/lit/ds/symlink/max232.pdf,,,Do not fit
Component Count:,13
Component Groups:,9
Schematic Version:,A.1
Schematic Date:,2016-05-15
BoM Date:,15-May-16 5:27:07 PM
Schematic Source:,C:/bom_test/Bom_Test.sch
KiCad Version:,"Eeschema (2016-05-06 BZR 6776, Git 63decd7)-product"
### XML Output
An XML file output can be generated simply by changing the file extension
<?xml version="1.0" ?>
<KiCad_BOM BOM_Date="14-Jan-18 5:27:03 PM" KiCad_Version="Eeschema (2016-05-06 BZR 6776, Git 63decd7)-product" Schematic_Date="2016-05-15" Schematic_Source="C:/bom_test/Bom_Test.sch" Schematic_Version="A.1" components="13" groups="9">
<group Datasheet="" Description="Unpolarized capacitor" Footprint="C_0805" Notes="" Part="C" Quantity="2" Rating="" References="C1 C2" Value="0.1uF" Vendor=""/>
<group Datasheet="" Description="Unpolarized capacitor" Footprint="C_0805" Notes="" Part="C" Quantity="2" Rating="" References="C3 C5" Value="2.2uF" Vendor=""/>
<group Datasheet="" Description="Unpolarized capacitor" Footprint="C_0603" Notes="" Part="C" Quantity="1" Rating="100V X7R" References="C4" Value="2.2uF" Vendor=""/>
<group Datasheet="" Description="Connector, single row, 01x09" Footprint="JST_XH_S09B-XH-A_09x2.50mm_Angled" Notes="" Part="CONN_01X09" Quantity="1" Rating="" References="P2" Value="Comms" Vendor=""/>
<group Datasheet="" Description="Connector, single row, 01x09" Footprint="JST_XH_S09B-XH-A_09x2.50mm_Angled" Notes="" Part="CONN_01X09" Quantity="1" Rating="" References="P1" Value="Power" Vendor=""/>
<group Datasheet="" Description="Resistor" Footprint="R_0805" Notes="" Part="R" Quantity="2" Rating="" References="R3 R4" Value="100" Vendor=""/>
<group Datasheet="" Description="Resistor" Footprint="R_0603" Notes="" Part="R" Quantity="1" Rating="0.5W 0.5%" References="R5" Value="100" Vendor=""/>
<group Datasheet="" Description="Resistor" Footprint="R_0805" Notes="" Part="R" Quantity="2" Rating="" References="R1 R2" Value="470R" Vendor="Digikey"/>
<group Datasheet="http://www.ti.com/lit/ds/symlink/max232.pdf" Description="Dual RS232 driver/receiver, 5V supply, 120kb/s, 0C-70C" Footprint="DIP-16_W7.62mm" Notes="Do not fit" Part="MAX232" Quantity="1 (DNF)" Rating="" References="U1" Value="MAX232" Vendor=""/>
</KiCad_BOM>
### XLSX Output
An XLSX file output can be generated simply by changing the file extension
## Contributors
With thanks to the following contributors:
* https://github.com/bootchk
* https://github.com/diegoherranz
* https://github.com/kylemanna
* https://github.com/pointhi
* https://github.com/schneidersoft
* https://github.com/suzizecat
* https://github.com/marcelobarrosalmeida
* https://github.com/fauxpark
* https://github.com/Swij
* https://github.com/Ximi1970
* https://github.com/AngusP
* https://github.com/trentks

View File

View File

View File

@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
from bomlib.csv_writer import WriteCSV
from bomlib.xml_writer import WriteXML
from bomlib.html_writer import WriteHTML
from bomlib.xlsx_writer import WriteXLSX
import bomlib.columns as columns
from bomlib.preferences import BomPref
import os
import shutil
def TmpFileCopy(filename, fmt):
# Make a tmp copy of a given file
filename = os.path.abspath(filename)
if os.path.exists(filename) and os.path.isfile(filename):
shutil.copyfile(filename, fmt.replace("%O", filename))
def WriteBoM(filename, groups, net, headings=columns.ColumnList._COLUMNS_DEFAULT, prefs=None):
"""
Write BoM to file
filename = output file path
groups = [list of ComponentGroup groups]
headings = [list of headings to display in the BoM file]
prefs = BomPref object
"""
filename = os.path.abspath(filename)
# No preferences supplied, use defaults
if not prefs:
prefs = BomPref()
# Remove any headings that appear in the ignore[] list
headings = [h for h in headings if not h.lower() in [i.lower() for i in prefs.ignore]]
# If no extension is given, assume .csv (and append!)
if len(filename.split('.')) < 2:
filename += ".csv"
# Make a temporary copy of the output file
if prefs.backup is not False:
TmpFileCopy(filename, prefs.backup)
ext = filename.split('.')[-1].lower()
result = False
# CSV file writing
if ext in ["csv", "tsv", "txt"]:
if WriteCSV(filename, groups, net, headings, prefs):
print("CSV Output -> {fn}".format(fn=filename))
result = True
else:
print("Error writing CSV output")
elif ext in ["htm", "html"]:
if WriteHTML(filename, groups, net, headings, prefs):
print("HTML Output -> {fn}".format(fn=filename))
result = True
else:
print("Error writing HTML output")
elif ext in ["xml"]:
if WriteXML(filename, groups, net, headings, prefs):
print("XML Output -> {fn}".format(fn=filename))
result = True
else:
print("Error writing XML output")
elif ext in ["xlsx"] and prefs.xlsxwriter_available:
if WriteXLSX(filename, groups, net, headings, prefs):
print("XLSX Output -> {fn}".format(fn=filename))
result = True
else:
print("Error writing XLSX output")
else:
print("Unsupported file extension: {ext}".format(ext=ext))
return result

View File

@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
class ColumnList:
# Default columns (immutable)
COL_REFERENCE = 'References'
COL_DESCRIPTION = 'Description'
COL_VALUE = 'Value'
COL_FP = 'Footprint'
COL_FP_LIB = 'Footprint Lib'
COL_PART = 'Part'
COL_PART_LIB = 'Part Lib'
COL_DATASHEET = 'Datasheet'
# Default columns for groups
COL_GRP_QUANTITY = 'Quantity Per PCB'
COL_GRP_TOTAL_COST = 'Total Cost'
COL_GRP_BUILD_QUANTITY = 'Build Quantity'
# Generated columns
_COLUMNS_GEN = [
COL_GRP_QUANTITY,
COL_GRP_BUILD_QUANTITY,
]
# Default columns
_COLUMNS_DEFAULT = [
COL_DESCRIPTION,
COL_PART,
COL_PART_LIB,
COL_REFERENCE,
COL_VALUE,
COL_FP,
COL_FP_LIB,
COL_GRP_QUANTITY,
COL_GRP_BUILD_QUANTITY,
COL_DATASHEET
]
# Default columns
# These columns are 'immutable'
_COLUMNS_PROTECTED = [
COL_REFERENCE,
COL_GRP_QUANTITY,
COL_VALUE,
COL_PART,
COL_PART_LIB,
COL_DESCRIPTION,
COL_DATASHEET,
COL_FP,
COL_FP_LIB
]
def __str__(self):
return " ".join(map(str, self.columns))
def __repr__(self):
return self.__str__()
def __init__(self, cols=_COLUMNS_DEFAULT):
self.columns = []
# Make a copy of the supplied columns
for col in cols:
self.AddColumn(col)
def _hasColumn(self, col):
# Col can either be <str> or <Column>
return col.lower() in [c.lower() for c in self.columns]
"""
Remove a column from the list. Specify either the heading or the index
"""
def RemoveColumn(self, col):
if type(col) is str:
self.RemoveColumnByName(col)
elif type(col) is int and col >= 0 and col < len(self.columns):
self.RemoveColumnByName(self.columns[col])
def RemoveColumnByName(self, name):
# First check if this is in an immutable colum
if name in self._COLUMNS_PROTECTED:
return
# Column does not exist, return
if name not in self.columns:
return
try:
index = self.columns.index(name)
del self.columns[index]
except ValueError:
return
# Add a new column (if it doesn't already exist!)
def AddColumn(self, col, index=None):
# Already exists?
if self._hasColumn(col):
return
if type(index) is not int or index < 0 or index >= len(self.columns):
self.columns.append(col)
# Otherwise, splice the new column in
else:
self.columns = self.columns[0:index] + [col] + self.columns[index:]
if __name__ == '__main__':
c = ColumnList()
c.AddColumn("Test1")
c.AddColumn("Test1")
c.AddColumn("Test2")
c.AddColumn("Test3")
c.AddColumn("Test4")
c.AddColumn("Test2")
c.RemoveColumn("Test2")
c.RemoveColumn("Part")
c.RemoveColumn(2)
c.RemoveColumn(5)

View File

@ -0,0 +1,576 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from bomlib.columns import ColumnList
from bomlib.preferences import BomPref
import bomlib.units as units
from bomlib.sort import natural_sort
import re
import sys
DNF = [
"dnf",
"dnl",
"dnp",
"do not fit",
"do not place",
"do not load",
"nofit",
"nostuff",
"noplace",
"noload",
"not fitted",
"not loaded",
"not placed",
"no stuff",
]
class Component():
"""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, prefs=None):
self.element = xml_element
self.libpart = None
if not prefs:
prefs = BomPref()
self.prefs = prefs
# Set to true when this component is included in a component group
self.grouped = False
# Compare the value of this part, to the value of another part (see if they match)
def compareValue(self, other):
# Simple string comparison
if self.getValue().lower() == other.getValue().lower():
return True
# Otherwise, perform a more complicated value comparison
if units.compareValues(self.getValue(), other.getValue()):
return True
# Ignore value if both components are connectors
if self.prefs.groupConnectors:
if 'connector' in self.getLibName().lower() and 'connector' in other.getLibName().lower():
return True
# No match, return False
return False
# Determine if two parts have the same name
def comparePartName(self, other):
pn1 = self.getPartName().lower()
pn2 = other.getPartName().lower()
# Simple direct match
if pn1 == pn2:
return True
# Compare part aliases e.g. "c" to "c_small"
for alias in self.prefs.aliases:
if pn1 in alias and pn2 in alias:
return True
return False
def compareField(self, other, field):
this_field = self.getField(field).lower()
other_field = other.getField(field).lower()
# If blank comparisons are allowed
if this_field == "" or other_field == "":
if not self.prefs.mergeBlankFields:
return False
if this_field == other_field:
return True
return False
def __eq__(self, other):
"""
Equivalency operator is used to determine if two parts are 'equal'
"""
# 'fitted' value must be the same for both parts
if self.isFitted() != other.isFitted():
return False
if len(self.prefs.groups) == 0:
return False
for c in self.prefs.groups:
# Perform special matches
if c.lower() == ColumnList.COL_VALUE.lower():
if not self.compareValue(other):
return False
# Match part name
elif c.lower() == ColumnList.COL_PART.lower():
if not self.comparePartName(other):
return False
# Generic match
elif not self.compareField(other, c):
return False
return True
def setLibPart(self, part):
self.libpart = part
def getPrefix(self):
"""
Get the reference prefix
e.g. if this component has a reference U12, will return "U"
"""
prefix = ""
for c in self.getRef():
if c.isalpha():
prefix += c
else:
break
return prefix
def getSuffix(self):
"""
Return the reference suffix #
e.g. if this component has a reference U12, will return "12"
"""
suffix = ""
for c in self.getRef():
if c.isalpha():
suffix = ""
else:
suffix += c
return int(suffix)
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 getDescription(self):
try:
return self.element.get("libsource", "description")
except:
# Compatibility with old KiCad versions (4.x)
ret = self.element.get("field", "name", "description")
if ret == "":
ret = self.libpart.getDescription()
return ret
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, ignoreCase=True, libraryToo=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
libraryToo -- look in the libpart's fields for the same name if not found
in component itself
"""
fp = self.getFootprint().split(":")
if name.lower() == ColumnList.COL_REFERENCE.lower():
return self.getRef().strip()
elif name.lower() == ColumnList.COL_DESCRIPTION.lower():
return self.getDescription().strip()
elif name.lower() == ColumnList.COL_DATASHEET.lower():
return self.getDatasheet().strip()
# Footprint library is first element
elif name.lower() == ColumnList.COL_FP_LIB.lower():
if len(fp) > 1:
return fp[0].strip()
else:
# Explicit empty return
return ""
elif name.lower() == ColumnList.COL_FP.lower():
if len(fp) > 1:
return fp[1].strip()
elif len(fp) == 1:
return fp[0]
else:
return ""
elif name.lower() == ColumnList.COL_VALUE.lower():
return self.getValue().strip()
elif name.lower() == ColumnList.COL_PART.lower():
return self.getPartName().strip()
elif name.lower() == ColumnList.COL_PART_LIB.lower():
return self.getLibName().strip()
# Other fields (case insensitive)
for f in self.getFieldNames():
if f.lower() == name.lower():
field = self.element.get("field", "name", f)
if field == "" and libraryToo:
field = self.libpart.getField(f)
return field.strip()
# Could not find a matching field
return ""
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")
# Determine if a component is FITTED or not
def isFitted(self):
check = self.getField(self.prefs.configField).lower()
# Check the value field first
if self.getValue().lower() in DNF:
return False
# Empty value means part is fitted
if check == "":
return True
opts = check.lower().split(",")
exclude = False
include = True
for opt in opts:
opt = opt.strip()
# Any option containing a DNF is not fitted
if opt in DNF:
exclude = True
break
# Options that start with '-' are explicitly removed from certain configurations
if opt.startswith("-") and str(opt[1:]) in [str(cfg) for cfg in self.prefs.pcbConfig]:
exclude = True
break
if opt.startswith("+"):
include = include or opt[1:] in [str(cfg) for cfg in self.prefs.pcbConfig]
return include and not exclude
# Test if this part should be included, based on any regex expressions provided in the preferences
def testRegExclude(self):
for reg in self.prefs.regExcludes:
if type(reg) == list and len(reg) == 2:
field_name, regex = reg
field_value = self.getField(field_name)
# Attempt unicode escaping...
# Filthy hack
try:
regex = regex.decode("unicode_escape")
except:
pass
if re.search(regex, field_value, flags=re.IGNORECASE) is not None:
if self.prefs.verbose:
print("Excluding '{ref}': Field '{field}' ({value}) matched '{reg}'".format(
ref=self.getRef(),
field=field_name,
value=field_value,
reg=regex).encode('utf-8'))
# Found a match
return True
# Default, could not find any matches
return False
def testRegInclude(self):
if len(self.prefs.regIncludes) == 0: # Nothing to match against
return True
for reg in self.prefs.regIncludes:
if type(reg) == list and len(reg) == 2:
field_name, regex = reg
field_value = self.getField(field_name)
print(field_name, field_value, regex)
if re.search(regex, field_value, flags=re.IGNORECASE) is not None:
if self.prefs.verbose:
print("")
# Found a match
return True
# Default, could not find a match
return False
def getFootprint(self, libraryToo=True):
ret = self.element.get("footprint")
if ret == "" and libraryToo:
if self.libpart:
ret = self.libpart.getFootprint()
return ret
def getDatasheet(self, libraryToo=True):
ret = self.element.get("datasheet")
if ret == "" and libraryToo:
ret = self.libpart.getDatasheet()
return ret
def getTimestamp(self):
return self.element.get("tstamp")
class joiner:
def __init__(self):
self.stack = []
def add(self, P, N):
if self.stack == []:
self.stack.append(((P, N), (P, N)))
return
S, E = self.stack[-1]
if N == E[1] + 1:
self.stack[-1] = (S, (P, N))
else:
self.stack.append(((P, N), (P, N)))
def flush(self, sep, N=None, dash='-'):
refstr = u''
c = 0
for Q in self.stack:
if bool(N) and c != 0 and c % N == 0:
refstr += u'\n'
elif c != 0:
refstr += sep
S, E = Q
if S == E:
refstr += "%s%d" % S
c += 1
else:
# Do we have space?
if bool(N) and (c + 1) % N == 0:
refstr += u'\n'
c += 1
refstr += "%s%d%s%s%d" % (S[0], S[1], dash, E[0], E[1])
c += 2
return refstr
class ComponentGroup():
"""
Initialize the group with no components, and default fields
"""
def __init__(self, prefs=None):
self.components = []
self.fields = dict.fromkeys(ColumnList._COLUMNS_DEFAULT) # Columns loaded from KiCad
if not prefs:
prefs = BomPref()
self.prefs = prefs
def getField(self, field):
if field not in self.fields.keys():
return ""
if not self.fields[field]:
return ""
return u''.join((self.fields[field]))
def getCount(self):
return len(self.components)
# Test if a given component fits in this group
def matchComponent(self, c):
if len(self.components) == 0:
return True
if c == self.components[0]:
return True
return False
def containsComponent(self, c):
# Test if a given component is already contained in this grop
if not self.matchComponent(c):
return False
for comp in self.components:
if comp.getRef() == c.getRef():
return True
return False
def addComponent(self, c):
# Add a component to the group
if len(self.components) == 0:
self.components.append(c)
elif self.containsComponent(c):
return
elif self.matchComponent(c):
self.components.append(c)
def isFitted(self):
return any([c.isFitted() for c in self.components])
def getRefs(self):
# Return a list of the components
return " ".join([c.getRef() for c in self.components])
def getAltRefs(self, wrapN=None):
S = joiner()
for n in self.components:
P, N = (n.getPrefix(), n.getSuffix())
S.add(P, N)
return S.flush(' ', N=wrapN)
# Sort the components in correct order
def sortComponents(self):
self.components = sorted(self.components, key=lambda c: natural_sort(c.getRef()))
# Update a given field, based on some rules and such
def updateField(self, field, fieldData):
# Protected fields cannot be overwritten
if field in ColumnList._COLUMNS_PROTECTED:
return
if field is None or field == "":
return
elif fieldData == "" or fieldData is None:
return
if (field not in self.fields.keys()) or (self.fields[field] is None) or (self.fields[field] == ""):
self.fields[field] = fieldData
elif fieldData.lower() in self.fields[field].lower():
return
else:
print("Field conflict: ({refs}) [{name}] : '{flds}' <- '{fld}'".format(
refs=self.getRefs(),
name=field,
flds=self.fields[field],
fld=fieldData).encode('utf-8'))
self.fields[field] += " " + fieldData
def updateFields(self, usealt=False, wrapN=None):
for c in self.components:
for f in c.getFieldNames():
# These columns are handled explicitly below
if f in ColumnList._COLUMNS_PROTECTED:
continue
self.updateField(f, c.getField(f))
# Update 'global' fields
if usealt:
self.fields[ColumnList.COL_REFERENCE] = self.getAltRefs(wrapN)
else:
self.fields[ColumnList.COL_REFERENCE] = self.getRefs()
q = self.getCount()
self.fields[ColumnList.COL_GRP_QUANTITY] = "{n}{dnf}".format(
n=q,
dnf=" (DNF)" if not self.isFitted() else "")
self.fields[ColumnList.COL_GRP_BUILD_QUANTITY] = str(q * self.prefs.boards) if self.isFitted() else "0"
self.fields[ColumnList.COL_VALUE] = self.components[0].getValue()
self.fields[ColumnList.COL_PART] = self.components[0].getPartName()
self.fields[ColumnList.COL_PART_LIB] = self.components[0].getLibName()
self.fields[ColumnList.COL_DESCRIPTION] = self.components[0].getDescription()
self.fields[ColumnList.COL_DATASHEET] = self.components[0].getDatasheet()
# Footprint field requires special attention
fp = self.components[0].getFootprint().split(":")
if len(fp) >= 2:
self.fields[ColumnList.COL_FP_LIB] = fp[0]
self.fields[ColumnList.COL_FP] = fp[1]
elif len(fp) == 1:
self.fields[ColumnList.COL_FP_LIB] = ""
self.fields[ColumnList.COL_FP] = fp[0]
else:
self.fields[ColumnList.COL_FP_LIB] = ""
self.fields[ColumnList.COL_FP] = ""
# Return a dict of the KiCad data based on the supplied columns
# NOW WITH UNICODE SUPPORT!
def getRow(self, columns):
row = []
for key in columns:
val = self.getField(key)
if val is None:
val = ""
else:
val = u'' + val
if sys.version_info[0] < 3:
val = val.encode('utf-8')
row.append(val)
return row

View File

@ -0,0 +1,92 @@
# _*_ coding:latin-1 _*_
import csv
import os
import sys
def WriteCSV(filename, groups, net, headings, prefs):
"""
Write BoM out to a CSV file
filename = path to output file (must be a .csv, .txt or .tsv file)
groups = [list of ComponentGroup groups]
net = netlist object
headings = [list of headings to display in the BoM file]
prefs = BomPref object
"""
filename = os.path.abspath(filename)
# Delimeter is assumed from file extension
# Override delimiter if separator specified
if prefs.separatorCSV is not None:
delimiter = prefs.separatorCSV
else:
if filename.endswith(".csv"):
delimiter = ","
elif filename.endswith(".tsv") or filename.endswith(".txt"):
delimiter = "\t"
else:
return False
nGroups = len(groups)
nTotal = sum([g.getCount() for g in groups])
nFitted = sum([g.getCount() for g in groups if g.isFitted()])
nBuild = nFitted * prefs.boards
if (sys.version_info[0] >= 3):
f = open(filename, "w", encoding='utf-8')
else:
f = open(filename, "w")
writer = csv.writer(f, delimiter=delimiter, lineterminator="\n")
if not prefs.hideHeaders:
if prefs.numberRows:
writer.writerow(["Component"] + headings)
else:
writer.writerow(headings)
count = 0
rowCount = 1
for group in groups:
if prefs.ignoreDNF and not group.isFitted():
continue
row = group.getRow(headings)
if prefs.numberRows:
row = [str(rowCount)] + row
# Deal with unicode characters
# Row = [el.decode('latin-1') for el in row]
writer.writerow(row)
try:
count += group.getCount()
except:
pass
rowCount += 1
if not prefs.hidePcbInfo:
# Add some blank rows
for i in range(5):
writer.writerow([])
writer.writerow(["Component Groups:", nGroups])
writer.writerow(["Component Count:", nTotal])
writer.writerow(["Fitted Components:", nFitted])
writer.writerow(["Number of PCBs:", prefs.boards])
writer.writerow(["Total components:", nBuild])
writer.writerow(["Schematic Version:", net.getVersion()])
writer.writerow(["Schematic Date:", net.getSheetDate()])
writer.writerow(["PCB Variant:", ' + '.join(prefs.pcbConfig)])
writer.writerow(["BoM Date:", net.getDate()])
writer.writerow(["Schematic Source:", net.getSource()])
writer.writerow(["KiCad Version:", net.getTool()])
f.close()
return True

View File

@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
from bomlib.component import ColumnList
BG_GEN = "#E6FFEE"
BG_KICAD = "#FFE6B3"
BG_USER = "#E6F9FF"
BG_EMPTY = "#FF8080"
def bgColor(col):
""" Return a background color for a given column title """
# Auto-generated columns
if col in ColumnList._COLUMNS_GEN:
return BG_GEN
# KiCad protected columns
elif col in ColumnList._COLUMNS_PROTECTED:
return BG_KICAD
# Additional user columns
else:
return BG_USER
def link(text):
for t in ["http", "https", "ftp", "www"]:
if text.startswith(t):
return '<a href="{t}">{t}</a>'.format(t=text)
return text
def WriteHTML(filename, groups, net, headings, prefs):
"""
Write BoM out to a HTML file
filename = path to output file (must be a .htm or .html file)
groups = [list of ComponentGroup groups]
net = netlist object
headings = [list of headings to display in the BoM file]
prefs = BomPref object
"""
if not filename.endswith(".html") and not filename.endswith(".htm"):
print("{fn} is not a valid html file".format(fn=filename))
return False
nGroups = len(groups)
nTotal = sum([g.getCount() for g in groups])
nFitted = sum([g.getCount() for g in groups if g.isFitted()])
nBuild = nFitted * prefs.boards
with open(filename, "w") as html:
# HTML Header
html.write("<html>\n")
html.write("<head>\n")
html.write('\t<meta charset="UTF-8">\n') # UTF-8 encoding for unicode support
html.write("</head>\n")
html.write("<body>\n")
# PCB info
if not prefs.hideHeaders:
html.write("<h2>KiBoM PCB Bill of Materials</h2>\n")
if not prefs.hidePcbInfo:
html.write('<table border="1">\n')
html.write("<tr><td>Source File</td><td>{source}</td></tr>\n".format(source=net.getSource()))
html.write("<tr><td>BoM Date</td><td>{date}</td></tr>\n".format(date=net.getDate()))
html.write("<tr><td>Schematic Version</td><td>{version}</td></tr>\n".format(version=net.getVersion()))
html.write("<tr><td>Schematic Date</td><td>{date}</td></tr>\n".format(date=net.getSheetDate()))
html.write("<tr><td>PCB Variant</td><td>{variant}</td></tr>\n".format(variant=', '.join(prefs.pcbConfig)))
html.write("<tr><td>KiCad Version</td><td>{version}</td></tr>\n".format(version=net.getTool()))
html.write("<tr><td>Component Groups</td><td>{n}</td></tr>\n".format(n=nGroups))
html.write("<tr><td>Component Count (per PCB)</td><td>{n}</td></tr>\n".format(n=nTotal))
html.write("<tr><td>Fitted Components (per PCB)</td><td>{n}</td></tr>\n".format(n=nFitted))
html.write("<tr><td>Number of PCBs</td><td>{n}</td></tr>\n".format(n=prefs.boards))
html.write("<tr><td>Total Component Count<br>(for {n} PCBs)</td><td>{t}</td></tr>\n".format(n=prefs.boards, t=nBuild))
html.write("</table>\n")
html.write("<br>\n")
if not prefs.hideHeaders:
html.write("<h2>Component Groups</h2>\n")
html.write('<p style="background-color: {bg}">KiCad Fields (default)</p>\n'.format(bg=BG_KICAD))
html.write('<p style="background-color: {bg}">Generated Fields</p>\n'.format(bg=BG_GEN))
html.write('<p style="background-color: {bg}">User Fields</p>\n'.format(bg=BG_USER))
html.write('<p style="background-color: {bg}">Empty Fields</p>\n'.format(bg=BG_EMPTY))
# Component groups
html.write('<table border="1">\n')
# Row titles:
html.write("<tr>\n")
if prefs.numberRows:
html.write("\t<th></th>\n")
for i, h in enumerate(headings):
# Cell background color
bg = bgColor(h)
html.write('\t<th align="center"{bg}>{h}</th>\n'.format(
h=h,
bg=' bgcolor="{c}"'.format(c=bg) if bg else '')
)
html.write("</tr>\n")
rowCount = 0
for i, group in enumerate(groups):
if prefs.ignoreDNF and not group.isFitted():
continue
row = group.getRow(headings)
rowCount += 1
html.write("<tr>\n")
if prefs.numberRows:
html.write('\t<td align="center">{n}</td>\n'.format(n=rowCount))
for n, r in enumerate(row):
if (len(r) == 0) or (r.strip() == "~"):
bg = BG_EMPTY
else:
bg = bgColor(headings[n])
html.write('\t<td align="center"{bg}>{val}</td>\n'.format(bg=' bgcolor={c}'.format(c=bg) if bg else '', val=link(r)))
html.write("</tr>\n")
html.write("</table>\n")
html.write("<br><br>\n")
html.write("</body></html>")
return True

View File

@ -0,0 +1,484 @@
# -*- coding: utf-8 -*-
"""
@package
Generate a HTML BOM list.
Components are sorted and grouped by value
Any existing fields are read
"""
from __future__ import print_function
import sys
import xml.sax as sax
from bomlib.component import (Component, ComponentGroup)
from bomlib.preferences import BomPref
# -----</Configure>---------------------------------------------------------------
class xmlElement():
"""xml element which can represent all nodes of the netlist tree. It can be
used to easily generate various output formats by propogating 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 = " " * nestLevel
if not amChild:
s = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\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 += "</" + self.name + ">"
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 = """<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title></title>
</head>
<body>
<table>
"""
s += "<tr><td><b>" + self.name + "</b><br>" + self.chars + "</td><td><ul>"
for a in self.attributes:
s += "<li>" + a + " = " + self.attributes[a] + "</li>"
s += "</ul></td></tr>\n"
for c in self.children:
s += c.formatHTML(True)
if not amChild:
s += """</table>
</body>
</html>"""
return s
def addAttribute(self, attr, value):
"""Add an attribute to this element"""
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:
return self.chars
else:
return self.attributes[attribute]
except AttributeError:
return ""
else:
return self.chars
for child in self.children:
ret = child.get(elemName, attribute, attrmatch)
if ret != "":
return ret
return ""
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 getLibName(self):
return self.element.get("libpart", "lib")
def getPartName(self):
return self.element.get("libpart", "part")
# For backwards Compatibility with v4.x only
def getDescription(self):
return self.element.get("description")
def getDocs(self):
return self.element.get("docs")
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 getDatasheet(self):
datasheet = self.getField("Datasheet")
if not datasheet or datasheet == "":
docs = self.getDocs()
if "http" in docs or ".pdf" in docs:
datasheet = docs
return 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 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="", prefs=None):
"""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
if not prefs:
prefs = BomPref() # Default values
self.prefs = prefs
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(Component(self._curr_element, prefs=self.prefs))
# 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 seperate 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"""
if (sys.version_info[0] >= 3):
return self.design.get("date")
else:
return self.design.get("date").encode('ascii', 'ignore')
def getSource(self):
"""Return the source string for the design"""
if (sys.version_info[0] >= 3):
return self.design.get("source")
else:
return self.design.get("source").encode('ascii', 'ignore')
def getTool(self):
"""Return the tool string which was used to create the netlist tree"""
if (sys.version_info[0] >= 3):
return self.design.get("tool")
else:
return self.design.get("tool").encode('ascii', 'ignore')
def getSheet(self):
return self.design.getChild("sheet")
def getSheetDate(self):
sheet = self.getSheet()
if sheet is None:
return ""
return sheet.get("date")
def getVersion(self):
"""Return the verison of the sheet info"""
sheet = self.getSheet()
if sheet is None:
return ""
return sheet.get("rev")
def getInterestingComponents(self):
# Copy out the components
ret = [c for c in self.components]
# Sort first by ref as this makes for easier to read BOM's
ret.sort(key=lambda g: g.getRef())
return ret
def groupComponents(self, components):
groups = []
# Iterate through each component, and test whether a group for these already exists
for c in components:
if self.prefs.useRegex:
# Skip components if they do not meet regex requirements
if not c.testRegInclude():
continue
if c.testRegExclude():
continue
found = False
for g in groups:
if g.matchComponent(c):
g.addComponent(c)
found = True
break
if not found:
g = ComponentGroup(prefs=self.prefs) # Pass down the preferences
g.addComponent(c)
groups.append(g)
# Sort the references within each group
for g in groups:
g.sortComponents()
g.updateFields(self.prefs.useAlt, self.prefs.altWrap)
# Sort the groups
# First priority is the Type of component (e.g. R?, U?, L?)
groups = sorted(groups, key=lambda g: [g.components[0].getPrefix(), g.components[0].getValue()])
return groups
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()

View File

@ -0,0 +1,299 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys
import re
import os
from bomlib.columns import ColumnList
# Check python version to determine which version of ConfirParser to import
if sys.version_info.major >= 3:
import configparser as ConfigParser
else:
import ConfigParser
class BomPref:
SECTION_IGNORE = "IGNORE_COLUMNS"
SECTION_COLUMN_ORDER = "COLUMN_ORDER"
SECTION_GENERAL = "BOM_OPTIONS"
SECTION_ALIASES = "COMPONENT_ALIASES"
SECTION_GROUPING_FIELDS = "GROUP_FIELDS"
SECTION_REGEXCLUDES = "REGEX_EXCLUDE"
SECTION_REGINCLUDES = "REGEX_INCLUDE"
OPT_PCB_CONFIG = "pcb_configuration"
OPT_NUMBER_ROWS = "number_rows"
OPT_GROUP_CONN = "group_connectors"
OPT_USE_REGEX = "test_regex"
OPT_USE_ALT = "use_alt"
OPT_ALT_WRAP = "alt_wrap"
OPT_MERGE_BLANK = "merge_blank_fields"
OPT_IGNORE_DNF = "ignore_dnf"
OPT_BACKUP = "make_backup"
OPT_OUTPUT_FILE_NAME = "output_file_name"
OPT_VARIANT_FILE_NAME_FORMAT = "variant_file_name_format"
OPT_DEFAULT_BOARDS = "number_boards"
OPT_DEFAULT_PCBCONFIG = "board_variant"
OPT_CONFIG_FIELD = "fit_field"
OPT_HIDE_HEADERS = "hide_headers"
OPT_HIDE_PCB_INFO = "hide_pcb_info"
def __init__(self):
# List of headings to ignore in BoM generation
self.ignore = [
ColumnList.COL_PART_LIB,
ColumnList.COL_FP_LIB,
]
self.corder = ColumnList._COLUMNS_DEFAULT
self.useAlt = False # Use alternate reference representation
self.altWrap = None # Wrap to n items when using alt representation
self.ignoreDNF = True # Ignore rows for do-not-fit parts
self.numberRows = True # Add row-numbers to BoM output
self.groupConnectors = True # Group connectors and ignore component value
self.useRegex = True # Test various columns with regex
self.boards = 1 # Quantity of boards to be made
self.mergeBlankFields = True # Blanks fields will be merged when possible
self.hideHeaders = False
self.hidePcbInfo = False
self.verbose = False # By default, is not verbose
self.configField = "Config" # Default field used for part fitting config
self.pcbConfig = ["default"]
self.backup = "%O.tmp"
self.separatorCSV = None
self.outputFileName = "%O_bom_%v%V"
self.variantFileNameFormat = "_(%V)"
self.xlsxwriter_available = False
self.xlsxwriter2_available = False
# Default fields used to group components
self.groups = [
ColumnList.COL_PART,
ColumnList.COL_PART_LIB,
ColumnList.COL_VALUE,
ColumnList.COL_FP,
ColumnList.COL_FP_LIB,
# User can add custom grouping columns in bom.ini
]
self.regIncludes = [] # None by default
self.regExcludes = [
[ColumnList.COL_REFERENCE, '^TP[0-9]*'],
[ColumnList.COL_REFERENCE, '^FID'],
[ColumnList.COL_PART, 'mount.*hole'],
[ColumnList.COL_PART, 'solder.*bridge'],
[ColumnList.COL_PART, 'test.*point'],
[ColumnList.COL_FP, 'test.*point'],
[ColumnList.COL_FP, 'mount.*hole'],
[ColumnList.COL_FP, 'fiducial'],
]
# Default component groupings
self.aliases = [
["c", "c_small", "cap", "capacitor"],
["r", "r_small", "res", "resistor"],
["sw", "switch"],
["l", "l_small", "inductor"],
["zener", "zenersmall"],
["d", "diode", "d_small"]
]
# Check an option within the SECTION_GENERAL group
def checkOption(self, parser, opt, default=False):
if parser.has_option(self.SECTION_GENERAL, opt):
return parser.get(self.SECTION_GENERAL, opt).lower() in ["1", "true", "yes"]
else:
return default
def checkInt(self, parser, opt, default=False):
if parser.has_option(self.SECTION_GENERAL, opt):
return int(parser.get(self.SECTION_GENERAL, opt).lower())
else:
return default
# Read KiBOM preferences from file
def Read(self, file, verbose=False):
file = os.path.abspath(file)
if not os.path.exists(file) or not os.path.isfile(file):
print("{f} is not a valid file!".format(f=file))
return
cf = ConfigParser.RawConfigParser(allow_no_value=True)
cf.optionxform = str
cf.read(file)
# Read general options
if self.SECTION_GENERAL in cf.sections():
self.ignoreDNF = self.checkOption(cf, self.OPT_IGNORE_DNF, default=True)
self.useAlt = self.checkOption(cf, self.OPT_USE_ALT, default=False)
self.altWrap = self.checkInt(cf, self.OPT_ALT_WRAP, default=None)
self.numberRows = self.checkOption(cf, self.OPT_NUMBER_ROWS, default=True)
self.groupConnectors = self.checkOption(cf, self.OPT_GROUP_CONN, default=True)
self.useRegex = self.checkOption(cf, self.OPT_USE_REGEX, default=True)
self.mergeBlankFields = self.checkOption(cf, self.OPT_MERGE_BLANK, default=True)
self.outputFileName = cf.get(self.SECTION_GENERAL, self.OPT_OUTPUT_FILE_NAME)
self.variantFileNameFormat = cf.get(self.SECTION_GENERAL, self.OPT_VARIANT_FILE_NAME_FORMAT)
if cf.has_option(self.SECTION_GENERAL, self.OPT_CONFIG_FIELD):
self.configField = cf.get(self.SECTION_GENERAL, self.OPT_CONFIG_FIELD)
if cf.has_option(self.SECTION_GENERAL, self.OPT_DEFAULT_BOARDS):
self.boards = self.checkInt(cf, self.OPT_DEFAULT_BOARDS, default=None)
if cf.has_option(self.SECTION_GENERAL, self.OPT_DEFAULT_PCBCONFIG):
self.pcbConfig = cf.get(self.SECTION_GENERAL, self.OPT_DEFAULT_PCBCONFIG).strip().split(",")
if cf.has_option(self.SECTION_GENERAL, self.OPT_BACKUP):
self.backup = cf.get(self.SECTION_GENERAL, self.OPT_BACKUP)
else:
self.backup = False
if cf.has_option(self.SECTION_GENERAL, self.OPT_HIDE_HEADERS):
self.hideHeaders = cf.get(self.SECTION_GENERAL, self.OPT_HIDE_HEADERS) == '1'
if cf.has_option(self.SECTION_GENERAL, self.OPT_HIDE_PCB_INFO):
self.hidePcbInfo = cf.get(self.SECTION_GENERAL, self.OPT_HIDE_PCB_INFO) == '1'
# Read out grouping colums
if self.SECTION_GROUPING_FIELDS in cf.sections():
self.groups = [i for i in cf.options(self.SECTION_GROUPING_FIELDS)]
# Read out ignored-rows
if self.SECTION_IGNORE in cf.sections():
self.ignore = [i for i in cf.options(self.SECTION_IGNORE)]
# Read out column order
if self.SECTION_COLUMN_ORDER in cf.sections():
self.corder = [i for i in cf.options(self.SECTION_COLUMN_ORDER)]
# Read out component aliases
if self.SECTION_ALIASES in cf.sections():
self.aliases = [re.split('[ \t]+', a) for a in cf.options(self.SECTION_ALIASES)]
if self.SECTION_REGEXCLUDES in cf.sections():
self.regExcludes = []
for pair in cf.options(self.SECTION_REGEXCLUDES):
if len(re.split('[ \t]+', pair)) == 2:
self.regExcludes.append(re.split('[ \t]+', pair))
if self.SECTION_REGINCLUDES in cf.sections():
self.regIncludes = []
for pair in cf.options(self.SECTION_REGINCLUDES):
if len(re.split('[ \t]+', pair)) == 2:
self.regIncludes.append(re.split('[ \t]+', pair))
# Add an option to the SECTION_GENRAL group
def addOption(self, parser, opt, value, comment=None):
if comment:
if not comment.startswith(";"):
comment = "; " + comment
parser.set(self.SECTION_GENERAL, comment)
parser.set(self.SECTION_GENERAL, opt, "1" if value else "0")
# Write KiBOM preferences to file
def Write(self, file):
file = os.path.abspath(file)
cf = ConfigParser.RawConfigParser(allow_no_value=True)
cf.optionxform = str
cf.add_section(self.SECTION_GENERAL)
cf.set(self.SECTION_GENERAL, "; General BoM options here")
self.addOption(cf, self.OPT_IGNORE_DNF, self.ignoreDNF, comment="If '{opt}' option is set to 1, rows that are not to be fitted on the PCB will not be written to the BoM file".format(opt=self.OPT_IGNORE_DNF))
self.addOption(cf, self.OPT_USE_ALT, self.useAlt, comment="If '{opt}' option is set to 1, grouped references will be printed in the alternate compressed style eg: R1-R7,R18".format(opt=self.OPT_USE_ALT))
self.addOption(cf, self.OPT_ALT_WRAP, self.altWrap, comment="If '{opt}' option is set to and integer N, the references field will wrap after N entries are printed".format(opt=self.OPT_ALT_WRAP))
self.addOption(cf, self.OPT_NUMBER_ROWS, self.numberRows, comment="If '{opt}' option is set to 1, each row in the BoM will be prepended with an incrementing row number".format(opt=self.OPT_NUMBER_ROWS))
self.addOption(cf, self.OPT_GROUP_CONN, self.groupConnectors, comment="If '{opt}' option is set to 1, connectors with the same footprints will be grouped together, independent of the name of the connector".format(opt=self.OPT_GROUP_CONN))
self.addOption(cf, self.OPT_USE_REGEX, self.useRegex, comment="If '{opt}' option is set to 1, each component group will be tested against a number of regular-expressions (specified, per column, below). If any matches are found, the row is ignored in the output file".format(opt=self.OPT_USE_REGEX))
self.addOption(cf, self.OPT_MERGE_BLANK, self.mergeBlankFields, comment="If '{opt}' option is set to 1, component groups with blank fields will be merged into the most compatible group, where possible".format(opt=self.OPT_MERGE_BLANK))
cf.set(self.SECTION_GENERAL, "; Specify output file name format, %O is the defined output name, %v is the version, %V is the variant name which will be ammended according to 'variant_file_name_format'.")
cf.set(self.SECTION_GENERAL, self.OPT_OUTPUT_FILE_NAME, self.outputFileName)
cf.set(self.SECTION_GENERAL, "; Specify the variant file name format, this is a unique field as the variant is not always used/specified. When it is unused you will want to strip all of this.")
cf.set(self.SECTION_GENERAL, self.OPT_VARIANT_FILE_NAME_FORMAT, self.variantFileNameFormat)
cf.set(self.SECTION_GENERAL, '; Field name used to determine if a particular part is to be fitted')
cf.set(self.SECTION_GENERAL, self.OPT_CONFIG_FIELD, self.configField)
cf.set(self.SECTION_GENERAL, '; Make a backup of the bom before generating the new one, using the following template')
cf.set(self.SECTION_GENERAL, self.OPT_BACKUP, self.backup)
cf.set(self.SECTION_GENERAL, '; Default number of boards to produce if none given on CLI with -n')
cf.set(self.SECTION_GENERAL, self.OPT_DEFAULT_BOARDS, self.boards)
cf.set(self.SECTION_GENERAL, '; Default PCB variant if none given on CLI with -r')
cf.set(self.SECTION_GENERAL, self.OPT_DEFAULT_PCBCONFIG, self.pcbConfig)
cf.set(self.SECTION_GENERAL, '; Whether to hide headers from output file')
cf.set(self.SECTION_GENERAL, self.OPT_HIDE_HEADERS, self.hideHeaders)
cf.set(self.SECTION_GENERAL, '; Whether to hide PCB info from output file')
cf.set(self.SECTION_GENERAL, self.OPT_HIDE_PCB_INFO, self.hidePcbInfo)
cf.add_section(self.SECTION_IGNORE)
cf.set(self.SECTION_IGNORE, "; Any column heading that appears here will be excluded from the Generated BoM")
cf.set(self.SECTION_IGNORE, "; Titles are case-insensitive")
for i in self.ignore:
cf.set(self.SECTION_IGNORE, i)
cf.add_section(self.SECTION_COLUMN_ORDER)
cf.set(self.SECTION_COLUMN_ORDER, "; Columns will apear in the order they are listed here")
cf.set(self.SECTION_COLUMN_ORDER, "; Titles are case-insensitive")
for i in self.corder:
cf.set(self.SECTION_COLUMN_ORDER, i)
# Write the component grouping fields
cf.add_section(self.SECTION_GROUPING_FIELDS)
cf.set(self.SECTION_GROUPING_FIELDS, '; List of fields used for sorting individual components into groups')
cf.set(self.SECTION_GROUPING_FIELDS, '; Components which match (comparing *all* fields) will be grouped together')
cf.set(self.SECTION_GROUPING_FIELDS, '; Field names are case-insensitive')
for i in self.groups:
cf.set(self.SECTION_GROUPING_FIELDS, i)
cf.add_section(self.SECTION_ALIASES)
cf.set(self.SECTION_ALIASES, "; A series of values which are considered to be equivalent for the part name")
cf.set(self.SECTION_ALIASES, "; Each line represents a list of equivalent component name values separated by white space")
cf.set(self.SECTION_ALIASES, "; e.g. 'c c_small cap' will ensure the equivalent capacitor symbols can be grouped together")
cf.set(self.SECTION_ALIASES, '; Aliases are case-insensitive')
for a in self.aliases:
cf.set(self.SECTION_ALIASES, "\t".join(a))
cf.add_section(self.SECTION_REGINCLUDES)
cf.set(self.SECTION_REGINCLUDES, '; A series of regular expressions used to include parts in the BoM')
cf.set(self.SECTION_REGINCLUDES, '; If there are any regex defined here, only components that match against ANY of them will be included in the BOM')
cf.set(self.SECTION_REGINCLUDES, '; Column names are case-insensitive')
cf.set(self.SECTION_REGINCLUDES, '; Format is: "[ColumName] [Regex]" (white-space separated)')
for i in self.regIncludes:
if not len(i) == 2:
continue
cf.set(self.SECTION_REGINCLUDES, i[0] + "\t" + i[1])
cf.add_section(self.SECTION_REGEXCLUDES)
cf.set(self.SECTION_REGEXCLUDES, '; A series of regular expressions used to exclude parts from the BoM')
cf.set(self.SECTION_REGEXCLUDES, '; If a component matches ANY of these, it will be excluded from the BoM')
cf.set(self.SECTION_REGEXCLUDES, '; Column names are case-insensitive')
cf.set(self.SECTION_REGEXCLUDES, '; Format is: "[ColumName] [Regex]" (white-space separated)')
for i in self.regExcludes:
if not len(i) == 2:
continue
cf.set(self.SECTION_REGEXCLUDES, i[0] + "\t" + i[1])
with open(file, 'wb') as configfile:
cf.write(configfile)

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
import re
def natural_sort(string):
"""
Natural sorting function which sorts by numerical value of a string,
rather than raw ASCII value.
"""
return [int(s) if s.isdigit() else s for s in re.split(r'(\d+)', string)]

View File

@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
"""
This file contains a set of functions for matching values which may be written in different formats
e.g.
0.1uF = 100n (different suffix specified, one has missing unit)
0R1 = 0.1Ohm (Unit replaces decimal, different units)
"""
from __future__ import unicode_literals
import re
PREFIX_MICRO = [u"μ", "u", "micro"]
PREFIX_MILLI = ["milli", "m"]
PREFIX_NANO = ["nano", "n"]
PREFIX_PICO = ["pico", "p"]
PREFIX_KILO = ["kilo", "k"]
PREFIX_MEGA = ["mega", "meg"]
PREFIX_GIGA = ["giga", "g"]
# All prefixes
PREFIX_ALL = PREFIX_PICO + PREFIX_NANO + PREFIX_MICRO + PREFIX_MILLI + PREFIX_KILO + PREFIX_MEGA + PREFIX_GIGA
# Common methods of expressing component units
UNIT_R = ["r", "ohms", "ohm", u"Ω"]
UNIT_C = ["farad", "f"]
UNIT_L = ["henry", "h"]
UNIT_ALL = UNIT_R + UNIT_C + UNIT_L
def getUnit(unit):
"""
Return a simplified version of a units string, for comparison purposes
"""
if not unit:
return None
unit = unit.lower()
if unit in UNIT_R:
return "R"
if unit in UNIT_C:
return "F"
if unit in UNIT_L:
return "H"
return None
def getPrefix(prefix):
"""
Return the (numerical) value of a given prefix
"""
if not prefix:
return 1
prefix = prefix.lower()
if prefix in PREFIX_PICO:
return 1.0e-12
if prefix in PREFIX_NANO:
return 1.0e-9
if prefix in PREFIX_MICRO:
return 1.0e-6
if prefix in PREFIX_MILLI:
return 1.0e-3
if prefix in PREFIX_KILO:
return 1.0e3
if prefix in PREFIX_MEGA:
return 1.0e6
if prefix in PREFIX_GIGA:
return 1.0e9
return 1
def groupString(group): # Return a reg-ex string for a list of values
return "|".join(group)
def matchString():
return "^([0-9\.]+)(" + groupString(PREFIX_ALL) + ")*(" + groupString(UNIT_ALL) + ")*(\d*)$"
def compMatch(component):
"""
Return a normalized value and units for a given component value string
e.g. compMatch("10R2") returns (10, R)
e.g. compMatch("3.3mOhm") returns (0.0033, R)
"""
# Remove any commas
component = component.strip().replace(",", "").lower()
match = matchString()
result = re.search(match, component)
if not result:
return None
if not len(result.groups()) == 4:
return None
value, prefix, units, post = result.groups()
# Special case where units is in the middle of the string
# e.g. "0R05" for 0.05Ohm
# In this case, we will NOT have a decimal
# We will also have a trailing number
if post and "." not in value:
try:
value = float(int(value))
postValue = float(int(post)) / (10 ** len(post))
value = value * 1.0 + postValue
except:
return None
try:
val = float(value)
except:
return None
val = "{0:.15f}".format(val * 1.0 * getPrefix(prefix))
return (val, getUnit(units))
def componentValue(valString):
result = compMatch(valString)
if not result:
return valString # Return the same string back
if not len(result) == 2: # Result length is incorrect
return valString
val = result[0]
return val
def compareValues(c1, c2):
""" Compare two values """
r1 = compMatch(c1)
r2 = compMatch(c2)
if not r1 or not r2:
return False
(v1, u1) = r1
(v2, u2) = r2
if v1 == v2:
# Values match
if u1 == u2:
return True # Units match
if not u1:
return True # No units for component 1
if not u2:
return True # No units for component 2
return False

View File

@ -0,0 +1,2 @@
KIBOM_VERSION = "1.52"
KIBOM_DATE = "2018-9-16"

View File

@ -0,0 +1,144 @@
# _*_ coding:latin-1 _*_
try:
import xlsxwriter
except:
def WriteXLSX(filename, groups, net, headings, prefs):
return False
else:
import os
"""
Write BoM out to a XLSX file
filename = path to output file (must be a .xlsx file)
groups = [list of ComponentGroup groups]
net = netlist object
headings = [list of headings to display in the BoM file]
prefs = BomPref object
"""
def WriteXLSX(filename, groups, net, headings, prefs):
filename = os.path.abspath(filename)
if not filename.endswith(".xlsx"):
return False
nGroups = len(groups)
nTotal = sum([g.getCount() for g in groups])
nFitted = sum([g.getCount() for g in groups if g.isFitted()])
nBuild = nFitted * prefs.boards
workbook = xlsxwriter.Workbook(filename)
worksheet = workbook.add_worksheet()
if prefs.numberRows:
row_headings = ["Component"] + headings
else:
row_headings = headings
cellformats = {}
column_widths = {}
for i in range(len(row_headings)):
cellformats[i] = workbook.add_format({'align': 'center_across'})
column_widths[i] = len(row_headings[i]) + 10
if not prefs.hideHeaders:
worksheet.write_string(0, i, row_headings[i], cellformats[i])
count = 0
rowCount = 1
for i, group in enumerate(groups):
if prefs.ignoreDNF and not group.isFitted():
continue
row = group.getRow(headings)
if prefs.numberRows:
row = [str(rowCount)] + row
for columnCount in range(len(row)):
cell = row[columnCount].decode('utf-8')
worksheet.write_string(rowCount, columnCount, cell, cellformats[columnCount])
if len(cell) > column_widths[columnCount] - 5:
column_widths[columnCount] = len(cell) + 5
try:
count += group.getCount()
except:
pass
rowCount += 1
if not prefs.hidePcbInfo:
# Add a few blank rows
for i in range(5):
rowCount += 1
cellformat_left = workbook.add_format({'align': 'left'})
worksheet.write_string(rowCount, 0, "Component Groups:", cellformats[0])
worksheet.write_number(rowCount, 1, nGroups, cellformat_left)
rowCount += 1
worksheet.write_string(rowCount, 0, "Component Count:", cellformats[0])
worksheet.write_number(rowCount, 1, nTotal, cellformat_left)
rowCount += 1
worksheet.write_string(rowCount, 0, "Fitted Components:", cellformats[0])
worksheet.write_number(rowCount, 1, nFitted, cellformat_left)
rowCount += 1
worksheet.write_string(rowCount, 0, "Number of PCBs:", cellformats[0])
worksheet.write_number(rowCount, 1, prefs.boards, cellformat_left)
rowCount += 1
worksheet.write_string(rowCount, 0, "Total components:", cellformats[0])
worksheet.write_number(rowCount, 1, nBuild, cellformat_left)
rowCount += 1
worksheet.write_string(rowCount, 0, "Schematic Version:", cellformats[0])
worksheet.write_string(rowCount, 1, net.getVersion(), cellformat_left)
rowCount += 1
if len(net.getVersion()) > column_widths[1]:
column_widths[1] = len(net.getVersion())
worksheet.write_string(rowCount, 0, "Schematic Date:", cellformats[0])
worksheet.write_string(rowCount, 1, net.getSheetDate(), cellformat_left)
rowCount += 1
if len(net.getSheetDate()) > column_widths[1]:
column_widths[1] = len(net.getSheetDate())
worksheet.write_string(rowCount, 0, "BoM Date:", cellformats[0])
worksheet.write_string(rowCount, 1, net.getDate(), cellformat_left)
rowCount += 1
if len(net.getDate()) > column_widths[1]:
column_widths[1] = len(net.getDate())
worksheet.write_string(rowCount, 0, "Schematic Source:", cellformats[0])
worksheet.write_string(rowCount, 1, net.getSource(), cellformat_left)
rowCount += 1
if len(net.getSource()) > column_widths[1]:
column_widths[1] = len(net.getSource())
worksheet.write_string(rowCount, 0, "KiCad Version:", cellformats[0])
worksheet.write_string(rowCount, 1, net.getTool(), cellformat_left)
rowCount += 1
if len(net.getTool()) > column_widths[1]:
column_widths[1] = len(net.getTool())
for i in range(len(column_widths)):
worksheet.set_column(i, i, column_widths[i])
workbook.close()
return True

View File

@ -0,0 +1,65 @@
"""
Write BoM out to an XML file
filename = path to output file (must be a .xml)
groups = [list of ComponentGroup groups]
net = netlist object
headings = [list of headings to display in the BoM file]
prefs = BomPref object
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from xml.etree import ElementTree
from xml.dom import minidom
def WriteXML(filename, groups, net, headings, prefs):
if not filename.endswith(".xml"):
return False
nGroups = len(groups)
nTotal = sum([g.getCount() for g in groups])
nFitted = sum([g.getCount() for g in groups if g.isFitted()])
nBuild = nFitted * prefs.boards
attrib = {}
attrib['Schematic_Source'] = net.getSource()
attrib['Schematic_Version'] = net.getVersion()
attrib['Schematic_Date'] = net.getSheetDate()
attrib['PCB_Variant'] = ', '.join(prefs.pcbConfig)
attrib['BOM_Date'] = net.getDate()
attrib['KiCad_Version'] = net.getTool()
attrib['Component_Groups'] = str(nGroups)
attrib['Component_Count'] = str(nTotal)
attrib['Fitted_Components'] = str(nFitted)
attrib['Number_of_PCBs'] = str(prefs.boards)
attrib['Total_Components'] = str(nBuild)
xml = ElementTree.Element('KiCad_BOM', attrib=attrib, encoding='utf-8')
for group in groups:
if prefs.ignoreDNF and not group.isFitted():
continue
row = group.getRow(headings)
attrib = {}
for i, h in enumerate(headings):
h = h.replace(' ', '_') # Replace spaces, xml no likey
h = h.replace('"', '')
h = h.replace("'", '')
attrib[h] = str(row[i]).decode('ascii', errors='ignore')
# sub = ElementTree.SubElement(xml, "group", attrib=attrib)
with open(filename, "w") as output:
out = ElementTree.tostring(xml, encoding="utf-8")
output.write(minidom.parseString(out).toprettyxml(indent="\t"))
return True

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

10
scripts/KiBoM/setup.cfg Normal file
View File

@ -0,0 +1,10 @@
[flake8]
ignore =
# - W293 - blank lines contain whitespace
W293,
# - E501 - line too long (82 characters)
E501, E722,
# - C901 - function is too complex
C901,
exclude = .git,__pycache__,*/migrations/*
max-complexity = 20

View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Common functions for running various tests
PASSED=0
FAILED=0
# Results
result() {
echo "--- Results ---"
echo "${PASSED}/$((PASSED+FAILED))"
if (( FAILED == 0 )); then
return 0
else
return 1
fi
}
# Runs a command, increments test passed/failed
# Args: Command
cmd() {
# Run command
echo "CMD: $@"
$@
local RET=$?
# Check command
if [[ ${RET} -ne 0 ]]; then
((FAILED++))
else
((PASSED++))
fi
return ${RET}
}

View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Basic run-time sanity check for KiBoM
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Common functions
source ${SCRIPT_DIR}/common.bash
# Start in kll top-level directory
cd ${SCRIPT_DIR}/..
## Tests
cmd ./KiBOM_CLI.py --help
## Tests complete
result
exit $?