#!/usr/bin/env python # -*- coding: utf-8 -*- from dataclasses import asdict from itertools import groupby from typing import Any, List, Tuple, Union from wireviz.DataClasses import AdditionalComponent, Connector, Cable from wireviz.wv_gv_html import html_line_breaks from wireviz.wv_helper import clean_whitespace def get_additional_component_table(harness, component: Union[Connector, Cable]) -> List[str]: rows = [] if component.additional_components: rows.append(["Additional components"]) for extra in component.additional_components: qty = extra.qty * component.get_qty_multiplier(extra.qty_multiplier) if harness.mini_bom_mode: id = get_bom_index(harness.bom(), extra) rows.append(component_table_entry(f'#{id} ({extra.type.rstrip()})', qty, extra.unit)) else: rows.append(component_table_entry(extra.description, qty, extra.unit, extra.pn, extra.manufacturer, extra.mpn)) return rows def get_additional_component_bom(component: Union[Connector, Cable]) -> List[dict]: bom_entries = [] for part in component.additional_components: qty = part.qty * component.get_qty_multiplier(part.qty_multiplier) bom_entries.append({ 'item': part.description, 'qty': qty, 'unit': part.unit, 'manufacturer': part.manufacturer, 'mpn': part.mpn, 'pn': part.pn, 'designators': component.name if component.show_name else None }) return bom_entries def bom_types_group(entry: dict) -> Tuple[str, ...]: """Return a tuple of values from the dict that must be equal to join BOM entries.""" return tuple(entry.get(key) for key in ('item', 'unit', 'manufacturer', 'mpn', 'pn')) def generate_bom(harness): from wireviz.Harness import Harness # Local import to avoid circular imports bom_entries = [] # connectors for connector in harness.connectors.values(): if not connector.ignore_in_bom: description = ('Connector' + (f', {connector.type}' if connector.type else '') + (f', {connector.subtype}' if connector.subtype else '') + (f', {connector.pincount} pins' if connector.show_pincount else '') + (f', {connector.color}' if connector.color else '')) bom_entries.append({ 'item': description, 'qty': 1, 'unit': None, 'designators': connector.name if connector.show_name else None, 'manufacturer': connector.manufacturer, 'mpn': connector.mpn, 'pn': connector.pn }) # add connectors aditional components to bom bom_entries.extend(get_additional_component_bom(connector)) # cables # TODO: If category can have other non-empty values than 'bundle', maybe it should be part of item name? for cable in harness.cables.values(): if not cable.ignore_in_bom: if cable.category != 'bundle': # process cable as a single entity description = ('Cable' + (f', {cable.type}' if cable.type else '') + (f', {cable.wirecount}') + (f' x {cable.gauge} {cable.gauge_unit}' if cable.gauge else ' wires') + (' shielded' if cable.shield else '')) bom_entries.append({ 'item': description, 'qty': cable.length, 'unit': cable.length_unit, 'designators': cable.name if cable.show_name else None, 'manufacturer': cable.manufacturer, 'mpn': cable.mpn, 'pn': cable.pn }) else: # add each wire from the bundle to the bom for index, color in enumerate(cable.colors): description = ('Wire' + (f', {cable.type}' if cable.type else '') + (f', {cable.gauge} {cable.gauge_unit}' if cable.gauge else '') + (f', {color}' if color else '')) bom_entries.append({ 'item': description, 'qty': cable.length, 'unit': cable.length_unit, 'designators': cable.name if cable.show_name else None, 'manufacturer': index_if_list(cable.manufacturer, index), 'mpn': index_if_list(cable.mpn, index), 'pn': index_if_list(cable.pn, index) }) # add cable/bundles aditional components to bom bom_entries.extend(get_additional_component_bom(cable)) for item in harness.additional_bom_items: bom_entries.append({ 'item': item.get('description', ''), 'qty': item.get('qty', 1), 'unit': item.get('unit'), 'designators': item.get('designators'), 'manufacturer': item.get('manufacturer'), 'mpn': item.get('mpn'), 'pn': item.get('pn') }) # remove line breaks if present and cleanup any resulting whitespace issues bom_entries = [{k: clean_whitespace(v) for k, v in entry.items()} for entry in bom_entries] # Sort entries to prepare grouping on the same key function. bom_entries.sort(key=lambda entry: tuple(attr or '' for attr in bom_types_group(entry))) # deduplicate bom bom = [] for _, group in groupby(bom_entries, bom_types_group): last_entry = None total_qty = 0 designators = [] for group_entry in group: designators.extend(make_list(group_entry.get('designators'))) total_qty += group_entry['qty'] last_entry = group_entry bom.append({**last_entry, 'qty': round(total_qty, 3), 'designators': sorted(set(designators))}) # add an incrementing id to each bom item return [{**entry, 'id': index} for index, entry in enumerate(bom, 1)] def get_bom_index(bom: List[dict], extra: AdditionalComponent) -> int: # Remove linebreaks and clean whitespace of values in search target = tuple(clean_whitespace(v) for v in bom_types_group({**asdict(extra), 'item': extra.description})) for entry in bom: if bom_types_group(entry) == target: return entry['id'] return None def bom_list(bom): keys = ['id', 'item', 'qty', 'unit', 'designators'] # these BOM columns will always be included for fieldname in ['pn', 'manufacturer', 'mpn']: # these optional BOM columns will only be included if at least one BOM item actually uses them if any(entry.get(fieldname) for entry in bom): keys.append(fieldname) # list of staic bom header names, headers not specified here are generated by capitilising the internal name bom_headings = { "pn": "P/N", "mpn": "MPN" } return ([[bom_headings.get(k, k.capitalize()) for k in keys]] + # Create header row with key names [[make_str(entry.get(k)) for k in keys] for entry in bom]) # Create string list for each entry row def component_table_entry(type, qty, unit=None, pn=None, manufacturer=None, mpn=None): output = f'{qty}' if unit: output += f' {unit}' output += f' x {type}' # print an extra line with part and manufacturer information if provided manufacturer_str = manufacturer_info_field(manufacturer, mpn) if pn or manufacturer_str: output += '
' if pn: output += f'P/N: {pn}' if manufacturer_str: output += ', ' if manufacturer_str: output += manufacturer_str output = html_line_breaks(output) # format the above output as left aligned text in a single visible cell # indent is set to two to match the indent in the generated html table return f'''
{output}
''' def manufacturer_info_field(manufacturer, mpn): if manufacturer or mpn: return f'{manufacturer if manufacturer else "MPN"}{": " + str(mpn) if mpn else ""}' else: return None # Return the value indexed if it is a list, or simply the value otherwise. def index_if_list(value, index): return value[index] if isinstance(value, list) else value def make_list(value: Any) -> list: """Return value if a list, empty list if None, or single element list otherwise.""" return value if isinstance(value, list) else [] if value is None else [value] def make_str(value: Any) -> str: """Return comma separated elements if a list, empty string if None, or value as a string otherwise.""" return ', '.join(str(element) for element in make_list(value))