This note demonstrates a small connectivity tester / check nets program for
KiCad. This program allows doing checks like: Is the U1:8 pin connected to
GND?
Sometimes visual errors can creep in the schematic (and the PCB subsequently).
This connectivity tester allows expressing the same connections in a
non-visual way (with a different probability of making errors).
The ERC and DRC checks in KiCad work great but this non-visual test assertions provide another level of sanity checking.
These test assertions can be run automatically and more importantly
continuously to ensure that the circuit correctness is still fine.
Update: Yes, it support (UART and other) voltage compatibility checks now - thanks to Akshar Vastarpara (Vicharak) for this awesome idea.
Code:
#!/usr/bin/env python3
"""
check_nets_pcbnew.py — Assert footprint pad → net mapping in a KiCad .kicad_pcb using pcbnew.
Usage:
python3 check_nets_pcbnew.py SDR-Board.kicad_pcb --test-file pcb_tests.txt
python check_nets_pcbnew.py /path/to/board.kicad_pcb \
--case U1:11=GND --case J1:2=+5V
Or with a test file:
python check_nets_pcbnew.py /path/to/board.kicad_pcb --test-file tests.txt
Voltage compatibility checking:
python check_nets_pcbnew.py /path/to/board.kicad_pcb --check-voltage-compat
Test file format:
- Positive assertion (must equal): REF:PAD=NET (e.g., U1:11=GND)
- Negative assertion (must not equal): REF:PAD!=NET (e.g., U1:14!=GND)
- Voltage compatibility check: REF1:PAD1<>REF2:PAD2 (e.g., U1:TX<>U2:RX)
- Lines starting with # are comments
- Blank lines are ignored
Voltage Level Detection:
The script can detect voltage levels from:
1. Net name suffixes: _5V, _3V3, _3.3V, _1V8, etc.
2. Component properties: IO_VOLTAGE field on footprints
3. Manual voltage specifications in test cases
Tip:
If 'import pcbnew' fails, run this with KiCad's bundled Python or add pcbnew to PYTHONPATH.
"""
import sys
import argparse
import re
try:
import pcbnew
except Exception as e:
print("ERROR: Could not import pcbnew. Run from KiCad's Python or ensure pcbnew is on PYTHONPATH.")
print(e)
sys.exit(2)
def extract_voltage_from_net_name(net_name: str):
"""
Extract voltage level from net name using common suffixes.
Examples: UART_TX_5V -> 5.0, I2C_SDA_3V3 -> 3.3, SPI_CLK_1V8 -> 1.8
Returns: voltage as float or None if not detected
"""
if not net_name:
return None
# Pattern for voltage indicators: _5V, _3V3, _3.3V, _1V8, _1.8V, etc.
# Also handles: _5v, _3v3, etc.
patterns = [
r'_(\d+)V(\d+)', # _3V3, _1V8 -> 3.3, 1.8
r'_(\d+)\.(\d+)V', # _3.3V, _1.8V -> 3.3, 1.8
r'_(\d+)V(?![0-9])', # _5V, _3V -> 5.0, 3.0
]
net_upper = net_name.upper()
for pattern in patterns:
match = re.search(pattern, net_upper)
if match:
if len(match.groups()) == 2:
# Format: XVY or X.YV
return float(f"{match.group(1)}.{match.group(2)}")
else:
# Format: XV
return float(match.group(1))
return None
def get_footprint_io_voltage(fp):
"""
Get IO voltage from footprint custom properties/fields.
Looks for fields like 'IO_VOLTAGE', 'VCC', 'SUPPLY_VOLTAGE'
Returns: voltage as float or None
"""
if fp is None:
return None
# Check common field names
field_names = ['IO_VOLTAGE', 'VCC', 'SUPPLY_VOLTAGE', 'VCCIO']
for field_name in field_names:
# Try to get the property value
try:
# KiCad 6+ uses GetProperty
if hasattr(fp, 'GetProperty'):
value = fp.GetProperty(field_name)
if value:
return parse_voltage_string(value)
except:
pass
# Try fields method (older KiCad or different access pattern)
try:
if hasattr(fp, 'GetFields'):
fields = fp.GetFields()
for field in fields:
if field.GetName() == field_name:
return parse_voltage_string(field.GetText())
except:
pass
return None
def parse_voltage_string(text: str):
"""
Parse a voltage value from text like '3.3V', '5V', '3V3', '1.8'
Returns: voltage as float or None
"""
if not text:
return None
text = text.strip().upper()
# Try standard formats: 3.3V, 5V, etc.
match = re.match(r'^(\d+\.?\d*)V?$', text)
if match:
return float(match.group(1))
# Try 3V3 format
match = re.match(r'^(\d+)V(\d+)$', text)
if match:
return float(f"{match.group(1)}.{match.group(2)}")
return None
def get_pad_voltage(board, fp, pad):
"""
Determine the voltage level of a pad by checking:
1. Net name suffix
2. Footprint IO_VOLTAGE property
Returns: voltage as float or None
"""
# First check net name
net_name = pad_net_name(pad)
voltage = extract_voltage_from_net_name(net_name)
if voltage is not None:
return voltage
# Then check footprint properties
voltage = get_footprint_io_voltage(fp)
if voltage is not None:
return voltage
return None
def are_voltages_compatible(v1, v2, tolerance=0.1):
"""
Check if two voltage levels are compatible.
Args:
v1, v2: voltages as floats (or None)
tolerance: acceptable voltage difference
Returns: (compatible: bool, reason: str)
"""
if v1 is None or v2 is None:
return True, "Unknown voltage - cannot verify compatibility"
# Same voltage level (within tolerance)
if abs(v1 - v2) <= tolerance:
return True, f"Compatible: {v1}V ≈ {v2}V"
# 5V to 3.3V is NOT safe (without level shifter)
if v1 > v2 + tolerance:
return False, f"UNSAFE: {v1}V output to {v2}V input (needs level shifter)"
# 3.3V to 5V might be OK depending on input thresholds (often acceptable for CMOS)
# But we'll flag it as a warning
if v2 > v1 + tolerance:
return True, f"Warning: {v1}V output to {v2}V input (verify input thresholds)"
return True, "Compatible"
def parse_case(expr: str):
"""
Parse a test case expression.
'U1:11=GND' -> ('net', ref, pad, net, is_positive=True)
'U1:14!=GND' -> ('net', ref, pad, net, is_positive=False)
'U1:TX<>U2:RX' -> ('voltage', ref1, pad1, ref2, pad2)
Returns: tuple with first element being test type ('net' or 'voltage')
"""
try:
# Check for voltage compatibility test (<>)
if "<>" in expr:
left, right = expr.split("<>", 1)
ref1, pad1 = left.strip().split(":", 1)
ref2, pad2 = right.strip().split(":", 1)
return ('voltage', ref1.strip(), pad1.strip(), ref2.strip(), pad2.strip())
# Check for negative assertion first (!=)
elif "!=" in expr:
left, net = expr.split("!=", 1)
ref, pad = left.split(":", 1)
return ('net', ref.strip(), pad.strip(), net.strip(), False)
# Positive assertion (=)
elif "=" in expr:
left, net = expr.split("=", 1)
ref, pad = left.split(":", 1)
return ('net', ref.strip(), pad.strip(), net.strip(), True)
else:
raise ValueError("No =, !=, or <> found")
except Exception:
raise ValueError(f"Bad test case format: '{expr}'. Use REF:PAD=NET, REF:PAD!=NET, or REF1:PAD1<>REF2:PAD2")
def find_footprint(board, ref: str):
# Robust across KiCad versions: iterate footprints and match reference
for fp in board.GetFootprints():
if fp.GetReference() == ref:
return fp
return None
def get_pad(fp, pad_number: str):
# Handles both string/number pad names
pad = fp.FindPadByNumber(str(pad_number))
return pad
def pad_net_name(pad) -> str:
net = pad.GetNet()
if net:
# Some versions include a leading slash; normalize by stripping spaces
return net.GetNetname().strip()
return ""
def check_all_voltage_compat(board):
"""
Scan all nets in the board and check for voltage compatibility issues.
Returns a list of issues, each with 'severity' and 'message'.
"""
issues = []
checked_nets = set()
# Build a map of nets to pads
net_to_pads = {}
for fp in board.GetFootprints():
ref = fp.GetReference()
for pad in fp.Pads():
net_name = pad_net_name(pad)
if not net_name or net_name in ['GND', 'VCC', '+3V3', '+5V', '+12V']:
# Skip power/ground nets
continue
if net_name not in net_to_pads:
net_to_pads[net_name] = []
voltage = get_pad_voltage(board, fp, pad)
pad_num = pad.GetNumber()
net_to_pads[net_name].append({
'ref': ref,
'pad': pad_num,
'voltage': voltage,
'fp': fp,
'pad_obj': pad
})
# Check each net for voltage compatibility
for net_name, pads in net_to_pads.items():
if net_name in checked_nets:
continue
checked_nets.add(net_name)
# Get all unique voltage levels on this net
voltages = {}
for pad_info in pads:
v = pad_info['voltage']
if v is not None:
if v not in voltages:
voltages[v] = []
voltages[v].append(pad_info)
# If we have multiple different voltages on the same net, that's a problem
if len(voltages) > 1:
voltage_list = sorted(voltages.keys())
for i, v1 in enumerate(voltage_list):
for v2 in voltage_list[i+1:]:
compatible, reason = are_voltages_compatible(v1, v2)
# Get example pads for each voltage
pad1_info = voltages[v1][0]
pad2_info = voltages[v2][0]
msg = f"Net '{net_name}': {pad1_info['ref']}:{pad1_info['pad']} ({v1}V) connected to {pad2_info['ref']}:{pad2_info['pad']} ({v2}V) - {reason}"
if not compatible:
issues.append({
'severity': 'error',
'message': msg
})
elif "Warning" in reason:
issues.append({
'severity': 'warning',
'message': msg
})
return issues
def load_test_cases_from_file(filepath: str):
"""
Load test cases from a text file.
Returns a list of test case strings.
Ignores blank lines and lines starting with #.
"""
cases = []
try:
with open(filepath, 'r') as f:
for line_num, line in enumerate(f, 1):
# Strip whitespace
line = line.strip()
# Skip blank lines
if not line:
continue
# Skip comments
if line.startswith('#'):
continue
# Add valid test case
cases.append(line)
return cases
except FileNotFoundError:
print(f"ERROR: Test file '{filepath}' not found.")
sys.exit(2)
except Exception as e:
print(f"ERROR: Failed to read test file '{filepath}': {e}")
sys.exit(2)
def main():
ap = argparse.ArgumentParser(description="Assert pad → net mapping in a KiCad .kicad_pcb.")
ap.add_argument("board", help="Path to .kicad_pcb")
ap.add_argument("--case", action="append", default=[],
help="Assertion in the form REF:PAD=NET or REF1:PAD1<>REF2:PAD2. May be used multiple times.")
ap.add_argument("--test-file", help="Path to text file containing test cases (one per line)")
ap.add_argument("--check-voltage-compat", action="store_true",
help="Automatically check all connections for voltage compatibility")
args = ap.parse_args()
# Collect test cases from both --case arguments and --test-file
test_cases = args.case[:]
if args.test_file:
file_cases = load_test_cases_from_file(args.test_file)
test_cases.extend(file_cases)
if not test_cases and not args.check_voltage_compat:
print("No test cases given. Use --case, --test-file, or --check-voltage-compat.")
print("Example: --case U1:11=GND --case J1:2=+5V")
print("Example: --case U1:TX<>U2:RX (voltage compatibility)")
print("Example: --test-file tests.txt")
print("Example: --check-voltage-compat")
return 2
board = pcbnew.LoadBoard(args.board)
failures = []
warnings = []
for expr in test_cases:
try:
parsed = parse_case(expr)
except ValueError as ve:
print(str(ve))
return 2
test_type = parsed[0]
if test_type == 'net':
# Original net connectivity test
_, ref, padno, want_net, is_positive = parsed
fp = find_footprint(board, ref)
if fp is None:
failures.append(f"[MISS] Footprint {ref} not found")
continue
pad = get_pad(fp, padno)
if pad is None:
failures.append(f"[MISS] {ref} pad {padno} not found")
continue
got = pad_net_name(pad) or "<no-net>"
if is_positive:
# Positive assertion: pad MUST be on the specified net
if got != want_net:
failures.append(f"[FAIL] {ref}:{padno} expected '{want_net}', got '{got}'")
else:
print(f"[PASS] {ref}:{padno} is on '{got}'")
else:
# Negative assertion: pad MUST NOT be on the specified net
if got == want_net:
failures.append(f"[FAIL] {ref}:{padno} must NOT be on '{want_net}', but it is!")
else:
print(f"[PASS] {ref}:{padno} is NOT on '{want_net}' (currently on '{got}')")
elif test_type == 'voltage':
# Voltage compatibility test
_, ref1, pad1, ref2, pad2 = parsed
fp1 = find_footprint(board, ref1)
fp2 = find_footprint(board, ref2)
if fp1 is None:
failures.append(f"[MISS] Footprint {ref1} not found")
continue
if fp2 is None:
failures.append(f"[MISS] Footprint {ref2} not found")
continue
p1 = get_pad(fp1, pad1)
p2 = get_pad(fp2, pad2)
if p1 is None:
failures.append(f"[MISS] {ref1} pad {pad1} not found")
continue
if p2 is None:
failures.append(f"[MISS] {ref2} pad {pad2} not found")
continue
# Check if they're on the same net
net1 = pad_net_name(p1)
net2 = pad_net_name(p2)
if net1 != net2:
failures.append(f"[FAIL] {ref1}:{pad1} and {ref2}:{pad2} are NOT connected ('{net1}' vs '{net2}')")
continue
# Get voltage levels
v1 = get_pad_voltage(board, fp1, p1)
v2 = get_pad_voltage(board, fp2, p2)
compatible, reason = are_voltages_compatible(v1, v2)
v1_str = f"{v1}V" if v1 is not None else "?"
v2_str = f"{v2}V" if v2 is not None else "?"
if not compatible:
failures.append(f"[FAIL] {ref1}:{pad1} ({v1_str}) <> {ref2}:{pad2} ({v2_str}): {reason}")
elif "Warning" in reason:
warnings.append(f"[WARN] {ref1}:{pad1} ({v1_str}) <> {ref2}:{pad2} ({v2_str}): {reason}")
print(f"[WARN] {ref1}:{pad1} ({v1_str}) <> {ref2}:{pad2} ({v2_str}): {reason}")
else:
print(f"[PASS] {ref1}:{pad1} ({v1_str}) <> {ref2}:{pad2} ({v2_str}): {reason}")
# Auto voltage compatibility check
if args.check_voltage_compat:
print("\n=== Running automatic voltage compatibility scan ===")
voltage_issues = check_all_voltage_compat(board)
for issue in voltage_issues:
if issue['severity'] == 'error':
failures.append(f"[FAIL] {issue['message']}")
elif issue['severity'] == 'warning':
warnings.append(f"[WARN] {issue['message']}")
print(f"[WARN] {issue['message']}")
if failures:
print("\n=== Connectivity check FAILED ===")
for line in failures:
print(" " + line)
return 1
if warnings:
print(f"\n{len(warnings)} warning(s) found - please review.")
print("\n=== Connectivity check PASSED ===")
return 0
if __name__ == "__main__":
sys.exit(main())
Usage:
$ python check_nets_pcbnew.py SDR-Board.kicad_pcb --case U4:11="Net-(CLK0-In)"
[PASS] U4:11 is on 'Net-(CLK0-In)'
Connectivity check PASSED.
Sample schematic:
Here are the test cases for this *-QSD-v15 project:
# Note: These test cases should ideally be derived from datasheets
#
# Usage: python check_nets_pcbnew.py board.kicad_pcb --test-file pcb_tests.txt --case U3:1=GND
# Power connections (7805 TO-252)
U1:1=/VIN
U1:2=GND
U1:3=+5V
U1:3!=GND
# Power connections (AMS1117 SOT-223)
U3:1=GND
U3:2=+3.3V
U3:3=5VF
U3:3!=GND
# QSD clocks derivation
U4:1=+3.3V
U4:2=/D
U4:3=/CLK0
U4:4=+3.3V
U4:5=/Q
U4:6=LO_I
U4:7=GND
U4:8=/D
U4:9=LO_Q
U4:10=+3.3V
U4:11=/CLK0
U4:12=/Q
U4:13=+3.3V
U4:14=+3.3V
# Positive assertions - these connections MUST exist
U7:4=GND
U7:5=/5VFL
# Negative assertions - prevent shorts and wrong connections
U4:14!=+5V # 3.3V pin must NOT be on 5V rail
# Check multiple rails aren't shorted together
U3:1!=+3.3V
U3:1!=+5V
# Signal connections
J1:1=DOUT
J1:2=WSEL
J1:3=BCLK
J1:4=GND
# Data and clock lines
U7:1=BCLK
U7:2=WSEL
U7:3=DOUT
The test case language is decently expressive enough.
Execution log:
$ python3 check_nets_pcbnew.py SDR-Board.kicad_pcb --test-file pcb_tests.txt
[PASS] U1:1 is on '/VIN'
[PASS] U1:2 is on 'GND'
[PASS] U1:3 is on '+5V'
[PASS] U1:3 is NOT on 'GND' (currently on '+5V')
[PASS] U3:1 is on 'GND'
[PASS] U3:2 is on '+3.3V'
[PASS] U3:3 is on '5VF'
[PASS] U3:3 is NOT on 'GND' (currently on '5VF')
[PASS] U4:1 is on '+3.3V'
[PASS] U4:2 is on '/D'
[PASS] U4:3 is on '/CLK0'
[PASS] U4:4 is on '+3.3V'
[PASS] U4:5 is on '/Q'
[PASS] U4:6 is on 'LO_I'
[PASS] U4:7 is on 'GND'
[PASS] U4:8 is on '/D'
[PASS] U4:9 is on 'LO_Q'
[PASS] U4:10 is on '+3.3V'
[PASS] U4:11 is on '/CLK0'
[PASS] U4:12 is on '/Q'
[PASS] U4:13 is on '+3.3V'
[PASS] U4:14 is on '+3.3V'
[PASS] U7:4 is on 'GND'
[PASS] U7:5 is on '/5VFL'
[PASS] U4:14 is NOT on '+5V # 3.3V pin must NOT be on 5V rail' (currently on '+3.3V')
[PASS] U3:1 is NOT on '+3.3V' (currently on 'GND')
[PASS] U3:1 is NOT on '+5V' (currently on 'GND')
[PASS] J1:1 is on 'DOUT'
[PASS] J1:2 is on 'WSEL'
[PASS] J1:3 is on 'BCLK'
[PASS] J1:4 is on 'GND'
[PASS] U7:1 is on 'BCLK'
[PASS] U7:2 is on 'WSEL'
[PASS] U7:3 is on 'DOUT'
=== Connectivity check PASSED ===