242 行
15 KiB
Python
242 行
15 KiB
Python
"""Compile KiCad netlists and check selected PhoneRail hardware requirements.
|
|
|
|
Exit 1 means a hardware check failed, including known open design issues.
|
|
ERC is reported independently; a passing contract is not a production release.
|
|
"""
|
|
import argparse
|
|
from collections import Counter
|
|
import csv
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import xml.etree.ElementTree as ET
|
|
|
|
from schematic_maintenance import ROOT, parse, children, child, properties, HC595_PINS
|
|
from fan_revision_checks import verify_fans, verify_unrelated
|
|
|
|
|
|
class Board:
|
|
def __init__(self, number):
|
|
self.number = number
|
|
self.name = f'PhoneRail-Board{number}'
|
|
self.path = ROOT / self.name / f'{self.name}.kicad_sch'
|
|
self.parts = {}
|
|
for path in sorted(self.path.parent.glob('*.kicad_sch')):
|
|
for symbol in children(parse(path.read_text(encoding='utf-8')), 'symbol'):
|
|
props = {k: v[2] for k, v in properties(symbol).items()}
|
|
ref = props['Reference']
|
|
if ref.startswith('#PWR'):
|
|
continue
|
|
if ref in self.parts:
|
|
raise ValueError(f'Duplicate reference {ref}')
|
|
props['page'] = path.name
|
|
props['in_bom'] = child(symbol, 'in_bom')[1]
|
|
props['dnp'] = child(symbol, 'dnp')[1]
|
|
self.parts[ref] = props
|
|
self.pins = {}
|
|
self.functions = {}
|
|
self.pin_types = {}
|
|
self.net_members = {}
|
|
|
|
def compile(self, cli, out):
|
|
def run(args, destination):
|
|
destination.unlink(missing_ok=True)
|
|
result = subprocess.run([cli, *args], capture_output=True, encoding='utf-8', errors='replace')
|
|
destination.with_suffix(destination.suffix + '.log').write_text(result.stdout + result.stderr, encoding='utf-8')
|
|
if result.returncode or not destination.exists():
|
|
raise RuntimeError(f'KiCad failed: {destination.name}; see its log')
|
|
netfile = out / f'Board{self.number}.xml'
|
|
run(['sch', 'export', 'netlist', '--format', 'kicadxml', '-o', str(netfile), str(self.path)], netfile)
|
|
root = ET.parse(netfile).getroot()
|
|
for net in root.findall('nets/net'):
|
|
self.net_members[net.attrib['name']] = {(p.attrib['ref'], p.attrib['pin']) for p in net.findall('node')}
|
|
for pin in net.findall('node'):
|
|
key = (pin.attrib['ref'], pin.attrib['pin'])
|
|
if key in self.pins:
|
|
raise ValueError(f'Pin appears on multiple nets: {key}')
|
|
self.pins[key] = net.attrib['name']
|
|
self.functions[key] = pin.attrib.get('pinfunction', '')
|
|
self.pin_types[key] = pin.attrib.get('pintype', '')
|
|
erc = out / f'Board{self.number}-erc.json'
|
|
run(['sch', 'erc', '--format', 'json', '-o', str(erc), str(self.path)], erc)
|
|
report = json.loads(erc.read_text(encoding='utf-8'))
|
|
violations = [v for sheet in report['sheets'] for v in sheet['violations']]
|
|
self.erc = dict(Counter(v['type'] for v in violations))
|
|
with (out / f'Board{self.number}-bom.csv').open('w', newline='', encoding='utf-8-sig') as stream:
|
|
writer = csv.writer(stream)
|
|
writer.writerow(['Reference', 'Value', 'Footprint', 'MPN', 'LCSC', 'DNP', 'Sheet'])
|
|
for ref, props in sorted(self.parts.items()):
|
|
if props['in_bom'] == 'yes':
|
|
writer.writerow([ref, *[props.get(k, '') for k in ['Value', 'Footprint', 'MPN', 'LCSC', 'dnp', 'page']]])
|
|
|
|
def net(self, ref, pin):
|
|
return self.pins[(ref, str(pin))]
|
|
|
|
def resistor(self, ref):
|
|
props = self.parts[ref]
|
|
code = re.fullmatch(r'0402WGF(\d{4})TCE', props.get('MPN', ''))
|
|
if code:
|
|
digits = code[1]
|
|
return int(digits[:3]) * 10 ** int(digits[3])
|
|
fixed_parts = {'FRC2512F10R0TS': 10, 'RHC2512FT2R00': 2}
|
|
if props.get('MPN') in fixed_parts:
|
|
return fixed_parts[props['MPN']]
|
|
value = re.fullmatch(r'([\d.]+)([km]?)Ω', props['Value'])
|
|
if not value:
|
|
raise ValueError(f'Unknown resistance {ref}: {props["Value"]}')
|
|
return float(value[1]) * {'': 1, 'k': 1000, 'm': .001}[value[2]]
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--cli', default=shutil.which('kicad-cli') or r'D:\KiCad\bin\kicad-cli.exe')
|
|
parser.add_argument('--firmware-dir', type=Path, default=ROOT.parent / 'firmware',
|
|
help='Firmware source root; default remains the sibling firmware')
|
|
parser.add_argument('--out', type=Path, default=ROOT / 'out',
|
|
help='Keep candidate reports separate from the integrated baseline')
|
|
parser.add_argument('--strict', action='store_true',
|
|
help='Include checks explicitly deferred from the R1 prototype scope')
|
|
args = parser.parse_args()
|
|
out = args.out.resolve()
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
boards = [Board(1), Board(2)]
|
|
for board in boards:
|
|
board.compile(args.cli, out)
|
|
a, b = boards
|
|
checks = []
|
|
|
|
def check(name, good, detail=''):
|
|
checks.append({'check': name, 'pass': bool(good), 'detail': detail})
|
|
|
|
def same(board, label, *pins):
|
|
nets = [board.net(*p) for p in pins]
|
|
check(label, len(set(nets)) == 1 and not nets[0].startswith('unconnected-'), str(nets))
|
|
|
|
check('Board1 component count', len(a.parts) == 301, 'Four-wire fan prototype R2: 24 PCB parts on P6; external fans in assembly BOM')
|
|
check('Board2 component count', len(b.parts) == 228, 'Hub strap ECO adds ten 100k pulldowns')
|
|
from hub_strap_checks import verify_hub_straps
|
|
verify_hub_straps(b, check)
|
|
# Opposite rows mate: design section 26.5, confirmed connector numbering.
|
|
for lower, upper in [('J24', 'J31'), ('J25', 'J32')]:
|
|
for pin in range(1, 41):
|
|
mate = pin + 1 if pin % 2 else pin - 1
|
|
check(f'IF-7 {lower}.{pin} <-> {upper}.{mate}', a.net(lower, pin) == b.net(upper, mate), f'{a.net(lower, pin)} / {b.net(upper, mate)}')
|
|
for ch in range(1, 6):
|
|
buck, efuse, ina = f'U{ch}', f'U{ch + 13}', f'U{ch + 7}'
|
|
r = lambda suffix: f'R{ch}{suffix:02}'
|
|
for suffix, resistance in {1:30100, 2:5490, 3:100000, 7:150000, 8:1000, 9:68100, 10:20000, 11:20000, 14:100000, 18:100000, 19:10}.items():
|
|
check(f'CH{ch} {r(suffix)} value', a.resistor(r(suffix)) == resistance)
|
|
same(a, f'CH{ch} feedback node', (buck, 7), (r(1), 2), (r(2), 1), (r(7), 1))
|
|
same(a, f'CH{ch} feedback bottom GND', (r(2), 2), (buck, 2))
|
|
same(a, f'CH{ch} OVP senses pre-eFuse output', (r(9), 1), (efuse, 9), (r(1), 1))
|
|
same(a, f'CH{ch} OVP divider', (r(9), 2), (r(10), 1), (efuse, 15))
|
|
check(f'CH{ch} OVP bottom GND', a.net(r(10), 2) == 'GND')
|
|
check(f'CH{ch} ILIM', a.net(r(11), 2) == 'GND' and a.net(r(11), 1) == a.net(efuse, 17))
|
|
same(a, f'CH{ch} disable interlock', (efuse, 14), (r(14), 1), (f'Q{ch}01', 1))
|
|
check(f'CH{ch} EN pulldown', a.net(r(14), 2) == 'GND')
|
|
same(a, f'CH{ch} discharge gate', (f'Q{ch}01', 3), (f'Q{ch}02', 1), (r(18), 2))
|
|
check(f'CH{ch} discharge gate pullup', a.net(r(18), 1) == '+3V3')
|
|
same(a, f'CH{ch} discharge load', (r(19), 2), (f'Q{ch}02', 3))
|
|
check(f'CH{ch} discharge output', a.net(r(19), 1) == f'VO{ch}_P')
|
|
check(f'CH{ch} discharge source GND', a.net(f'Q{ch}02', 2) == 'GND' and a.net(f'Q{ch}01', 2) == 'GND')
|
|
same(a, f'CH{ch} shunt upstream', (f'Rs{ch}01', 2), (ina, 10), (efuse, 4))
|
|
same(a, f'CH{ch} shunt downstream', (f'Rs{ch}01', 1), (ina, 9), (ina, 8))
|
|
check(f'CH{ch} battery pads', b.net(f'J8{ch}', 1) == f'VO{ch}_P' and b.net(f'J8{ch}', 2) == 'GND')
|
|
check(f'CH{ch} BM24 supply anchors', b.net(f'J7{ch}', 51) == f'VBUS{ch}' and b.net(f'J7{ch}', 52) == 'GND')
|
|
check(f'CH{ch} VBUS pulldown', b.resistor(f'R70{ch}') == 100000 and {b.net(f'R70{ch}', 1), b.net(f'R70{ch}', 2)} == {'GND', f'VBUS_EN{ch}'})
|
|
check(f'CH{ch} EDL pulldown', b.resistor(f'R74{ch}') == 100000 and {b.net(f'R74{ch}', 1), b.net(f'R74{ch}', 2)} == {'GND', f'EDL_EN{ch}'})
|
|
|
|
rt, rb, ri = a.resistor('R101'), a.resistor('R102'), a.resistor('R107') + a.resistor('R108')
|
|
maximum = .6 * (1 + rt / rb + rt / ri)
|
|
minimum = maximum - 2.424 * rt / ri
|
|
check('PR-4 nominal DAC output range', 3.51 < minimum < 3.53 and 4.00 < maximum < 4.02, f'{minimum:.6f} .. {maximum:.6f} V; includes R108 series resistance')
|
|
ovp = .99 * (1 + a.resistor('R109') / a.resistor('R110'))
|
|
check('SR-3 nominal OVP setpoint', 4.35 < ovp < 4.37, f'{ovp:.6f} V; not a transient or tolerance certification')
|
|
for index in range(41, 61):
|
|
p = b.parts[f'J{index}']
|
|
check(f'Contact J{index} BOM', p['in_bom'] == 'no' and not p.get('MPN') and not p.get('LCSC'))
|
|
|
|
fw = args.firmware_dir.resolve() / 'main'
|
|
for ref, cap in [('U21', 'C604'), ('U22', 'C605'), ('U33', 'C739')]:
|
|
for pin, (_, kind) in HC595_PINS.items():
|
|
check(f'595 {ref}.{pin} electrical type', b.pin_types[(ref, pin)].split('+')[0] == kind)
|
|
for pin, net in [(8, 'GND'), (10, '+3V3_HUB'), (16, '+3V3_HUB'), (11, 'KEY_SRCLK'), (12, 'KEY_RCLK'), (13, 'KEY_OE#')]:
|
|
check(f'595 {ref}.{pin} {net}', b.net(ref, pin) == net)
|
|
check(f'595 {ref} decoupling connection', {b.net(cap, 1), b.net(cap, 2)} == {'+3V3_HUB', 'GND'} and b.parts[cap]['Value'] == '100nF')
|
|
check('595 OE pullup', b.resistor('R609') == 100000 and {b.net('R609', 1), b.net('R609', 2)} == {'KEY_OE#', '+3V3_HUB'})
|
|
check('595 serial input', b.net('U21', 14) == 'KEY_SER')
|
|
for upstream, downstream in [('U21', 'U22'), ('U22', 'U33')]:
|
|
check(f'595 cascade {upstream} -> {downstream}', b.net_members[b.net(upstream, 9)] == {(upstream, '9'), (downstream, '14')})
|
|
check('595 HUB reset output', b.net('U33', 7) == b.net('U32', 43) == 'HUB_RST')
|
|
for ref, pin in [('U21', '4'), ('U22', '4'), ('U22', '15'), ('U33', '9')]:
|
|
check(f'595 reviewed unused output {ref}.{pin}', b.net_members[b.net(ref, pin)] == {(ref, pin)} and 'no_connect' in b.pin_types[(ref, pin)].split('+'))
|
|
if fw.exists():
|
|
header = (fw / 'board_pins.h').read_text(encoding='utf-8')
|
|
config = (fw / 'rail_cfg.c').read_text(encoding='utf-8')
|
|
gpio = {net: int(a.functions[key].split('_')[0][2:]) for key, net in a.pins.items() if key[0] == 'U20' and re.fullmatch(r'IO\d+(?:_\d+)?', a.functions[key])}
|
|
sr_header = (fw.parent / 'components/drv/include/sr595.h').read_text(encoding='utf-8')
|
|
for define, net in {'SER': 'KEY_SER', 'SRCLK': 'KEY_SRCLK', 'RCLK': 'KEY_RCLK', 'OE_N': 'KEY_OE#'}.items():
|
|
match = re.search(r'#define\s+SR595_PIN_' + define + r'\s+(\d+)', sr_header)
|
|
check(f'Firmware 595 GPIO {net}', bool(match) and int(match[1]) == gpio.get(net))
|
|
for define, net in {'I2C_SDA':'SDA', 'I2C_SCL':'SCL', 'FAN_EN':'FAN_EN', 'FAN_TACH_IN':'FAN_TACH_IN', 'FAN_TACH_OUT':'FAN_TACH_OUT', 'FAN_PWM':'FAN_PWM', 'UP_VBUS_DET':'UP_VBUS_DET', 'NTC1':'NTC1', 'NTC2':'NTC2', 'USB_DM':'USB_DM', 'USB_DP':'USB_DP'}.items():
|
|
match = re.search(r'#define\s+BOARD_PIN_' + define + r'\s+(\d+)', header)
|
|
check(f'Firmware GPIO {net}', bool(match) and int(match[1]) == gpio.get(net))
|
|
rows = re.findall(r'/\* CH(\d) \*/\s*\{\s*(0x\w+),\s*(0x\w+),\s*(\d+),\s*(\d+),\s*(\d+),\s*\{\s*(\d+),\s*(\d+),\s*(\d+)\s*\},\s*(\d+)', config)
|
|
check('Firmware rail table parsed', len(rows) == 5)
|
|
for row in rows:
|
|
ch, ina_addr, dac_addr, dac_ch, en, vbus, pwr, up, down, edl = [int(x, 0) for x in row]
|
|
check(f'Firmware CH{ch} enables', gpio[f'EFUSE_EN{ch}'] == en and gpio[f'VBUS_EN{ch}'] == vbus)
|
|
straps = {'GND':0, '+3V3':1, 'SDA':2, 'SCL':3}
|
|
address = 0x40 + 4 * straps[a.net(f'U{ch+7}', 1)] + straps[a.net(f'U{ch+7}', 2)]
|
|
check(f'Firmware CH{ch} INA address', address == ina_addr)
|
|
dac = {0x4a:'U6', 0x4b:'U7'}[dac_addr]
|
|
check(f'Firmware CH{ch} DAC output', a.net(dac, {0:11, 1:10, 2:3, 3:2}[dac_ch]) == f'VDAC{ch}')
|
|
for bit, net in [(pwr, f'K{ch}_PWR'), (up, f'K{ch}_VUP'), (down, f'K{ch}_VDN'), (edl, f'EDL_EN{ch}')]:
|
|
chip, q = ['U21', 'U22', 'U33'][bit // 8], bit % 8
|
|
check(f'Firmware shift bit {bit} {net}', b.net(chip, 15 if q == 0 else q) == net)
|
|
else:
|
|
check('Firmware sources available', False, 'Sibling firmware/main required to verify GPIO mapping')
|
|
|
|
check('FR-10 two fan connectors only', all(r in a.parts for r in ('J26', 'J27')) and
|
|
not any(r in a.parts for r in ('J28', 'J29', 'J30')))
|
|
check('FR-10 selected fan four-wire interface', all((r, '4') in a.pins for r in ('J26', 'J27')),
|
|
'SANYO 9G0612P4S001 / C2920592: four bare leads, screw-terminal interface')
|
|
check('FR-10 obsolete divider removed', not any(r in a.parts for r in ('R730', 'R731', 'R732')))
|
|
check('FR-10 independent raw TACH nets', a.net('J26', 3) != a.net('J27', 3))
|
|
check('FR-10 reset pulldown', a.resistor('R734') == 100000 and
|
|
{a.net('R734', 1), a.net('R734', 2)} == {'GND', a.net('Q6', 1)})
|
|
for ref, pin, name in [('J26', 9, 'FAN_TACH_IN'), ('J27', 4, 'FAN_TACH_OUT')]:
|
|
check(f'FR-10 reserved input {name}', a.net('U20', pin) == name)
|
|
check(f'FR-10 raw TACH separated from MCU {ref}',
|
|
not any(r == 'U20' for r, _ in a.net_members[a.net(ref, 3)]))
|
|
check(f'FR-10 receiver populated {name}', len(a.net_members[a.net('U20', pin)]) > 1,
|
|
'Discrete MOS receiver fitted; electrical assumptions checked separately')
|
|
verify_fans(a, check)
|
|
verify_unrelated(check, out)
|
|
scope = json.loads((ROOT / 'prototype-scope.json').read_text(encoding='utf-8'))
|
|
deferred = {} if args.strict else scope['deferred_checks']
|
|
for result in checks:
|
|
result['status'] = 'passed' if result['pass'] else 'deferred' if result['check'] in deferred else 'failed'
|
|
if result['status'] == 'deferred':
|
|
result['scope_reason'] = deferred[result['check']]
|
|
report = {'firmware_dir': str(fw.parent), 'scope': 'strict' if args.strict else scope['revision'],
|
|
'checks': checks, 'passed': sum(x['pass'] for x in checks),
|
|
'failed': sum(x['status'] == 'failed' for x in checks),
|
|
'deferred': sum(x['status'] == 'deferred' for x in checks),
|
|
'erc': {b.name:b.erc for b in boards}}
|
|
(out / 'hardware-checks.json').write_text(json.dumps(report, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
|
print(f'Hardware contracts: {report["passed"]} passed, {report["failed"]} failed, {report["deferred"]} deferred ({report["scope"]})')
|
|
print(f'Firmware source: {fw.parent}')
|
|
for check_result in checks:
|
|
if not check_result['pass']:
|
|
print(check_result['status'].upper(), check_result['check'], check_result['detail'])
|
|
for board in boards:
|
|
print(board.name, 'ERC', board.erc)
|
|
return 1 if report['failed'] else 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|