文件

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