from pathlib import Path
import sys,re,json,shutil,hashlib,zipfile
R=Path(__file__).resolve().parent;B=R.parent;H=B/'engineering_handoff_local';sys.path.insert(0,str(B/'parity_metadata_closure_A22'))
from prepare_candidates import forms,run,sha
from align_and_verify import footprint_rows,netlist
D=R/'controller';N='drill_control_evt_a3_pf1_sleep';P=D/(N+'.kicad_pcb');S=B/'drill_interface_A31/controller_candidate';O='drill_control_evt_a2_paired'
# Refresh native evidence against final saved board, not an earlier routing trial.
for args,name in [(['sch','erc','--format','json','--output',str(D/'erc.json'),str(D/(N+'.kicad_sch'))],'erc.log'),(['pcb','drc','--refill-zones','--save-board','--schematic-parity','--format','json','--output',str(D/'drc.json'),str(P)],'drc.log')]:assert run(args,D/name)==0
j=json.loads((D/'drc.json').read_text());e=json.loads((D/'erc.json').read_text());assert not j['violations'] and not j['unconnected_items'] and not j['schematic_parity'];assert not [v for x in e['sheets'] for v in x['violations']]
_,a=netlist(S/'netlist.xml');_,b=netlist(D/'netlist.xml');diff=[(k,a.get(k),b.get(k)) for k in set(a)|set(b) if a.get(k)!=b.get(k)];assert {k for k,_,_ in diff}=={('U1','3'),('U1','6')}
s0=(S/(O+'.kicad_pcb')).read_text();s1=P.read_text()
def padmap(s):
 z={}
 for _,_,f,r,_ in footprint_rows(s):
  for _,_,p in forms(f,'pad'):
   n=re.search(r'\(net "([^\"]+)"',p)
   if n:z[(r,re.match(r'\(pad "([^\"]+)"',p)[1])]=n[1].replace('{slash}','/')
 return z
pa,pb=padmap(s0),padmap(s1);assert {k for k in pa if pa[k]!=pb[k]}=={('U1','3'),('U1','6')};assert pb[('U1','6')]=='/DRV_SLEEP_N'
for tag in ['segment','via']:
 def other(s):return {re.search(r'\(uuid "([^\"]+)"',x)[1]:re.sub(r'\s+',' ',x) for _,_,x in forms(s,tag) if '/DRV_SLEEP_N' not in x}
 assert other(s0)==other(s1),'Only sleep copper may change'
# BOM and physical placement must be invariant. U1 electrical net assignment changes only.
fa={r:f for _,_,f,r,_ in footprint_rows(s0)};fb={r:f for _,_,f,r,_ in footprint_rows(s1)};assert set(fa)==set(fb)
for r in fa:
 def geom(x):return re.sub(r'\s+',' ',re.sub(r'\(net "[^\"]+"\)','',x))
 assert geom(fa[r])==geom(fb[r]),r
for folder in ['gerbers','drill']:(D/folder).mkdir(exist_ok=True)
lay='F.Cu,B.Cu,F.Paste,B.Paste,F.Silkscreen,B.Silkscreen,F.Mask,B.Mask,Edge.Cuts'
for args,name in [(['pcb','export','gerbers','--layers',lay,'--output',str(D/'gerbers')+'/',str(P)],'gerber-export.log'),(['pcb','export','drill','--format','excellon','--excellon-separate-th','--output',str(D/'drill')+'/',str(P)],'drill-export.log'),(['pcb','export','pos','--format','csv','--units','mm','--side','front','--smd-only','--exclude-dnp','--output',str(D/'centroid-front.csv'),str(P)],'centroid-export.log')]:assert run(args,D/name)==0
baseline=R/'baseline_cam';baseline.mkdir(exist_ok=True);assert run(['pcb','export','gerbers','--layers',lay,'--output',str(baseline)+'/',str(S/(O+'.kicad_pcb'))],R/'baseline-export.log')==0
suff={'.gbr','.gtl','.gbl','.gts','.gbs','.gtp','.gbp','.gto','.gbo','.gm1'}
def layers(d):return {p.name.split('-',1)[1].rsplit('.',1)[0]:p for p in d.iterdir() if p.suffix in suff}
def geom(p):return '\n'.join(x for x in p.read_text().splitlines() if not x.startswith(('%TF.CreationDate,','%TF.ProjectId,','%TO.N,','G04 Created by KiCad')))
la,lb=layers(baseline),layers(D/'gerbers');assert len(lb)==9 and la.keys()==lb.keys();cam={k:geom(la[k])==geom(lb[k]) for k in la}
for k in ['F_Paste','B_Paste','F_Silkscreen','B_Silkscreen','Edge_Cuts']:assert cam[k],k
v0=[x for _,_,x in forms(s0,'via')];v1=[x for _,_,x in forms(s1,'via')];newvias=[x for x in v1 if '/DRV_SLEEP_N' in x]
binpath=R/'firmware/固件/drill_target/build/drill_control_g431cb.bin';img=json.loads((binpath.parent/'build_verification.json').read_text());assert not img['motor_power_enabled'] and not img['runtime_control_enabled']
checks={'status':'A32_PF1_CAD_FIRMWARE_REVIEW_PASS_NOT_PHYSICAL','native':{'ERC':0,'DRC':0,'unconnected':0,'parity':0},'pin_differences':diff,'board_sha256':sha(P),'other_net_tracks_and_vias':'UNCHANGED','footprints_and_BOM':'UNCHANGED','cam_layer_geometry_identical_to_A31':cam,'new_sleep_vias':len(newvias),'new_via_size_drill_mm':[0.5,0.3],'total_vias_before_after':[len(v0),len(v1)],'binary_sha256':sha(binpath),'host_boot_test':'PASS actual boot; both prior GPIO states; PC14 preserved; HSEON/HSEBYP entry rejected before GPIO changes','negative_control':'A31 boot fails new physical PF1 assertion','clock_constraint':'Cold reset HSI only; PF1 cannot serve GPIO sleep and OSC_OUT simultaneously. SystemInit is not a clock-reset routine. HSE-enabled entry is rejected.','manufacturer_source':'https://www.st.com/resource/en/datasheet/stm32g431cb.pdf DS12589 Rev6 LQFP48 pin6 PF1','physical_validation':'NOT_RUN_NO_SAMPLE','manufacturing_status':'FABRICATION_HOLD'}
(R/'checks.json').write_text(json.dumps(checks,ensure_ascii=False,indent=2)+'\n')
report=H/'A32-drill-PF1-review.md';report.write_text('''# A32 电磨唤醒 GPIO 配对评审

状态：本地 CAD / 固件验证通过；FABRICATION_HOLD。没有实机上电、功能等效或制造放行。

把控制板 U1 pin3 PC14 的 DRV_SLEEP_N 移到空闲的 pin6 PF1，原 PC14 改为明确 NC。板间 J1 pin9 及电机板接收端不变。只有这两个 MCU 脚的逻辑网络变化；其余网络的走线/过孔未改。封装、位置和器件数量不变，没有新增 BOM 器件。重新填充地平面后，原生 ERC / DRC / 未连接 / 原理图一致性检查均为 0。

重新导出 9 层 Gerber、PTH/NPTH 和贴装坐标。铜层及过孔相关层有真实变化，旧 A31 CAM 不能与 A32 源混用；焊膏、丝印及板框几何逐层比较未变。新增唤醒过孔数量、板文件与固件 SHA256 见 checks.json。0.5mm 外径 / 0.3mm 钻孔符合现有规则，但仍需工厂确认孔环与 DFM；没有修改设计规则来通过检查。

固件同步 GPIOF 时钟和 PF1 输出，在改变输出模式前预置低电平。实际 drill_safe_boot 的主机寄存器测试通过，两种初始 GPIO 状态及 PC14 未改均验证。旧 PC14 代码未通过新物理引脚断言。HSEON 或 HSEBYP 入口会进入等待，测试确认未改变 GPIO 或使能 GPIO 时钟。该映像要求冷复位的 HSI 运行；SystemInit 本身不重置时钟。未来时钟设计必须禁止 PF1 同时作为 OSC_OUT 和唤醒 GPIO。

ARM 映像已干净构建并通过静态向量/链接检查，电机、TIM1、充电和运行控制保持关闭。FOC 仅编译对象，未链接运行。寄存器模型不代表实测电平、负载、时序或电机运行。只允许 A32 板与 A32 固件作为同一评审配对；不要给 A31 PC14 板烧写 A32 PF1 映像。

原厂引脚依据：https://www.st.com/resource/en/datasheet/stm32g431cb.pdf （DS12589 Rev6，LQFP48 pin6 PF1）。本次不打包原厂 PDF。

未完成：真实手柄电机类型与额定参数、全板电平/电源/驱动时序审核、台架启动与带载、温升/EMC、结构尺寸和工厂 DFM、原产品同台效果对比。控制板仍是 165×79mm 台架候选，整机装配适配未证实。三款每款 1000 台整机成本仍待完整 BOM 与工厂报价；此前 90/111 只覆盖所选电机/吸尘/灯电源板，此控制板另有 24 个器件位置未计价。移脚不新增器件不代表整机成本已达标。
''',encoding='utf-8')
T=R/'release';T.mkdir(exist_ok=True);C=T/'controller';C.mkdir(exist_ok=True)
for name in [N+'.kicad_pcb',N+'.kicad_sch',N+'.kicad_pro','Project.kicad_sym','sym-lib-table','netlist.xml','erc.json','erc.log','drc.json','drc.log','centroid-front.csv','gerber-export.log','drill-export.log','centroid-export.log']:shutil.copyfile(D/name,C/name)
for n in ['gerbers','drill']:shutil.copytree(D/n,C/n,dirs_exist_ok=True)
shutil.copytree(R/'firmware',T/'firmware',dirs_exist_ok=True,ignore=shutil.ignore_patterns('__pycache__'))
shutil.copytree(R/'host_boot_test',T/'host_boot_test',dirs_exist_ok=True,ignore=shutil.ignore_patterns('test_boot','old_negative','*.o'))
for n in ['checks.json','route_points.json','boot_register_test.log','negative_control.log','target_build.log','build_target.sh','run_host_tests.sh']:shutil.copyfile(R/n,T/n)
shutil.copyfile(report,T/report.name);shutil.copyfile(B/'drill_interface_A31/drill_control_24refs_cost_scope_A31.csv',T/'controller_cost_scope.csv')
files=sorted(p for p in T.rglob('*') if p.is_file());manifest=''.join(sha(p)+'  A32/'+p.relative_to(T).as_posix()+'\n' for p in files);zp=H/'Drill-PF1-CAD-firmware-review-A32.zip'
with zipfile.ZipFile(zp,'w',zipfile.ZIP_DEFLATED) as z:
 for p in files:z.write(p,'A32/'+p.relative_to(T).as_posix())
 z.writestr('A32/SHA256SUMS.txt',manifest)
with zipfile.ZipFile(zp) as z:
 assert z.testzip() is None
 for ln in z.read('A32/SHA256SUMS.txt').decode().splitlines():
  dig,n=ln.split('  ',1);assert hashlib.sha256(z.read(n)).hexdigest()==dig
entry={'archive':zp.name,'sha256':sha(zp),'bytes':zp.stat().st_size,'zip_entries':len(files)+1,'manifest_entries':len(files),'zip_crc':'PASS','manifest_readback':'PASS','native_checks':'ERC0 DRC0 unconnected0 parity0','host_boot_test':'PASS register model; original implementation negative control FAIL as expected','target_build':'STATIC_PASS_OUTPUTS_OFF','physical_validation':'NOT_RUN_NO_SAMPLE','manufacturing_status':'FABRICATION_HOLD'}
(H/'A32-SHA256SUMS.txt').write_text(manifest);ip=H/'LOCAL-PACKAGES.json';idx=json.loads(ip.read_text());idx['packages']=[x for x in idx['packages'] if x.get('archive')!=zp.name]+[entry];idx.update({'status':'A32_DRILL_PF1_PAIRED_CAD_FIRMWARE_PASS_FABRICATION_HOLD','latest_report':report.name,'latest_machine_report':'../sleep_gpio_A32/checks.json','latest_manifest':'A32-SHA256SUMS.txt'});idx['latest_candidate_sources']['drill_control']='../sleep_gpio_A32/controller/'+N+'.kicad_pcb';idx['latest_drill_safe_firmware_A32']={'source':'../sleep_gpio_A32/firmware/固件/drill_target','sha256':sha(binpath),'board_pair':N,'output_enabled':False,'physical_test_release':False};idx['drill_sleep_drive_gate_A32']={'source':'U1pin6 PF1','prior_PC14_issue':'Moved to ordinary GPIO; no physical drive/load qualification yet','clock_constraint':'Cold reset HSI; HSE forbidden; runtime guard tested','powered_test_release':False};idx['manufacturing_exclusions']['drill_A31_A32_mixing']='A31 PC14 CAD/CAM/firmware and A32 PF1 CAD/CAM/firmware must not be mixed; both FABRICATION_HOLD.';ip.write_text(json.dumps(idx,ensure_ascii=False,indent=2)+'\n');print(json.dumps({'package':entry,'checks':checks},ensure_ascii=False,indent=2))
