"""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())