Add KiCad connector generator plugin

This commit is contained in:
Anton 2026-06-21 20:50:05 +03:00
parent 3091611d3b
commit 283c6ce6cf
12 changed files with 33720 additions and 32021 deletions

File diff suppressed because it is too large Load diff

137
Readme.md
View file

@ -1,17 +1,136 @@
# Настройка симлинка для библиотеки PDF # KiCad User Library
Этот репозиторий/проект использует внешнюю директорию для хранения документации (Altium DataLIB). Чтобы пути работали корректно, необходимо создать символическую ссылку. Пользовательская библиотека KiCad: символы, футпринты, DB Library, рабочие
листы, шрифты и ссылки на внешние PDF/3D-ресурсы.
## Инструкция по созданию ссылки PDF-документация и 3D-модели хранятся вне этого репозитория. KiCad должен
получать к ним доступ через переменные путей, заданные в `Configure Paths`.
Выполните следующую команду в терминале, находясь в корневой папке проекта: ## Настройка
Склонируйте библиотеку в удобную папку и запустите настройку:
```bash ```bash
ln -s /mnt/диск/YandexDisk/Altium/DataLIB/PDF ./PDF ./scripts/setup_kicad_user_library.py --pdf-dir "<путь-к-папке-PDF>"
' ```
## Настройка библиотеки Если используется отдельная папка 3D-моделей:
Необходимо создать переменные окружения (пути к библиотекам) ```bash
Название KICKAD_USER_LIB_DIR ./scripts/setup_kicad_user_library.py \
--pdf-dir "<путь-к-папке-PDF>" \
--3d-dir "<путь-к-папке-3D-моделей>"
```
Скрипт:
- прописывает переменные путей в `Configure Paths`;
- добавляет подключение DB Library;
- подключает таблицы символов и футпринтов из этого репозитория;
- создаёт резервные копии изменяемых файлов настроек KiCad.
## Переменные KiCad
| Переменная | Назначение |
| --- | --- |
| `KICAD_LIB_DIR_USER` | корень этого репозитория |
| `KICAD_PDF_DIR_USER` | папка с PDF-документацией компонентов |
| `KICAD_3D_PARTY_USER` | папка с пользовательскими 3D-моделями |
`KICAD_PDF_DIR_USER` должна совпадать по смыслу с настройкой сервера
eda-core `kicad.pdf_dir_var`: сервер передаёт KiCad путь Datasheet в виде
`${KICAD_PDF_DIR_USER}/имя_файла.pdf`.
## Подключения в KiCad
После настройки в менеджере библиотек должны быть такие записи.
### Symbol Libraries
| Имя | Путь | Тип |
| --- | --- | --- |
| `eda-core` | `${KICAD_LIB_DIR_USER}/eda-core.kicad_dbl` | `Database` |
| `sym-lib-table` | `${KICAD_LIB_DIR_USER}/sym-lib-table` | `Table` |
### Footprint Libraries
| Имя | Путь | Тип |
| --- | --- | --- |
| `fp-lib-table` | `${KICAD_LIB_DIR_USER}/fp-lib-table` | `Table` |
## Символы и футпринты
Таблицы из репозитория уже содержат подключения к библиотекам:
```text
${KICAD_LIB_DIR_USER}/Connectors/Ki-Connectors.kicad_sym
${KICAD_LIB_DIR_USER}/Resistors/Ki-Resistors.kicad_sym
${KICAD_LIB_DIR_USER}/Capacitors/Ki-Capacitors.kicad_sym
```
И аналогичные записи для остальных разделов библиотеки.
## 3D-модели
Футпринты должны ссылаться на 3D-модели через переменную
`KICAD_3D_PARTY_USER`.
Пример:
```text
${KICAD_3D_PARTY_USER}/Connectors/Molex-20pin-0039281203.stp
```
## PDF-документация
В базе компонента хранится только имя PDF-файла. Полный путь для KiCad
собирает сервер eda-core:
```text
${KICAD_PDF_DIR_USER}/имя_файла.pdf
```
## Плагин генератора разъёмов
В библиотеке есть KiCad-плагин для генерации однотипных символов разъёмов.
Установка:
```bash
./scripts/install_connector_generator_plugin.sh
```
Скрипт создаёт symlink из пользовательской папки плагинов KiCad на плагин
внутри этого репозитория. После установки перезапустите KiCad и откройте:
```text
PCB Editor -> Tools -> External Plugins -> Generate Connector Symbols
```
Плагин сам находит корень библиотеки рядом с собой и по умолчанию работает с:
```text
${KICAD_LIB_DIR_USER}/Connectors/Ki-Connectors.kicad_sym
```
## Рабочие листы и шрифты
Рабочий лист:
```text
${KICAD_LIB_DIR_USER}/gost_landscape.kicad_wks
```
Шрифты:
```text
${KICAD_LIB_DIR_USER}/Fonts
```
## Проверка
1. В `Configure Paths` заданы `KICAD_LIB_DIR_USER`, `KICAD_PDF_DIR_USER` и при необходимости `KICAD_3D_PARTY_USER`.
2. В Symbol Libraries видны `eda-core` и `sym-lib-table`.
3. В Footprint Libraries видна `fp-lib-table`.
4. Компоненты из DB Library добавляются на схему.
5. Datasheet открывается по пути `${KICAD_PDF_DIR_USER}/...`.
6. 3D-модели открываются по пути `${KICAD_3D_PARTY_USER}/...`.

View file

@ -1,11 +1,11 @@
(fp_lib_table (fp_lib_table
(version 7) (version 7)
(lib (name "Ki-Capacitors") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Capacitors/Ki-Capacitors.pretty") (options "") (descr "")) (lib (name "Ki-Capacitors") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Capacitors/Ki-Capacitors.pretty") (options "") (descr ""))
(lib (name "Ki-Connectors") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Connectors/Ki-Connectors.pretty") (options "") (descr "")) (lib (name "Ki-Connectors") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Connectors/Ki-Connectors.pretty") (options "") (descr ""))
(lib (name "Ki-Diodes") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Diodes/Ki-Diodes.pretty") (options "") (descr "")) (lib (name "Ki-Diodes") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Diodes/Ki-Diodes.pretty") (options "") (descr ""))
(lib (name "Ki-IC") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/IC/Ki-IC.pretty") (options "") (descr "")) (lib (name "Ki-IC") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/IC/Ki-IC.pretty") (options "") (descr ""))
(lib (name "Ki-Modules") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Modules/Ki-Modules.pretty") (options "") (descr "")) (lib (name "Ki-Modules") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Modules/Ki-Modules.pretty") (options "") (descr ""))
(lib (name "Ki-Resistors") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Resistors/Ki-Resistors.pretty") (options "") (descr "")) (lib (name "Ki-Resistors") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Resistors/Ki-Resistors.pretty") (options "") (descr ""))
(lib (name "Ki-Transistor") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Transistor/Ki-Transistor.pretty") (options "") (descr "")) (lib (name "Ki-Transistor") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Transistor/Ki-Transistor.pretty") (options "") (descr ""))
(lib (name "Ki-Other") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Other/Ki-Other.pretty") (options "") (descr "")) (lib (name "Ki-Other") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Other/Ki-Other.pretty") (options "") (descr ""))
) )

View file

@ -0,0 +1,41 @@
# Connector Generator Plugin
Python ActionPlugin для KiCad, который запускает генератор регулярных символов
разъемов из интерфейса KiCad.
Плагин появляется в:
```text
PCB Editor -> Tools -> External Plugins -> Generate Connector Symbols
```
Плагин самодостаточен и находится внутри репозитория библиотеки. Корень
библиотеки определяется автоматически по файлу `eda-core.kicad_dbl` и папке
`Connectors`.
## Установка
```bash
./scripts/install_connector_generator_plugin.sh
```
Скрипт создает symlink в пользовательскую папку плагинов KiCad. После установки
перезапустите KiCad.
## Возможности
- выбрать `connectors.toml`;
- задать серию, ряды, отдельные списки пинов для 1/2/3 рядов и схемы нумерации;
- настроить показ номера и имени вывода;
- включить или выключить заливку прямоугольника фоном;
- увидеть список создаваемых/обновляемых символов;
- посмотреть таблицу пинов выбранного символа;
- сгенерировать и записать библиотеку;
- открыть технический diff при необходимости;
- открыть TOML-конфиг во внешнем редакторе.
## Заметка
Это `pcbnew.ActionPlugin`, поэтому KiCad показывает его из PCB Editor. Сам
генератор работает с `.kicad_sym` как с обычным файлом и не зависит от открытой
платы.

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
"""KiCad ActionPlugin registration for the connector generator."""
try:
import pcbnew # noqa: F401
from .main import ConnectorGeneratorPlugin
ConnectorGeneratorPlugin().register()
except ImportError:
pass
except Exception as exc:
import logging
logging.getLogger("connector_generator_plugin").error("Failed to register plugin: %s", exc)

View file

@ -0,0 +1,750 @@
#!/usr/bin/env python3
"""Generate regular KiCad connector symbols while preserving manual variants."""
from __future__ import annotations
import argparse
import difflib
import re
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
DEFAULT_SCHEME_MAP = {
"R": "row",
"S": "column",
"Z": "column_snake",
}
REFERENCE_FIELD_Y = 1.25
VALUE_FIELD_BOTTOM_OFFSET = 2.5
@dataclass(frozen=True)
class Geometry:
body_width: float = 20.0
pin_pitch: float = 5.0
pin_length: float = 2.5
body_fill: str = "none"
@dataclass(frozen=True)
class SymbolSpec:
name: str
rows: int
pins_per_row: int
numbering: str
reference: str = "X"
value: str = "XX"
display_name: str = ""
show_pin_numbers: bool = True
show_pin_names: bool = False
geometry: Geometry = Geometry()
@dataclass(frozen=True)
class SeriesSpec:
prefix: str
rows: tuple[int, ...]
pins_per_row: tuple[int, ...]
pins_per_row_by_rows: dict[int, tuple[int, ...]] | None
schemes: tuple[str, ...]
scheme_map: dict[str, str]
reference: str = "X"
value: str = "XX"
display_name: str = ""
show_pin_numbers: bool = True
show_pin_names: bool = False
zero_pad_pins: int = 2
skip_single_row_non_row: bool = True
geometry: Geometry = Geometry()
@dataclass(frozen=True)
class SymbolBlock:
name: str
start: int
end: int
text: str
def fmt_num(value: float) -> str:
if value == int(value):
return str(int(value))
return f"{value:g}"
def skip_string(text: str, i: int) -> int:
i += 1
while i < len(text):
if text[i] == "\\":
i += 2
continue
if text[i] == '"':
return i + 1
i += 1
return i
def read_quoted(text: str, i: int) -> tuple[str, int]:
assert text[i] == '"'
i += 1
out: list[str] = []
while i < len(text):
ch = text[i]
if ch == "\\" and i + 1 < len(text):
out.append(text[i + 1])
i += 2
continue
if ch == '"':
return "".join(out), i + 1
out.append(ch)
i += 1
raise ValueError("unterminated quoted string")
def is_list_at(text: str, i: int, head: str) -> bool:
token = f"({head}"
return text.startswith(token, i) and (
i + len(token) == len(text) or text[i + len(token)].isspace() or text[i + len(token)] == ")"
)
def symbol_name_at(text: str, i: int) -> str:
pos = i + len("(symbol")
while pos < len(text) and text[pos].isspace():
pos += 1
if pos >= len(text) or text[pos] != '"':
raise ValueError(f"symbol at offset {i} has no quoted name")
name, _ = read_quoted(text, pos)
return name
def matching_paren(text: str, start: int) -> int:
depth = 0
i = start
while i < len(text):
ch = text[i]
if ch == '"':
i = skip_string(text, i)
continue
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return i + 1
i += 1
raise ValueError(f"unclosed list at offset {start}")
def find_symbol_blocks(text: str, target_depth: int) -> list[SymbolBlock]:
blocks: list[SymbolBlock] = []
depth = 0
i = 0
while i < len(text):
ch = text[i]
if ch == '"':
i = skip_string(text, i)
continue
if ch == "(":
if depth == target_depth and is_list_at(text, i, "symbol"):
end = matching_paren(text, i)
blocks.append(SymbolBlock(symbol_name_at(text, i), i, end, text[i:end]))
i = end
continue
depth += 1
elif ch == ")":
depth -= 1
i += 1
return blocks
def find_list_blocks(text: str, head: str, target_depth: int) -> list[str]:
blocks: list[str] = []
depth = 0
i = 0
while i < len(text):
ch = text[i]
if ch == '"':
i = skip_string(text, i)
continue
if ch == "(":
if depth == target_depth and is_list_at(text, i, head):
end = matching_paren(text, i)
blocks.append(text[i:end])
i = end
continue
depth += 1
elif ch == ")":
depth -= 1
i += 1
return blocks
def child_symbol_blocks(symbol_text: str) -> list[SymbolBlock]:
return find_symbol_blocks(symbol_text, target_depth=1)
def child_style(symbol_name: str, parent_name: str) -> tuple[int, int] | None:
prefix = parent_name + "_"
if not symbol_name.startswith(prefix):
return None
suffix = symbol_name[len(prefix) :]
parts = suffix.rsplit("_", 1)
if len(parts) != 2:
return None
try:
return int(parts[0]), int(parts[1])
except ValueError:
return None
def manual_children(existing_symbol_text: str | None, parent_name: str) -> list[str]:
if not existing_symbol_text:
return []
out: list[str] = []
for block in child_symbol_blocks(existing_symbol_text):
style = child_style(block.name, parent_name)
if style is None or style[1] >= 2:
out.append("\t\t" + block.text)
return out
def manual_variant_count(existing_symbol_text: str | None, parent_name: str) -> int:
if not existing_symbol_text:
return 0
count = 0
for block in child_symbol_blocks(existing_symbol_text):
style = child_style(block.name, parent_name)
if style is None or style[1] >= 2:
count += 1
return count
def existing_body_styles(existing_symbol_text: str | None) -> str | None:
if not existing_symbol_text:
return None
blocks = find_list_blocks(existing_symbol_text, "body_styles", target_depth=1)
if not blocks:
return None
return "\t\t" + blocks[0]
def pin_number(spec: SymbolSpec, row: int, index: int) -> int:
scheme = spec.numbering
if scheme == "row":
return row * spec.pins_per_row + index + 1
if scheme == "column":
return index * spec.rows + row + 1
if scheme == "column_snake":
row_in_column = row if index % 2 == 0 else spec.rows - row - 1
return index * spec.rows + row_in_column + 1
raise ValueError(f"unsupported numbering scheme: {scheme}")
def pin_matrix(spec: SymbolSpec) -> list[list[int]]:
return [
[pin_number(spec, row, index) for row in range(spec.rows)]
for index in range(spec.pins_per_row)
]
def format_pin_preview(spec: SymbolSpec) -> str:
matrix = pin_matrix(spec)
width = max(len(str(number)) for row in matrix for number in row)
rows = [" ".join(f"{number:>{width}}" for number in row) for row in matrix]
return "\n".join(
[
f"{spec.name}",
f"{spec.rows} ряд(а), {spec.pins_per_row} позиций, {spec.numbering}",
"",
*rows,
]
)
def validate_numbers(spec: SymbolSpec) -> None:
nums = [
pin_number(spec, row, index)
for row in range(spec.rows)
for index in range(spec.pins_per_row)
]
expected = list(range(1, spec.rows * spec.pins_per_row + 1))
if sorted(nums) != expected:
raise ValueError(
f"{spec.name}: numbering {spec.numbering!r} produced {nums}, expected {expected}"
)
def generate_property(
name: str,
value: str,
x: float,
y: float,
size: float,
hide: bool = True,
justify: str | None = None,
do_not_autoplace: bool = True,
) -> str:
hide_line = "\n\t\t\t(hide yes)" if hide else ""
justify_block = f"\n\t\t\t\t(justify {justify})" if justify else ""
autoplace = "yes" if do_not_autoplace else "no"
return (
f'\t\t(property "{name}" "{value}"\n'
f"\t\t\t(at {fmt_num(x)} {fmt_num(y)} 0)\n"
"\t\t\t(show_name no)\n"
f"\t\t\t(do_not_autoplace {autoplace})"
f"{hide_line}\n"
"\t\t\t(effects\n"
"\t\t\t\t(font\n"
f"\t\t\t\t\t(size {fmt_num(size)} {fmt_num(size)})\n"
"\t\t\t\t)"
f"{justify_block}\n"
"\t\t\t)\n"
"\t\t)"
)
def generate_pin_label_settings(spec: SymbolSpec) -> list[str]:
lines: list[str] = []
if not spec.show_pin_numbers:
lines += [
"\t\t(pin_numbers",
"\t\t\t(hide yes)",
"\t\t)",
]
if spec.show_pin_names:
lines += [
"\t\t(pin_names",
"\t\t\t(offset 1)",
"\t\t)",
]
else:
lines += [
"\t\t(pin_names",
"\t\t\t(hide yes)",
"\t\t)",
]
return lines
def generate_unit(spec: SymbolSpec, row: int) -> str:
geo = spec.geometry
height = spec.pins_per_row * geo.pin_pitch
unit = row + 1
lines: list[str] = [f'\t\t(symbol "{spec.name}_{unit}_1"']
lines += [
"\t\t\t(rectangle",
"\t\t\t\t(start 0 0)",
f"\t\t\t\t(end {fmt_num(geo.body_width)} -{fmt_num(height)})",
"\t\t\t\t(stroke",
"\t\t\t\t\t(width 0)",
"\t\t\t\t\t(type solid)",
"\t\t\t\t)",
"\t\t\t\t(fill",
f"\t\t\t\t\t(type {geo.body_fill})",
"\t\t\t\t)",
"\t\t\t)",
]
for idx in range(1, spec.pins_per_row):
y = idx * geo.pin_pitch
lines += [
"\t\t\t(polyline",
"\t\t\t\t(pts",
f"\t\t\t\t\t(xy 0 -{fmt_num(y)}) (xy {fmt_num(geo.body_width)} -{fmt_num(y)})",
"\t\t\t\t)",
"\t\t\t\t(stroke",
"\t\t\t\t\t(width 0)",
"\t\t\t\t\t(type solid)",
"\t\t\t\t)",
"\t\t\t\t(fill",
"\t\t\t\t\t(type none)",
"\t\t\t\t)",
"\t\t\t)",
]
lines += [
"\t\t\t(polyline",
"\t\t\t\t(pts",
f"\t\t\t\t\t(xy 5 0) (xy 5 -{fmt_num(height)})",
"\t\t\t\t)",
"\t\t\t\t(stroke",
"\t\t\t\t\t(width 0)",
"\t\t\t\t\t(type solid)",
"\t\t\t\t)",
"\t\t\t\t(fill",
"\t\t\t\t\t(type none)",
"\t\t\t\t)",
"\t\t\t)",
]
for idx in range(spec.pins_per_row):
number = str(pin_number(spec, row, idx))
y = idx * geo.pin_pitch + geo.pin_pitch / 2
lines += [
f'\t\t\t(text "{number}"',
f"\t\t\t\t(at 2.5 -{fmt_num(y)} 0)",
"\t\t\t\t(effects",
"\t\t\t\t\t(font",
"\t\t\t\t\t\t(size 2.1844 2.1844)",
"\t\t\t\t\t)",
"\t\t\t\t)",
"\t\t\t)",
]
lines += [
"\t\t\t(pin passive line",
f"\t\t\t\t(at -{fmt_num(geo.pin_length)} -{fmt_num(y)} 0)",
f"\t\t\t\t(length {fmt_num(geo.pin_length)})",
f'\t\t\t\t(name "{number}"',
"\t\t\t\t\t(effects",
"\t\t\t\t\t\t(font",
"\t\t\t\t\t\t\t(size 1.8 1.8)",
"\t\t\t\t\t\t)",
"\t\t\t\t\t)",
"\t\t\t\t)",
f'\t\t\t\t(number "{number}"',
"\t\t\t\t\t(effects",
"\t\t\t\t\t\t(font",
"\t\t\t\t\t\t\t(size 1.8 1.8)",
"\t\t\t\t\t\t)",
"\t\t\t\t\t)",
"\t\t\t\t)",
"\t\t\t)",
]
lines.append("\t\t)")
return "\n".join(lines)
def generate_symbol(spec: SymbolSpec, existing_symbol_text: str | None = None) -> str:
validate_numbers(spec)
geo = spec.geometry
height = spec.pins_per_row * geo.pin_pitch
reference_y = REFERENCE_FIELD_Y
value_y = -height - VALUE_FIELD_BOTTOM_OFFSET
manual = manual_children(existing_symbol_text, spec.name)
styles = existing_body_styles(existing_symbol_text) if manual else None
lines: list[str] = [f'(symbol "{spec.name}"']
if styles:
lines.append(styles)
lines += generate_pin_label_settings(spec)
lines += [
"\t\t(exclude_from_sim no)",
"\t\t(in_bom yes)",
"\t\t(on_board yes)",
"\t\t(in_pos_files yes)",
"\t\t(duplicate_pin_numbers_are_jumpers no)",
generate_property("Reference", spec.reference, 0, reference_y, 2, hide=False, justify="left"),
generate_property("Value", spec.value, 0, value_y, 2, hide=False, justify="left bottom"),
generate_property("Footprint", "", 0, 0, 1.27, do_not_autoplace=False),
generate_property("Datasheet", "", 0, 0, 1.27, do_not_autoplace=False),
generate_property("Description", "", 0, 0, 1.27, do_not_autoplace=False),
]
if spec.display_name:
lines.append(
generate_property(
"Name",
spec.display_name,
5.5,
1,
1.8288,
justify="left bottom",
do_not_autoplace=False,
)
)
for row in range(spec.rows):
lines.append(generate_unit(spec, row))
lines.extend(manual)
lines.append("\t)")
return "\n".join(lines)
def replace_top_level_symbols(library_text: str, replacements: dict[str, str]) -> str:
blocks = find_symbol_blocks(library_text, target_depth=1)
out: list[str] = []
pos = 0
replaced: set[str] = set()
for block in blocks:
out.append(library_text[pos : block.start])
if block.name in replacements:
out.append(replacements[block.name])
replaced.add(block.name)
else:
out.append(block.text)
pos = block.end
out.append(library_text[pos:])
result = "".join(out)
missing = [name for name in replacements if name not in replaced]
if missing:
insert_at = result.rfind("\n)")
if insert_at == -1:
raise ValueError("cannot find library closing paren")
addition = "\n" + "\n".join(replacements[name] for name in missing)
result = result[:insert_at] + addition + result[insert_at:]
return result
def int_list(values: object, field_name: str) -> tuple[int, ...]:
if not isinstance(values, list):
raise ValueError(f"{field_name} must be a list")
return tuple(int(value) for value in values)
def str_list(values: object, field_name: str) -> tuple[str, ...]:
if not isinstance(values, list):
raise ValueError(f"{field_name} must be a list")
return tuple(str(value) for value in values)
def parse_geometry(data: dict) -> Geometry:
defaults = data.get("defaults", {})
geo_defaults = defaults.get("geometry", {})
return Geometry(
body_width=float(geo_defaults.get("body_width", 20.0)),
pin_pitch=float(geo_defaults.get("pin_pitch", 5.0)),
pin_length=float(geo_defaults.get("pin_length", 2.5)),
body_fill=str(geo_defaults.get("body_fill", "none")),
)
def default_pin_label_settings(data: dict) -> tuple[bool, bool]:
defaults = data.get("defaults", {})
pin_labels = defaults.get("pin_labels", {})
return (
bool(pin_labels.get("show_pin_numbers", True)),
bool(pin_labels.get("show_pin_names", False)),
)
def make_symbol_name(prefix: str, rows: int, pins_per_row: int, scheme_code: str, zero_pad: int = 2) -> str:
pin_text = f"{pins_per_row:0{zero_pad}d}" if zero_pad > 0 else str(pins_per_row)
return f"{prefix}-{rows}X-{pin_text}P-{scheme_code}"
def parse_series(
item: dict,
geometry: Geometry,
default_show_pin_numbers: bool,
default_show_pin_names: bool,
) -> SeriesSpec:
scheme_map = dict(DEFAULT_SCHEME_MAP)
scheme_map.update({str(key): str(value) for key, value in item.get("scheme_map", {}).items()})
pins_by_rows = {
int(rows): int_list(values, f"series.pins_per_row_by_rows.{rows}")
for rows, values in item.get("pins_per_row_by_rows", {}).items()
}
return SeriesSpec(
prefix=str(item.get("prefix", "Con")),
rows=int_list(item.get("rows", []), "series.rows"),
pins_per_row=int_list(item.get("pins_per_row", []), "series.pins_per_row"),
pins_per_row_by_rows=pins_by_rows or None,
schemes=str_list(item.get("schemes", ["R"]), "series.schemes"),
scheme_map=scheme_map,
reference=str(item.get("reference", "X")),
value=str(item.get("value", "XX")),
display_name=str(item.get("display_name", "")),
show_pin_numbers=bool(item.get("show_pin_numbers", default_show_pin_numbers)),
show_pin_names=bool(item.get("show_pin_names", default_show_pin_names)),
zero_pad_pins=int(item.get("zero_pad_pins", 2)),
skip_single_row_non_row=bool(item.get("skip_single_row_non_row", True)),
geometry=geometry,
)
def specs_from_series(series: SeriesSpec) -> list[SymbolSpec]:
specs: list[SymbolSpec] = []
for rows in series.rows:
pin_values = series.pins_per_row
if series.pins_per_row_by_rows is not None:
pin_values = series.pins_per_row_by_rows.get(rows, ())
if not pin_values:
raise ValueError(f"no pins_per_row configured for {rows} row(s) in series {series.prefix!r}")
for pins in pin_values:
for scheme_code in series.schemes:
numbering = series.scheme_map.get(scheme_code)
if not numbering:
raise ValueError(f"unknown scheme code {scheme_code!r} in series {series.prefix!r}")
if series.skip_single_row_non_row and rows == 1 and numbering != "row":
continue
specs.append(
SymbolSpec(
name=make_symbol_name(
series.prefix,
rows,
pins,
scheme_code,
zero_pad=series.zero_pad_pins,
),
rows=rows,
pins_per_row=pins,
numbering=numbering,
reference=series.reference,
value=series.value,
display_name=series.display_name,
show_pin_numbers=series.show_pin_numbers,
show_pin_names=series.show_pin_names,
geometry=series.geometry,
)
)
return specs
def parse_symbol(
item: dict,
geometry: Geometry,
default_show_pin_numbers: bool,
default_show_pin_names: bool,
) -> SymbolSpec:
return SymbolSpec(
name=item["name"],
rows=int(item["rows"]),
pins_per_row=int(item["pins_per_row"]),
numbering=item["numbering"],
reference=item.get("reference", "X"),
value=item.get("value", "XX"),
display_name=item.get("display_name", ""),
show_pin_numbers=bool(item.get("show_pin_numbers", default_show_pin_numbers)),
show_pin_names=bool(item.get("show_pin_names", default_show_pin_names)),
geometry=geometry,
)
def dedupe_specs(specs: list[SymbolSpec]) -> list[SymbolSpec]:
by_name: dict[str, SymbolSpec] = {}
order: list[str] = []
for spec in specs:
if spec.name not in by_name:
order.append(spec.name)
by_name[spec.name] = spec
return [by_name[name] for name in order]
def load_config(config_path: Path) -> tuple[Path, list[SeriesSpec], list[SymbolSpec]]:
config_path = config_path.expanduser().resolve()
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
library_path = Path(data["library"]["path"]).expanduser()
if not library_path.is_absolute():
library_path = (config_path.parent / library_path).resolve()
geometry = parse_geometry(data)
show_pin_numbers, show_pin_names = default_pin_label_settings(data)
series = [
parse_series(item, geometry, show_pin_numbers, show_pin_names)
for item in data.get("series", [])
]
symbols = [
parse_symbol(item, geometry, show_pin_numbers, show_pin_names)
for item in data.get("symbols", [])
]
return library_path, series, symbols
def load_specs(config_path: Path) -> tuple[Path, list[SymbolSpec]]:
library_path, series, symbols = load_config(config_path)
specs: list[SymbolSpec] = []
for item in series:
specs.extend(specs_from_series(item))
specs.extend(symbols)
return library_path, dedupe_specs(specs)
def generate_library(library_text: str, specs: list[SymbolSpec]) -> str:
existing = {block.name: block.text for block in find_symbol_blocks(library_text, target_depth=1)}
replacements = {
spec.name: generate_symbol(spec, existing_symbol_text=existing.get(spec.name)) for spec in specs
}
return replace_top_level_symbols(library_text, replacements)
def unified_diff(old: str, new: str, old_name: str, new_name: str) -> str:
return "".join(
difflib.unified_diff(
old.splitlines(keepends=True),
new.splitlines(keepends=True),
fromfile=old_name,
tofile=new_name,
)
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--write", action="store_true", help="overwrite library file")
parser.add_argument("--check", action="store_true", help="fail if generated output differs")
parser.add_argument("--diff", action="store_true", help="print unified diff")
parser.add_argument("--output", type=Path, help="write generated library to another file")
args = parser.parse_args(argv)
library_path, specs = load_specs(args.config)
library_text = library_path.read_text(encoding="utf-8")
generated = generate_library(library_text, specs)
if args.diff or args.check:
diff = unified_diff(str(library_text), str(generated), str(library_path), "generated")
if diff:
sys.stdout.write(diff)
if args.check:
return 1 if library_text != generated else 0
if args.output:
args.output.write_text(generated, encoding="utf-8")
return 0
if args.write:
library_path.write_text(generated, encoding="utf-8")
return 0
print(f"Generated {len(specs)} symbols from {args.config}. Use --diff, --output, or --write.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,34 @@
[library]
path = "../../../Connectors/Ki-Connectors.kicad_sym"
[defaults.geometry]
body_width = 20.0
pin_pitch = 5.0
pin_length = 2.5
body_fill = "background"
[defaults.pin_labels]
show_pin_numbers = true
show_pin_names = false
[[series]]
prefix = "Con"
rows = [1, 2, 3]
schemes = ["R", "S"]
reference = "X"
value = "XX"
display_name = "Разъем универсальный"
show_pin_numbers = true
show_pin_names = false
zero_pad_pins = 2
skip_single_row_non_row = true
[series.scheme_map]
R = "row"
S = "column"
Z = "column_snake"
[series.pins_per_row_by_rows]
"1" = [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]
"2" = [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]
"3" = [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]

View file

@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
"""KiCad ActionPlugin entry point."""
from __future__ import annotations
import logging
import os
LOG_PATH = os.path.expanduser("~/.kicad_connector_generator_plugin.log")
logger = logging.getLogger("connector_generator_plugin")
if not logger.handlers:
logger.setLevel(logging.DEBUG)
try:
handler = logging.FileHandler(LOG_PATH, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger.addHandler(handler)
except Exception:
pass
try:
import pcbnew
_ActionPluginBase = pcbnew.ActionPlugin
except ImportError:
class _ActionPluginBase:
def register(self):
pass
def defaults(self):
pass
def Run(self):
pass
class ConnectorGeneratorPlugin(_ActionPluginBase):
"""Open the connector generator dialog."""
def defaults(self):
self.name = "Generate Connector Symbols"
self.category = "Library Tools"
self.description = "Генерация регулярных символов разъемов KiCad"
self.show_toolbar_button = True
icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icon.png")
self.icon_file_name = icon_path if os.path.isfile(icon_path) else ""
def Run(self):
logger.info("Plugin launched")
try:
import wx
from .ui import ConnectorGeneratorDialog
parent = None
app = wx.GetApp()
if app is not None and hasattr(app, "GetTopWindow"):
try:
parent = app.GetTopWindow()
except Exception:
parent = None
dlg = ConnectorGeneratorDialog(parent)
dlg.ShowModal()
dlg.Destroy()
except Exception:
logger.exception("Unhandled plugin error")
try:
import wx
wx.MessageBox(
"Ошибка плагина Connector Generator.\n"
f"Подробности: {LOG_PATH}",
"Connector Generator",
wx.OK | wx.ICON_ERROR,
)
except Exception:
pass
def _standalone():
import wx
from connector_generator_plugin.ui import ConnectorGeneratorDialog
app = wx.App(False)
dlg = ConnectorGeneratorDialog(None)
dlg.ShowModal()
dlg.Destroy()
app.MainLoop()
if __name__ == "__main__":
_standalone()

View file

@ -0,0 +1,485 @@
# -*- coding: utf-8 -*-
"""wxPython dialog for the connector generator plugin."""
from __future__ import annotations
import difflib
import os
import subprocess
import sys
import tempfile
from pathlib import Path
def _plugin_dir() -> Path:
return Path(__file__).resolve().parent
def _library_root() -> Path:
for parent in _plugin_dir().parents:
if (
(parent / "eda-core.kicad_dbl").is_file()
and (parent / "Connectors" / "Ki-Connectors.kicad_sym").is_file()
):
return parent
raise RuntimeError("Не найден корень KiCad-библиотеки рядом с плагином")
def _load_generator():
try:
from . import connector_generator as generator
except ImportError:
plugin_dir = _plugin_dir()
if str(plugin_dir) not in sys.path:
sys.path.insert(0, str(plugin_dir))
import connector_generator as generator
return _library_root(), generator
def _default_config_path() -> Path:
return _plugin_dir() / "connectors.toml"
def _parse_int_list(text: str) -> tuple[int, ...]:
values: list[int] = []
for chunk in text.replace(";", ",").split(","):
chunk = chunk.strip()
if not chunk:
continue
values.append(int(chunk))
if not values:
raise ValueError("Список пинов пуст")
return tuple(values)
class ConnectorGeneratorDialog:
def __new__(cls, *args, **kwargs):
import wx
class _Dialog(wx.Dialog):
def __init__(self, parent):
super().__init__(
parent,
title="Connector Generator",
style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER,
size=(1040, 720),
)
self.root, self.generator = _load_generator()
self.config_path = _default_config_path()
self.library_path: Path | None = None
self.specs = []
self._build_ui()
self._load_config()
def _build_ui(self):
import wx
panel = wx.Panel(self)
main = wx.BoxSizer(wx.VERTICAL)
title = wx.StaticText(panel, label="Генератор разъемов")
font = title.GetFont()
font.SetPointSize(font.GetPointSize() + 4)
font.SetWeight(wx.FONTWEIGHT_BOLD)
title.SetFont(font)
main.Add(title, 0, wx.ALL, 10)
config_row = wx.BoxSizer(wx.HORIZONTAL)
config_row.Add(wx.StaticText(panel, label="Конфиг:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 8)
self.config_ctrl = wx.TextCtrl(panel, value=str(self.config_path))
config_row.Add(self.config_ctrl, 1, wx.RIGHT, 6)
browse = wx.Button(panel, label="Выбрать")
browse.Bind(wx.EVT_BUTTON, self._on_browse_config)
config_row.Add(browse, 0)
main.Add(config_row, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)
lib_row = wx.BoxSizer(wx.HORIZONTAL)
lib_row.Add(wx.StaticText(panel, label="Библиотека:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 8)
self.library_ctrl = wx.TextCtrl(panel, style=wx.TE_READONLY)
lib_row.Add(self.library_ctrl, 1)
main.Add(lib_row, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)
series_box = wx.StaticBoxSizer(wx.StaticBox(panel, label="Серия"), wx.VERTICAL)
grid = wx.FlexGridSizer(rows=5, cols=4, hgap=8, vgap=8)
grid.AddGrowableCol(1, 1)
grid.AddGrowableCol(3, 1)
self.prefix_ctrl = wx.TextCtrl(panel, value="Con")
self.reference_ctrl = wx.TextCtrl(panel, value="X")
self.value_ctrl = wx.TextCtrl(panel, value="XX")
self.name_ctrl = wx.TextCtrl(panel, value="Разъем универсальный")
self.pin_ctrls = {
1: wx.TextCtrl(panel, value=",".join(str(value) for value in range(1, 41))),
2: wx.TextCtrl(panel, value=",".join(str(value) for value in range(2, 41))),
3: wx.TextCtrl(panel, value=",".join(str(value) for value in range(3, 41))),
}
grid.Add(wx.StaticText(panel, label="Префикс"), 0, wx.ALIGN_CENTER_VERTICAL)
grid.Add(self.prefix_ctrl, 1, wx.EXPAND)
grid.Add(wx.StaticText(panel, label="Reference"), 0, wx.ALIGN_CENTER_VERTICAL)
grid.Add(self.reference_ctrl, 1, wx.EXPAND)
grid.Add(wx.StaticText(panel, label="Value"), 0, wx.ALIGN_CENTER_VERTICAL)
grid.Add(self.value_ctrl, 1, wx.EXPAND)
grid.Add(wx.StaticText(panel, label="Имя"), 0, wx.ALIGN_CENTER_VERTICAL)
grid.Add(self.name_ctrl, 1, wx.EXPAND)
grid.Add(wx.StaticText(panel, label="Пины: 1 ряд"), 0, wx.ALIGN_CENTER_VERTICAL)
grid.Add(self.pin_ctrls[1], 1, wx.EXPAND)
grid.Add(wx.StaticText(panel, label="Пины: 2 ряда"), 0, wx.ALIGN_CENTER_VERTICAL)
grid.Add(self.pin_ctrls[2], 1, wx.EXPAND)
grid.Add(wx.StaticText(panel, label="Пины: 3 ряда"), 0, wx.ALIGN_CENTER_VERTICAL)
grid.Add(self.pin_ctrls[3], 1, wx.EXPAND)
grid.AddSpacer(1)
grid.AddSpacer(1)
series_box.Add(grid, 0, wx.EXPAND | wx.ALL, 8)
options = wx.BoxSizer(wx.HORIZONTAL)
options.Add(wx.StaticText(panel, label="Ряды:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 8)
self.row_checks = {}
for rows in (1, 2, 3):
chk = wx.CheckBox(panel, label=str(rows))
chk.SetValue(True)
self.row_checks[rows] = chk
options.Add(chk, 0, wx.RIGHT, 12)
options.AddSpacer(18)
options.Add(wx.StaticText(panel, label="Нумерация:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 8)
self.scheme_checks = {}
for code, label in (
("R", "R рядами"),
("S", "S колонками"),
("Z", "Z змейкой"),
):
chk = wx.CheckBox(panel, label=label)
chk.SetValue(code in ("R", "S"))
self.scheme_checks[code] = chk
options.Add(chk, 0, wx.RIGHT, 12)
series_box.Add(options, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)
labels = wx.BoxSizer(wx.HORIZONTAL)
labels.Add(wx.StaticText(panel, label="Надписи выводов:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 8)
self.show_pin_numbers_chk = wx.CheckBox(panel, label="Показать номер вывода")
self.show_pin_numbers_chk.SetValue(True)
labels.Add(self.show_pin_numbers_chk, 0, wx.RIGHT, 16)
self.show_pin_names_chk = wx.CheckBox(panel, label="Показать имя вывода")
self.show_pin_names_chk.SetValue(False)
labels.Add(self.show_pin_names_chk, 0, wx.RIGHT, 16)
self.body_fill_chk = wx.CheckBox(panel, label="Заливка фоном")
self.body_fill_chk.SetValue(True)
labels.Add(self.body_fill_chk, 0, wx.RIGHT, 16)
series_box.Add(labels, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8)
main.Add(series_box, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
actions = wx.BoxSizer(wx.HORIZONTAL)
for label, handler in (
("Обновить список", self._on_preview),
("Сгенерировать", self._on_write),
("Открыть конфиг", self._on_open_config),
("Технический diff", self._on_diff),
):
btn = wx.Button(panel, label=label)
btn.Bind(wx.EVT_BUTTON, handler)
actions.Add(btn, 0, wx.RIGHT, 8)
actions.AddStretchSpacer(1)
close = wx.Button(panel, wx.ID_CLOSE, label="Закрыть")
close.Bind(wx.EVT_BUTTON, lambda _event: self.Close())
actions.Add(close, 0)
main.Add(actions, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
content = wx.BoxSizer(wx.HORIZONTAL)
self.symbols = wx.ListCtrl(panel, style=wx.LC_REPORT | wx.LC_SINGLE_SEL)
for idx, (heading, width) in enumerate(
[
("Символ", 210),
("Пинов", 70),
("Схема", 110),
("Статус", 260),
]
):
self.symbols.InsertColumn(idx, heading, width=width)
self.symbols.Bind(wx.EVT_LIST_ITEM_SELECTED, self._on_select_symbol)
content.Add(self.symbols, 1, wx.EXPAND | wx.RIGHT, 8)
self.preview = wx.TextCtrl(
panel,
style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2 | wx.HSCROLL,
)
mono = wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)
self.preview.SetFont(mono)
content.Add(self.preview, 1, wx.EXPAND)
main.Add(content, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
self.status = wx.StaticText(panel, label="")
main.Add(self.status, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
panel.SetSizer(main)
sizer = wx.BoxSizer(wx.VERTICAL)
sizer.Add(panel, 1, wx.EXPAND)
self.SetSizer(sizer)
def _current_config_path(self) -> Path:
return Path(self.config_ctrl.GetValue()).expanduser()
def _format_int_list(self, values):
return ",".join(str(value) for value in values)
def _load_config(self):
try:
config = self._current_config_path()
library_path, series, symbols = self.generator.load_config(config)
self.library_path = library_path
self.library_ctrl.SetValue(str(library_path))
if series:
first = series[0]
self.prefix_ctrl.SetValue(first.prefix)
for rows, ctrl in self.pin_ctrls.items():
values = first.pins_per_row
if first.pins_per_row_by_rows is not None:
values = first.pins_per_row_by_rows.get(rows, ())
ctrl.SetValue(self._format_int_list(values))
self.reference_ctrl.SetValue(first.reference)
self.value_ctrl.SetValue(first.value)
self.name_ctrl.SetValue(first.display_name)
for rows, chk in self.row_checks.items():
chk.SetValue(rows in first.rows)
for code, chk in self.scheme_checks.items():
chk.SetValue(code in first.schemes)
self.show_pin_numbers_chk.SetValue(first.show_pin_numbers)
self.show_pin_names_chk.SetValue(first.show_pin_names)
self.body_fill_chk.SetValue(first.geometry.body_fill == "background")
self._refresh_preview()
if symbols:
self.status.SetLabel(f"Конфиг загружен. Точечных override: {len(symbols)}")
else:
self.status.SetLabel("Конфиг загружен.")
except Exception as exc:
self.library_path = None
self.library_ctrl.SetValue("")
self.symbols.DeleteAllItems()
self.preview.SetValue(f"Ошибка загрузки конфига:\n{exc}\n")
self.status.SetLabel("Ошибка конфигурации")
def _series_from_form(self):
rows = tuple(row for row, chk in self.row_checks.items() if chk.GetValue())
schemes = tuple(code for code, chk in self.scheme_checks.items() if chk.GetValue())
if not rows:
raise ValueError("Выберите хотя бы один ряд")
if not schemes:
raise ValueError("Выберите хотя бы одну схему нумерации")
_library_path, _series, _symbols = self.generator.load_config(self._current_config_path())
geometry = self.generator.parse_geometry(
{
"defaults": {
"geometry": {
"body_width": 20.0,
"pin_pitch": 5.0,
"pin_length": 2.5,
}
}
}
)
try:
_library_path, loaded_series, _symbols = self.generator.load_config(self._current_config_path())
if loaded_series:
geometry = loaded_series[0].geometry
except Exception:
pass
geometry = self.generator.Geometry(
body_width=geometry.body_width,
pin_pitch=geometry.pin_pitch,
pin_length=geometry.pin_length,
body_fill="background" if self.body_fill_chk.GetValue() else "none",
)
return self.generator.SeriesSpec(
prefix=self.prefix_ctrl.GetValue().strip() or "Con",
rows=rows,
pins_per_row=(),
pins_per_row_by_rows={
row: _parse_int_list(ctrl.GetValue())
for row, ctrl in self.pin_ctrls.items()
},
schemes=schemes,
scheme_map=dict(self.generator.DEFAULT_SCHEME_MAP),
reference=self.reference_ctrl.GetValue().strip() or "X",
value=self.value_ctrl.GetValue().strip() or "XX",
display_name=self.name_ctrl.GetValue().strip(),
show_pin_numbers=self.show_pin_numbers_chk.GetValue(),
show_pin_names=self.show_pin_names_chk.GetValue(),
zero_pad_pins=2,
skip_single_row_non_row=True,
geometry=geometry,
)
def _specs_from_form(self):
return self.generator.specs_from_series(self._series_from_form())
def _existing_symbols(self):
if self.library_path is None:
return {}
if not self.library_path.exists():
return {}
text = self.library_path.read_text(encoding="utf-8")
return {
block.name: block.text
for block in self.generator.find_symbol_blocks(text, target_depth=1)
}
def _refresh_preview(self):
self.specs = self._specs_from_form()
existing = self._existing_symbols()
self.symbols.DeleteAllItems()
for index, spec in enumerate(self.specs):
old = existing.get(spec.name)
manual = self.generator.manual_variant_count(old, spec.name)
status = "создать" if old is None else "обновить"
if manual:
status += f", сохранить ручных: {manual}"
self.symbols.InsertItem(index, spec.name)
self.symbols.SetItem(index, 1, str(spec.rows * spec.pins_per_row))
self.symbols.SetItem(index, 2, spec.numbering)
self.symbols.SetItem(index, 3, status)
if self.specs:
self.symbols.Select(0)
self._show_spec(self.specs[0])
else:
self.preview.SetValue("Нет символов для генерации.")
self.status.SetLabel(f"Будет создано/обновлено символов: {len(self.specs)}")
def _show_spec(self, spec):
text = self.generator.format_pin_preview(spec)
if spec.rows == 1 and spec.numbering != "row":
text += "\n\nДля одного ряда не-row схемы пропускаются автоматически."
self.preview.SetValue(text)
def _load_generation_from_form(self):
if self.library_path is None:
self._load_config()
if self.library_path is None:
raise ValueError("Библиотека не задана")
specs = self._specs_from_form()
library_text = self.library_path.read_text(encoding="utf-8")
generated = self.generator.generate_library(library_text, specs)
return self.library_path, specs, library_text, generated
def _on_browse_config(self, _event):
import wx
with wx.FileDialog(
self,
"Выберите connectors.toml",
wildcard="TOML files (*.toml)|*.toml|All files (*.*)|*.*",
style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST,
) as dlg:
if dlg.ShowModal() != wx.ID_OK:
return
self.config_ctrl.SetValue(dlg.GetPath())
self._load_config()
def _on_preview(self, _event):
try:
self._refresh_preview()
except Exception as exc:
self.preview.SetValue(f"Ошибка предпросмотра:\n{exc}\n")
self.status.SetLabel("Ошибка предпросмотра")
def _on_select_symbol(self, event):
index = event.GetIndex()
if 0 <= index < len(self.specs):
self._show_spec(self.specs[index])
def _on_diff(self, _event):
try:
library_path, _specs, old, new = self._load_generation_from_form()
diff = "".join(
difflib.unified_diff(
old.splitlines(keepends=True),
new.splitlines(keepends=True),
fromfile=str(library_path),
tofile="generated",
)
)
self.preview.SetValue(diff or "Изменений нет.\n")
self.status.SetLabel("Технический diff сформирован")
except Exception as exc:
self.preview.SetValue(f"Ошибка diff:\n{exc}\n")
def _validate_with_kicad_cli(self, generated_text: str) -> str:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".kicad_sym", delete=False) as tmp:
tmp.write(generated_text)
tmp_path = tmp.name
out_path = tmp_path + ".upgraded.kicad_sym"
try:
result = subprocess.run(
["kicad-cli", "sym", "upgrade", tmp_path, "--output", out_path, "--force"],
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
return (result.stdout + result.stderr).strip() or "kicad-cli returned an error"
return "kicad-cli validation OK"
except FileNotFoundError:
return "kicad-cli не найден, синтаксическая проверка пропущена"
finally:
for path in (tmp_path, out_path):
try:
os.unlink(path)
except OSError:
pass
def _on_write(self, _event):
import wx
try:
library_path, specs, old, new = self._load_generation_from_form()
if old == new:
self.status.SetLabel("Изменений нет, запись не требуется.")
return
answer = wx.MessageBox(
f"Сгенерировать {len(specs)} символов и обновить библиотеку?\n\n{library_path}",
"Connector Generator",
wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION,
)
if answer != wx.YES:
return
validation = self._validate_with_kicad_cli(new)
if "returned an error" in validation:
self.preview.SetValue(f"Запись отменена.\n{validation}\n")
self.status.SetLabel("Проверка KiCad не пройдена")
return
library_path.write_text(new, encoding="utf-8")
self.status.SetLabel(f"Готово. Обновлено символов: {len(specs)}. {validation}")
self._refresh_preview()
except Exception as exc:
self.preview.SetValue(f"Ошибка генерации:\n{exc}\n")
self.status.SetLabel("Ошибка генерации")
def _on_open_config(self, _event):
config = self._current_config_path()
try:
subprocess.Popen(["xdg-open", str(config)])
self.status.SetLabel(f"Открыт конфиг: {config}")
except Exception as exc:
self.status.SetLabel(f"Не удалось открыть конфиг: {exc}")
return _Dialog(*args, **kwargs)

View file

@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
LIB_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
PLUGIN_SRC="$LIB_ROOT/plugins/connector-generator/connector_generator_plugin"
FORCE=0
for arg in "$@"; do
case "$arg" in
--force)
FORCE=1
;;
-h|--help)
echo "Usage: $0 [--force]"
echo
echo "Install Connector Generator plugin as a symlink into KiCad user plugins."
echo " --force replace existing connector_generator_plugin symlink/directory"
exit 0
;;
*)
echo "Unknown argument: $arg" >&2
exit 2
;;
esac
done
if [[ ! -d "$PLUGIN_SRC" ]]; then
echo "Connector Generator plugin source not found: $PLUGIN_SRC" >&2
exit 1
fi
version=""
if command -v kicad-cli >/dev/null 2>&1; then
version="$(
kicad-cli version 2>/dev/null \
| awk 'match($0, /[0-9]+\.[0-9]+/) { print substr($0, RSTART, RLENGTH); exit }'
)"
fi
if [[ -z "$version" ]]; then
version="10.0"
fi
PLUGIN_DIR="${KICAD_PLUGIN_DIR:-$HOME/.local/share/kicad/$version/scripting/plugins}"
PLUGIN_DEST="$PLUGIN_DIR/connector_generator_plugin"
mkdir -p "$PLUGIN_DIR"
if [[ -e "$PLUGIN_DEST" || -L "$PLUGIN_DEST" ]]; then
current="$(readlink -f "$PLUGIN_DEST" 2>/dev/null || true)"
source_real="$(readlink -f "$PLUGIN_SRC")"
if [[ "$current" != "$source_real" ]]; then
if [[ "$FORCE" != "1" ]]; then
echo "Destination already exists and is not this plugin:" >&2
echo " $PLUGIN_DEST" >&2
echo "Use --force to replace it or set KICAD_PLUGIN_DIR." >&2
exit 1
fi
rm -rf "$PLUGIN_DEST"
ln -s "$PLUGIN_SRC" "$PLUGIN_DEST"
fi
else
ln -s "$PLUGIN_SRC" "$PLUGIN_DEST"
fi
echo "Connector Generator plugin installed:"
echo " $PLUGIN_DEST -> $PLUGIN_SRC"
echo
echo "Restart KiCad, then open:"
echo " PCB Editor -> Tools -> External Plugins -> Generate Connector Symbols"

View file

@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Configure KiCad user paths and library table entries for this library."""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
ENV_VARS = ("KICAD_LIB_DIR_USER", "KICAD_PDF_DIR_USER", "KICAD_3D_PARTY_USER")
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def detect_kicad_config(version: str | None) -> Path:
base = Path.home() / ".config" / "kicad"
if version:
path = base / version / "kicad_common.json"
if not path.exists():
raise SystemExit(f"KiCad config not found: {path}")
return path
candidates = sorted(base.glob("*/kicad_common.json"))
if not candidates:
raise SystemExit(f"KiCad config not found under {base}")
def version_key(path: Path) -> tuple[int, ...]:
parts = []
for item in path.parent.name.split("."):
try:
parts.append(int(item))
except ValueError:
parts.append(0)
return tuple(parts)
return max(candidates, key=version_key)
def read_existing_vars(config_path: Path) -> dict[str, str]:
data = json.loads(config_path.read_text(encoding="utf-8"))
vars_obj = data.get("environment", {}).get("vars")
return vars_obj if isinstance(vars_obj, dict) else {}
def ask_path(name: str, prompt: str, default: str | None, required: bool = True) -> str:
env_value = os.environ.get(name)
if env_value:
return str(Path(env_value).expanduser())
suffix = f" [{default}]" if default else ""
while True:
value = input(f"{prompt}{suffix}: ").strip()
if not value and default:
value = default
if value or not required:
return str(Path(value).expanduser()) if value else ""
print("Path is required.")
def backup(path: Path) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
target = path.with_name(f"{path.name}.bak-{stamp}")
shutil.copy2(path, target)
return target
def update_common_config(config_path: Path, values: dict[str, str]) -> None:
data = json.loads(config_path.read_text(encoding="utf-8"))
environment = data.setdefault("environment", {})
vars_obj = environment.get("vars")
if not isinstance(vars_obj, dict):
vars_obj = {}
environment["vars"] = vars_obj
vars_obj.update(values)
config_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def ensure_table(path: Path, header: str) -> None:
if not path.exists():
path.write_text(f"({header}\n\t(version 7)\n)\n", encoding="utf-8")
def upsert_library_entry(table_path: Path, header: str, entry: str) -> None:
ensure_table(table_path, header)
text = table_path.read_text(encoding="utf-8")
name_match = re.search(r'\(name "([^"]+)"\)', entry)
if not name_match:
raise ValueError(f"Library entry has no name: {entry}")
name = re.escape(name_match.group(1))
text = re.sub(
rf'\n[^\n]*\(lib\s+\(name "{name}"\)[^\n]*',
"",
text,
)
insert_at = text.rfind("\n)")
if insert_at == -1:
raise SystemExit(f"Unexpected KiCad table format: {table_path}")
text = text[:insert_at] + "\n\t" + entry + text[insert_at:]
table_path.write_text(text, encoding="utf-8")
def configure_library_tables(config_dir: Path) -> None:
sym_table = config_dir / "sym-lib-table"
fp_table = config_dir / "fp-lib-table"
upsert_library_entry(
sym_table,
"sym_lib_table",
'(lib (name "eda-core") (type "Database") (uri "${KICAD_LIB_DIR_USER}/eda-core.kicad_dbl") (options "") (descr ""))',
)
upsert_library_entry(
sym_table,
"sym_lib_table",
'(lib (name "sym-lib-table") (type "Table") (uri "${KICAD_LIB_DIR_USER}/sym-lib-table") (options "") (descr ""))',
)
upsert_library_entry(
fp_table,
"fp_lib_table",
'(lib (name "fp-lib-table") (type "Table") (uri "${KICAD_LIB_DIR_USER}/fp-lib-table") (options "") (descr ""))',
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Configure KiCad paths and library tables for KiCad User Library."
)
parser.add_argument("--kicad-version", help="KiCad config version, for example 10.0")
parser.add_argument("--lib-dir", default=str(repo_root()), help="Path to this library repo")
parser.add_argument("--pdf-dir", help="Path to PDF datasheet directory")
parser.add_argument("--3d-dir", dest="models_dir", help="Path to user 3D models directory")
parser.add_argument("--non-interactive", action="store_true", help="Do not ask questions")
args = parser.parse_args()
config_path = detect_kicad_config(args.kicad_version)
existing = read_existing_vars(config_path)
lib_dir = str(Path(args.lib_dir).expanduser().resolve())
pdf_dir = args.pdf_dir or existing.get("KICAD_PDF_DIR_USER")
models_dir = args.models_dir or existing.get("KICAD_3D_PARTY_USER")
if not args.non_interactive:
lib_dir = ask_path("KICAD_LIB_DIR_USER", "KiCad library path", lib_dir)
pdf_dir = ask_path("KICAD_PDF_DIR_USER", "PDF directory path", pdf_dir)
if args.models_dir or existing.get("KICAD_3D_PARTY_USER"):
models_dir = ask_path("KICAD_3D_PARTY_USER", "3D models directory path", models_dir)
values = {
"KICAD_LIB_DIR_USER": lib_dir,
"KICAD_PDF_DIR_USER": pdf_dir or "",
}
if models_dir:
values["KICAD_3D_PARTY_USER"] = models_dir
if not values["KICAD_PDF_DIR_USER"]:
raise SystemExit("Missing required value: KICAD_PDF_DIR_USER")
for key, value in values.items():
if key != "KICAD_LIB_DIR_USER" and not Path(value).expanduser().exists():
print(f"warning: {key} path does not exist yet: {value}", file=sys.stderr)
backups = [backup(config_path)]
config_dir = config_path.parent
for table_name in ("sym-lib-table", "fp-lib-table"):
table_path = config_dir / table_name
if table_path.exists():
backups.append(backup(table_path))
update_common_config(config_path, values)
configure_library_tables(config_dir)
print(f"Configured KiCad {config_dir.name}:")
for key in ENV_VARS:
if key in values:
print(f" {key} = {values[key]}")
print("Library table entries updated:")
print(" sym-lib-table: eda-core, sym-lib-table")
print(" fp-lib-table: fp-lib-table")
print("Backups:")
for item in backups:
print(f" {item}")
print("Restart KiCad if it was running.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,14 +1,14 @@
(sym_lib_table (sym_lib_table
(version 7) (version 7)
(lib (name "eda-core") (type "Database") (uri "${KICAD_USER_LIB_DIR}/eda-core.kicad_dbl") (options "") (descr "")) (lib (name "eda-core") (type "Database") (uri "${KICAD_LIB_DIR_USER}/eda-core.kicad_dbl") (options "") (descr ""))
(lib (name "Ki-Capacitors") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Capacitors/Ki-Capacitors.kicad_sym") (options "") (descr "")) (lib (name "Ki-Capacitors") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Capacitors/Ki-Capacitors.kicad_sym") (options "") (descr ""))
(lib (name "Ki-Connectors") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Connectors/Ki-Connectors.kicad_sym") (options "") (descr "")) (lib (name "Ki-Connectors") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Connectors/Ki-Connectors.kicad_sym") (options "") (descr ""))
(lib (name "Ki-Diodes") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Diodes/Ki-Diodes.kicad_sym") (options "") (descr "")) (lib (name "Ki-Diodes") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Diodes/Ki-Diodes.kicad_sym") (options "") (descr ""))
(lib (name "Ki-Graphics") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Graphics/Ki-Graphics.kicad_sym") (options "") (descr "")) (lib (name "Ki-Graphics") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Graphics/Ki-Graphics.kicad_sym") (options "") (descr ""))
(lib (name "Ki-IC") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/IC/Ki-IC.kicad_sym") (options "") (descr "")) (lib (name "Ki-IC") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/IC/Ki-IC.kicad_sym") (options "") (descr ""))
(lib (name "Ki-Modules") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Modules/Ki-Modules.kicad_sym") (options "") (descr "")) (lib (name "Ki-Modules") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Modules/Ki-Modules.kicad_sym") (options "") (descr ""))
(lib (name "Ki-Resistors") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Resistors/Ki-Resistors.kicad_sym") (options "") (descr "")) (lib (name "Ki-Resistors") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Resistors/Ki-Resistors.kicad_sym") (options "") (descr ""))
(lib (name "Ki-Switches") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Switches/Ki-Switches.kicad_sym") (options "") (descr "")) (lib (name "Ki-Switches") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Switches/Ki-Switches.kicad_sym") (options "") (descr ""))
(lib (name "Ki-Transistor") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Transistor/Ki-Transistor.kicad_sym") (options "") (descr "")) (lib (name "Ki-Transistor") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Transistor/Ki-Transistor.kicad_sym") (options "") (descr ""))
(lib (name "Ki-Other") (type "KiCad") (uri "${KICAD_USER_LIB_DIR}/Other/Ki-Other.kicad_sym") (options "") (descr "")) (lib (name "Ki-Other") (type "KiCad") (uri "${KICAD_LIB_DIR_USER}/Other/Ki-Other.kicad_sym") (options "") (descr ""))
) )