Skip to content

Commit 6350be3

Browse files
authored
Support for Eagle / Fusion 360 Electronics (#216)
* initial commit - added EagleParser This adds a third parser for JSON files produced from Eagle or Fusion 360 Electronics using the ULP available at https://github.com/Funkenjaeger/brd2json * Stylistic fixes * Refactored EagleParser to GenericJsonParser, added 'spec version' logic and more robust error handling * _type and _spec_version in top level object only * #216 code review updates * Fix format string syntax * Initial implementation of JSON validation with schema in GenericJsonParser * Updated to implement the rest of the pcbdata struct as defined in DATAFORMAT.md * Fix ExtraData (array vs. object) * Initial cut at support for extra_fields in generic JSON schema & parser * More schema updates based on code review * Removed parser-specific code from the core code in ibom.py Pushed all parsing of extra_fields data down into the respective parsers. This includes some experimental code associated with GenericJsonParser, as well as the existing netlist-based (kicad-specific) code that was in ibom.py * #216 code review updates * Revert to returning extra_fields only within components * Extra field data embedded in components for kicad parser also * Override board bounding box from generic JSON based on edges * Restore warning for outdated netlist/xml in kicad parser * Fix clerical issues noted in code review * Fix improper access of footprint ref in kicad.py
1 parent d75e74f commit 6350be3

File tree

7 files changed

+777
-94
lines changed

7 files changed

+777
-94
lines changed

InteractiveHtmlBom/core/ibom.py

Lines changed: 17 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ def warn(self, msg):
5050
log = None # type: Logger or None
5151

5252

53-
def skip_component(m, config, extra_data):
54-
# type: (Component, Config, dict) -> bool
53+
def skip_component(m, config):
54+
# type: (Component, Config) -> bool
5555
# skip blacklisted components
5656
ref_prefix = re.findall('^[A-Z]*', m.ref)[0]
5757
if m.ref in config.component_blacklist:
@@ -67,29 +67,27 @@ def skip_component(m, config, extra_data):
6767
return True
6868

6969
# skip components with dnp field not empty
70-
if config.dnp_field and m.ref in extra_data \
71-
and config.dnp_field in extra_data[m.ref] \
72-
and extra_data[m.ref][config.dnp_field]:
70+
if config.dnp_field \
71+
and config.dnp_field in m.extra_fields \
72+
and m.extra_fields[config.dnp_field]:
7373
return True
7474

7575
# skip components with wrong variant field
7676
if config.board_variant_field and config.board_variant_whitelist:
77-
if m.ref in extra_data:
78-
ref_variant = extra_data[m.ref].get(config.board_variant_field, '')
79-
if ref_variant not in config.board_variant_whitelist:
80-
return True
77+
ref_variant = m.extra_fields.get(config.board_variant_field, '')
78+
if ref_variant not in config.board_variant_whitelist:
79+
return True
8180

8281
if config.board_variant_field and config.board_variant_blacklist:
83-
if m.ref in extra_data:
84-
ref_variant = extra_data[m.ref].get(config.board_variant_field, '')
85-
if ref_variant and ref_variant in config.board_variant_blacklist:
86-
return True
82+
ref_variant = m.extra_fields.get(config.board_variant_field, '')
83+
if ref_variant and ref_variant in config.board_variant_blacklist:
84+
return True
8785

8886
return False
8987

9088

91-
def generate_bom(pcb_footprints, config, extra_data):
92-
# type: (list, Config, dict) -> dict
89+
def generate_bom(pcb_footprints, config):
90+
# type: (list, Config) -> dict
9391
"""
9492
Generate BOM from pcb layout.
9593
:param pcb_footprints: list of footprints on the pcb
@@ -113,11 +111,10 @@ def natural_sort(l):
113111
return sorted(l, key=lambda r: (alphanum_key(r[0]), r[1]))
114112

115113
# build grouped part list
116-
warning_shown = False
117114
skipped_components = []
118115
part_groups = {}
119116
for i, f in enumerate(pcb_footprints):
120-
if skip_component(f, config, extra_data):
117+
if skip_component(f, config):
121118
skipped_components.append(i)
122119
continue
123120

@@ -126,23 +123,13 @@ def natural_sort(l):
126123

127124
extras = []
128125
if config.extra_fields:
129-
if f.ref in extra_data:
130-
extras = [extra_data[f.ref].get(ef, '')
131-
for ef in config.extra_fields]
132-
else:
133-
# Some components are on pcb but not in schematic data.
134-
# Show a warning about possibly outdated netlist/xml file.
135-
log.warn(
136-
'Component %s is missing from schematic data.' % f.ref)
137-
warning_shown = True
138-
extras = [''] * len(config.extra_fields)
126+
extras = [f.extra_fields.get(ef, '')
127+
for ef in config.extra_fields]
139128

140129
group_key = (norm_value, tuple(extras), f.footprint, f.attr)
141130
valrefs = part_groups.setdefault(group_key, [f.val, []])
142131
valrefs[1].append((f.ref, i))
143132

144-
if warning_shown:
145-
log.warn('Netlist/xml file is likely out of date.')
146133
# build bom table, sort refs
147134
bom_table = []
148135
for (norm_value, extras, footprint, attr), valrefs in part_groups.items():
@@ -230,7 +217,6 @@ def get_file_content(file_name):
230217
with io.open(path, 'r', encoding='utf-8') as f:
231218
return f.read()
232219

233-
234220
if os.path.isabs(config.bom_dest_dir):
235221
bom_file_dir = config.bom_dest_dir
236222
else:
@@ -274,36 +260,11 @@ def main(parser, config, logger):
274260
pcb_file_name = os.path.basename(parser.file_name)
275261
pcb_file_dir = os.path.dirname(parser.file_name)
276262

277-
# Get extra field data
278-
extra_fields = None
279-
if config.netlist_file and os.path.isfile(config.netlist_file):
280-
extra_fields = parser.extra_data_func(
281-
config.netlist_file, config.normalize_field_case)
282-
283-
need_extra_fields = (config.extra_fields or
284-
config.board_variant_whitelist or
285-
config.board_variant_blacklist or
286-
config.dnp_field)
287-
288-
if not config.netlist_file and need_extra_fields:
289-
logger.warn('Ignoring extra fields related config parameters '
290-
'since no netlist/xml file was specified.')
291-
config.extra_fields = []
292-
config.board_variant_whitelist = []
293-
config.board_variant_blacklist = []
294-
config.dnp_field = ''
295-
need_extra_fields = False
296-
297-
if extra_fields is None and need_extra_fields:
298-
raise ParsingException('Failed parsing %s' % config.netlist_file)
299-
300-
extra_fields = extra_fields[1] if extra_fields else None
301-
302263
pcbdata, components = parser.parse()
303264
if not pcbdata and not components:
304265
raise ParsingException('Parsing failed.')
305266

306-
pcbdata["bom"] = generate_bom(components, config, extra_fields)
267+
pcbdata["bom"] = generate_bom(components, config)
307268
pcbdata["ibom_version"] = config.version
308269

309270
# build BOM

InteractiveHtmlBom/ecad/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@ def get_parser_by_extension(file_name, config, logger):
66
if ext == '.kicad_pcb':
77
return get_kicad_parser(file_name, config, logger)
88
elif ext == '.json':
9-
return get_easyeda_parser(file_name, config, logger)
9+
""".json file may be from EasyEDA or a generic json format"""
10+
import io
11+
import json
12+
with io.open(file_name, 'r') as f:
13+
obj = json.load(f)
14+
if 'pcbdata' in obj:
15+
return get_generic_json_parser(file_name, config, logger)
16+
else:
17+
return get_easyeda_parser(file_name, config, logger)
1018
else:
1119
return None
1220

@@ -19,3 +27,8 @@ def get_kicad_parser(file_name, config, logger, board=None):
1927
def get_easyeda_parser(file_name, config, logger):
2028
from .easyeda import EasyEdaParser
2129
return EasyEdaParser(file_name, config, logger)
30+
31+
32+
def get_generic_json_parser(file_name, config, logger):
33+
from .genericjson import GenericJsonParser
34+
return GenericJsonParser(file_name, config, logger)

InteractiveHtmlBom/ecad/common.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ def __init__(self, file_name, config, logger):
1919
def parse(self):
2020
"""
2121
Abstract method that should be overridden in implementations.
22-
Performs all the parsing and returns a tuple of (pcbdata, components)
22+
Performs all the parsing and returns a tuple of
23+
(pcbdata, components)
2324
pcbdata is described in DATAFORMAT.md
2425
components is list of Component objects
2526
:return:
@@ -35,16 +36,49 @@ def latest_extra_data(self, extra_dirs=None):
3536
"""
3637
return None
3738

39+
def add_drawing_bounding_box(self, drawing, bbox):
40+
# type: (dict, BoundingBox) -> None
41+
42+
def add_segment():
43+
bbox.add_segment(drawing['start'][0], drawing['start'][1],
44+
drawing['end'][0], drawing['end'][1],
45+
drawing['width'] / 2)
46+
47+
def add_circle():
48+
bbox.add_circle(drawing['start'][0], drawing['start'][1],
49+
drawing['radius'] + drawing['width'] / 2)
50+
51+
def add_svgpath():
52+
width = drawing.get('width', 0)
53+
bbox.add_svgpath(drawing['svgpath'], width, self.logger)
54+
55+
def add_polygon():
56+
if 'polygons' not in drawing:
57+
add_svgpath()
58+
return
59+
polygon = drawing['polygons'][0]
60+
for point in polygon:
61+
bbox.add_point(point[0], point[1])
62+
63+
{
64+
'segment': add_segment,
65+
'circle': add_circle,
66+
'arc': add_svgpath,
67+
'polygon': add_polygon,
68+
'text': lambda: None, # text is not really needed for bounding box
69+
}.get(drawing['type'])()
70+
3871

3972
class Component(object):
4073
"""Simple data object to store component data needed for bom table."""
4174

42-
def __init__(self, ref, val, footprint, layer, attr=None):
75+
def __init__(self, ref, val, footprint, layer, attr=None, extra_fields={}):
4376
self.ref = ref
4477
self.val = val
4578
self.footprint = footprint
4679
self.layer = layer
4780
self.attr = attr
81+
self.extra_fields = extra_fields
4882

4983

5084
class BoundingBox(object):

InteractiveHtmlBom/ecad/easyeda.py

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -273,38 +273,6 @@ def add_custom():
273273
'custom': add_custom,
274274
}.get(pad['shape'])()
275275

276-
def add_drawing_bounding_box(self, drawing, bbox):
277-
# type: (dict, BoundingBox) -> None
278-
279-
def add_segment():
280-
bbox.add_segment(drawing['start'][0], drawing['start'][1],
281-
drawing['end'][0], drawing['end'][1],
282-
drawing['width'] / 2)
283-
284-
def add_circle():
285-
bbox.add_circle(drawing['start'][0], drawing['start'][1],
286-
drawing['radius'] + drawing['width'] / 2)
287-
288-
def add_svgpath():
289-
width = drawing.get('width', 0)
290-
bbox.add_svgpath(drawing['svgpath'], width, self.logger)
291-
292-
def add_polygon():
293-
if 'polygons' not in drawing:
294-
add_svgpath()
295-
return
296-
polygon = drawing['polygons'][0]
297-
for point in polygon:
298-
bbox.add_point(point[0], point[1])
299-
300-
{
301-
'segment': add_segment,
302-
'circle': add_circle,
303-
'arc': add_svgpath,
304-
'polygon': add_polygon,
305-
'text': lambda: None, # text is not really needed for bounding box
306-
}.get(drawing['type'])()
307-
308276
def parse_lib(self, shape):
309277
parts = self.sharp_split(shape)
310278
head = self.tilda_split(parts[0])
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import io
2+
import json
3+
from jsonschema import validate, ValidationError
4+
5+
from .common import EcadParser, Component, BoundingBox
6+
7+
8+
class GenericJsonParser(EcadParser):
9+
COMPATIBLE_SPEC_VERSIONS = [1]
10+
11+
def get_generic_json_pcb(self):
12+
from os import path
13+
with io.open(self.file_name, 'r') as f:
14+
pcb = json.load(f)
15+
16+
if 'spec_version' not in pcb:
17+
raise ValidationError("'spec_version' is a required property")
18+
19+
if pcb['spec_version'] not in self.COMPATIBLE_SPEC_VERSIONS:
20+
raise ValidationError("Unsupported spec_version ({})"
21+
.format(pcb['spec_version']))
22+
23+
schema_dir = path.join(path.dirname(__file__), 'schema')
24+
schema_file_name = path.join(schema_dir,
25+
'genericjsonpcbdata_v{}.schema'
26+
.format(pcb['spec_version']))
27+
28+
with io.open(schema_file_name, 'r') as f:
29+
schema = json.load(f)
30+
31+
validate(instance=pcb, schema=schema)
32+
33+
return pcb
34+
35+
def _verify(self, pcb):
36+
37+
"""Spot check the pcb object."""
38+
39+
if len(pcb['pcbdata']['footprints']) != len(pcb['components']):
40+
self.logger.error("Length of components list doesn't match"
41+
" length of footprints list.")
42+
return False
43+
44+
return True
45+
46+
def parse(self):
47+
try:
48+
pcb = self.get_generic_json_pcb()
49+
except ValidationError as e:
50+
self.logger.error('File {f} does not comply with json schema. {m}'
51+
.format(f=self.file_name, m=e.message))
52+
return None, None
53+
54+
if not self._verify(pcb):
55+
self.logger.error('File {} does not appear to be valid generic'
56+
' InteractiveHtmlBom json file.'
57+
.format(self.file_name))
58+
return None, None
59+
60+
self.logger.info('Successfully parsed {}'.format(self.file_name))
61+
62+
pcbdata = pcb['pcbdata']
63+
components = [Component(**c) for c in pcb['components']]
64+
65+
# override board bounding box based on edges
66+
board_outline_bbox = BoundingBox()
67+
for drawing in pcbdata['edges']:
68+
self.add_drawing_bounding_box(drawing, board_outline_bbox)
69+
if board_outline_bbox.initialized():
70+
pcbdata['edges_bbox'] = board_outline_bbox.to_dict()
71+
72+
if self.config.extra_fields:
73+
for c in components:
74+
extra_field_data = {}
75+
for f in self.config.extra_fields:
76+
fv = ("" if f not in c.extra_fields else c.extra_fields[f])
77+
extra_field_data[f] = fv
78+
c.extra_fields = extra_field_data
79+
80+
return pcbdata, components

0 commit comments

Comments
 (0)