1# -*- coding: utf-8 -*-2import sys3from typing import Dict, List, Optional, Tuple, Union4from dataclasses import dataclass, field, InitVar5from pathlib import Path67from wireviz.wv_helper import int2tuple, aspect_ratio8from wireviz.wv_colors import Color, Colors, ColorMode, ColorScheme, COLOR_CODES91011# Each type alias have their legal values described in comments - validation might be implemented in the future12PlainText = str # Text not containing HTML tags nor newlines13Hypertext = str # Text possibly including HTML hyperlinks that are removed in all outputs except HTML output14MultilineHypertext = str # Hypertext possibly also including newlines to break lines in diagram output15Designator = PlainText # Case insensitive unique name of connector or cable1617# Literal type aliases below are commented to avoid requiring python 3.818ConnectorMultiplier = PlainText # = Literal['pincount', 'populated']19CableMultiplier = PlainText # = Literal['wirecount', 'terminations', 'length', 'total_length']20ImageScale = PlainText # = Literal['false', 'true', 'width', 'height', 'both']2122# Type combinations23Pin = Union[int, PlainText] # Pin identifier24PinIndex = int # Zero-based pin index25Wire = Union[int, PlainText] # Wire number or Literal['s'] for shield26NoneOrMorePinIndices = Union[PinIndex, Tuple[PinIndex, ...], None] # None, one, or a tuple of zero-based pin indices27OneOrMoreWires = Union[Wire, Tuple[Wire, ...]] # One or a tuple of wires2829# Metadata can contain whatever is needed by the HTML generation/template.30MetadataKeys = PlainText # Literal['title', 'description', 'notes', ...]31class Metadata(dict):32 pass33343536class Options:37 fontname: PlainText = 'arial'38 bgcolor: Color = 'WH'39 bgcolor_node: Optional[Color] = 'WH'40 bgcolor_connector: Optional[Color] = None41 bgcolor_cable: Optional[Color] = None42 bgcolor_bundle: Optional[Color] = None43 color_mode: ColorMode = 'SHORT'44 mini_bom_mode: bool = True4546 def __post_init__(self):47 if not self.bgcolor_node:48 self.bgcolor_node = self.bgcolor49 if not self.bgcolor_connector:50 self.bgcolor_connector = self.bgcolor_node51 if not self.bgcolor_cable:52 self.bgcolor_cable = self.bgcolor_node53 if not self.bgcolor_bundle:54 self.bgcolor_bundle = self.bgcolor_cable55565758class Tweak:59 override: Optional[Dict[Designator, Dict[str, Optional[str]]]] = None60 append: Union[str, List[str], None] = None61626364class Image:65 gv_dir: InitVar[Path] # Directory of .gv file injected as context during parsing66 # Attributes of the image object <img>:67 src: str68 scale: Optional[ImageScale] = None69 # Attributes of the image cell <td> containing the image:70 width: Optional[int] = None71 height: Optional[int] = None72 fixedsize: Optional[bool] = None73 bgcolor: Optional[Color] = None74 # Contents of the text cell <td> just below the image cell:75 caption: Optional[MultilineHypertext] = None76 # See also HTML doc at https://graphviz.org/doc/info/shapes.html#html7778 def __post_init__(self, gv_dir):7980 if self.fixedsize is None:81 # Default True if any dimension specified unless self.scale also is specified.82 self.fixedsize = (self.width or self.height) and self.scale is None8384 if self.scale is None:85 self.scale = "false" if not self.width and not self.height \86 else "both" if self.width and self.height \87 else "true" # When only one dimension is specified.8889 if self.fixedsize:90 # If only one dimension is specified, compute the other91 # because Graphviz requires both when fixedsize=True.92 if self.height:93 if not self.width:94 self.width = self.height * aspect_ratio(gv_dir.joinpath(self.src))95 else:96 if self.width:97 self.height = self.width / aspect_ratio(gv_dir.joinpath(self.src))9899100101class AdditionalComponent:102 type: MultilineHypertext103 subtype: Optional[MultilineHypertext] = None104 manufacturer: Optional[MultilineHypertext] = None105 mpn: Optional[MultilineHypertext] = None106 supplier: Optional[MultilineHypertext] = None107 spn: Optional[MultilineHypertext] = None108 pn: Optional[Hypertext] = None109 qty: float = 1110 unit: Optional[str] = None111 qty_multiplier: Union[ConnectorMultiplier, CableMultiplier, None] = None112 bgcolor: Optional[Color] = None113114115 def description(self) -> str:116 return self.type.rstrip() + (f', {self.subtype.rstrip()}' if self.subtype else '')117118119120class Connector:121 name: Designator122 bgcolor: Optional[Color] = None123 bgcolor_title: Optional[Color] = None124 manufacturer: Optional[MultilineHypertext] = None125 mpn: Optional[MultilineHypertext] = None126 supplier: Optional[MultilineHypertext] = None127 spn: Optional[MultilineHypertext] = None128 pn: Optional[Hypertext] = None129 style: Optional[str] = None130 category: Optional[str] = None131 type: Optional[MultilineHypertext] = None132 subtype: Optional[MultilineHypertext] = None133 pincount: Optional[int] = None134 image: Optional[Image] = None135 notes: Optional[MultilineHypertext] = None136 pins: List[Pin] = field(default_factory=list)137 pinlabels: List[Pin] = field(default_factory=list)138 pincolors: List[Color] = field(default_factory=list)139 color: Optional[Color] = None140 show_name: Optional[bool] = None141 show_pincount: Optional[bool] = None142 hide_disconnected_pins: bool = False143 autogenerate: bool = False144 loops: List[List[Pin]] = field(default_factory=list)145 ignore_in_bom: bool = False146 additional_components: List[AdditionalComponent] = field(default_factory=list)147148 def __post_init__(self) -> None:149150 if isinstance(self.image, dict):151 self.image = Image(**self.image)152153 self.ports_left = False154 self.ports_right = False155 self.visible_pins = {}156157 if self.style == 'simple':158 if self.pincount and self.pincount > 1:159 raise Exception('Connectors with style set to simple may only have one pin')160 self.pincount = 1161162 if not self.pincount:163 self.pincount = max(len(self.pins), len(self.pinlabels), len(self.pincolors))164 if not self.pincount:165 raise Exception('You need to specify at least one, pincount, pins, pinlabels, or pincolors')166167 # create default list for pins (sequential) if not specified168 if not self.pins:169 self.pins = list(range(1, self.pincount + 1))170171 if len(self.pins) != len(set(self.pins)):172 raise Exception('Pins are not unique')173174 if self.show_name is None:175 self.show_name = not self.autogenerate # hide auto-generated designators by default176177 if self.show_pincount is None:178 self.show_pincount = self.style != 'simple' # hide pincount for simple (1 pin) connectors by default179180 for loop in self.loops:181 # TODO: check that pins to connect actually exist182 # TODO: allow using pin labels in addition to pin numbers, just like when defining regular connections183 # TODO: include properties of wire used to create the loop184 if len(loop) != 2:185 raise Exception('Loops must be between exactly two pins!')186187 for i, item in enumerate(self.additional_components):188 if isinstance(item, dict):189 self.additional_components[i] = AdditionalComponent(**item)190191 def activate_pin(self, pin: Pin) -> None:192 self.visible_pins[pin] = True193194 def get_qty_multiplier(self, qty_multiplier: Optional[ConnectorMultiplier]) -> int:195 if not qty_multiplier:196 return 1197 elif qty_multiplier == 'pincount':198 return self.pincount199 elif qty_multiplier == 'populated':200 return sum(self.visible_pins.values())201 else:202 raise ValueError(f'invalid qty multiplier parameter for connector {qty_multiplier}')203204205206class Cable:207 name: Designator208 bgcolor: Optional[Color] = None209 bgcolor_title: Optional[Color] = None210 manufacturer: Union[MultilineHypertext, List[MultilineHypertext], None] = None211 mpn: Union[MultilineHypertext, List[MultilineHypertext], None] = None212 supplier: Union[MultilineHypertext, List[MultilineHypertext], None] = None213 spn: Union[MultilineHypertext, List[MultilineHypertext], None] = None214 pn: Union[Hypertext, List[Hypertext], None] = None215 category: Optional[str] = None216 type: Optional[MultilineHypertext] = None217 gauge: Optional[float] = None218 gauge_unit: Optional[str] = None219 show_equiv: bool = False220 length: float = 0221 length_unit: Optional[str] = None222 color: Optional[Color] = None223 wirecount: Optional[int] = None224 shield: Union[bool, Color] = False225 image: Optional[Image] = None226 notes: Optional[MultilineHypertext] = None227 colors: List[Colors] = field(default_factory=list)228 wirelabels: List[Wire] = field(default_factory=list)229 color_code: Optional[ColorScheme] = None230 show_name: bool = True231 show_wirecount: bool = True232 show_wirenumbers: Optional[bool] = None233 ignore_in_bom: bool = False234 additional_components: List[AdditionalComponent] = field(default_factory=list)235236 def __post_init__(self) -> None:237238 if isinstance(self.image, dict):239 self.image = Image(**self.image)240241 if isinstance(self.gauge, str): # gauge and unit specified242 try:243 g, u = self.gauge.split(' ')244 except Exception:245 raise Exception(f'Cable {self.name} gauge={self.gauge} - Gauge must be a number, or number and unit separated by a space')246 self.gauge = g247248 if self.gauge_unit is not None:249 sys.stderr.write(f'Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}')250 if u.upper() == 'AWG':251 self.gauge_unit = u.upper()252 else:253 self.gauge_unit = u.replace('mm2', 'mm\u00B2')254255 elif self.gauge is not None: # gauge specified, assume mm2256 if self.gauge_unit is None:257 self.gauge_unit = 'mm\u00B2'258 else:259 pass # gauge not specified260261 if isinstance(self.length, str): # length and unit specified262 try:263 L, u = self.length.split(' ')264 L = float(L)265 except Exception:266 raise Exception(f'Cable {self.name} length={self.length} - Length must be a number, or number and unit separated by a space')267 self.length = L268 if self.length_unit is not None:269 sys.stderr.write(f'Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}')270 self.length_unit = u271 elif not any(isinstance(self.length, t) for t in [int, float]):272 raise Exception(f'Cable {self.name} length has a non-numeric value')273 elif self.length_unit is None:274 self.length_unit = 'm'275276 self.connections = []277278 if self.wirecount: # number of wires explicitly defined279 if self.colors: # use custom color palette (partly or looped if needed)280 pass281 elif self.color_code: # use standard color palette (partly or looped if needed)282 if self.color_code not in COLOR_CODES:283 raise Exception('Unknown color code')284 self.colors = COLOR_CODES[self.color_code]285 else: # no colors defined, add dummy colors286 self.colors = [''] * self.wirecount287288 # make color code loop around if more wires than colors289 if self.wirecount > len(self.colors):290 m = self.wirecount // len(self.colors) + 1291 self.colors = self.colors * int(m)292 # cut off excess after looping293 self.colors = self.colors[:self.wirecount]294 else: # wirecount implicit in length of color list295 if not self.colors:296 raise Exception('Unknown number of wires. Must specify wirecount or colors (implicit length)')297 self.wirecount = len(self.colors)298299 if self.wirelabels:300 if self.shield and 's' in self.wirelabels:301 raise Exception('"s" may not be used as a wire label for a shielded cable.')302303 # if lists of part numbers are provided check this is a bundle and that it matches the wirecount.304 for idfield in [self.manufacturer, self.mpn, self.supplier, self.spn, self.pn]:305 if isinstance(idfield, list):306 if self.category == "bundle":307 # check the length308 if len(idfield) != self.wirecount:309 raise Exception('lists of part data must match wirecount')310 else:311 raise Exception('lists of part data are only supported for bundles')312313 # by default, show wire numbers for cables, hide for bundles314 if self.show_wirenumbers is None:315 self.show_wirenumbers = self.category != 'bundle'316317 for i, item in enumerate(self.additional_components):318 if isinstance(item, dict):319 self.additional_components[i] = AdditionalComponent(**item)320321 # The *_pin arguments accept a tuple, but it seems not in use with the current code.322 def connect(self, from_name: Optional[Designator], from_pin: NoneOrMorePinIndices, via_wire: OneOrMoreWires,323 to_name: Optional[Designator], to_pin: NoneOrMorePinIndices) -> None:324 from_pin = int2tuple(from_pin)325 via_wire = int2tuple(via_wire)326 to_pin = int2tuple(to_pin)327 if len(from_pin) != len(to_pin):328 raise Exception('from_pin must have the same number of elements as to_pin')329 for i, _ in enumerate(from_pin):330 self.connections.append(Connection(from_name, from_pin[i], via_wire[i], to_name, to_pin[i]))331332 def get_qty_multiplier(self, qty_multiplier: Optional[CableMultiplier]) -> float:333 if not qty_multiplier:334 return 1335 elif qty_multiplier == 'wirecount':336 return self.wirecount337 elif qty_multiplier == 'terminations':338 return len(self.connections)339 elif qty_multiplier == 'length':340 return self.length341 elif qty_multiplier == 'total_length':342 return self.length * self.wirecount343 else:344 raise ValueError(f'invalid qty multiplier parameter for cable {qty_multiplier}')345346347348class Connection:349 from_name: Optional[Designator]350 from_port: Optional[PinIndex]351 via_port: Wire352 to_name: Optional[Designator]353 to_port: Optional[PinIndex]