"""Scoped migration cleanup; preserve all untouched S-expression source spans.""" import argparse from collections import Counter import json from pathlib import Path import re ROOT = Path(__file__).resolve().parents[1] TOKEN = re.compile(r'"(?:\\.|[^"\\])*"|[()]|[^\s()]+') # Pin numbers/types cross-checked against KiCad 74xx:74HC595. HC595_PINS = { **{str(i): (f'Q{i}', 'tri_state') for i in range(1, 8)}, '8': ('GND', 'power_in'), '9': ('Q7S', 'output'), '10': ('MR#', 'input'), '11': ('SHCP', 'input'), '12': ('STCP', 'input'), '13': ('OE#', 'input'), '14': ('DS', 'input'), '15': ('Q0', 'tri_state'), '16': ('VCC', 'power_in'), } # Reviewed dangling extensions from the migration baseline, not arbitrary ERC errors. WIRE_STUBS = { 'a65a7700-a243-55e0-8007-fd0b3d962b61', '0a207484-be28-5acb-b11a-f92435941381', '6cd4551d-b945-53d0-80d0-21d019ad50b8', '503168b2-0b79-59b7-bc16-76fc375faae5', '441e621d-54a9-5730-ba9d-e935bb1fb902', 'b8b87b0a-b722-5042-99c4-f48791a7a13a', 'f0891bf2-0ec0-5b8f-a2e7-8aae61bc25c3', '543b8815-4995-5fb6-808d-969700a6774a', '8a9d3b65-768d-59a5-a13f-19fae717ebc4', '32a92a88-d05a-56aa-9132-fde8e08df99b', '5c2de7b9-896a-5a8b-9cfa-054a4be9c2c4', '33ec1b87-42b3-5371-95f4-c14859255644', '15fea150-9f17-5ddd-97f0-f3618ed282ae', '56ba2d24-026a-56c4-a8b9-f2fe385f63aa', '0a7ffeba-9293-5255-8d6d-e59c113ecf11', '4d8be1e0-5a90-519d-bd30-3b2092db6667', '5939369d-292c-5284-aba7-35b9e8c034eb', } class Node(list): def __init__(self, start): super().__init__() self.start = start self.end = None def parse(source): stack = [] root = None for match in TOKEN.finditer(source): token = match.group() if token == '(': node = Node(match.start()) if stack: stack[-1].append(node) else: if root is not None: raise ValueError('Multiple roots') root = node stack.append(node) elif token == ')': stack.pop().end = match.end() else: stack[-1].append(json.loads(token) if token.startswith('"') else token) if stack or root is None: raise ValueError('Unbalanced S-expression') return root def children(node, key): return [x for x in node if isinstance(x, Node) and x[0] == key] def child(node, key): return next(iter(children(node, key)), None) def properties(node): return {x[1]: x for x in children(node, 'property')} def cleanup(path, apply=False): source = path.read_text(encoding='utf-8') root = parse(source) edits = [] counts = Counter() replace_fan_notes = (path.name == 'P6-FAN-EDL.kicad_sch' and '风扇电路审查:FR-10 尚未满足' not in source and 'TWO-FAN PROTOTYPE: INTAKE + EXHAUST' not in source and 'FOUR-WIRE FAN PROTOTYPE R1' not in source and 'SANYO FAN SELECTION R2' not in source) replace_site_notes = path.name.startswith('C4-SITE') and '侧键采用裸铜压接,非采购元件' not in source replace_keys_notes = path.name == 'C6-KEYS.kicad_sch' and '控制链核查:四个未用输出已逐脚标 NC' not in source def replace(node, replacement, reason): old = source[node.start:node.end] if replacement != old: edits.append((node.start, node.end, replacement)) counts[reason] += 1 libraries = children(root, 'symbol') if root[0] == 'kicad_symbol_lib' else children(child(root, 'lib_symbols'), 'symbol') for symbol in libraries: props = properties(symbol) ref = props['Reference'][2] if symbol[1].removeprefix('PhoneRail:') == '74HC595D,118': pins = [p for unit in children(symbol, 'symbol') for p in children(unit, 'pin')] assert {child(p, 'number')[1] for p in pins} == set(HC595_PINS) for pin in pins: name, kind = HC595_PINS[child(pin, 'number')[1]] assert child(pin, 'name')[1] == name replace(pin, re.sub(r'^\(pin\s+\S+', '(pin ' + kind, source[pin.start:pin.end]), 'hc595_pin_types') if ref not in {'R', 'Rs', 'RT', 'C', 'L', 'F', 'Y', 'J', 'D', 'DZ'}: continue for unit in children(symbol, 'symbol'): for pin in children(unit, 'pin'): raw = source[pin.start:pin.end] replace(pin, re.sub(r'^\(pin\s+\S+', '(pin passive', raw), 'passive_pins') if root[0] == 'kicad_sch': for wire in children(root, 'wire'): if child(wire, 'uuid')[1] in WIRE_STUBS: replace(wire, '', 'reviewed_wire_stubs') for symbol in children(root, 'symbol'): props = properties(symbol) ref = props['Reference'][2] if replace_site_notes and props.get('Value', ['', '', ''])[2].startswith('6.3TPV2200'): p = props['Value'] replace(p, re.sub(r'^(\(property "Value"\s+)"[^"]*"', r'\1"2200uF / 6.3V"', source[p.start:p.end]), 'capacitor_label') if ref.startswith('#PWR'): for key in ('in_bom', 'on_board'): replace(child(symbol, key), f'({key} no)', 'power_metadata') if re.fullmatch(r'J(?:4[1-9]|5[0-9]|60)', ref) and 'Board2' in str(path): assert props['Footprint'][2] == 'PhoneRail:PAD-D1.5_MI8-KEY' replace(child(symbol, 'in_bom'), '(in_bom no)', 'contact_bom') for key, value in {'Value': 'KEY_PAD_D1.5', 'MPN': '', 'LCSC': '', 'Manufacturer': '', 'Datasheet': '', 'Description': 'ENIG contact, no paste; PCB feature, not a purchased part'}.items(): if key in props: p = props[key] raw = source[p.start:p.end] updated = re.sub(r'^(\(property\s+"(?:\\.|[^"\\])*"\s+)"(?:\\.|[^"\\])*"', lambda m: m[1] + json.dumps(value), raw) replace(p, updated, 'contact_metadata') # Only identical text at identical coordinates is redundant. Different revisions # are retained except the fan page, whose full annotation is replaced below. seen = set() for text in children(root, 'text'): signature = (text[1], tuple(child(text, 'at')[1:])) if replace_fan_notes or replace_site_notes or replace_keys_notes: replace(text, '', 'keys_stale_notes' if replace_keys_notes else 'fan_stale_notes' if replace_fan_notes else 'site_stale_notes') elif signature in seen: replace(text, '', 'duplicate_notes') else: seen.add(signature) if path.name.startswith('C4-SITE'): value = text[1] if 'YTC1P-2510-01' in value: value = ' 侧键为 PCB 顶层 Ø1.5 ENIG 裸铜触点;不开钢网、不装顶针。' elif '冠径只有' in value: value = ' 主板弹片压接触点,装配间隙 1.0 mm;触点位置须实物复测。' else: value = value.replace('四根顶针顶主板反面的', '四个裸铜触点连接主板的').replace('侧键顶针', '侧键裸铜触点') raw = source[text.start:text.end] replace(text, re.sub(r'^\(text\s+"(?:\\.|[^"\\])*"', lambda m: '(text ' + json.dumps(value, ensure_ascii=False), raw), 'contact_notes') if replace_fan_notes or replace_site_notes or replace_keys_notes: import uuid notes = [ (12.7, 13.716, '风扇电路审查:FR-10 尚未满足,禁止按本页直接投板。'), (12.7, 19.05, 'FAN_PWM = GPIO18 / U20.11;FAN_TACH = GPIO16 / U20.9。'), (12.7, 86.36, 'Q6 低边调速,D2 续流;五个风扇共用供电回路。'), (12.7, 92.456, 'R730/R731/R732 = 10k/10k/3.3k:开漏释放时高电平仅 1.70V。'), (12.7, 98.552, '计算:12 x 3.3 / (10 + 10 + 3.3),低于 GPIO 保证高阈值 2.475V。'), (12.7, 104.648, '五路 TACH 并联不能保证识别单风扇停转;不得按频率下降 1/5 判定。'), (12.7, 110.744, '测速需独立采集或故障汇总电路;低边关断时须校核浮地和 GPIO 钳位。'), (12.7, 170.18, 'J26~J30:1=+12V,2=风扇回流/Q6.D,3=TACH(当前并联,待整改)。'), (12.7, 176.276, '型号、电平接口与装配高度待实物确认。详见 HARDWARE_REVIEW.md。'), ] if replace_site_notes: ch = int(path.stem[-1]) notes = [ (15, 17, f'CH{ch}:电池直供、电容组、侧键触点与 EDL'), (15, 23, f'J8{ch}:1=P+,2=P-,3=TEMP,4=ID;旁路电池保护板,保留排线 BTB。'), (15, 29, f'R78{ch}=10k 模拟温度;R79{ch}=68k 模拟 ID。2200uF 使用指定普通铝电解,勿换低 ESR 聚合物。'), (15, 86, '侧键采用裸铜压接,非采购元件:Ø1.5 ENIG,钢网不开口,不安装顶针。'), (15, 92, '主板弹片直压触点,间隙 1.0mm;接触位置、压力及绝缘须实物确认。'), (15, 98, f'Q{ch}03~Q{ch}05:栅极 1k 串阻、100k 下拉;595 高阻期间保持按键释放。'), (15, 167, f'J6{ch} 连接主板 TP1204/TP1205,U4{ch} 导通时短接两点进入 EDL。'), (15, 173, f'R74{ch}=100k 下拉:复位或连接器未插时 EDL 不触发。'), (15, 179, '装配与未完成检查见 HARDWARE_REVIEW.md。'), ] if replace_keys_notes: notes = [ (15, 16, 'C6-KEYS:三片 74HC595,15 路侧键 + 5 路 EDL + HUB_RST'), (15, 22, '控制链核查:四个未用输出已逐脚标 NC;电气参数与电源时序仍待复核。'), (15, 28, '级联 U21 → U22 → U33;固件发送 U33 → U22 → U21,每字节高位先发。'), (185, 44, '三片 VCC / MR# 接 +3V3_HUB。'), (185, 51, 'R609 100k 将 OE# 拉高,复位时输出高阻。'), (185, 58, '先锁存安全值,再拉低 OE# 使能输出。'), (185, 65, 'HUB_RST 低有效;安全值为 1 << 23。'), (185, 72, 'U33.Q7 → U32.43;R716 为复位上拉。'), (185, 82, 'NC:U21.4、U22.4、U22.15、U33.9。'), (185, 89, '前三脚为预留并行输出;末脚为链尾输出。'), (185, 99, 'KEY_SER / SRCLK / RCLK / OE# 经板间连接。'), (185, 106, '跨电源域上掉电、注入电流及阈值待审。'), (185, 116, 'K*_PWR / VUP / VDN 驱动 C4 页按键 MOS。'), (185, 123, 'EDL_EN1~5 控制各路 TS5A3157。'), ] new = ''.join('\n\t(text ' + json.dumps(value, ensure_ascii=False) + f' (at {x} {y} 0) (effects (font (size 1.587 1.587)) (justify left bottom)) (uuid "{uuid.uuid5(uuid.NAMESPACE_URL, value)}"))' for x, y, value in notes) edits.append((root.end - 1, root.end - 1, new + '\n')) for start, end, replacement in sorted(edits, reverse=True): source = source[:start] + replacement + source[end:] if apply and edits: path.write_text(source, encoding='utf-8', newline='\n') return counts if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--apply', action='store_true') args = parser.parse_args() total = Counter() for path in [ROOT / 'lib/PhoneRail.kicad_sym', *sorted(ROOT.glob('PhoneRail-*/*.kicad_sch'))]: counts = cleanup(path, args.apply) if counts: print(path.name, dict(counts)) total.update(counts) print('TOTAL', dict(total))