diff --git a/kiplot/kicad/v5_sch.py b/kiplot/kicad/v5_sch.py new file mode 100644 index 00000000..ee1ba99a --- /dev/null +++ b/kiplot/kicad/v5_sch.py @@ -0,0 +1,577 @@ +""" +KiCad v5 (and older) Schematic format. + +A basic implementation of the .sch file format. +""" +import re +import os +from ..gs import GS +from .. import log + +logger = log.get_logger(__name__) + +_sch_line_number = 0 + + +class SchError(Exception): + pass + + +class SchFileError(SchError): + pass + + +def _get_line(f): + res = f.readline() + if not res: + raise SchFileError('Unexpected end of file') + global _sch_line_number + _sch_line_number += 1 + return res.rstrip() + + +def _split_space(s): + res = s.lstrip().split(' ') + return [a for a in res if a] + + +class SchematicField(object): + field_re = re.compile(r'F\s+(\d+)\s+"([^"]*)"\s+([HV])\s+(-?\d+)\s+(-?\d+)\s+(\d+)\s+(\d+)' + r'\s+([LRCBT])\s+([LRCBT][IN][BN])\s*("[^"]*")?') + + def __init__(self): + super().__init__() + + @staticmethod + def parse(line): + m = SchematicField.field_re.match(line) + if not m: + raise SchFileError('Malformed component field', line, _sch_line_number) + field = SchematicField() + gs = m.groups() + field.number = int(gs[0]) + field.value = gs[1] + field.horizontal = gs[2] == 'H' # H -> True, V -> False + field.x = int(gs[3]) + field.y = int(gs[4]) + field.size = int(gs[5]) + field.flags = gs[6] + field.hjustify = gs[7] + field.vjustify = gs[8][0] + field.italic = gs[8][1] == 'I' + field.bold = gs[8][2] == 'B' + if gs[9]: + field.name = gs[9][1:-1] + else: + if field.number > 4: + raise SchFileError('Missing component field name', line, _sch_line_number) + field.name = ['Reference', 'Value', 'Footprint', 'Datasheet'][field.number] + return field + + +class SchematicAltRef(): + def __init__(self): + super().__init__() + self.path = None + self.ref = None + self.part = None + + @staticmethod + def parse(line): + ar = SchematicAltRef() + res = _split_space(line[3:]) + for r in res: + if r.startswith('Path='): + ar.path = r[6:-1] + elif r.startswith('Ref='): + ar.ref = r[5:-1] + elif r.startswith('Part='): + ar.part = r[6:-1] + else: + logger.warning('Unknown AR field `{}`'.format(r)) + if not ar.path: + logger.warning('Alternative Reference without path `{}`'.format(line)) + if not ar.ref: + logger.warning('Alternative Reference without reference `{}`'.format(line)) + return ar + + +class SchematicComponent(object): + ref_re = re.compile(r'([^\d]+)([\?\d]+)') + + def __init__(self): + super().__init__() + self.field_ref = '' + self.value = '' + self.footprint = '' + self.datasheet = '' + + def get_field_value(self, field): + field = field.lower() + if field in self.dfields: + return self.dfields[field].value + return '' + + def get_field_names(self): + return [f.name for f in self.fields] + + def _solve_ref(self, path): + """ Look fo the correct reference for this path. + Returns the default reference if no paths defined. + Returns the first not empty reference if the current is empty. """ + ref = self.f_ref + # If the reference is empty try the reference field + if ref[-1] == '?' and self.field_ref and self.field_ref[-1] != '?': + ref = self.fields[0].value + if self.ar: + path += '/'+self.id + for o in self.ar: + if o.path == path and o.ref[-1] != '?': + return o.ref + if ref[-1] == '?' and o.ref[-1] != '?': + ref = o.ref + return ref + + def _solve_fields(self): + """ Fills the default fields from the fields attribute """ + f = self.fields + c = len(f) + if len(f) < 4: + logger.warning('Component {} without the basic fields'.format(self.ref)) + if c > 0: + self.field_ref = f[0].value + if c > 1: + self.value = f[1].value + if c > 2: + self.footprint = f[2].value + if c > 3: + self.datasheet = f[3].value + + def __str__(self): + if self.name == self.value: + return '{} ({})'.format(self.ref, self.name, self.value) + return '{} ({} {})'.format(self.ref, self.name, self.value) + + @staticmethod + def load(f, sheet_path): + # L lib:name reference + line = _get_line(f) + if line[0] != 'L': + raise SchFileError('Missing component label', line, _sch_line_number) + res = _split_space(line[2:]) + if len(res) != 2: + raise SchFileError('Malformed component label', line, _sch_line_number) + comp = SchematicComponent() + comp.name, comp.f_ref = res + res = comp.name.split(':') + comp.lib = None + if len(res) == 2: + comp.name = res[1] + comp.lib = res[0] + # U N mm time_stamp + line = _get_line(f) + if line[0] != 'U': + raise SchFileError('Missing component unit', line, _sch_line_number) + res = _split_space(line[2:]) + if len(res) != 3: + raise SchFileError('Malformed component unit', line, _sch_line_number) + comp.unit = int(res[0]) + comp.unit2 = int(res[1]) + comp.id = res[2] + # P x y + line = _get_line(f) + if line[0] != 'P': + raise SchFileError('Missing component position', line, _sch_line_number) + res = _split_space(line[2:]) + if len(res) != 2: + raise SchFileError('Malformed component position', line, _sch_line_number) + comp.x = int(res[0]) + comp.y = int(res[1]) + # Optional "Alternative References" + line = _get_line(f) + comp.ar = [] + while line[:2] == 'AR': + comp.ar.append(SchematicAltRef.parse(line)) + line = _get_line(f) + # F field_number "text" orientation posX posY size Flags (see below) hjustify vjustify/italic/bold "name" + comp.fields = [] + comp.dfields = {} + while line[0] == 'F': + field = SchematicField.parse(line) + comp.fields.append(field) + comp.dfields[field.name.lower()] = field + line = _get_line(f) + # Redundant pos + if not line.startswith('\t'+str(comp.unit)): + raise SchFileError('Missing component redundant position', line, _sch_line_number) + res = _split_space(line[2:]) + if len(res) != 2: + raise SchFileError('Malformed component redundant position', line, _sch_line_number) + xr = int(res[0]) + yr = int(res[1]) + if comp.x != xr or comp.y != yr: + logger.warning('Inconsistent position for component {} ({},{} vs {},{})'. + format(comp.f_ref, comp.x, comp.y, xr, yr), line, _sch_line_number) + # Orientation matrix + line = _get_line(f) + if line[0] != '\t': + raise SchFileError('Missing component orientation matrix', line, _sch_line_number) + res = _split_space(line[1:]) + if len(res) != 4: + raise SchFileError('Malformed component orientation matrix', line, _sch_line_number) + comp.matrix = [int(v) for v in res] + line = _get_line(f) + while not line.startswith('$EndComp'): + line = _get_line(f) + comp._solve_fields() + comp.ref = comp._solve_ref(sheet_path) + # Power, ground or power flag + comp.is_power = comp.ref.startswith('#PWR') or comp.ref.startswith('#FLG') + if comp.ref[-1] == '?': + logger.warning('Component {} is not annotated'.format(comp)) + # Separate the reference in its components + m = SchematicComponent.ref_re.match(comp.ref) + if not m: + raise SchFileError('Malformed component reference', comp.ref, _sch_line_number) + comp.ref_prefix, comp.ref_suffix = m.groups() + if GS.debug_level > 1: + logger.debug("- Loaded component {}".format(comp)) + return comp + + +class SchematicConnection(object): + conn_re = re.compile(r'\s*~\s+(-?\d+)\s+(-?\d+)') + + def __init__(self): + super().__init__() + + @staticmethod + def parse(connect, line): + m = SchematicConnection.conn_re.match(line) + if not m: + raise SchFileError('Malformed no/connection', line, _sch_line_number) + c = SchematicConnection() + c.connect = connect + c.x = int(m.group(1)) + c.y = int(m.group(2)) + return c + + +class SchematicText(object): + label_re = re.compile(r'Text\s+(Notes|HLabel|GLabel|Label)\s+(-?\d+)\s+(-?\d+)\s+(\d)\s+(\d+)\s+(\S+)') + + def __init__(self): + super().__init__() + + @staticmethod + def load(f, line): + m = SchematicText.label_re.match(line) + if not m: + raise SchFileError('Malformed text', line, _sch_line_number) + text = SchematicText() + gs = m.groups() + text.type = gs[0] + text.x = int(gs[1]) + text.y = int(gs[2]) + text.orient = int(gs[3]) + text.size = int(gs[4]) + text.shape = gs[5] + text.text = _get_line(f) + return text + + +class SchematicWire(object): + WIRE = 0 + WIRE_BUS = 1 + WIRE_DOT = 2 + WIRES = {'Wire': WIRE, 'Bus': WIRE_BUS, 'Notes': WIRE_DOT} + ENTRY_WIRE = 3 + ENTRY_BUS = 4 + ENTRIES = {'Wire': ENTRY_WIRE, 'Bus': ENTRY_BUS} + + def __init__(self): + super().__init__() + + @staticmethod + def load(f, line): + res = _split_space(line) + if len(res) != 3: + raise SchFileError('Malformed wire', line, _sch_line_number) + wire = SchematicText() + if res[0] == 'Wire': + if res[2] != 'Line' or res[1] not in SchematicWire.WIRES: + raise SchFileError('Malformed wire', line, _sch_line_number) + wire.type = SchematicWire.WIRES[res[1]] + else: # Entry + if res[2] != 'Bus' or res[1] not in SchematicWire.ENTRIES: + raise SchFileError('Malformed entry', line, _sch_line_number) + wire.type = SchematicWire.ENTRIES[res[1]] + line = _get_line(f) + if line[0] != '\t': + raise SchFileError('Malformed wire', line, _sch_line_number) + res = _split_space(line[1:]) + if len(res) != 4: + raise SchFileError('Malformed wire', line, _sch_line_number) + wire.x = int(res[0]) + wire.y = int(res[1]) + wire.ex = int(res[2]) + wire.ey = int(res[3]) + return wire + + +class SchematicBitmap(object): + def __init__(self): + super().__init__() + + @staticmethod + def load(f): + # Position + line = _get_line(f) + res = _split_space(line.split) + if len(res) != 3: + raise SchFileError('Malformed bitmap position', line, _sch_line_number) + if res[0] != 'Pos': + raise SchFileError('Missing bitmap position', line, _sch_line_number) + bmp = SchematicBitmap() + bmp.x = int(res[1]) + bmp.y = int(res[2]) + # Scale + line = _get_line(f) + res = _split_space(line) + if len(res) != 2: + raise SchFileError('Malformed bitmap scale', line, _sch_line_number) + if res[0] != 'Scale': + raise SchFileError('Missing bitmap scale', line, _sch_line_number) + bmp.scale = float(res[1].replace(',', '.')) + # Data + line = _get_line(f) + if line != 'Data': + raise SchFileError('Missing bitmap data', line, _sch_line_number) + line = _get_line(f) + bmp.data = b'' + while line != 'EndData': + res = _split_space(line) + bmp.data += bytes([int(b, 16) for b in res]) + line = _get_line(f) + # End of bitmap + line = _get_line(f) + if line != '$EndBitmap': + raise SchFileError('Missing end of bitmap', line, _sch_line_number) + return bmp + + +class SchematicPort(object): + port_re = re.compile(r'(\d+)\s+"(.*?)"\s+([IOBTU])\s+([RLTB])\s+(-?\d+)\s+(-?\d+)\s+(\d+)$') + + def __init__(self): + super().__init__() + + @staticmethod + def parse(line): + m = SchematicPort.port_re.match(line) + if not m: + raise SchFileError('Malformed sheet port label', line, _sch_line_number) + port = SchematicPort() + res = m.groups() + port.number = int(res[0]) + port.name = res[1] + port.form = res[2] + port.side = res[3] + port.x = int(res[4]) + port.y = int(res[5]) + port.size = int(res[6]) + return port + + +class SchematicSheet(object): + name_re = re.compile(r'"(.*?)"\s+(\d+)$') + + def __init__(self): + super().__init__() + self.sheet = None + self.id = '' + + def load_sheet(self, parent, sheet_path): + assert self.name + self.sheet = Schematic() + parent_dir = os.path.dirname(parent) + sheet_path += '/'+self.id + self.sheet.load(os.path.join(parent_dir, self.file), sheet_path) + return self.sheet + + @staticmethod + def load(f): + # Position & Size + line = _get_line(f) + if line[0] != 'S': + raise SchFileError('Missing sheet size and position', line, _sch_line_number) + res = _split_space(line[2:]) + if len(res) != 4: + raise SchFileError('Malformed sheet size and position', line, _sch_line_number) + sch = SchematicSheet() + sch.x = int(res[0]) + sch.y = int(res[1]) + sch.w = int(res[2]) + sch.h = int(res[3]) + # Optional U + line = _get_line(f) + if line[0] == 'U': + sch.id = line[2:] + line = _get_line(f) + # Labels + sch.labels = [] + sch.name = None + sch.file = None + while not line.startswith('$EndSheet'): + if line[0] != 'F': + raise SchFileError('Malformed sheet label', line, _sch_line_number) + if line[1] == '0': + m = SchematicSheet.name_re.match(line[2:].lstrip()) + if not m: + raise SchFileError('Malformed sheet name', line, _sch_line_number) + sch.name = m.group(1) + sch.name_size = int(m.group(2)) + elif line[1] == '1' and line[2] == ' ': + m = SchematicSheet.name_re.match(line[2:].lstrip()) + if not m: + raise SchFileError('Malformed sheet file name', line, _sch_line_number) + sch.file = m.group(1) + sch.file_size = int(m.group(2)) + else: + sch.labels.append(SchematicPort.parse(line[1:])) + line = _get_line(f) + if not sch.name: + raise SchFileError('Missing sub-sheet name') + if not sch.file: + raise SchFileError('Missing sub-sheet file name', sch.name) + return sch + + +class Schematic(object): + def __init__(self): + super().__init__() + + def _get_title_block(self, f): + line = _get_line(f) + m = re.match(r'\$Descr (\S+) (\d+) (\d+)', line) + if not m: + raise SchFileError('Missing $Descr', line, _sch_line_number) + self.page_type = m.group(1) + self.page_width = m.group(2) + self.page_height = m.group(3) + self.sheet = 1 + self.sheets = 1 + self.title_block = {} + while True: + line = _get_line(f) + if line.startswith('$EndDescr'): + return + elif line.startswith('encoding'): + if line[9:14] != 'utf-8': + raise SchFileError('Unsupported encoding', line, _sch_line_number) + elif line.startswith('Sheet'): + res = _split_space(line[6:]) + if len(res) != 2: + raise SchFileError('Wrong sheet number', line, _sch_line_number) + self.sheet = int(res[0]) + self.sheets = int(res[1]) + else: + m = re.match(r'(\S+)\s+"(.*)"', line) + if not m: + raise SchFileError('Wrong entry in title block', line, _sch_line_number) + self.title_block[m.group(1)] = m.group(2) + + def load(self, fname, sheet_path=''): + """ Load a v5.x KiCad Schematic. + The caller must be sure the file exists. """ + logger.debug("Loading sheet from "+fname) + self.fname = fname + with open(fname, 'rt') as f: + global _sch_line_number + _sch_line_number = 0 + line = _get_line(f) + m = re.match(r'EESchema Schematic File Version (\d+)', line) + if not m: + raise SchFileError('No eeschema signature', line, _sch_line_number) + self.version = int(m.group(1)) + line = _get_line(f) + if line.startswith('LIBS'): + # LIBS is optional and can be skipped + line = _get_line(f) + m = re.match(r'EELAYER (\d+) (\d+)', line, _sch_line_number) + if not m: + raise SchFileError('Missing EELAYER', line, _sch_line_number) + self.eelayer_n = int(m.group(1)) + self.eelayer_m = int(m.group(2)) + line = _get_line(f) + if not line.startswith('EELAYER END'): + raise SchFileError('Missing EELAYER END', line, _sch_line_number) + self._get_title_block(f) + line = _get_line(f) + self.all = [] + self.components = [] + self.conn = [] + self.texts = [] + self.wires = [] + self.bitmaps = [] + self.sheets = [] + while not line.startswith('$EndSCHEMATC'): + if line.startswith('$Comp'): + obj = SchematicComponent.load(f, sheet_path) + self.components.append(obj) + elif line.startswith('NoConn'): + obj = SchematicConnection.parse(False, line[7:]) + self.conn.append(obj) + elif line.startswith('Connection'): + obj = SchematicConnection.parse(True, line[11:]) + self.conn.append(obj) + elif line.startswith('Text'): + obj = SchematicText.load(f, line) + self.texts.append(obj) + elif line.startswith('Wire') or line.startswith('Entry'): + obj = SchematicWire.load(f, line) + self.wires.append(obj) + elif line.startswith('$Bitmap'): + obj = SchematicBitmap.load(f) + self.bitmaps.append(obj) + elif line.startswith('$Sheet'): + obj = SchematicSheet.load(f) + self.sheets.append(obj) + else: + raise SchFileError('Unknown definition', line, _sch_line_number) + self.all.append(obj) + line = _get_line(f) + # Load sub-sheets + self.sub_sheets = [] + for sch in self.sheets: + self.sub_sheets.append(sch.load_sheet(fname, sheet_path)) + + def get_files(self): + """ A list of the names for all the sheets, including this one. """ + files = [self.fname] + for sch in self.sheets: + files.extend(sch.sheet.get_files()) + return files + + def get_components(self, exclude_power=True): + """ A list of all the components. """ + if exclude_power: + components = [c for c in self.components if not c.is_power] + else: + components = [c for c in self.components] + for sch in self.sheets: + components.extend(sch.sheet.get_components(exclude_power)) + components.sort(key=lambda g: g.ref) + return components + + def get_field_names(self, fields): + fields_lc = {v.lower(): 1 for v in fields} + for c in self.components: + for f in c.fields: + name_lc = f.name.lower() + if name_lc not in fields_lc: + fields.append(f.name) + fields_lc[name_lc] = 1 + for sch in self.sheets: + fields = sch.sheet.get_field_names(fields) + return fields diff --git a/kiplot/kiplot.py b/kiplot/kiplot.py index d7ce68e6..2e1ec4d8 100644 --- a/kiplot/kiplot.py +++ b/kiplot/kiplot.py @@ -12,9 +12,11 @@ from distutils.version import StrictVersion from importlib.util import (spec_from_file_location, module_from_spec) from .gs import (GS) -from .misc import (PLOT_ERROR, NO_PCBNEW_MODULE, MISSING_TOOL, CMD_EESCHEMA_DO, URL_EESCHEMA_DO, CORRUPTED_PCB, EXIT_BAD_ARGS) +from .misc import (PLOT_ERROR, NO_PCBNEW_MODULE, MISSING_TOOL, CMD_EESCHEMA_DO, URL_EESCHEMA_DO, CORRUPTED_PCB, + EXIT_BAD_ARGS, CORRUPTED_SCH) from .error import (PlotError, KiPlotConfigurationError, config_error) from .pre_base import BasePreFlight +from .kicad.v5_sch import Schematic, SchFileError from . import log logger = log.get_logger(__name__) @@ -116,6 +118,17 @@ def load_board(pcb_file=None): return board +def load_sch(): + GS.check_sch() + GS.sch = Schematic() + try: + GS.sch.load(GS.sch_file) + except SchFileError as e: + logger.error('At line {} of `{}`: {}'.format(e.args[2], GS.sch_file, e.args[0])) + logger.error('Line content: `{}`'.format(e.args[1])) + exit(CORRUPTED_SCH) + + def preflight_checks(skip_pre): logger.debug("Preflight checks") @@ -177,7 +190,7 @@ def generate_outputs(outputs, target, invert, skip_pre): if out.is_pcb() and (board is None): board = load_board() if out.is_sch(): - GS.check_sch() + load_sch() config_output(out) logger.info('- '+str(out)) try: diff --git a/kiplot/misc.py b/kiplot/misc.py index 799215a6..6d609b69 100644 --- a/kiplot/misc.py +++ b/kiplot/misc.py @@ -22,6 +22,7 @@ CORRUPTED_PCB = 17 KICAD2STEP_ERR = 18 WONT_OVERWRITE = 19 PCBDRAW_ERR = 20 +CORRUPTED_SCH = 21 CMD_EESCHEMA_DO = 'eeschema_do' URL_EESCHEMA_DO = 'https://github.com/INTI-CMNB/kicad-automation-scripts' diff --git a/tests/board_samples/print_err.sch b/tests/board_samples/print_err.sch new file mode 100644 index 00000000..1ae8ad1c --- /dev/null +++ b/tests/board_samples/print_err.sch @@ -0,0 +1,27 @@ +EESchema Schematic File Version 4 +EELAYER 30 0 +EELAYER END +$Descr Alalala 11693 8268 +encoding utf-8 +Sheet 1 1 +Title "BoM Test" +Date "13/07/2020" +Rev "r1" +Comp "INTI-CMNB" +Comment1 "" +Comment2 "" +Comment3 "" +Comment4 "" +$EndDescr +$Comp +L Device:Rulito R1 +U 1 1 5EBE8A2E +P 3500 2200 +F 0 "R1" H 3570 2246 50 0000 L CNN +F 1 "100" H 3570 2155 50 0000 L CNN +F 2 "Resistor_SMD:R_0805_2012Metric" V 3430 2200 50 0001 C CNN +F 3 "~" H 3500 2200 50 0001 C CNN + 1 3500 2200 + 1 0 0 -1 +$EndComp +$EndSCHEMATC diff --git a/tests/test_plot/test_misc.py b/tests/test_plot/test_misc.py index 2ffac7ff..92f99b52 100644 --- a/tests/test_plot/test_misc.py +++ b/tests/test_plot/test_misc.py @@ -168,7 +168,7 @@ def test_miss_pcb_2(): def test_miss_yaml(): - prj = '3Rs' + prj = 'bom' ctx = context.TestContext('MissingYaml', prj, 'pre_and_position', POS_DIR) ctx.run(EXIT_BAD_ARGS, no_yaml_file=True) diff --git a/tests/test_plot/test_print_sch.py b/tests/test_plot/test_print_sch.py index 75ea552a..fe3ad79f 100644 --- a/tests/test_plot/test_print_sch.py +++ b/tests/test_plot/test_print_sch.py @@ -35,5 +35,5 @@ def test_print_sch_ok(): def test_print_sch_fail(): prj = '3Rs' ctx = context.TestContext('PrSCHFail', prj, 'print_sch', PDF_DIR) - ctx.run(PDF_SCH_PRINT) + ctx.run(PDF_SCH_PRINT, no_board_file=True, extra=['-e', os.path.join(ctx.get_board_dir(), 'print_err.sch')]) ctx.clean_up()