# -*- coding: utf-8 -*- from collections import namedtuple from dataclasses import dataclass, field, asdict from enum import Enum from itertools import zip_longest from typing import Any, Dict, List, Optional, Tuple, Union from wireviz.wv_bom import ( BomHash, BomHashList, PartNumberInfo, QtyMultiplierCable, QtyMultiplierConnector, ) from wireviz.wv_colors import ( COLOR_CODES, ColorOutputMode, MultiColor, SingleColor, get_color_by_colorcode_index, ) from wireviz.wv_utils import ( NumberAndUnit, aspect_ratio, awg_equiv, mm2_equiv, parse_number_and_unit, remove_links, ) # Each type alias have their legal values described in comments # - validation might be implemented in the future PlainText = str # Text not containing HTML tags nor newlines Hypertext = str # Text possibly including HTML hyperlinks that are removed in all outputs except HTML output MultilineHypertext = ( str # Hypertext possibly also including newlines to break lines in diagram output ) Designator = PlainText # Case insensitive unique name of connector or cable # Literal type aliases below are commented to avoid requiring python 3.8 ImageScale = PlainText # = Literal['false', 'true', 'width', 'height', 'both'] # Type combinations Pin = Union[int, PlainText] # Pin identifier PinIndex = int # Zero-based pin index Wire = Union[int, PlainText] # Wire number or Literal['s'] for shield NoneOrMorePins = Union[ Pin, Tuple[Pin, ...], None ] # None, one, or a tuple of pin identifiers NoneOrMorePinIndices = Union[ PinIndex, Tuple[PinIndex, ...], None ] # None, one, or a tuple of zero-based pin indices OneOrMoreWires = Union[Wire, Tuple[Wire, ...]] # One or a tuple of wires # Metadata can contain whatever is needed by the HTML generation/template. MetadataKeys = PlainText # Literal['title', 'description', 'notes', ...] Side = Enum("Side", "LEFT RIGHT") ArrowDirection = Enum("ArrowDirection", "NONE BACK FORWARD BOTH") ArrowWeight = Enum("ArrowWeight", "SINGLE DOUBLE") AUTOGENERATED_PREFIX = "AUTOGENERATED_" @dataclass class Arrow: direction: ArrowDirection weight: ArrowWeight class Metadata(dict): pass @dataclass class Options: fontname: PlainText = "arial" bgcolor: SingleColor = "WH" # will be converted to SingleColor in __post_init__ bgcolor_node: SingleColor = "WH" bgcolor_connector: SingleColor = None bgcolor_cable: SingleColor = None bgcolor_bundle: SingleColor = None color_output_mode: ColorOutputMode = ColorOutputMode.EN_UPPER mini_bom_mode: bool = True template_separator: str = "." _pad: int = 0 # TODO: resolve template and image paths during rendering, not during YAML parsing _template_paths: List = field(default_factory=list) _image_paths: List = field(default_factory=list) def __post_init__(self): self.bgcolor = SingleColor(self.bgcolor) self.bgcolor_node = SingleColor(self.bgcolor_node) self.bgcolor_connector = SingleColor(self.bgcolor_connector) self.bgcolor_cable = SingleColor(self.bgcolor_cable) self.bgcolor_bundle = SingleColor(self.bgcolor_bundle) if not self.bgcolor_node: self.bgcolor_node = self.bgcolor if not self.bgcolor_connector: self.bgcolor_connector = self.bgcolor_node if not self.bgcolor_cable: self.bgcolor_cable = self.bgcolor_node if not self.bgcolor_bundle: self.bgcolor_bundle = self.bgcolor_cable @dataclass class Tweak: override: Optional[Dict[Designator, Dict[str, Optional[str]]]] = None append: Union[str, List[str], None] = None @dataclass class Image: # Attributes of the image object : src: str scale: Optional[ImageScale] = None # Attributes of the image cell containing the image: width: Optional[int] = None height: Optional[int] = None fixedsize: Optional[bool] = None bgcolor: SingleColor = None # Contents of the text cell just below the image cell: caption: Optional[MultilineHypertext] = None # See also HTML doc at https://graphviz.org/doc/info/shapes.html#html def __post_init__(self): self.bgcolor = SingleColor(self.bgcolor) if self.fixedsize is None: # Default True if any dimension specified unless self.scale also is specified. self.fixedsize = (self.width or self.height) and self.scale is None if self.scale is None: if not self.width and not self.height: self.scale = "false" elif self.width and self.height: self.scale = "both" else: self.scale = "true" # When only one dimension is specified. if self.fixedsize: # If only one dimension is specified, compute the other # because Graphviz requires both when fixedsize=True. if self.height: if not self.width: self.width = self.height * aspect_ratio(self.src) else: if self.width: self.height = self.width / aspect_ratio(self.src) @dataclass class PinClass: index: int id: str label: str color: MultiColor parent: str # designator of parent connector _num_connections = 0 # incremented in Connector.connect() _anonymous: bool = False # true for pins on autogenerated connectors _simple: bool = False # true for simple connector def __str__(self): snippets = [ # use str() for each in case they are int or other non-str str(self.parent) if not self._anonymous else "", str(self.id) if not self._anonymous and not self._simple else "", str(self.label) if self.label else "", ] return ":".join([snip for snip in snippets if snip != ""]) @dataclass class Component: category: Optional[str] = None # currently only used by cables, to define bundles type: Union[MultilineHypertext, List[MultilineHypertext]] = None subtype: Union[MultilineHypertext, List[MultilineHypertext]] = None # part number partnumbers: PartNumberInfo = None # filled by __post_init__() # the following are provided for user convenience and should not be accessed later. # their contents are loaded into partnumbers during the child class __post_init__() pn: str = None manufacturer: str = None mpn: str = None supplier: str = None spn: str = None # BOM info qty: Optional[Union[None, int, float]] = None amount: Optional[NumberAndUnit] = None sum_amounts_in_bom: bool = True ignore_in_bom: bool = False bom_id: Optional[str] = None # to be filled after harness is built def __post_init__(self): partnos = [self.pn, self.manufacturer, self.mpn, self.supplier, self.spn] partnos = [remove_links(entry) for entry in partnos] partnos = tuple(partnos) self.partnumbers = PartNumberInfo(*partnos) self.amount = parse_number_and_unit(self.amount, None) @property def bom_hash(self) -> BomHash: if isinstance(self, AdditionalComponent): _amount = self.amount_computed else: _amount = self.amount if self.sum_amounts_in_bom: _hash = BomHash( description=self.description, qty_unit=_amount.unit if _amount else None, amount=None, partnumbers=self.partnumbers, ) else: _hash = BomHash( description=self.description, qty_unit=None, amount=_amount, partnumbers=self.partnumbers, ) return _hash @property def has_pn_info(self) -> bool: return any([self.pn, self.manufacturer, self.mpn, self.supplier, self.spn]) @property def description(self) -> str: return f"{self.type}{', ' + self.subtype if self.subtype else ''}" @dataclass class AdditionalBomItem(Component): designators: Optional[str] = None @property def additional_components(self): # An additional item may not have further nested additional comonents. # This property is currently needed for objects in the same list as # TopLevelGraphicalComponent objects in a Harness method. return [] @dataclass class GraphicalComponent(Component): # abstract class bgcolor: Optional[SingleColor] = None def __post_init__(self): super().__post_init__() self.bgcolor = SingleColor(self.bgcolor) @dataclass class AdditionalComponent(GraphicalComponent): qty_multiplier: Union[QtyMultiplierConnector, QtyMultiplierCable, int] = 1 qty_computed: Optional[int] = None explicit_qty: bool = True amount_computed: Optional[NumberAndUnit] = None note: str = None color: Optional[MultiColor] = None references: Optional[List[str]] = field(default_factory=list) def __post_init__(self): super().__post_init__() self.color = MultiColor(self.color) if isinstance(self.qty_multiplier, float) or isinstance( self.qty_multiplier, int ): pass else: self.qty_multiplier = self.qty_multiplier.upper() if self.qty_multiplier in QtyMultiplierConnector.__members__.keys(): self.qty_multiplier = QtyMultiplierConnector[self.qty_multiplier] elif self.qty_multiplier in QtyMultiplierCable.__members__.keys(): self.qty_multiplier = QtyMultiplierCable[self.qty_multiplier] else: raise Exception(f"Unknown qty multiplier: {self.qty_multiplier}") if self.qty is None and self.qty_multiplier in [ QtyMultiplierCable.TOTAL_LENGTH, QtyMultiplierCable.LENGTH, 1, ]: # simplify add.comp. table in parent node for implicit qty 1 self.qty = 1 self.explicit_qty = False @dataclass class TopLevelGraphicalComponent(GraphicalComponent): # abstract class # component properties designator: Designator = None color: Optional[MultiColor] = None image: Optional[Image] = None additional_parameters: Optional[Dict] = None additional_components: List[AdditionalComponent] = field(default_factory=list) notes: Optional[MultilineHypertext] = None # rendering options bgcolor_title: Optional[SingleColor] = None show_name: Optional[bool] = None @dataclass class Connector(TopLevelGraphicalComponent): # connector-specific properties style: Optional[str] = None # TODO: Move shorts and loops to PinClass loops: Dict[str, List[int]] = field(default_factory=dict) shorts: Dict[str, List[int]] = field(default_factory=dict) # pin information in particular pincount: Optional[int] = None pins: List[Pin] = field(default_factory=list) # legacy pinlabels: List[Pin] = field(default_factory=list) # legacy pincolors: List[str] = field(default_factory=list) # legacy pin_objects: Dict[Any, PinClass] = field(default_factory=dict) # new # rendering option show_pincount: Optional[bool] = None hide_disconnected_pins: bool = False @property def is_autogenerated(self): return self.designator.startswith(AUTOGENERATED_PREFIX) @property def description(self) -> str: substrs = [ "Connector", self.type, self.subtype, f"{self.pincount} pins" if self.show_pincount else None, str(self.color) if self.color else None, ] return ", ".join([str(s) for s in substrs if s is not None and s != ""]) def should_show_pin(self, pin_id): return ( not self.hide_disconnected_pins or self.pin_objects[pin_id]._num_connections > 0 ) @property def unit(self): # for compatibility with BOM hashing return None # connectors do not support units. def __post_init__(self) -> None: super().__post_init__() self.bgcolor_title = SingleColor(self.bgcolor_title) self.color = MultiColor(self.color) # connectors do not support custom qty or amount if self.qty is None: self.qty = 1 if self.qty != 1: raise Exception("Connector qty != 1 not supported") if self.amount is not None: raise Exception("Connector amount not supported") if isinstance(self.image, dict): self.image = Image(**self.image) self.ports_left = False self.ports_right = False self.visible_pins = {} if self.style == "simple": if self.pincount and self.pincount > 1: raise Exception( "Connectors with style set to simple may only have one pin" ) self.pincount = 1 if not self.pincount: self.pincount = max( len(self.pins), len(self.pinlabels), len(self.pincolors) ) if not self.pincount: raise Exception( "You need to specify at least one: " "pincount, pins, pinlabels, or pincolors" ) # create default list for pins (sequential) if not specified if not self.pins: self.pins = list(range(1, self.pincount + 1)) if len(self.pins) != len(set(self.pins)): raise Exception("Pins are not unique") # all checks have passed pin_tuples = zip_longest( self.pins, self.pinlabels, self.pincolors, ) for pin_index, (pin_id, pin_label, pin_color) in enumerate(pin_tuples): self.pin_objects[pin_id] = PinClass( index=pin_index, id=pin_id, label=pin_label, color=MultiColor(pin_color), parent=self.designator, _anonymous=self.is_autogenerated, _simple=self.style == "simple", ) if self.show_name is None: self.show_name = self.style != "simple" and not self.is_autogenerated if self.show_pincount is None: # hide pincount for simple (1 pin) connectors by default self.show_pincount = self.style != "simple" # TODO: allow using pin labels in addition to pin numbers, # just like when defining regular connections # TODO: include properties of wire used to create the loop for loopName in self.loops: for pin in self.loops[loopName]: if pin not in self.pins: raise Exception( f'Unknown loop pin "{pin}" for connector "{self.designator}"!' ) # Make sure loop connected pins are not hidden. self.activate_pin(pin, None) for short in self.shorts: for pin in self.shorts[short]: if pin not in self.pins: raise Exception( f'Unknown loop pin "{pin}" for connector "{self.designator}"!' ) # Make sure loop connected pins are not hidden. self.activate_pin(pin, None) # TODO: Remove the outcommented code here if it is no longer needed as reference # for loop in self.loops: # # TODO: allow using pin labels in addition to pin numbers, # # just like when defining regular connections # # TODO: include properties of wire used to create the loop # if len(loop) != 2: # raise Exception("Loops must be between exactly two pins!") # for pin in loop: # if pin not in self.pins: # raise Exception( # f'Unknown loop pin "{pin}" for connector "{self.name}"!' # ) # # Make sure loop connected pins are not hidden. # # side=None, determine side to show loops during rendering # self.activate_pin(pin, side=None, is_connection=True) for i, item in enumerate(self.additional_components): if isinstance(item, dict): self.additional_components[i] = AdditionalComponent(**item) def activate_pin(self, pin_id, side: Side = None, is_connection=True) -> None: if is_connection: self.pin_objects[pin_id]._num_connections += 1 if side == Side.LEFT: self.ports_left = True elif side == Side.RIGHT: self.ports_right = True def compute_qty_multipliers(self): # do not run before all connections in harness have been made! num_populated_pins = len( [pin for pin in self.pin_objects.values() if pin._num_connections > 0] ) num_connections = sum( [pin._num_connections for pin in self.pin_objects.values()] ) qty_multipliers_computed = { "PINCOUNT": self.pincount, "POPULATED": num_populated_pins, "UNPOPULATED": max(0, self.pincount - num_populated_pins), "CONNECTIONS": num_connections, } for subitem in self.additional_components: if isinstance(subitem.qty_multiplier, QtyMultiplierConnector): computed_factor = qty_multipliers_computed[subitem.qty_multiplier.name] elif isinstance(subitem.qty_multiplier, QtyMultiplierCable): raise Exception("Used a cable multiplier in a connector!") else: # int or float computed_factor = subitem.qty_multiplier if subitem.qty is not None: subitem.qty_computed = subitem.qty * computed_factor else: subitem.qty_computed = computed_factor subitem.amount_computed = subitem.amount @dataclass class WireClass: parent: str # designator of parent cable/bundle # wire-specific properties index: int id: str label: str color: MultiColor # ... bom_id: Optional[str] = None # to be filled after harness is built # inheritable from parent cable type: Union[MultilineHypertext, List[MultilineHypertext]] = None subtype: Union[MultilineHypertext, List[MultilineHypertext]] = None gauge: Optional[NumberAndUnit] = None length: Optional[NumberAndUnit] = None ignore_in_bom: Optional[bool] = False sum_amounts_in_bom: bool = True partnumbers: PartNumberInfo = None @property def bom_hash(self) -> BomHash: if self.sum_amounts_in_bom: _hash = BomHash( description=self.description, qty_unit=self.length.unit if self.length else None, amount=None, partnumbers=self.partnumbers, ) else: _hash = BomHash( description=self.description, qty_unit=None, amount=self.length, partnumbers=self.partnumbers, ) return _hash @property def gauge_str(self): if not self.gauge: return None actual_gauge = f"{self.gauge.number} {self.gauge.unit}" actual_gauge = actual_gauge.replace("mm2", "mm\u00B2") return actual_gauge @property def description(self) -> str: substrs = [ "Wire", self.type, self.subtype, self.gauge_str, str(self.color) if self.color else None, ] desc = ", ".join([s for s in substrs if s is not None and s != ""]) return desc @dataclass class ShieldClass(WireClass): pass # TODO, for wires with multiple shields more shield details, ... @dataclass class Connection: from_: PinClass = None via: Union[WireClass, ShieldClass] = None to: PinClass = None @dataclass class Cable(TopLevelGraphicalComponent): # cable-specific properties gauge: Optional[NumberAndUnit] = None length: Optional[NumberAndUnit] = None color_code: Optional[str] = None # wire information in particular wirecount: Optional[int] = None shield: Union[bool, MultiColor] = False colors: List[str] = field(default_factory=list) # legacy wirelabels: List[Wire] = field(default_factory=list) # legacy wire_objects: Dict[Any, WireClass] = field(default_factory=dict) # new # internal _connections: List[Connection] = field(default_factory=list) # rendering options show_name: Optional[bool] = None show_equiv: bool = False show_wirecount: bool = True show_wirenumbers: Optional[bool] = None @property def is_autogenerated(self): return self.designator.startswith(AUTOGENERATED_PREFIX) @property def unit(self): # for compatibility with parent class return self.length @property def gauge_str(self): if not self.gauge: return None actual_gauge = f"{self.gauge.number} {self.gauge.unit}" actual_gauge = actual_gauge.replace("mm2", "mm\u00B2") return actual_gauge @property def gauge_str_with_equiv(self): if not self.gauge: return None actual_gauge = self.gauge_str equivalent_gauge = "" if self.show_equiv: # convert unit if known if self.gauge.unit == "mm2": equivalent_gauge = f" ({awg_equiv(self.gauge.number)} AWG)" elif self.gauge.unit.upper() == "AWG": equivalent_gauge = f" ({mm2_equiv(self.gauge.number)} mm2)" out = f"{actual_gauge}{equivalent_gauge}" out = out.replace("mm2", "mm\u00B2") return out @property def length_str(self): if not self.length: return None out = f"{self.length.number} {self.length.unit}" return out @property def bom_hash(self): if self.category == "bundle": # This line should never be reached, since caller checks # whether item is a bundle and if so, calls bom_hash # for each individual wire instead raise Exception("Do this at the wire level!") else: return super().bom_hash @property def description(self) -> str: if self.category == "bundle": raise Exception("Do this at the wire level!") else: substrs = [ ("", "Cable"), (", ", self.type), (", ", self.subtype), (", ", self.wirecount), (" ", f"x {self.gauge_str}" if self.gauge else "wires"), (" ", "shielded" if self.shield else None), (", ", str(self.color) if self.color else None), ] desc = "".join( [f"{s[0]}{s[1]}" for s in substrs if s[1] is not None and s[1] != ""] ) return desc def _get_wire_partnumber(self, idx) -> PartNumberInfo: def _get_correct_element(inp, idx): return inp[idx] if isinstance(inp, List) else inp # TODO: possibly make more robust/elegant if self.category == "bundle": return PartNumberInfo( _get_correct_element(self.partnumbers.pn, idx), _get_correct_element(self.partnumbers.manufacturer, idx), _get_correct_element(self.partnumbers.mpn, idx), _get_correct_element(self.partnumbers.supplier, idx), _get_correct_element(self.partnumbers.spn, idx), ) else: return None # non-bundles do not support lists of part data def __post_init__(self) -> None: super().__post_init__() self.bgcolor_title = SingleColor(self.bgcolor_title) self.color = MultiColor(self.color) # cables do not support custom qty or amount if self.qty is None: self.qty = 1 if self.qty != 1: raise Exception("Cable qty != 1 not supported") if isinstance(self.image, dict): self.image = Image(**self.image) # TODO: # allow gauge, length, and other fields to be lists too (like part numbers), # and assign them the same way to bundles. self.gauge = parse_number_and_unit(self.gauge, "mm2") self.length = parse_number_and_unit(self.length, "m") self.amount = self.length # for BOM if self.wirecount: # number of wires explicitly defined if self.colors: # use custom color palette (partly or looped if needed) self.colors = [ self.colors[i % len(self.colors)] for i in range(self.wirecount) ] elif self.color_code: # use standard color palette (partly or looped if needed) if self.color_code not in COLOR_CODES: raise Exception("Unknown color code") self.colors = [ get_color_by_colorcode_index(self.color_code, i) for i in range(self.wirecount) ] else: # no colors defined, add dummy colors self.colors = [""] * self.wirecount else: # wirecount implicit in length of color list if not self.colors: raise Exception( "Unknown number of wires. " "Must specify wirecount or colors (implicit length)" ) self.wirecount = len(self.colors) if self.wirelabels: if self.shield and "s" in self.wirelabels: raise Exception( '"s" may not be used as a wire label for a shielded cable.' ) # if lists of part numbers are provided, # check this is a bundle and that it matches the wirecount. for idfield in [self.manufacturer, self.mpn, self.supplier, self.spn, self.pn]: if isinstance(idfield, list): if self.category == "bundle": # check the length if len(idfield) != self.wirecount: raise Exception("lists of part data must match wirecount") else: raise Exception("lists of part data are only supported for bundles") # all checks have passed wire_tuples = zip_longest( # TODO: self.wire_ids self.colors, self.wirelabels, ) for wire_index, (wire_color, wire_label) in enumerate(wire_tuples): id = wire_index + 1 self.wire_objects[id] = WireClass( parent=self.designator, # wire-specific properties index=wire_index, # TODO: wire_id id=id, # TODO: wire_id label=wire_label, color=MultiColor(wire_color), # inheritable from parent cable type=self.type, subtype=self.subtype, gauge=self.gauge, length=self.length, sum_amounts_in_bom=self.sum_amounts_in_bom, ignore_in_bom=self.ignore_in_bom, partnumbers=self._get_wire_partnumber(wire_index), ) if self.shield: index_offset = len(self.wire_objects) # TODO: add support for multiple shields id = "s" self.wire_objects[id] = ShieldClass( index=index_offset, id=id, label="Shield", color=( MultiColor(self.shield) if isinstance(self.shield, str) else MultiColor(None) ), parent=self.designator, ) if self.show_name is None: self.show_name = not self.is_autogenerated if self.show_wirenumbers is None: # by default, show wire numbers for cables, hide for bundles self.show_wirenumbers = self.category != "bundle" for i, item in enumerate(self.additional_components): if isinstance(item, dict): self.additional_components[i] = AdditionalComponent(**item) def _connect( self, from_pin_obj: List[PinClass], via_wire_id: str, to_pin_obj: List[PinClass], ) -> None: via_wire_obj = self.wire_objects[via_wire_id] self._connections.append(Connection(from_pin_obj, via_wire_obj, to_pin_obj)) def compute_qty_multipliers(self): # do not run before all connections in harness have been made! total_length = sum( [ wire.length.number if wire.length else 0 for wire in self.wire_objects.values() ] ) qty_multipliers_computed = { "WIRECOUNT": len(self.wire_objects), # "TERMINATIONS": ___, # TODO "LENGTH": self.length.number if self.length else 0, "TOTAL_LENGTH": total_length, } for subitem in self.additional_components: if isinstance(subitem.qty_multiplier, QtyMultiplierCable): computed_factor = qty_multipliers_computed[subitem.qty_multiplier.name] if subitem.qty_multiplier.name in ["LENGTH", "TOTAL_LENGTH"]: # since length can have a unit, use amount fields to hold if subitem.amount is not None: raise Exception( f"No amount may be specified when using " f"{subitem.qty_multiplier.name} as a multiplier." ) subitem.qty_computed = subitem.qty if subitem.qty else 1 subitem.amount_computed = NumberAndUnit( computed_factor, self.length.unit ) else: # multiplier unrelated to length, therefore no unit if subitem.qty is not None: subitem.qty_computed = subitem.qty * computed_factor else: subitem.qty_computed = computed_factor subitem.amount_computed = subitem.amount elif isinstance(subitem.qty_multiplier, QtyMultiplierConnector): raise Exception("Used a connector multiplier in a cable!") else: # int or float if subitem.qty is not None: subitem.qty_computed = subitem.qty * subitem.qty_multiplier else: subitem.qty_computed = subitem.qty_multiplier subitem.amount_computed = subitem.amount @dataclass class MatePin: from_: PinClass to: PinClass arrow: Arrow @dataclass class MateComponent: from_: str # Designator to: str # Designator arrow: Arrow