# -*- coding: utf-8 -*-
from dataclasses import dataclass, field
from enum import Enum
from itertools import zip_longest
from typing import Dict, List, Optional, Tuple, Union
from wireviz.wv_colors import COLOR_CODES, Color, ColorMode, Colors, ColorScheme
from wireviz.wv_helper import aspect_ratio, awg_equiv, mm2_equiv
# 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
ConnectorMultiplier = PlainText # = Literal['pincount', 'populated']
CableMultiplier = (
PlainText # = Literal['wirecount', 'terminations', 'length', 'total_length']
)
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: Color = "WH"
bgcolor_node: Optional[Color] = "WH"
bgcolor_connector: Optional[Color] = None
bgcolor_cable: Optional[Color] = None
bgcolor_bundle: Optional[Color] = None
color_mode: ColorMode = "SHORT"
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):
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: Optional[Color] = 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):
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 AdditionalComponent:
type: MultilineHypertext
subtype: Optional[MultilineHypertext] = None
manufacturer: Optional[MultilineHypertext] = None
mpn: Optional[MultilineHypertext] = None
supplier: Optional[MultilineHypertext] = None
spn: Optional[MultilineHypertext] = None
pn: Optional[Hypertext] = None
qty: float = 1
unit: Optional[str] = None
qty_multiplier: Union[ConnectorMultiplier, CableMultiplier, None] = None
bgcolor: Optional[Color] = None
@property
def description(self) -> str:
s = self.type.rstrip() + f", {self.subtype.rstrip()}" if self.subtype else ""
return s
@dataclass
class Component:
pass
@dataclass
class PinClass:
index: int
id: str
label: str
color: str
parent: str # designator of parent connector
_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 WireClass:
index: int
id: str
label: str
color: str
parent: str # designator of parent cable/bundle
# gauge: Gauge
# pn: str
# manufacturer: str
# mpn: str
# supplier: str
# spn: str
@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 Connector(Component):
name: Designator
bgcolor: Optional[Color] = None
bgcolor_title: Optional[Color] = None
manufacturer: Optional[MultilineHypertext] = None
mpn: Optional[MultilineHypertext] = None
supplier: Optional[MultilineHypertext] = None
spn: Optional[MultilineHypertext] = None
pn: Optional[Hypertext] = None
style: Optional[str] = None
category: Optional[str] = None
type: Optional[MultilineHypertext] = None
subtype: Optional[MultilineHypertext] = None
pincount: Optional[int] = None
image: Optional[Image] = None
notes: Optional[MultilineHypertext] = None
pins: List[Pin] = field(default_factory=list)
pinlabels: List[Pin] = field(default_factory=list)
pincolors: List[Color] = field(default_factory=list)
color: Optional[Color] = None
show_name: Optional[bool] = None
show_pincount: Optional[bool] = None
hide_disconnected_pins: bool = False
loops: List[List[Pin]] = field(default_factory=list)
ignore_in_bom: bool = False
additional_components: List[AdditionalComponent] = field(default_factory=list)
pin_objects: List[PinClass] = field(default_factory=list)
@property
def is_autogenerated(self):
return self.name.startswith(AUTOGENERATED_PREFIX)
def should_show_pin(self, pin_name):
return not self.hide_disconnected_pins or self.visible_pins.get(pin_name, False)
def __post_init__(self) -> None:
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.append(
PinClass(
index=pin_index,
id=pin_id,
label=pin_label,
color=pin_color,
parent=self.name,
_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"
for loop in self.loops:
# TODO: check that pins to connect actually exist
# 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 i, item in enumerate(self.additional_components):
if isinstance(item, dict):
self.additional_components[i] = AdditionalComponent(**item)
def _check_if_unique_id(self, id):
results = [pin for pin in self.pin_objects if pin.id == id]
if len(results) == 0:
raise Exception(f"Pin ID {id} not found in {self.name}")
if len(results) > 1:
raise Exception(f"Pin ID {id} found more than once in {self.name}")
return True
def get_pin_by_id(self, id):
if self._check_if_unique_id(id):
pin = [pin for pin in self.pin_objects if pin.id == id]
return pin[0]
def activate_pin(self, pin: Pin, side: Side) -> None:
self.visible_pins[pin] = True
if side == Side.LEFT:
self.ports_left = True
elif side == Side.RIGHT:
self.ports_right = True
def get_qty_multiplier(self, qty_multiplier: Optional[ConnectorMultiplier]) -> int:
if not qty_multiplier:
return 1
elif qty_multiplier == "pincount":
return self.pincount
elif qty_multiplier == "populated":
return sum(self.visible_pins.values())
else:
raise ValueError(
f"invalid qty multiplier parameter for connector {qty_multiplier}"
)
@dataclass
class Cable(Component):
name: Designator
bgcolor: Optional[Color] = None
bgcolor_title: Optional[Color] = None
manufacturer: Union[MultilineHypertext, List[MultilineHypertext], None] = None
mpn: Union[MultilineHypertext, List[MultilineHypertext], None] = None
supplier: Union[MultilineHypertext, List[MultilineHypertext], None] = None
spn: Union[MultilineHypertext, List[MultilineHypertext], None] = None
pn: Union[Hypertext, List[Hypertext], None] = None
category: Optional[str] = None
type: Optional[MultilineHypertext] = None
gauge: Optional[float] = None
gauge_unit: Optional[str] = None
show_equiv: bool = False
length: float = 0
length_unit: Optional[str] = None
color: Optional[Color] = None
wirecount: Optional[int] = None
shield: Union[bool, Color] = False
image: Optional[Image] = None
notes: Optional[MultilineHypertext] = None
colors: List[Colors] = field(default_factory=list)
wirelabels: List[Wire] = field(default_factory=list)
color_code: Optional[ColorScheme] = None
show_name: Optional[bool] = None
show_wirecount: bool = True
show_wirenumbers: Optional[bool] = None
ignore_in_bom: bool = False
additional_components: List[AdditionalComponent] = field(default_factory=list)
connections: List[Connection] = field(default_factory=list)
wire_objects: List[WireClass] = field(default_factory=list)
@property
def is_autogenerated(self):
return self.name.startswith(AUTOGENERATED_PREFIX)
@property
def gauge_str(self):
if not self.gauge:
return None
actual_gauge = f"{self.gauge} {self.gauge_unit}"
equivalent_gauge = ""
if self.show_equiv:
# Only convert units we actually know about, i.e. currently
# mm2 and awg --- other units _are_ technically allowed,
# and passed through as-is.
if self.gauge_unit == "mm\u00B2":
equivalent_gauge = f" ({awg_equiv(self.gauge)} AWG)"
elif self.gauge_unit.upper() == "AWG":
equivalent_gauge = f" ({mm2_equiv(self.gauge)} mm\u00B2)"
return f"{actual_gauge}{equivalent_gauge}"
def __post_init__(self) -> None:
if isinstance(self.image, dict):
self.image = Image(**self.image)
if isinstance(self.gauge, str): # gauge and unit specified
try:
g, u = self.gauge.split(" ")
except Exception:
raise Exception(
f"Cable {self.name} gauge={self.gauge} - Gauge must be a number, or number and unit separated by a space"
)
self.gauge = g
if self.gauge_unit is not None:
print(
f"Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}"
)
if u.upper() == "AWG":
self.gauge_unit = u.upper()
else:
self.gauge_unit = u.replace("mm2", "mm\u00B2")
elif self.gauge is not None: # gauge specified, assume mm2
if self.gauge_unit is None:
self.gauge_unit = "mm\u00B2"
else:
pass # gauge not specified
if isinstance(self.length, str): # length and unit specified
try:
L, u = self.length.split(" ")
L = float(L)
except Exception:
raise Exception(
f"Cable {self.name} length={self.length} - Length must be a number, or number and unit separated by a space"
)
self.length = L
if self.length_unit is not None:
print(
f"Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}"
)
self.length_unit = u
elif not any(isinstance(self.length, t) for t in [int, float]):
raise Exception(f"Cable {self.name} length has a non-numeric value")
elif self.length_unit is None:
self.length_unit = "m"
if self.wirecount: # number of wires explicitly defined
if self.colors: # use custom color palette (partly or looped if needed)
pass
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 = COLOR_CODES[self.color_code]
else: # no colors defined, add dummy colors
self.colors = [""] * self.wirecount
# make color code loop around if more wires than colors
if self.wirecount > len(self.colors):
m = self.wirecount // len(self.colors) + 1
self.colors = self.colors * int(m)
# cut off excess after looping
self.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):
self.wire_objects.append(
WireClass(
index=wire_index, # TODO: wire_id
id=wire_index + 1, # TODO: wire_id
label=wire_label,
color=wire_color,
parent=self.name,
)
)
if self.shield:
index_offset = len(self.wire_objects)
# TODO: add support for multiple shields
self.wire_objects.append(
ShieldClass(
index=index_offset,
id="s",
label="Shield",
color=self.shield if isinstance(self.shield, str) else None,
parent=self.name,
)
)
if self.show_name is None:
self.show_name = not self.is_autogenerated
if not self.show_wirenumbers:
# 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 get_wire_by_id(self, id):
wire = [wire for wire in self.wire_objects if wire.id == id]
if len(wire) == 0:
raise Exception(f"Wire ID {id} not found in {self.name}")
if len(wire) > 1:
raise Exception(f"Wire ID {id} found more than once in {self.name}")
return wire[0]
def connect(
self, from_pin_obj: [PinClass], via_wire_id: str, to_pin_obj: [PinClass]
) -> None:
via_wire_obj = self.get_wire_by_id(via_wire_id)
self.connections.append(Connection(from_pin_obj, via_wire_obj, to_pin_obj))
def get_qty_multiplier(self, qty_multiplier: Optional[CableMultiplier]) -> float:
if not qty_multiplier:
return 1
elif qty_multiplier == "wirecount":
return self.wirecount
elif qty_multiplier == "terminations":
return len(self.connections)
elif qty_multiplier == "length":
return self.length
elif qty_multiplier == "total_length":
return self.length * self.wirecount
else:
raise ValueError(
f"invalid qty multiplier parameter for cable {qty_multiplier}"
)
@dataclass
class MatePin:
from_: PinClass
to: PinClass
arrow: Arrow
@dataclass
class MateComponent:
from_: str # Designator
to: str # Designator
arrow: Arrow
|