Some checks failed
Create Examples / build (ubuntu-22.04, 3.7) (push) Has been cancelled
Create Examples / build (ubuntu-22.04, 3.8) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.10) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.11) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.12) (push) Has been cancelled
Create Examples / build (ubuntu-latest, 3.9) (push) Has been cancelled
- Document return contract, resolution precedence, and type-sensitivity note in resolve_pin() docstring - Add ex17_loop_labels.yml: RS-232 loopback adapter demonstrating loops with pin labels, mixed number+label, and label-based connections
493 lines
18 KiB
Python
493 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from dataclasses import InitVar, dataclass, field
|
|
from enum import Enum, auto
|
|
from pathlib import Path
|
|
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, int2tuple
|
|
|
|
# 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', 'unpopulated']
|
|
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")
|
|
|
|
|
|
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 = "."
|
|
|
|
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 <img>:
|
|
src: str
|
|
scale: Optional[ImageScale] = None
|
|
# Attributes of the image cell <td> containing the image:
|
|
width: Optional[int] = None
|
|
height: Optional[int] = None
|
|
fixedsize: Optional[bool] = None
|
|
bgcolor: Optional[Color] = None
|
|
# Contents of the text cell <td> 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:
|
|
t = self.type.rstrip()
|
|
st = f", {self.subtype.rstrip()}" if self.subtype else ""
|
|
t = t + st
|
|
return t
|
|
|
|
|
|
@dataclass
|
|
class Connector:
|
|
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)
|
|
|
|
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")
|
|
|
|
if self.show_name is None:
|
|
# hide designators for simple and for auto-generated connectors by default
|
|
self.show_name = self.style != "simple" and self.name[0:2] != "__"
|
|
|
|
if self.show_pincount is None:
|
|
# hide pincount for simple (1 pin) connectors by default
|
|
self.show_pincount = self.style != "simple"
|
|
|
|
for i, loop in enumerate(self.loops):
|
|
# TODO: include properties of wire used to create the loop
|
|
if len(loop) != 2:
|
|
raise Exception("Loops must be between exactly two pins!")
|
|
resolved = []
|
|
for pin in loop:
|
|
pin = self.resolve_pin(pin)
|
|
resolved.append(pin)
|
|
# Make sure loop connected pins are not hidden.
|
|
self.activate_pin(pin, None)
|
|
if resolved[0] == resolved[1]:
|
|
raise Exception(
|
|
f'Loop in connector "{self.name}" connects pin '
|
|
f'"{resolved[0]}" to itself.'
|
|
)
|
|
self.loops[i] = resolved
|
|
|
|
for i, item in enumerate(self.additional_components):
|
|
if isinstance(item, dict):
|
|
self.additional_components[i] = AdditionalComponent(**item)
|
|
|
|
def resolve_pin(self, pin: Pin) -> Pin:
|
|
"""Resolve a pin identifier to its canonical pin number.
|
|
|
|
Given a value that may be either a pin number (from self.pins)
|
|
or a pin label (from self.pinlabels), returns the corresponding
|
|
pin number from self.pins.
|
|
|
|
Callers needing a positional index should use
|
|
self.pins.index(return_value).
|
|
|
|
Resolution order:
|
|
1. Value in both pins and pinlabels at the same position
|
|
-> return directly (no ambiguity).
|
|
2. Value in both at different positions -> raise.
|
|
3. Value only in pinlabels -> return corresponding pin number.
|
|
4. Value only in pins -> return directly.
|
|
5. Not found -> raise.
|
|
|
|
Note: Lookups are type-sensitive (int 1 != str "1").
|
|
"""
|
|
in_pins = pin in self.pins
|
|
in_labels = pin in self.pinlabels if self.pinlabels else False
|
|
|
|
if in_pins and in_labels:
|
|
# present in both lists — check for duplicate labels first
|
|
if self.pinlabels.count(pin) > 1:
|
|
raise Exception(
|
|
f'Pin label "{pin}" in connector "{self.name}" '
|
|
f"is defined more than once in pinlabels."
|
|
)
|
|
# then check for positional ambiguity
|
|
if self.pins.index(pin) != self.pinlabels.index(pin):
|
|
raise Exception(
|
|
f'"{pin}" in connector "{self.name}" exists in both '
|
|
f"pins and pinlabels at different positions."
|
|
)
|
|
return pin # same position, no ambiguity
|
|
|
|
if in_labels:
|
|
if self.pinlabels.count(pin) > 1:
|
|
raise Exception(
|
|
f'Pin label "{pin}" in connector "{self.name}" '
|
|
f"is defined more than once."
|
|
)
|
|
return self.pins[self.pinlabels.index(pin)]
|
|
|
|
if in_pins:
|
|
return pin
|
|
|
|
raise Exception(
|
|
f'Unknown pin "{pin}" for connector "{self.name}"!'
|
|
)
|
|
|
|
def activate_pin(self, pin: Pin, side: Optional[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())
|
|
elif qty_multiplier == "unpopulated":
|
|
return max(0, self.pincount - sum(self.visible_pins.values()))
|
|
else:
|
|
raise ValueError(
|
|
f"invalid qty multiplier parameter for connector {qty_multiplier}"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Cable:
|
|
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)
|
|
|
|
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 isinstance(self.length, (int, float)):
|
|
raise Exception(f"Cable {self.name} length has a non-numeric value")
|
|
elif self.length_unit is None:
|
|
self.length_unit = "m"
|
|
|
|
self.connections = []
|
|
|
|
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")
|
|
|
|
if self.show_name is None:
|
|
# hide designators for auto-generated cables by default
|
|
self.show_name = self.name[0:2] != "__"
|
|
|
|
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)
|
|
|
|
# The *_pin arguments accept a tuple, but it seems not in use with the current code.
|
|
def connect(
|
|
self,
|
|
from_name: Optional[Designator],
|
|
from_pin: NoneOrMorePinIndices,
|
|
via_wire: OneOrMoreWires,
|
|
to_name: Optional[Designator],
|
|
to_pin: NoneOrMorePinIndices,
|
|
) -> None:
|
|
from_pin = int2tuple(from_pin)
|
|
via_wire = int2tuple(via_wire)
|
|
to_pin = int2tuple(to_pin)
|
|
if len(from_pin) != len(to_pin):
|
|
raise Exception("from_pin must have the same number of elements as to_pin")
|
|
for i, _ in enumerate(from_pin):
|
|
self.connections.append(
|
|
Connection(from_name, from_pin[i], via_wire[i], to_name, to_pin[i])
|
|
)
|
|
|
|
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 Connection:
|
|
from_name: Optional[Designator]
|
|
from_pin: Optional[Pin]
|
|
via_port: Wire
|
|
to_name: Optional[Designator]
|
|
to_pin: Optional[Pin]
|
|
|
|
|
|
@dataclass
|
|
class MatePin:
|
|
from_name: Designator
|
|
from_pin: Pin
|
|
to_name: Designator
|
|
to_pin: Pin
|
|
shape: str
|
|
|
|
|
|
@dataclass
|
|
class MateComponent:
|
|
from_name: Designator
|
|
to_name: Designator
|
|
shape: str
|