diff --git a/scripts/KiBoM/.gitignore b/scripts/KiBoM/.gitignore new file mode 100644 index 0000000..468a5a1 --- /dev/null +++ b/scripts/KiBoM/.gitignore @@ -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 diff --git a/scripts/KiBoM/.travis.yml b/scripts/KiBoM/.travis.yml new file mode 100644 index 0000000..4a17dbb --- /dev/null +++ b/scripts/KiBoM/.travis.yml @@ -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 . diff --git a/scripts/KiBoM/KiBOM_CLI.py b/scripts/KiBoM/KiBOM_CLI.py new file mode 100644 index 0000000..a273047 --- /dev/null +++ b/scripts/KiBoM/KiBOM_CLI.py @@ -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) diff --git a/scripts/KiBoM/LICENSE.md b/scripts/KiBoM/LICENSE.md new file mode 100644 index 0000000..9ca0e71 --- /dev/null +++ b/scripts/KiBoM/LICENSE.md @@ -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. \ No newline at end of file diff --git a/scripts/KiBoM/README.md b/scripts/KiBoM/README.md new file mode 100644 index 0000000..92edb5b --- /dev/null +++ b/scripts/KiBoM/README.md @@ -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 + + + + + + + + + + + + + + +### 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 diff --git a/scripts/KiBoM/__init__.py b/scripts/KiBoM/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/KiBoM/bomlib/__init__.py b/scripts/KiBoM/bomlib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/KiBoM/bomlib/bom_writer.py b/scripts/KiBoM/bomlib/bom_writer.py new file mode 100644 index 0000000..15d5df5 --- /dev/null +++ b/scripts/KiBoM/bomlib/bom_writer.py @@ -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 diff --git a/scripts/KiBoM/bomlib/columns.py b/scripts/KiBoM/bomlib/columns.py new file mode 100644 index 0000000..b2cb3ba --- /dev/null +++ b/scripts/KiBoM/bomlib/columns.py @@ -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 or + 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) diff --git a/scripts/KiBoM/bomlib/component.py b/scripts/KiBoM/bomlib/component.py new file mode 100644 index 0000000..f9d1217 --- /dev/null +++ b/scripts/KiBoM/bomlib/component.py @@ -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 diff --git a/scripts/KiBoM/bomlib/csv_writer.py b/scripts/KiBoM/bomlib/csv_writer.py new file mode 100644 index 0000000..46622a1 --- /dev/null +++ b/scripts/KiBoM/bomlib/csv_writer.py @@ -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 diff --git a/scripts/KiBoM/bomlib/html_writer.py b/scripts/KiBoM/bomlib/html_writer.py new file mode 100644 index 0000000..6945e8d --- /dev/null +++ b/scripts/KiBoM/bomlib/html_writer.py @@ -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 '{t}'.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("\n") + html.write("\n") + html.write('\t\n') # UTF-8 encoding for unicode support + html.write("\n") + html.write("\n") + + # PCB info + if not prefs.hideHeaders: + html.write("

KiBoM PCB Bill of Materials

\n") + if not prefs.hidePcbInfo: + html.write('\n') + html.write("\n".format(source=net.getSource())) + html.write("\n".format(date=net.getDate())) + html.write("\n".format(version=net.getVersion())) + html.write("\n".format(date=net.getSheetDate())) + html.write("\n".format(variant=', '.join(prefs.pcbConfig))) + html.write("\n".format(version=net.getTool())) + html.write("\n".format(n=nGroups)) + html.write("\n".format(n=nTotal)) + html.write("\n".format(n=nFitted)) + html.write("\n".format(n=prefs.boards)) + html.write("\n".format(n=prefs.boards, t=nBuild)) + html.write("
Source File{source}
BoM Date{date}
Schematic Version{version}
Schematic Date{date}
PCB Variant{variant}
KiCad Version{version}
Component Groups{n}
Component Count (per PCB){n}
Fitted Components (per PCB){n}
Number of PCBs{n}
Total Component Count
(for {n} PCBs)
{t}
\n") + html.write("
\n") + + if not prefs.hideHeaders: + html.write("

Component Groups

\n") + html.write('

KiCad Fields (default)

\n'.format(bg=BG_KICAD)) + html.write('

Generated Fields

\n'.format(bg=BG_GEN)) + html.write('

User Fields

\n'.format(bg=BG_USER)) + html.write('

Empty Fields

\n'.format(bg=BG_EMPTY)) + + # Component groups + html.write('\n') + + # Row titles: + html.write("\n") + + if prefs.numberRows: + html.write("\t\n") + + for i, h in enumerate(headings): + # Cell background color + bg = bgColor(h) + html.write('\t\n'.format( + h=h, + bg=' bgcolor="{c}"'.format(c=bg) if bg else '') + ) + + html.write("\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("\n") + + if prefs.numberRows: + html.write('\t\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\n'.format(bg=' bgcolor={c}'.format(c=bg) if bg else '', val=link(r))) + + html.write("\n") + + html.write("
{h}
{n}{val}
\n") + html.write("

\n") + + html.write("") + + return True diff --git a/scripts/KiBoM/bomlib/netlist_reader.py b/scripts/KiBoM/bomlib/netlist_reader.py new file mode 100644 index 0000000..1664e08 --- /dev/null +++ b/scripts/KiBoM/bomlib/netlist_reader.py @@ -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 + +# -------------------------------------------------------------------- + + +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 = "\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""" + 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() diff --git a/scripts/KiBoM/bomlib/preferences.py b/scripts/KiBoM/bomlib/preferences.py new file mode 100644 index 0000000..1a4733e --- /dev/null +++ b/scripts/KiBoM/bomlib/preferences.py @@ -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) diff --git a/scripts/KiBoM/bomlib/sort.py b/scripts/KiBoM/bomlib/sort.py new file mode 100644 index 0000000..08f365a --- /dev/null +++ b/scripts/KiBoM/bomlib/sort.py @@ -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)] diff --git a/scripts/KiBoM/bomlib/units.py b/scripts/KiBoM/bomlib/units.py new file mode 100644 index 0000000..da68c4d --- /dev/null +++ b/scripts/KiBoM/bomlib/units.py @@ -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 diff --git a/scripts/KiBoM/bomlib/version.py b/scripts/KiBoM/bomlib/version.py new file mode 100644 index 0000000..8ecfdea --- /dev/null +++ b/scripts/KiBoM/bomlib/version.py @@ -0,0 +1,2 @@ +KIBOM_VERSION = "1.52" +KIBOM_DATE = "2018-9-16" diff --git a/scripts/KiBoM/bomlib/xlsx_writer.py b/scripts/KiBoM/bomlib/xlsx_writer.py new file mode 100644 index 0000000..43018e5 --- /dev/null +++ b/scripts/KiBoM/bomlib/xlsx_writer.py @@ -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 diff --git a/scripts/KiBoM/bomlib/xml_writer.py b/scripts/KiBoM/bomlib/xml_writer.py new file mode 100644 index 0000000..b72b3b5 --- /dev/null +++ b/scripts/KiBoM/bomlib/xml_writer.py @@ -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 diff --git a/scripts/KiBoM/example/bom.png b/scripts/KiBoM/example/bom.png new file mode 100644 index 0000000..775221c Binary files /dev/null and b/scripts/KiBoM/example/bom.png differ diff --git a/scripts/KiBoM/example/html.png b/scripts/KiBoM/example/html.png new file mode 100644 index 0000000..f0a8fb1 Binary files /dev/null and b/scripts/KiBoM/example/html.png differ diff --git a/scripts/KiBoM/example/html_ex.png b/scripts/KiBoM/example/html_ex.png new file mode 100644 index 0000000..035b814 Binary files /dev/null and b/scripts/KiBoM/example/html_ex.png differ diff --git a/scripts/KiBoM/example/ini.png b/scripts/KiBoM/example/ini.png new file mode 100644 index 0000000..c6e671a Binary files /dev/null and b/scripts/KiBoM/example/ini.png differ diff --git a/scripts/KiBoM/example/schem.png b/scripts/KiBoM/example/schem.png new file mode 100644 index 0000000..1c9b30e Binary files /dev/null and b/scripts/KiBoM/example/schem.png differ diff --git a/scripts/KiBoM/example/usage.png b/scripts/KiBoM/example/usage.png new file mode 100644 index 0000000..960e9b1 Binary files /dev/null and b/scripts/KiBoM/example/usage.png differ diff --git a/scripts/KiBoM/setup.cfg b/scripts/KiBoM/setup.cfg new file mode 100644 index 0000000..61a1b69 --- /dev/null +++ b/scripts/KiBoM/setup.cfg @@ -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 diff --git a/scripts/KiBoM/tests/common.bash b/scripts/KiBoM/tests/common.bash new file mode 100644 index 0000000..109928e --- /dev/null +++ b/scripts/KiBoM/tests/common.bash @@ -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} +} diff --git a/scripts/KiBoM/tests/sanity.bash b/scripts/KiBoM/tests/sanity.bash new file mode 100644 index 0000000..9edede2 --- /dev/null +++ b/scripts/KiBoM/tests/sanity.bash @@ -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 $?