. */ declare(strict_types=1); namespace App\Services\EDA; use App\Entity\Attachments\Attachment; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Part; use App\Services\Cache\ElementCacheTagGenerator; use App\Services\EntityURLGenerator; use App\Services\Trees\NodesListBuilder; use App\Settings\MiscSettings\KiCadEDASettings; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\TagAwareCacheInterface; use Symfony\Contracts\Translation\TranslatorInterface; class KiCadHelper { /** @var int The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */ private readonly int $category_depth; /** @var bool Whether to resolve actual datasheet PDF URLs (true) or use Part-DB page links (false) */ private readonly bool $datasheetAsPdf; public function __construct( private readonly NodesListBuilder $nodesListBuilder, private readonly TagAwareCacheInterface $kicadCache, private readonly EntityManagerInterface $em, private readonly ElementCacheTagGenerator $tagGenerator, private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityURLGenerator $entityURLGenerator, private readonly TranslatorInterface $translator, KiCadEDASettings $kiCadEDASettings, ) { $this->category_depth = $kiCadEDASettings->categoryDepth; $this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true; } /** * Returns an array of objects containing all categories in the database in the format required by KiCAD. * The categories are flattened and sorted by their full path. * Categories, which contain no parts, are filtered out. * The result is cached for performance and invalidated on category changes. * @return array */ public function getCategories(): array { return $this->kicadCache->get('kicad_categories_' . $this->category_depth, function (ItemInterface $item) { //Invalidate the cache on category changes $secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class); $item->tag($secure_class_name); //Invalidate the cache on part changes (as the visibility depends on parts, and the parts can change) $secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Part::class); $item->tag($secure_class_name); //If the category depth is smaller than 0, create only one dummy category if ($this->category_depth < 0) { return [ [ 'id' => '0', 'name' => 'Part-DB', ] ]; } //Otherwise just get the categories and filter them $categories = $this->nodesListBuilder->typeToNodesList(Category::class); $repo = $this->em->getRepository(Category::class); $result = []; foreach ($categories as $category) { //Skip invisible categories if ($category->getEdaInfo()->getVisibility() === false) { continue; } //Skip categories with a depth greater than the configured one if ($category->getLevel() > $this->category_depth) { continue; } //Ensure that the category contains parts //For the last level, we need to use a recursive query, otherwise we can use a simple query /** @var Category $category */ $parts_count = $category->getLevel() >= $this->category_depth ? $repo->getPartsCountRecursive($category) : $repo->getPartsCount($category); if ($parts_count < 1) { continue; } //Check if the category should be visible if (!$this->shouldCategoryBeVisible($category)) { continue; } //Format the category for KiCAD // Use the category comment as description if available, otherwise use the Part-DB URL $description = $category->getComment(); if ($description === null || $description === '') { $description = $this->entityURLGenerator->listPartsURL($category); } $result[] = [ 'id' => (string)$category->getId(), 'name' => $category->getFullPath('/'), 'description' => $description, ]; } return $result; }); } /** * Returns an array of objects containing all parts for the given category in the format required by KiCAD. * The result is cached for performance and invalidated on category or part changes. * @param Category|null $category * @param bool $minimal If true, only return id and name (faster for symbol chooser listing) * @return array */ public function getCategoryParts(?Category $category, bool $minimal = false): array { $cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth . ($minimal ? '_min' : ''); return $this->kicadCache->get($cacheKey, function (ItemInterface $item) use ($category) { $item->tag([ $this->tagGenerator->getElementTypeCacheTag(Category::class), $this->tagGenerator->getElementTypeCacheTag(Part::class), //Visibility can change based on the footprint $this->tagGenerator->getElementTypeCacheTag(Footprint::class) ]); if ($this->category_depth >= 0) { //Ensure that the category is set if ($category === null) { throw new NotFoundHttpException('Category must be set, if category_depth is greater than 1!'); } $category_repo = $this->em->getRepository(Category::class); if ($category->getLevel() >= $this->category_depth) { //Get all parts for the category and its children $parts = $category_repo->getPartsRecursive($category); } else { //Get only direct parts for the category (without children), as the category is not collapsed $parts = $category_repo->getParts($category); } } else { //Get all parts $parts = $this->em->getRepository(Part::class)->findAll(); } $result = []; foreach ($parts as $part) { //If the part is invisible, then skip it if (!$this->shouldPartBeVisible($part)) { continue; } $result[] = [ 'id' => (string)$part->getId(), 'name' => $part->getName(), 'description' => $part->getDescription(), ]; } return $result; }); } /** * @param bool $apiV2 If true, use API v2 format with volatile field support */ public function getKiCADPart(Part $part, bool $apiV2 = false): array { $result = [ 'id' => (string)$part->getId(), 'name' => $part->getName(), "symbolIdStr" => $part->getEdaInfo()->getKicadSymbol() ?? $part->getCategory()?->getEdaInfo()->getKicadSymbol() ?? "", "exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false), "exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false), "exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false), "fields" => [] ]; $result["fields"]["footprint"] = $this->createField($part->getEdaInfo()->getKicadFootprint() ?? $part->getFootprint()?->getEdaInfo()->getKicadFootprint() ?? ""); $result["fields"]["reference"] = $this->createField($part->getEdaInfo()->getReferencePrefix() ?? $part->getCategory()?->getEdaInfo()->getReferencePrefix() ?? 'U', true); $result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true); $result["fields"]["keywords"] = $this->createField($part->getTags()); //Use the part info page as Part-DB link. It must be an absolute URL. $partUrl = $this->urlGenerator->generate( 'part_info', ['id' => $part->getId()], UrlGeneratorInterface::ABSOLUTE_URL ); //Try to find an actual datasheet attachment (configurable: PDF URL vs Part-DB page link) if ($this->datasheetAsPdf) { $datasheetUrl = $this->findDatasheetUrl($part); $result["fields"]["datasheet"] = $this->createField($datasheetUrl ?? $partUrl); } else { $result["fields"]["datasheet"] = $this->createField($partUrl); } $result["fields"]["Part-DB URL"] = $this->createField($partUrl); //Add basic fields $result["fields"]["description"] = $this->createField($part->getDescription()); if ($part->getCategory() !== null) { $result["fields"]["Category"] = $this->createField($part->getCategory()->getFullPath('/')); } if ($part->getManufacturer() !== null) { $result["fields"]["Manufacturer"] = $this->createField($part->getManufacturer()->getName()); } if ($part->getManufacturerProductNumber() !== "") { $result['fields']["MPN"] = $this->createField($part->getManufacturerProductNumber()); } if ($part->getManufacturingStatus() !== null) { $result["fields"]["Manufacturing Status"] = $this->createField( //Always use the english translation $this->translator->trans($part->getManufacturingStatus()->toTranslationKey(), locale: 'en') ); } if ($part->getFootprint() !== null) { $result["fields"]["Part-DB Footprint"] = $this->createField($part->getFootprint()->getName()); } if ($part->getPartUnit() !== null) { $unit = $part->getPartUnit()->getName(); if ($part->getPartUnit()->getUnit() !== "") { $unit .= ' ('.$part->getPartUnit()->getUnit().')'; } $result["fields"]["Part-DB Unit"] = $this->createField($unit); } if ($part->getPartCustomState() !== null) { $customState = $part->getPartCustomState()->getName(); $result["fields"]["Part-DB Custom state"] = $this->createField($customState); } if ($part->getMass()) { $result["fields"]["Mass"] = $this->createField($part->getMass() . ' g'); } $result["fields"]["Part-DB ID"] = $this->createField($part->getId()); if ($part->getIpn() !== null && $part->getIpn() !== '' && $part->getIpn() !== '0') { $result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn()); } //Add KiCost manufacturer fields (always present, independent of orderdetails) if ($part->getManufacturer() !== null) { $result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName()); } if ($part->getManufacturerProductNumber() !== "") { $result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber()); } // Add supplier information from orderdetails (include obsolete orderdetails) // If any orderdetail has kicad_export=true, only export those; otherwise export all (backward compat) $allOrderdetails = $part->getOrderdetails(false); if ($allOrderdetails->count() > 0) { $hasKicadExportFlag = false; foreach ($allOrderdetails as $od) { if ($od->isKicadExport()) { $hasKicadExportFlag = true; break; } } $supplierCounts = []; foreach ($allOrderdetails as $orderdetail) { if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') { // Skip orderdetails not marked for export when the flag is used if ($hasKicadExportFlag && !$orderdetail->isKicadExport()) { continue; } $supplierName = $orderdetail->getSupplier()->getName() . ' SPN'; if (!isset($supplierCounts[$supplierName])) { $supplierCounts[$supplierName] = 0; } $supplierCounts[$supplierName]++; // Create field name with sequential number if more than one from same supplier $fieldName = $supplierCounts[$supplierName] > 1 ? $supplierName . ' ' . $supplierCounts[$supplierName] : $supplierName; $result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr()); //Also add a KiCost-compatible field (supplier_name# = SPN) $kicostFieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#'; $result["fields"][$kicostFieldName] = $this->createField($orderdetail->getSupplierPartNr()); } } } //Add stock quantity and storage locations (only count non-expired lots with known quantity) $totalStock = 0; $locations = []; foreach ($part->getPartLots() as $lot) { $isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true; if ($isAvailable) { $totalStock += $lot->getAmount(); if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) { $locations[] = $lot->getStorageLocation()->getName(); } } } // In API v2, stock and location are volatile (shown but not saved to schematic) $result['fields']['Stock'] = $this->createField($totalStock, false, $apiV2); if ($locations !== []) { $result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)), false, $apiV2); } //Add parameters marked for KiCad export foreach ($part->getParameters() as $parameter) { if ($parameter->isKicadExport() && $parameter->getName() !== '') { $fieldName = $parameter->getName(); //Don't overwrite hardcoded fields if (!isset($result['fields'][$fieldName])) { $result['fields'][$fieldName] = $this->createField($parameter->getFormattedValue()); } } } return $result; } /** * Determine if the given part should be visible for the EDA. * @param Category $category * @return bool */ private function shouldCategoryBeVisible(Category $category): bool { $eda_info = $category->getEdaInfo(); //If the category visibility is explicitly set, then use it if ($eda_info->getVisibility() !== null) { return $eda_info->getVisibility(); } //try to check if the fields were set if ($eda_info->getKicadSymbol() !== null || $eda_info->getReferencePrefix() !== null) { return true; } //Check if there is any part in this category, which should be visible $category_repo = $this->em->getRepository(Category::class); if ($category->getLevel() >= $this->category_depth) { //Get all parts for the category and its children $parts = $category_repo->getPartsRecursive($category); } else { //Get only direct parts for the category (without children), as the category is not collapsed $parts = $category_repo->getParts($category); } foreach ($parts as $part) { if ($this->shouldPartBeVisible($part)) { return true; } } //Otherwise the category should be not visible return false; } /** * Determine if the given part should be visible for the EDA. * @param Part $part * @return bool */ private function shouldPartBeVisible(Part $part): bool { $eda_info = $part->getEdaInfo(); $category = $part->getCategory(); //If the user set a visibility, then use it if ($eda_info->getVisibility() !== null) { return $eda_info->getVisibility(); } //If the part has a category, then use the category visibility if possible if ($category && $category->getEdaInfo()->getVisibility() !== null) { return $category->getEdaInfo()->getVisibility(); } //If both are null, then we try to determine the visibility based on if fields are set if ($eda_info->getKicadSymbol() !== null || $eda_info->getKicadFootprint() !== null || $eda_info->getReferencePrefix() !== null || $eda_info->getValue() !== null) { return true; } //Check also if the fields are set for the category (if it exists) if ($category && ( $category->getEdaInfo()->getKicadSymbol() !== null || $category->getEdaInfo()->getReferencePrefix() !== null )) { return true; } //And on the footprint //Otherwise the part should be not visible return $part->getFootprint() && $part->getFootprint()->getEdaInfo()->getKicadFootprint() !== null; } /** * Converts a boolean value to the format required by KiCAD. * @param bool $value * @return string */ private function boolToKicadBool(bool $value): string { return $value ? 'True' : 'False'; } /** * Creates a field array for KiCAD * @param string|int|float $value * @param bool $visible * @param bool $volatile If true (API v2), field is shown in KiCad but NOT saved to schematic * @return array */ private function createField(string|int|float $value, bool $visible = false, bool $volatile = false): array { $field = [ 'value' => (string)$value, 'visible' => $this->boolToKicadBool($visible), ]; if ($volatile) { $field['volatile'] = $this->boolToKicadBool(true); } return $field; } /** * Finds the URL to the actual datasheet file for the given part. * Searches attachments by type name, attachment name, and file extension. * @return string|null The datasheet URL, or null if no datasheet was found. */ private function findDatasheetUrl(Part $part): ?string { $firstPdf = null; foreach ($part->getAttachments() as $attachment) { //Check if the attachment type name contains "datasheet" $typeName = $attachment->getAttachmentType()?->getName() ?? ''; if (str_contains(mb_strtolower($typeName), 'datasheet')) { return $this->getAttachmentUrl($attachment); } //Check if the attachment name contains "datasheet" $name = mb_strtolower($attachment->getName()); if (str_contains($name, 'datasheet') || str_contains($name, 'data sheet')) { return $this->getAttachmentUrl($attachment); } //Track first PDF as fallback (check internal extension or external URL path) if ($firstPdf === null) { $extension = $attachment->getExtension(); if ($extension === null && $attachment->hasExternal()) { $urlPath = parse_url($attachment->getExternalPath(), PHP_URL_PATH); $extension = is_string($urlPath) ? strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) : null; } if ($extension === 'pdf') { $firstPdf = $attachment; } } } //Use first PDF attachment as fallback if ($firstPdf !== null) { return $this->getAttachmentUrl($firstPdf); } return null; } /** * Returns an absolute URL for viewing the given attachment. * Prefers the external URL (direct link) over the internal view route. */ private function getAttachmentUrl(Attachment $attachment): string { if ($attachment->hasExternal()) { return $attachment->getExternalPath(); } return $this->urlGenerator->generate( 'attachment_view', ['id' => $attachment->getId()], UrlGeneratorInterface::ABSOLUTE_URL ); } }