From b4e3a6602d9bac20e01575a0e21943c5444706ce Mon Sep 17 00:00:00 2001 From: panda Date: Tue, 3 Mar 2026 00:59:20 -0800 Subject: [PATCH] Initial commit --- .gitignore | 1 + .hooks/kicad_cli_tools.py | 221 +++++++++++++++++++++++++++++++++++ README.md | 23 ++++ docs/test_bom.csv | 1 + docs/test_drc.rpt | 13 +++ docs/test_erc.rpt | 5 + docs/test_schematic.pdf | Bin 0 -> 10625 bytes hardware/test/test.kicad_pcb | 2 + hardware/test/test.kicad_pro | 32 +++++ hardware/test/test.kicad_sch | 14 +++ res/test_render.png | Bin 0 -> 6288 bytes setup.py | 28 +++++ 12 files changed, 340 insertions(+) create mode 100644 .gitignore create mode 100644 .hooks/kicad_cli_tools.py create mode 100644 README.md create mode 100644 docs/test_bom.csv create mode 100644 docs/test_drc.rpt create mode 100644 docs/test_erc.rpt create mode 100644 docs/test_schematic.pdf create mode 100644 hardware/test/test.kicad_pcb create mode 100644 hardware/test/test.kicad_pro create mode 100644 hardware/test/test.kicad_sch create mode 100644 res/test_render.png create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd78447 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +temp/ \ No newline at end of file diff --git a/.hooks/kicad_cli_tools.py b/.hooks/kicad_cli_tools.py new file mode 100644 index 0000000..36e9783 --- /dev/null +++ b/.hooks/kicad_cli_tools.py @@ -0,0 +1,221 @@ +# yes we are using json because +# i dont want to install a yaml +# parser on the users computer +import json +import os +import subprocess +import csv +from enum import Enum +from pathlib import Path +from os import listdir +from os.path import isfile, join + +KICAD_CLI_PATH = "kicad-cli" +TEMP_FILE_PATH = "docs/" + +PCB_IMAGE_OUTPUT_PATH = "res/" + +PCB_PDF_OUTPUT_PATH = "docs/" +PCB_PDF_FILE_SUFFIX = "_pcb" + +SCHEMATIC_OUTPUT_PATH = "docs/" +SCHEMATIC_FILE_SUFFIX = "_schematic" + +BOM_OUTPUT_PATH = "docs/" +BOM_REPORT_NAME = "_bom" + +TEMP_DRC_REPORT_NAME = "_drc" +TEMP_ERC_REPORT_NAME = "_erc" + +# quiet +KICAD_CLI_STDOUT=subprocess.DEVNULL +# verbose +# KICAD_CLI_STDOUT=subprocess.STDOUT + +class OutputReportType(Enum): + JSON = 1 + REPORT = 2 + + def get_file_extension(self) -> str: + match self: + case OutputReportType.JSON: return "json" + case OutputReportType.REPORT: return "rpt" + # dont trust it + case _: return "txt" + +# this is a thin vale on the kicad cli tool +class KicadProject: + + def __init__(self, path : Path) -> None: + self.project_path = path.parent + self.project_name = path.name.removesuffix(".kicad_pro") + self.created_files : list[Path] = [] + print(f"{self.project_path=}") + print(f"{self.project_name=}") + + def erc_check( + self, + report_format : OutputReportType = OutputReportType.JSON, + return_report : bool = False, + additional_args : str = "" + ) -> None | dict | str: + format_type = report_format.name.lower() + sch_file_path = self.project_path / f"{self.project_name}.kicad_sch" + erc_report_path = Path(TEMP_FILE_PATH) / f"{self.project_name}{TEMP_ERC_REPORT_NAME}.{report_format.get_file_extension()}" + + retcode = subprocess.call( + f'{KICAD_CLI_PATH} sch erc {sch_file_path} --output {erc_report_path} --format {format_type} {additional_args}', + shell=True, + stdout=KICAD_CLI_STDOUT + ) + + if (retcode != 0): + print(f"erc check failed return code {retcode}") + exit(1) + + self.created_files.append(erc_report_path) + + if (return_report): + with open(erc_report_path, "r") as txt: + if format_type == OutputReportType.JSON: + return json.loads(txt.read()) + if format_type == OutputReportType.RPT: + return txt.read() + + def drc_check( + self, + report_format : OutputReportType = OutputReportType.JSON, + return_report : bool = False, + additional_args : str = "" + ) -> None | dict | str: + format_type = report_format.name.lower() + pcb_file_path = self.project_path / f"{self.project_name}.kicad_pcb" + drc_report_path = Path(TEMP_FILE_PATH) / f"{self.project_name}{TEMP_DRC_REPORT_NAME}.{report_format.get_file_extension()}" + print(f"{format_type=}, {drc_report_path=}") + retcode = subprocess.call( + f'{KICAD_CLI_PATH} pcb drc {pcb_file_path} --output {drc_report_path} --format {format_type} {additional_args}', + shell=True, + stdout=KICAD_CLI_STDOUT + ) + print(f"{retcode=}") + if (retcode != 0): + print(f"drc check failed return code {retcode}") + exit(1) + + self.created_files.append(drc_report_path) + + if (return_report): + with open(drc_report_path, "r") as txt: + if format_type == OutputReportType.JSON: + return json.loads(txt.read()) + if format_type == OutputReportType.RPT: + return txt.read() + + def process_bom( + self, + return_csv : bool = False, + additional_args : str = "" + ) -> None | list[list[str]]: + sch_file_path = self.project_path / f"{self.project_name}.kicad_sch" + bom_output_path = Path(BOM_OUTPUT_PATH) / f"{self.project_name}{BOM_REPORT_NAME}.csv" + retcode = subprocess.call( + f'{KICAD_CLI_PATH} sch export bom {sch_file_path} --output {bom_output_path} {additional_args}', + shell=True, + stdout=KICAD_CLI_STDOUT + ) + + if (retcode != 0): + print(f"process_bom failed return code {retcode}") + exit(1) + + self.created_files.append(bom_output_path) + if (return_csv): + with open(bom_output_path, "r") as csvfile: + bom_csv = csv.reader(csvfile, delimiter=',', quotechar='"') + return [row for row in bom_csv] + + def get_image(self, + image_type : str = "png", + height : int = 900, + width : int = 1600, + side : str = "top", + background : str = "default", + preset : str = "follow_pcb_editor", + zoom : int = 2, + additional_args : str = "" + ) -> None: + """ + image_typ = "png" | "jpg" + side = "top" | "bottom" | "left" | "right" | "front" | "back" + background = "default" | "transparent" | "opaque" + """ + pcb_file_path = self.project_path / f"{self.project_name}.kicad_pcb" + render_output_path = Path(PCB_IMAGE_OUTPUT_PATH) / f"{self.project_name}_render.{image_type}" + retcode = subprocess.call( + f'{KICAD_CLI_PATH} pcb render {pcb_file_path} --output {render_output_path} --preset {preset} --zoom {zoom} {additional_args}', + shell=True, + stdout=KICAD_CLI_STDOUT + ) + + if (retcode != 0): + print(f"get_image failed return code {retcode}") + exit(1) + + self.created_files.append(render_output_path) + + # i am not giving you the pdf to output if you want to do that yourself go ahead + def create_schmatic_pdf(self, additional_args="") -> None: + sch_file_path = self.project_path / f"{self.project_name}.kicad_sch" + sch_report_path = Path(SCHEMATIC_OUTPUT_PATH) / f"{self.project_name}{SCHEMATIC_FILE_SUFFIX}.pdf" + retcode = subprocess.call( + f'{KICAD_CLI_PATH} sch export pdf {sch_file_path} --output {sch_report_path} {additional_args}', + shell=True, + stdout=KICAD_CLI_STDOUT + ) + + if (retcode != 0): + print(f"create_schmatic_pdf failed return code {retcode}") + exit(1) + + self.created_files.append(sch_report_path) + + + def create_pcb_pdf(self, layers : list[str] = ["F.Cu", "B.Cu"], additional_args : str = "") -> None: + pcb_file_path = self.project_path / f"{self.project_name}.kicad_pcb" + pcb_report_path = Path(PCB_PDF_OUTPUT_PATH) / f"{self.project_name}{PCB_PDF_FILE_SUFFIX}.pdf" + retcode = subprocess.call( + f'{KICAD_CLI_PATH} pcb export pdf {pcb_file_path} --output {pcb_report_path} --layers {",".join(layers)} {additional_args}', + shell=True, + stdout=KICAD_CLI_STDOUT + ) + + if (retcode != 0): + print(f"create_pcb_pdf failed return code {retcode}") + exit(1) + + self.created_files.append(pcb_report_path) + +def commit_files(files: list[Path], commit_message : str) -> None: + for file in files: + # add & commit, could use the return code however these should never fail + print(f"adding and commiting {file}") + ret_add = subprocess.call(f"git add {file}", shell=True) + ret_commit = subprocess.call(f"git commit -m \"{commit_message}\"", shell=True) + +def main() -> None: + # find all kicad project files to operate on + for path in Path(".").rglob('*.kicad_pro'): + k = KicadProject(path) + k.drc_check(report_format = OutputReportType.REPORT) + k.erc_check(report_format = OutputReportType.REPORT) + k.process_bom() + k.create_schmatic_pdf() + k.get_image() + commit_files(k.created_files, "auto commited") + +if __name__ == "__main__": + try: + main() + exit(0) + except Exception as e: + exit(1) diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fed5a8 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# How to use this template + +this template will auto: + - run erc checks + - run drc checks + - create schematic pdfs + - create BOM for each project + - create images of the current pcb for your readme like you can see below + +![rendered pcb](res/test_render.png) + +the code for creating all of this lives in `.hooks/` + +## setup +dependencies: + - python3.9+ (used for crossplatfrom scripting) + - kicad-cli + +to set up the hooks just run +``` +python setup.py +``` +This script will add a line in the `.git/hooks/pre-push` to auto run `.hooks/kicad_cli_tools.py` \ No newline at end of file diff --git a/docs/test_bom.csv b/docs/test_bom.csv new file mode 100644 index 0000000..bc40a48 --- /dev/null +++ b/docs/test_bom.csv @@ -0,0 +1 @@ +"Refs","Value","Footprint","Qty","DNP" diff --git a/docs/test_drc.rpt b/docs/test_drc.rpt new file mode 100644 index 0000000..67c1d1d --- /dev/null +++ b/docs/test_drc.rpt @@ -0,0 +1,13 @@ +** Drc report for test.kicad_pcb ** +** Created on 2026-02-18T17:38:29+1300 ** + +** Found 1 DRC violations ** +[invalid_outline]: Board has malformed outline (no edges found on Edge.Cuts layer) + Local override; error + @(0.0000 mm, 0.0000 mm): PCB + +** Found 0 unconnected pads ** + +** Found 0 Footprint errors ** + +** End of Report ** diff --git a/docs/test_erc.rpt b/docs/test_erc.rpt new file mode 100644 index 0000000..db4e72c --- /dev/null +++ b/docs/test_erc.rpt @@ -0,0 +1,5 @@ +ERC report (2026-02-18T17:38:29+1300, Encoding UTF8) + +***** Sheet / + + ** ERC messages: 0 Errors 0 Warnings 0 diff --git a/docs/test_schematic.pdf b/docs/test_schematic.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c7eb01d7e49353904807f303097a3c434bdab00c GIT binary patch literal 10625 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