-
Notifications
You must be signed in to change notification settings - Fork 1
/
BOM.py
executable file
·318 lines (242 loc) · 9.89 KB
/
BOM.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
#!/usr/bin/env python
#
# Generate/update a XLSX BOM from a KiCad generic netlist
#
"""
@package
Generate/update a XLSX BOM.
Components are sorted by ref and grouped by value with same footprint
Fields are (if exist)
'Ref', 'Qty', 'Value', 'Rating', 'Footprint', 'Description', 'MPN', ...
Command line:
python "pathToFile/BOM.py" "%I" "%O"
"""
# Import the KiCad python helper module and XLS tools
import netlist_reader
from translate_fp import translate_fp
import openpyxl
import sys
if not len(sys.argv) >= 3:
print("Command line:\npython \"pathToFile/BOM.py\" bom-from-KiCad.xml generated-bom-xlsx-file")
print("Command line (from KiCad):\npython \"pathToFile/BOM.py\" \"%I\" \"%O\"")
sys.exit()
# Generate an instance of a generic netlist, and load the netlist tree from
# the command line option. If the file doesn't exist, execution will stop
net = netlist_reader.netlist(sys.argv[1])
# Open a file to write to, if the file cannot be opened output to stdout
# instead
xlsfile = sys.argv[2] + '.xlsx'
header_names = ['Ref', 'Footprint', 'Value', 'Rating', 'Qty', 'Manufacturer', 'MPN', 'Farnell', 'Mouser', 'Digikey']
# Get all of the components in groups of matching value + footprint
# A custom compare / grouping can be defined (for instance named myEqu):
# add the line:
# kicad_netlist_reader.comp.__eq__ = myEqu
# in this bom generator script before calling the netliste reader by something like:
# net = kicad_netlist_reader.netlist(sys.argv[1])
# see netlist_reader.py for more info
components = net.components
grouped = net.groupComponents()
def init_BOM_sheet(xls):
sheet = xls['BOM']
sheet.insert_rows(1)
headers = ['Sync'] + header_names
for i, col in enumerate(headers):
col_no = i + 1
cell = sheet.cell(column=col_no, row=1)
cell.value = col
cell.font = cell.font.copy(bold=True)
try:
xls = openpyxl.load_workbook(xlsfile)
# Create a new empty sheet with only the headers.
# The rest of the sync process will add all the data
except FileNotFoundError:
xls = openpyxl.Workbook()
xls.active.title = 'BOM'
init_BOM_sheet(xls)
if not 'BOM' in xls:
print("WARNING: xls file {} did not contain a 'BOM' worksheet, adding new sheet..".format(xlsfile))
xls.create_sheet('BOM')
init_BOM_sheet(xls)
# Build a lookup from column header -> column index
sheet = xls['BOM']
col_lookup = {}
col = 0
while(True):
col+=1
val = sheet.cell(column=col,row=1).value
if val is None:
break
col_lookup[val] = (col - 1)
if not 'Value' in col_lookup or not 'Footprint' in col_lookup:
print("ERROR: xls file {} does not have 'Value' and 'Footprint' colums".format(xlsfile))
sys.exit()
## XLS: styling
# New part
new_color = openpyxl.styles.colors.Color(rgb='0000F200')
new_fill = openpyxl.styles.fills.PatternFill(patternType='solid', fgColor=new_color)
# Changed part
changed_color = openpyxl.styles.colors.Color(rgb='00FFF200')
changed_fill = openpyxl.styles.fills.PatternFill(patternType='solid', fgColor=changed_color)
# Translated content (e.g. simplfied footprint name)
translate_color = openpyxl.styles.colors.Color(rgb='007DF2E6')
translate_fill = openpyxl.styles.fills.PatternFill(patternType='solid', fgColor=translate_color)
# Obsolete part: part is not in the Kicad design anymore
obsolete_color = openpyxl.styles.colors.Color(rgb='00F20000')
obsolete_fill = openpyxl.styles.fills.PatternFill(patternType='solid', fgColor=obsolete_color)
# Default style
none_fill = openpyxl.styles.fills.PatternFill(patternType=None)
## XLS: prepare by clearing the sync column
if 'Sync' in col_lookup:
col_no = col_lookup['Sync']+1
for r in range(sheet.max_row)[1:]:
row_no = r+1
cell = sheet.cell(column=col_no, row=row_no)
try:
cell.value = None
except:
pass
cell.fill = none_fill
else:
print("WARNING: xls file {} does not have a 'Sync' column."
"This means you cannot detect obsolete entries...".format(xlsfile))
last_updated_row = 1
def update_xls(part):
global last_updated_row
for row in sheet.iter_rows():
xls_val = row[col_lookup['Value']].value
xls_fp = row[col_lookup['Footprint']].value
# Not the right row: skip
if not xls_val == part['Value'] or not translate_fp(xls_fp) == translate_fp(part['Footprint']):
continue
# Matching row found: mark it as 'in sync'
if 'Sync' in col_lookup:
row[col_lookup['Sync']].value = 1
# Check each property agains the XLS value in the corresponding column
first_change = True
for prop in part:
new_value = str(part[prop]).strip()
if prop == 'Footprint':
new_value = translate_fp(new_value)
if prop == 'Qty':
new_value = int(new_value)
# no value is set: skip
if not new_value:
continue
# column not in sheet: skip
if not prop in col_lookup:
continue
col_index = col_lookup[prop]
old_value = str(row[col_index].value).strip()
if not old_value == str(new_value):
if first_change:
first_change = False
print("Change(s) found for component with value='{}', "
"footprint '{}':".format(xls_val, xls_fp))
print("'{}' changed from '{}' to '{}'".format(prop, old_value, new_value))
# This can only be because of changes in translation,
# otherwise this row would not have matched
if prop == 'Footprint':
print("Translated")
row[col_index].fill = translate_fill
else:
row[col_index].fill = changed_fill
row[col_index].value = new_value
last_updated_row = row[0].row
return
# No matching row was found: insert a new one
print("New component found with value='{}', "
"footprint '{}':".format(part['Value'], part['Footprint']))
last_updated_row+=1
if len(list(sheet.rows)) < last_updated_row:
# Append new row to sheet by writing a dummy value
# (dummy value is overwritten, but forces sheet.rows[i] to exist)
sheet.cell(row=last_updated_row,column=1).value='dummy'
else:
# Insert new row before given index
sheet.insert_rows(last_updated_row)
for prop in part:
new_value = str(part[prop]).strip()
# no value is set: skip
if not new_value:
continue
# column not in sheet: skip
if not prop in col_lookup:
continue
row = list(sheet.rows)[last_updated_row-1]
# Footprint is 'special': translate it to more readable format
if prop == 'Footprint':
new_value = translate_fp(new_value)
if prop == 'Qty':
new_value = int(new_value)
# update property
col_index = col_lookup[prop]
row[col_index].fill = new_fill
row[col_index].value = new_value
# Matching row found: mark it as 'in sync'
if 'Sync' in col_lookup:
row[col_lookup['Sync']].fill = new_fill
row[col_lookup['Sync']].value = 1
## Output all of the component information
for group in grouped:
refs = ""
# Add the reference of every component in the group and keep a reference
# to the component so that the other data can be filled in once per group
ratings = set()
filtered_group = []
for component in group:
# Skip DNI parts based on value prefix
value = component.getValue().strip()
if value.startswith('DNI') or value in ['DNI', 'DNP', 'LOGO', 'mousebite', 'inf']:
continue
# Skip DNI parts if non-empty DNI/DNP field or 'dnp' property (KiCad7+) is found
elif component.getDNP() or component.getField("DNI") or component.getField("DNP") or component.getField("dnp"):
continue
refs += component.getRef() + ", "
filtered_group.append(component)
c = component
# Gather all component ratings for this group of components.
# All unique ratings are combined into one field in the BOM.
# While ordering, a component should be selected that satisfies all of them
rating = str(component.getField("Rating") or component.getField("rating")).strip()
for r in rating.split(','):
if len(r):
ratings.add(r)
# Skip empty groups
if len(refs) <= 0:
continue
# Convert ratings from 'set' to a CSV-string.
# Sorting is to guarantee reproducibility
ratings = list(ratings)
ratings.sort()
ratings = ','.join(ratings)
part = {}
part['Ref'] = refs
part['Qty'] = len(filtered_group)
part['Value'] = c.getValue()
part['Footprint'] = c.getFootprint()
part['Description'] = c.getDescription()
part['Rating'] = ratings
part['MPN'] = c.getField("MPN")
part['Manufacturer'] = c.getField("Manufacturer")
part['Farnell'] = c.getField("Farnell")
part['Mouser'] = c.getField("Mouser")
part['Digikey'] = c.getField("Digikey")
# Avoid whitespace mismatch
for prop in part:
part[prop] = str(part[prop]).strip()
update_xls(part)
## XLS: style all obsolete entries
if 'Sync' in col_lookup:
print("Styling obsolete entries")
col_no = col_lookup['Sync']+1
for r in range(sheet.max_row)[1:]:
row_no = r+1
cell = sheet.cell(column=col_no, row=row_no)
if not cell.value:
val = sheet.cell(column=col_lookup['Value']+1, row=row_no).value
fp = sheet.cell(column=col_lookup['Footprint']+1, row=row_no).value
if val or fp:
print("Obsolete component found with value='{}', "
"footprint '{}':".format(val, fp))
cell.fill = obsolete_fill
xls.save(filename=xlsfile)