KiBot/kibot/out_qr_lib.py

538 lines
22 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2022 Salvador E. Tropea
# Copyright (c) 2022 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
import os
from qrcodegen import QrCode
from tempfile import NamedTemporaryFile
from .gs import GS
from .optionable import BaseOptions, Optionable
from .error import KiPlotConfigurationError
from .kicad.sexpdata import Symbol, dumps, Sep, load, SExpData, sexp_iter
from .kicad.v6_sch import DrawRectangleV6, PointXY, Stroke, Fill, SchematicFieldV6, FontEffects
from .kiplot import load_board
from .macros import macros, document, output_class # noqa: F401
from . import log
QR_ECCS = {'low': QrCode.Ecc.LOW,
'medium': QrCode.Ecc.MEDIUM,
'quartile': QrCode.Ecc.QUARTILE,
'high': QrCode.Ecc.HIGH}
logger = log.get_logger()
TO_SEPARATE = set(['kicad_pcb', 'general', 'title_block', 'layers', 'setup', 'pcbplotparams', 'net_class', 'module',
'kicad_sch', 'lib_symbols', 'symbol', 'sheet', 'sheet_instances', 'symbol_instances'])
def is_symbol(name, sexp):
return isinstance(sexp, list) and len(sexp) >= 1 and isinstance(sexp[0], Symbol) and sexp[0].value() == name
def make_separated(sexp):
if not isinstance(sexp, list):
return sexp
if not isinstance(sexp[0], Symbol) or sexp[0].value() not in TO_SEPARATE:
return sexp
separated = []
for s in sexp:
separated.append(make_separated(s))
if isinstance(s, list):
separated.append(Sep())
return separated
def compute_size(qr, is_sch=True, use_mm=True):
if is_sch:
qrc = qr._code_sch
full_size = qr.size_sch
else:
qrc = qr._code_pcb
full_size = qr.size_pcb
size = qrc.get_size()
if not is_sch and qr.pcb_negative:
size += 2
if use_mm:
full_size *= 1 if qr.size_units == 'millimeters' else 25.4
center = round(full_size/2, 2)
size_rect = round(full_size/size, 2)
else:
full_size *= 39.37007874 if qr.size_units == 'millimeters' else 1000
center = round(full_size/2)
size_rect = full_size/size
return qrc, size, full_size, center, size_rect
class QRCodeOptions(Optionable):
""" A QR code """
def __init__(self, field=None):
super().__init__()
with document:
self.name = 'QR'
""" Name for the symbol/footprint """
self.text = '%p %r'
""" Text to encode as QR """
self.correction_level = 'low'
""" [low,medium,quartile,high] Error correction level """
self.size_sch = 15
""" Size of the QR symbol """
self.size_pcb = 15
""" Size of the QR footprint """
self.size_units = 'millimeters'
""" [millimeters,inches] Units used for the size """
self.layer = 'silk'
""" [silk,copper] Layer for the footprint """
self.pcb_negative = False
""" Generate a negative image for the PCB """
self._unkown_is_error = True
self._update_mode = False
def config(self, parent):
super().config(parent)
self.correction_level = QR_ECCS[self.correction_level]
self.layer = 'F.SilkS' if self.layer == 'silk' else 'F.Cu'
class QR_LibOptions(BaseOptions):
def __init__(self):
with document:
self.output = GS.def_global_output
""" Filename for the output (%i=qr, %x=lib) """
self.lib = 'QR'
""" Short name for the library """
self.reference = 'QR'
""" The reference prefix """
self.use_sch_dir = True
""" Generate the libs relative to the schematic/PCB dir """
self.qrs = QRCodeOptions
""" [list(dict)] QR codes to include in the library """
super().__init__()
self._expand_id = 'qr'
self._expand_ext = 'lib'
def config(self, parent):
super().config(parent)
if isinstance(self.qrs, type):
raise KiPlotConfigurationError("You must specify at least one QR code")
names = set()
for qr in self.qrs:
if qr.name in names:
raise KiPlotConfigurationError("QR code name `{}` repeated".format(qr.name))
names.add(qr.name)
def symbol_k5(self, f, qr):
# Compute the size
qrc, size, full_size, center, size_rect = compute_size(qr, use_mm=False)
# Generate the symbol
f.write("#\n# {}\n#\n".format(qr.name))
f.write("DEF {} {} 0 {} N N 1 F N\n".format(qr.name, '#'+self.reference, 0))
# Reference
f.write('F0 "{}" {} {} 50 H I L BNN\n'.format('#'+self.reference, -center, center+60))
# Value
f.write('F1 "{}" {} {} 50 H I L TNN\n'.format(qr.name, -center, -center))
# Footprint
f.write('F2 "{}:{}" 0 150 50 H I C CNN\n'.format(self.lib, qr.name))
# Datasheet
f.write('F3 "" 0 0 50 H I C CNN\n')
# QR information
f.write('F4 "{}" 0 0 50 H I C CNN "qr_version"\n'.format(qrc.get_version()))
f.write('F5 "{}" 0 0 50 H I C CNN "qr_size"\n'.format(size))
ecc = qrc.get_error_correction_level()
f.write('F6 "{},{}" 0 0 50 H I C CNN "qr_ecc"\n'.format(ecc.ordinal, ecc.formatbits))
f.write('F7 "{}" 0 0 50 H I C CNN "qr_mask"\n'.format(qrc.get_mask()))
f.write('F8 "{}" 0 0 50 H I C CNN "qr_text"\n'.format(qr._text_sch.replace('"', '\"')))
f.write("DRAW\n")
for y in range(size):
for x in range(size):
if qrc.get_module(x, y):
x_pos = round(x*size_rect-center)
y_pos = round(center-y*size_rect)
f.write('S {} {} {} {} 0 0 1 F\n'.format(x_pos, y_pos, round(x_pos+size_rect), round(y_pos+size_rect)))
f.write("ENDDRAW\n")
f.write("ENDDEF\n")
def fp_field(self, center, name, value, layer, id):
if id == 0:
pos_y = center+1.25
else:
pos_y = -(center+1.25+1.7*(id-1))
fld = [Symbol('fp_text'), Symbol(name), value]
fld.append([Symbol('at'), 0, pos_y])
fld.append([Symbol('layer'), Symbol(layer)])
if name == 'user':
fld.append(Symbol('hide'))
fld.append(Sep())
font = [Symbol('font')]
font.append([Symbol('size'), 1, 1])
font.append([Symbol('thickness'), 0.15])
fld.append([Symbol('effects'), font])
fld.append(Sep())
return fld
def qr_draw_fp(self, size, size_rect, center, qrc, negative, layer, do_sep=True):
mod = []
for y in range(size):
for x in range(size):
if qrc.get_module(x-negative, y-negative) ^ negative:
x_pos = round(x*size_rect-center, 2)
y_pos = round(y*size_rect-center, 2)
x_pos2 = round(x_pos+size_rect, 2)
y_pos2 = round(y_pos+size_rect, 2)
rect = [Symbol('fp_poly')] # fp_rect not in v5
pts = [Symbol('pts')]
pts.append([Symbol('xy'), x_pos, y_pos])
pts.append([Symbol('xy'), x_pos, y_pos2])
pts.append([Symbol('xy'), x_pos2, y_pos2])
pts.append([Symbol('xy'), x_pos2, y_pos])
rect.append(pts)
if layer:
rect.append([Symbol('layer'), Symbol(layer)])
rect.append([Symbol('width'), 0])
mod.append(rect)
if do_sep:
mod.append(Sep())
return mod
def qr_draw_sym(self, size, size_rect, center, qrc, do_sep=True):
mod = []
for y in range(size):
for x in range(size):
if qrc.get_module(x, y):
x_pos = round(x*size_rect-center, 2)
y_pos = round(center-y*size_rect, 2)
rect = DrawRectangleV6()
rect.start = PointXY(x_pos, y_pos)
rect.end = PointXY(round(x_pos+size_rect, 2), round(y_pos-size_rect, 2))
rect.stroke = Stroke()
rect.stroke.width = 0.001
rect.fill = Fill()
rect.fill.type = 'outline'
mod.append(rect.write())
if do_sep:
mod.append(Sep())
return mod
def footprint(self, dir, qr):
# Compute the size
qrc, size, full_size, center, size_rect = compute_size(qr, is_sch=False)
# Generate the footprint
fname = os.path.join(dir, qr.name+'.kicad_mod')
mod = [Symbol('module'), Symbol(qr.name)]
mod.append([Symbol('layer'), Symbol(qr.layer)])
mod.append([Symbol('tedit'), 0])
mod.append(Sep())
mod.append([Symbol('attr'), Symbol('virtual')])
mod.append(Sep())
mod.append(self.fp_field(center, 'reference', self.reference+'***', qr.layer, 0))
mod.append(Sep())
mod.append(self.fp_field(center, 'value', qr.name, qr.layer, 1))
mod.append(Sep())
mod.append(self.fp_field(center, 'user', 'qr_version: '+str(qrc.get_version()), qr.layer, 2))
mod.append(Sep())
mod.append(self.fp_field(center, 'user', 'qr_size: '+str(size), qr.layer, 3))
mod.append(Sep())
ecc = qrc.get_error_correction_level()
mod.append(self.fp_field(center, 'user', 'qr_ecc: {},{}'.format(ecc.ordinal, ecc.formatbits), qr.layer, 4))
mod.append(Sep())
mod.append(self.fp_field(center, 'user', 'qr_mask: '+str(qrc.get_mask()), qr.layer, 5))
mod.append(Sep())
mod.append(self.fp_field(center, 'user', qr._text_pcb, qr.layer, 6))
mod.append(Sep())
# The QR itself
mod.extend(self.qr_draw_fp(size, size_rect, center, qrc, qr.pcb_negative, qr.layer))
with open(fname, 'wt') as f:
f.write(dumps(mod))
f.write('\n')
def symbol_lib_k5(self):
self._expand_ext = 'lib'
output = os.path.join(self._odir_sch, self.expand_filename_sch(self.output))
logger.debug('Creating KiCad 5 symbols library: '+output)
with open(output, 'wt') as f:
f.write("EESchema-LIBRARY Version 2.4\n")
f.write("#encoding utf-8\n")
for qr in self.qrs:
logger.debug('Adding symbol: '+qr.name)
self.symbol_k5(f, qr)
f.write("#\n#End Library\n")
def sym_field(self, center, name, value, id):
if id == 0:
pos_y = center+1.25
else:
pos_y = -(center+1.25+1.7*(id-1))
f = SchematicFieldV6(name, str(value), id, 0, round(pos_y, 2))
if id > 1:
f.effects = FontEffects()
f.effects.hide = True
return f.write()+[Sep()]
def symbol_lib_k6(self):
self._expand_ext = 'kicad_sym'
output = os.path.join(self._odir_sch, self.expand_filename_sch(self.output))
logger.debug('Creating KiCad 6 symbols library: '+output)
# Lib header
lib = [Symbol('kicad_symbol_lib')]
lib.append([Symbol('version'), 20211014])
lib.append([Symbol('generator'), Symbol('kibot')])
lib.append(Sep())
for qr in self.qrs:
logger.debug('Adding symbol: '+qr.name)
# Compute the size
qrc, size, full_size, center, size_rect = compute_size(qr)
# Symbol main attributes
sym = [Symbol('symbol'), qr.name]
sym.append([Symbol('pin_numbers'), Symbol('hide')])
sym.append([Symbol('pin_names'), Symbol('hide')])
sym.append([Symbol('in_bom'), Symbol('no')])
sym.append([Symbol('on_board'), Symbol('yes')])
sym.append(Sep())
# Properties (Fields)
sym.append(self.sym_field(center, 'Reference', '#'+self.reference, 0))
sym.append(Sep())
sym.append(self.sym_field(center, 'Value', qr.name, 1))
sym.append(Sep())
sym.append(self.sym_field(center, 'Footprint', self.lib+':'+qr.name, 2))
sym.append(Sep())
sym.append(self.sym_field(center, 'Datasheet', '', 3))
sym.append(Sep())
sym.append(self.sym_field(center, 'qr_version', qrc.get_version(), 4))
sym.append(Sep())
sym.append(self.sym_field(center, 'qr_size', size, 5))
sym.append(Sep())
ecc = qrc.get_error_correction_level()
sym.append(self.sym_field(center, 'qr_ecc', '{},{}'.format(ecc.ordinal, ecc.formatbits), 6))
sym.append(Sep())
sym.append(self.sym_field(center, 'qr_mask', qrc.get_mask(), 7))
sym.append(Sep())
sym.append(self.sym_field(center, 'qr_text', qr._text_sch, 8))
sym.append(Sep())
sym.extend(self.qr_draw_sym(size, size_rect, center, qrc))
lib.append(sym)
lib.append(Sep())
with open(output, 'wt') as f:
f.write(dumps(lib))
f.write('\n')
def update_footprint(self, name, sexp, qr):
logger.debug('- Updating QR footprint: '+name)
# Compute the size
qrc, size, full_size, center, size_rect = compute_size(qr, is_sch=False)
# Remove old drawing
sexp[:] = list(filter(lambda s: not is_symbol('fp_poly', s), sexp))
# Add the new drawings
sexp.extend(self.qr_draw_fp(size, size_rect, center, qrc, qr.pcb_negative, qr.layer, do_sep=False))
# Update the fields
for s in sexp:
if (is_symbol('fp_text', s) and len(s) > 2 and isinstance(s[1], Symbol) and s[1].value() == 'user' and
isinstance(s[2], str)):
res = s[2].split(':')
if len(res) > 1:
logger.debug('- Updating field `{}`'.format(res[0]))
if res[0] == 'qr_version':
s[2] = 'qr_version: '+str(qrc.get_version())
elif res[0] == 'qr_size':
s[2] = 'qr_size: '+str(size)
elif res[0] == 'qr_ecc':
ecc = qrc.get_error_correction_level()
s[2] = 'qr_ecc: {},{}'.format(ecc.ordinal, ecc.formatbits)
elif res[0] == 'qr_mask':
s[2] = 'qr_mask: '+str(qrc.get_mask())
elif s[2][0] == ' ':
logger.debug('- Updating text `{}`'.format(qr._text_pcb))
s[2] = ' '+qr._text_pcb
def update_footprints(self, known_qrs):
# Replace known QRs in the PCB
updated = False
pcb = self.load_sexp_file(GS.pcb_file)
for iter in [sexp_iter(pcb, 'kicad_pcb/module'), sexp_iter(pcb, 'kicad_pcb/footprint')]:
for s in iter:
if len(s) < 2:
continue
if isinstance(s[1], Symbol):
name = s[1].value().lower()
else:
name = s[1].lower()
if name in known_qrs:
updated = True
self.update_footprint(name, s, known_qrs[name])
# Save the resulting PCB
if updated:
# Make it readable
separated = make_separated(pcb[0])
# Save it to a temporal
with NamedTemporaryFile(mode='wt', suffix='.kicad_pcb', delete=False) as f:
logger.debug('- Saving updated PCB to: '+f.name)
f.write(dumps(separated))
f.write('\n')
tmp_pcb = f.name
# Reload it
GS.board = None
logger.debug('- Loading the temporal PCB')
load_board(tmp_pcb)
# Create a back-up and save it in the original place
logger.debug('- Replacing the old PCB')
os.remove(tmp_pcb)
bkp = GS.pcb_file+'-bak'
if os.path.isfile(bkp):
os.remove(bkp)
os.rename(GS.pcb_file, bkp)
prl = None
if GS.ki6():
# KiCad 6 is destroying the PRL ...
prl_name = GS.pcb_no_ext+'.kicad_prl'
if os.path.isfile(prl_name):
with open(prl_name, 'rt') as f:
prl = f.read()
GS.board.Save(GS.pcb_file)
if prl:
with open(prl_name, 'wt') as f:
f.write(prl)
def update_symbol(self, name, c_name, sexp, qr):
logger.debug('- Updating QR symbol: '+name)
# Compute the size
qrc, size, full_size, center, size_rect = compute_size(qr)
# Create the new drawings
sub_unit_name = c_name+"_1_1"
sub_unit_sexp = [Symbol('symbol'), sub_unit_name]
sub_unit_sexp.extend(self.qr_draw_sym(size, size_rect, center, qrc, do_sep=False))
# Replace the old one
for s in sexp_iter(sexp, 'symbol'):
if len(s) >= 2 and isinstance(s[1], str) and s[1] == sub_unit_name:
s[:] = list(sub_unit_sexp)
# Update the fields
for s in sexp:
if is_symbol('property', s) and len(s) > 2 and isinstance(s[1], str) and isinstance(s[2], str):
new_val = None
field = s[1]
if field == 'qr_version':
new_val = str(qrc.get_version())
elif field == 'qr_size':
new_val = str(size)
elif field == 'qr_ecc':
ecc = qrc.get_error_correction_level()
new_val = '{},{}'.format(ecc.ordinal, ecc.formatbits)
elif field == 'qr_mask':
new_val = str(qrc.get_mask())
elif field == 'qr_text':
new_val = qr._text_sch
if new_val is not None:
logger.debug('- Updating field `{}` {} -> {}'.format(field, s[2], new_val))
s[2] = new_val
def update_symbols(self, fname, sexp, known_qrs):
# Replace known QRs in the Schematic
updated = False
for s in sexp_iter(sexp, 'kicad_sch/lib_symbols/symbol'):
if len(s) < 2 or not isinstance(s[1], str):
continue
name = s[1].lower()
c_name = s[1].split(':')[1]
if name in known_qrs:
updated = True
self.update_symbol(name, c_name, s, known_qrs[name])
# Save the resulting Schematic
if updated:
# Make it readable
separated = make_separated(sexp[0])
# Create a back-up and save it in the original place
logger.debug('- Replacing the old SCH')
bkp = fname+'-bak'
if os.path.isfile(bkp):
os.remove(bkp)
os.rename(fname, bkp)
with open(fname, 'wt') as f:
f.write(dumps(separated))
f.write('\n')
def load_sexp_file(self, fname):
with open(fname, 'rt') as fh:
error = None
try:
ki_file = load(fh)
except SExpData as e:
error = str(e)
if error:
raise KiPlotConfigurationError(error)
return ki_file
def load_k6_sheets(self, fname, sheets={}):
logger.debug('- Loading '+fname)
sheet = self.load_sexp_file(fname)
sheets[fname] = sheet
if not is_symbol('kicad_sch', sheet[0]):
raise KiPlotConfigurationError('No kicad_sch signature in '+fname)
path = os.path.dirname(fname)
for s in sexp_iter(sheet, 'kicad_sch/sheet'):
sub_name = None
for prop in sexp_iter(s, 'property'):
if len(prop) > 2 and isinstance(prop[1], str) and isinstance(prop[2], str) and prop[1] == 'Sheet file':
sub_name = prop[2]
if sub_name is not None:
sub_name = os.path.abspath(os.path.join(path, sub_name))
if sub_name not in sheets:
self.load_k6_sheets(os.path.join(path, sub_name), sheets)
return sheets
def run(self, output):
if self.use_sch_dir:
self._odir_sch = GS.sch_dir
self._odir_pcb = GS.pcb_dir
else:
self._odir_pcb = self._odir_sch = self._parent.output_dir
# Create the QR codes
for qr in self.qrs:
qr._text_sch = self.expand_filename_both(qr.text, make_safe=False)
qr._code_sch = QrCode.encode_text(qr._text_sch, qr.correction_level)
qr._text_pcb = self.expand_filename_both(qr.text, is_sch=False, make_safe=False)
qr._code_pcb = QrCode.encode_text(qr._text_pcb, qr.correction_level)
# Create the symbols
if GS.ki5():
self.symbol_lib_k5()
else:
self.symbol_lib_k6()
# Create the footprints
self._expand_ext = 'pretty'
dir_pretty = os.path.join(self._odir_pcb, self.expand_filename_pcb(self.output))
logger.debug('Creating footprints library: '+dir_pretty)
os.makedirs(dir_pretty, exist_ok=True)
for qr in self.qrs:
logger.debug('Adding footprint: '+qr.name)
self.footprint(dir_pretty, qr)
# Update the files
if self._parent._update_mode:
logger.debug('Updating the PCB and schematic')
# Create a dict with the known QRs
known_qrs = {}
for qr in self.qrs:
name = self.lib+':'+qr.name
known_qrs[name.lower()] = qr
# PCB
self.update_footprints(known_qrs)
# Schematic
if GS.ki6():
# KiCad 5 reads the lib, but KiCad 6 is more like the PCB
assert GS.sch_file is not None
sheets = self.load_k6_sheets(GS.sch_file)
for k, v in sheets.items():
self.update_symbols(k, v, known_qrs)
@output_class
class QR_Lib(BaseOutput): # noqa: F821
""" QR_Lib
Generates a QR code symbol and footprint.
This output creates a library containing a symbol and footprint for a QR code.
To refresh the generated symbols and footprints use the `update_qr` preflight.
The workflow is as follows:
- Create the symbol and footprints using this output.
- Use them in your schematic and PCB.
- To keep them updated add the `update_qr` preflight """
def __init__(self):
super().__init__()
with document:
self.options = QR_LibOptions
""" [dict] Options for the `boardview` output """
self._both_related = True