0
mirror of https://github.com/incutec-hw/OpenFC-Lite.git synced 2026-08-14 23:09:43 +00:00
Files
incutec-OpenFC-Lite/hardware/tools/audit_design.py
JustStan d80bf9baaf Fix contradictions and duplicated information
Docs contradicted the design files and each other. Verified every claim
against hardware/*.kicad_sch and hardware/OpenFC.kicad_pcb before writing.

Contradictions:
- DESIGN.md: document that the 1.8 V gyro LDO is U15 LP5912-1.8DRVR in the
  schematic but U6 NCV8187AMT180TAG on both the board and the production
  panel, so fabricated boards ship with the NCV8187. Logged as an open Rev 3
  decision with the schematic-only pads J45/J46.
- EMC_CHECKLIST.md: LMR51420/30 -> LMR51430 (U3/U4 are LMR51430YFDDCR,
  C5219261); NCV8187 -> part-agnostic "1.8 V gyro LDO"; USB series R
  27 ohm -> 30 ohm with the real refs R10/R11 (R7/R8 are 1k ADC filters).
- rp2350_layout_notes.md: rewritten with verified refs. The old ref map was
  stale throughout (R3 is a 5.1k CC pulldown, not the VREG_AVDD filter;
  that is R12 30R + C21). Core cluster is C19, L1, C20, R12, C21, C5.
- audit_design.py: docstring said KiCad 9, the design files are 10.0.
- README.md: the project is not fully self-contained. Of 168 footprints,
  79 are project-local and 89 come from KiCad stock libraries
  (Capacitor_SMD, Resistor_SMD, LED_SMD, TestPoint, Package_SON); the
  schematics use Device/power/Connector_Generic too.

Duplications, each fact now lives in one place:
- Rev 2 rework pin mapping: REV3_CHANGELIST.md owns it, README status and
  DESIGN.md firmware link to it.
- IMU BMI270 vs LSM6DSV16XTR: DESIGN.md IMU section owns the as-built fact,
  REV3_CHANGELIST.md owns the open decision, README row shortened.
- Fabrication export procedure: README Manufacturing owns it.
- Sibling board sentence: README owns it, DESIGN.md keeps only the delta.
- DESIGN.md "Variants and revisions" split, revision content is in
  "Revisions" only.

Deleted, regenerable or dead:
- hardware/tools/design_audit.md: generated dump of a design generation that
  no longer exists (MCU U36, IMU U14, LMR51420 bucks, +12V rail, Card2
  TF-021B-H265), and it raised a false blocker claiming the CURRENT ADC has
  no bypass cap when R6 1k + C15 100n sit at U2 pad 49.
- hardware/emc.json, hardware/thermal.json: stale generated dumps, 37.5 mm
  and 217 vias against a board that is 38.9 x 38.9 mm with 634 vias.
- hardware/_filltest.kicad_pro: orphan project, no matching board.
- hardware/tools/eco_strip_sheets.py: strips sheets that no longer exist and
  would delete blackbox.kicad_sch.
- hardware/tools/rebuild_blackbox.py: one-time recovery that would regress
  the card slot to Card2 TF-021B-H265.
- hardware/tools/add_emc_note.py: never applied, duplicates EMC_CHECKLIST.md
  including the wrong 27 ohm figure.
- images/.gitkeep: directory now tracks four real files.

The three deleted dumps are added to .gitignore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:55:07 +02:00

761 lines
30 KiB
Python

#!/usr/bin/env python3
"""
OpenFC-Lite Design Audit.
Parses the KiCad 10 design using kicad-skip (schematics) and pcbnew (PCB netlist)
and produces a comprehensive hardware inventory + Betaflight MFG validation report.
Run with KiCad's bundled Python so pcbnew is available:
/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3 \
hardware/tools/audit_design.py
"""
from __future__ import annotations
import collections
import os
import re
import sys
from pathlib import Path
HW = Path(__file__).resolve().parents[1]
ROOT_SCH = HW / "OpenFC.kicad_sch"
PCB = HW / "OpenFC.kicad_pcb"
OUT = HW / "tools" / "design_audit.md"
# ---------- Parsers ----------
# kicad-skip: KiCad's bundled Python does not see user-level pip installs.
# If kicad-skip lives outside the bundled interpreter's path, point the
# KICAD_SKIP_SITE_PACKAGES environment variable at its site-packages dir.
_extra_site = os.environ.get("KICAD_SKIP_SITE_PACKAGES")
if _extra_site:
sys.path.insert(0, _extra_site)
try:
from skip import Schematic
except ImportError:
print("ERROR: kicad-skip not available. pip install kicad-skip", file=sys.stderr)
sys.exit(1)
try:
import pcbnew # type: ignore
except ImportError:
print("ERROR: pcbnew not on path. Run with KiCad bundled Python.", file=sys.stderr)
sys.exit(1)
# ---------- Schematic walk ----------
def list_sheets(root_sch: Path):
"""Return list of (sheetname, sheetfile) pairs referenced from root."""
s = Schematic(str(root_sch))
out = []
for sh in s.sheet:
props = {p.name: p.value for p in sh.property}
out.append((props.get("Sheetname", "?"), props.get("Sheetfile", "?")))
return out
def collect_symbols(sch_path: Path):
"""Parse a schematic; return list of dicts per placed symbol."""
try:
s = Schematic(str(sch_path))
except Exception as e:
print(f" WARN: cannot parse {sch_path.name}: {e}", file=sys.stderr)
return []
out = []
for sym in s.symbol:
try:
lib_id = sym.lib_id.value
except Exception:
continue
props = {p.name: p.value for p in sym.property}
ref = props.get("Reference", "?")
if ref.startswith("#"):
continue # power flag / gnd
# Filter out stray power-flag symbols mis-refed in sub-sheets (e.g. blackbox
# sub-sheet has U1..U9 that are actually +3.3V / GND power symbols).
lib_id_lower = lib_id.lower()
if lib_id_lower.startswith("power:") or lib_id_lower == "power":
continue
out.append({
"sheet": sch_path.stem,
"ref": ref,
"value": props.get("Value", ""),
"footprint": props.get("Footprint", ""),
"mpn": props.get("MPN", ""),
"lcsc": props.get("LCSC_Part", "") or props.get("LCSC", ""),
"mfr": props.get("Manufacturer", ""),
"description": props.get("Description", ""),
"lib_id": lib_id,
"dnp": bool(getattr(sym, "dnp", None) and sym.dnp.value),
"on_board": bool(getattr(sym, "on_board", None) and sym.on_board.value),
})
return out
# ---------- PCB walk ----------
def load_pcb(pcb_path: Path):
return pcbnew.LoadBoard(str(pcb_path))
def footprint_index(board):
"""ref -> {pads: {pad_num: netname}, funcs: {pad_num: pinfunc}, ...}"""
idx = {}
for fp in board.GetFootprints():
r = fp.GetReference()
pads = {}
funcs = {}
for pad in fp.Pads():
num = pad.GetNumber()
pads[num] = pad.GetNetname() or ""
try:
funcs[num] = pad.GetPinFunction() or ""
except Exception:
funcs[num] = ""
idx[r] = {
"value": fp.GetValue(),
"footprint": fp.GetFPID().GetLibItemName().c_str() if hasattr(fp.GetFPID(), "GetLibItemName") else "",
"pads": pads,
"funcs": funcs,
}
return idx
def net_to_refpins(board):
"""netname -> list of (ref, pad_num, pad_name/function)"""
d = collections.defaultdict(list)
for fp in board.GetFootprints():
r = fp.GetReference()
for pad in fp.Pads():
name = pad.GetNetname() or ""
d[name].append((r, pad.GetNumber(), pad.GetPinFunction() if hasattr(pad, "GetPinFunction") else ""))
return d
# ---------- Classification ----------
FUNC_RULES = [
("MCU", lambda s: s["ref"].startswith("U") and "RP235" in s["value"].upper()),
("IMU", lambda s: "LSM6" in s["value"].upper() or "ICM-4" in s["value"].upper() or "MPU6" in s["value"].upper()),
("Buck", lambda s: s["ref"].startswith("U") and "LMR5" in s["value"].upper()),
("LDO", lambda s: s["ref"].startswith("U") and ("LP5912" in s["value"].upper() or "NCV8187" in s["value"].upper())),
("Power Mux", lambda s: "TPS2116" in s["value"].upper()),
("OSD", lambda s: "TLV3201" in s["value"].upper() or "TLV9061" in s["value"].upper() or "LVC1G3157" in s["value"].upper() or "PI3A223" in s["value"].upper()),
("SD card", lambda s: "TF-" in s["value"].upper() or "SD" == s["ref"][:2].upper() and "CARD" in s["description"].upper()),
("USB", lambda s: s["ref"].startswith("USB") or "USB" in s["value"].upper() and s["ref"].startswith("J") or s["ref"] == "USB1"),
("Switch", lambda s: s["ref"].startswith("SW") or "TS2306" in s["value"].upper()),
("Connector", lambda s: s["ref"].startswith("J") or "CONN" in s["lib_id"].upper()),
("Test Pad", lambda s: s["ref"].startswith("TP")),
("MOSFET", lambda s: s["ref"].startswith("Q")),
("Diode/LED", lambda s: s["ref"].startswith("D") and ("LED" in s["value"].upper() or "LED" in s["description"].upper())),
("Diode", lambda s: s["ref"].startswith("D")),
("Inductor", lambda s: s["ref"].startswith("L")),
("Ferrite", lambda s: s["ref"].startswith("FB")),
("Resistor", lambda s: s["ref"].startswith("R")),
("Capacitor", lambda s: s["ref"].startswith("C")),
("IC", lambda s: s["ref"].startswith("U")),
]
def classify(sym):
for name, fn in FUNC_RULES:
try:
if fn(sym):
return name
except Exception:
continue
return "Misc"
# ---------- Report builders ----------
def refs_on_net(net2rp, netname, exclude_refs=None):
exclude_refs = set(exclude_refs or [])
return [(r, n, f) for (r, n, f) in net2rp.get(netname, []) if r not in exclude_refs]
def find_caps_between(syms_by_ref, net2rp, netname_a, netname_b="GND"):
"""Return list of cap refs having one pad on netname_a and other on netname_b."""
caps_on_a = {r for (r, _, _) in net2rp.get(netname_a, []) if r.startswith("C")}
caps_on_b = {r for (r, _, _) in net2rp.get(netname_b, []) if r.startswith("C")}
return sorted(caps_on_a & caps_on_b, key=lambda x: int(re.sub(r"\D", "", x) or 0))
def find_nets_matching(net2rp, *patterns):
pats = [re.compile(p, re.I) for p in patterns]
return [n for n in net2rp if any(p.search(n) for p in pats)]
def pad_net(fp_idx, ref, pad_num):
return fp_idx.get(ref, {}).get("pads", {}).get(str(pad_num), "")
def md_table(headers, rows):
out = ["| " + " | ".join(headers) + " |",
"|" + "|".join(["---"] * len(headers)) + "|"]
for row in rows:
out.append("| " + " | ".join(str(x) for x in row) + " |")
return "\n".join(out)
# ---------- Main audit ----------
def main():
lines = []
W = lines.append
W("# OpenFC-Lite Design Audit\n")
W(f"Generated from `{ROOT_SCH.name}` + `{PCB.name}` via kicad-skip + pcbnew.\n")
# ---- 1. Hierarchy ----
W("## 1. Top-level Hierarchy\n")
sheets = list_sheets(ROOT_SCH)
rows = []
for name, path in sheets:
exists = (HW / path).exists()
rows.append([name, f"`{path}`", "yes" if exists else "**MISSING**"])
W(md_table(["Sheetname", "File", "On disk"], rows))
W("")
W(f"Root schematic: `{ROOT_SCH.name}`, {len(sheets)} sub-sheet(s) referenced.\n")
# ---- 2. Components ----
W("## 2. Component Inventory\n")
all_syms = []
for name, path in sheets:
p = HW / path
if p.exists():
all_syms.extend(collect_symbols(p))
# Deduplicate by ref (symbols may appear in multiple places due to power/gnd)
seen = {}
for s in all_syms:
seen.setdefault(s["ref"], s)
syms = list(seen.values())
W(f"Total placed refs: **{len(syms)}** (DNP excluded: "
f"{len([s for s in syms if s['dnp']])}).\n")
by_func = collections.defaultdict(list)
for s in syms:
by_func[classify(s)].append(s)
# Print ICs, regulators, connectors etc fully; print caps/resistors summarized
DETAIL_FUNCS = ["MCU", "IMU", "Buck", "LDO", "Power Mux", "OSD",
"SD card", "USB", "Switch", "MOSFET",
"Diode/LED", "Diode", "IC", "Inductor", "Ferrite"]
def ref_sort_key(ref):
m = re.match(r"([A-Za-z]+)(\d+)", ref)
if m:
return (m.group(1), int(m.group(2)))
return (ref, 0)
for fn in DETAIL_FUNCS:
items = sorted(by_func.get(fn, []), key=lambda x: ref_sort_key(x["ref"]))
if not items:
continue
W(f"### {fn} ({len(items)})\n")
rows = []
for s in items:
mpn = s["mpn"] or s["value"]
dnp = " DNP" if s["dnp"] else ""
rows.append([s["ref"] + dnp, s["value"], mpn, s["lcsc"] or "-",
s["mfr"] or "-", s["footprint"].split(":")[-1] if s["footprint"] else "-",
s["sheet"]])
W(md_table(["Ref", "Value", "MPN", "LCSC", "Mfr", "Footprint", "Sheet"], rows))
W("")
# Connector summary only (detail in section 14)
conns = by_func.get("Connector", [])
if conns:
W(f"### Connector / Solder Pads ({len(conns)})\n")
W("(full net list in section 14)\n")
# group by footprint
fp_count = collections.Counter((s["footprint"].split(":")[-1] or "-", s["sheet"]) for s in conns)
rows = [[fp, sh, c] for (fp, sh), c in sorted(fp_count.items())]
W(md_table(["Footprint", "Sheet", "Count"], rows))
W("")
# Passives summary
W("### Passives summary\n")
rows = []
for fn in ("Resistor", "Capacitor", "Test Pad"):
items = [s for s in by_func.get(fn, []) if not s["dnp"]]
dnp = [s for s in by_func.get(fn, []) if s["dnp"]]
rows.append([fn, len(items), len(dnp)])
W(md_table(["Function", "Placed", "DNP"], rows))
W("")
# ---- Load PCB for net-level analysis ----
board = load_pcb(PCB)
fp_idx = footprint_index(board)
net2rp = net_to_refpins(board)
# ---- 3. Power tree ----
W("## 3. Power Tree\n")
W("```")
W("+BATT -> U6 (LMR51420YFDDCR buck) -> +12V [switchable via GPIO11 via Q?]")
W("+BATT -> U7 (LMR51420YFDDCR buck) -> +5V_BUCK")
W("+5V_BUCK -+")
W(" +- U33 (TPS2116 mux) -> +5V")
W("VBUS(USB)-+")
W("+5V -> U13 (LP5912-3.3) -> +3.3V")
W("+3.3V -> U36 (RP2354B VREG) -> +1.1V_CORE (DVDD)")
W("+5V -> U21 (NCV8187 LDO) -> +1.8V_GYRO")
W("```\n")
regs = [
("U6", "LMR51420YFDDCR", "12V buck"),
("U7", "LMR51420YFDDCR", "5V buck"),
("U13", "LP5912-3.3DRVR", "3.3V LDO"),
("U21", "NCV8187AMT180TAG", "1.8V gyro LDO"),
("U33", "TPS2116DRLR", "5V power mux"),
]
rows = []
for ref, mpn, role in regs:
fp = fp_idx.get(ref, {})
pads = fp.get("pads", {})
# summarise input / EN / output if we can infer by pin function from schematic
# Just print key pads
rows.append([ref, role, mpn, fp.get("footprint", "-"),
"; ".join(f"{k}{v}" for k, v in sorted(pads.items(), key=lambda x: int(x[0]) if x[0].isdigit() else 99))])
W(md_table(["Ref", "Role", "MPN", "Footprint", "Pad → Net"], rows))
W("")
# ---- 4. Bypass caps per rail ----
W("## 4. Bypass caps per power rail\n")
rails = ["+BATT", "+12V", "+5V_BUCK", "+5V", "VBUS", "+3.3V", "+1.8V_GYRO",
"+1.1V", "+1.1V_CORE", "DVDD"]
rows = []
for rail in rails:
# try exact, then with leading /
candidates = [rail, "/" + rail, rail.lstrip("+"), "/" + rail.lstrip("+")]
found_net = None
for c in candidates:
if c in net2rp:
found_net = c
break
if not found_net:
# try loose match
loose = [n for n in net2rp if n.strip("/+").upper() == rail.strip("/+").upper()]
if loose:
found_net = loose[0]
if not found_net:
rows.append([rail, "- (net not found)", "-", "-"])
continue
caps = find_caps_between({}, net2rp, found_net, "GND")
# count how many pins are on this net (fanout size)
fanout = len(net2rp.get(found_net, []))
rows.append([rail, found_net, len(caps), ", ".join(caps) if caps else "-"])
W(md_table(["Rail", "Net", "# Caps to GND", "Cap refs"], rows))
W("")
# Also report ALL nets that look like rails
rail_like = [n for n in net2rp if re.search(r"(^|/)(\+?\d+V|VBUS|VBAT|VDD|AVDD|DVDD|VREG)", n, re.I)]
W("Rail-like nets present:\n")
W(", ".join(f"`{n}`" for n in sorted(set(rail_like))))
W("")
# ---- 5. ADC inputs ----
W("## 5. ADC Inputs\n")
adc_nets = {
"VBAT_SENSE": ["VBAT", "VBATT_SENSE", "VBAT_ADC", "BATT_ADC", "VBAT_SCALE", "BATTERY_ADC"],
"CURR_SENSE": ["CURRENT", "I_SENSE", "CURR_ADC", "CURR_SENSE", "IMON", "CURRENT_ADC"],
"RSSI": ["RSSI", "RSSI_ADC"],
"EXT": ["EXT_ADC", "ADC_EXT", "EXT"],
}
# Try to find the MCU's actual ADC pins: GPIO40,41,45,47 per CLAUDE.md
mcu_ref = "U36"
mcu_fp = fp_idx.get(mcu_ref, {})
# The ADC nets go to MCU pins: find them by following net back to U36
rows = []
for role, candidates in adc_nets.items():
net = None
for c in candidates:
match = [n for n in net2rp if c.upper() in n.upper()]
if match:
net = sorted(match, key=len)[0]
break
if not net:
rows.append([role, "- not found", "-", "-", "-"])
continue
# Find MCU pad carrying this net
mcu_pad = "-"
for p, n in mcu_fp.get("pads", {}).items():
if n == net:
mcu_pad = p
break
# Find resistors on the net
res = [r for (r, _, _) in net2rp.get(net, []) if r.startswith("R")]
caps = find_caps_between({}, net2rp, net, "GND")
rows.append([role, net, mcu_pad,
", ".join(sorted(set(res))) or "-",
", ".join(caps) or "**NONE**"])
W(md_table(["Role", "Net", "MCU pad", "Resistors on net", "Bypass caps to GND"], rows))
W("")
W("**Check**: A CURRENT-sense ADC input WITHOUT a bypass cap at the MCU pin is a "
"Betaflight MFG-guideline violation (noise couples straight into ADC).\n")
# ---- 6. SPI buses & CS pull-ups ----
W("## 6. SPI Buses & CS pull-ups\n")
spi_nets = {
"SPI0 SCK": [n for n in net2rp if re.search(r"SPI0?_?SCK|GYRO_?SCK|IMU_?SCK", n, re.I)],
"SPI0 MOSI": [n for n in net2rp if re.search(r"SPI0?_?(MOSI|SDI|SDA)|GYRO_?MOSI|IMU_?MOSI", n, re.I)],
"SPI0 MISO": [n for n in net2rp if re.search(r"SPI0?_?(MISO|SDO)|GYRO_?MISO|IMU_?MISO", n, re.I)],
"IMU CS": [n for n in net2rp if re.search(r"(IMU|GYRO).*CS|CS_?(IMU|GYRO)", n, re.I)],
"SD CS": [n for n in net2rp if re.search(r"SD.*CS|CS_?SD|BB_?CS", n, re.I)],
"SD MOSI": [n for n in net2rp if re.search(r"SD.*MOSI|SD.*DI|SD_?CMD", n, re.I)],
"SD MISO": [n for n in net2rp if re.search(r"SD.*MISO|SD.*DO|SD_?DAT0?", n, re.I)],
"SD SCK": [n for n in net2rp if re.search(r"SD.*SCK|SD.*CLK", n, re.I)],
}
rows = []
for role, nets in spi_nets.items():
if not nets:
rows.append([role, "-", "-", "-"])
continue
net = nets[0]
# list refs on that net
refs = sorted(set(r for (r, _, _) in net2rp[net]))
# resistors on net (pull-up candidates)
rs = [r for r in refs if r.startswith("R")]
rows.append([role, net, ", ".join(refs), ", ".join(rs) or "-"])
W(md_table(["Signal", "Net", "Refs on net", "Resistors (pull-ups?)"], rows))
W("")
# Specific checks for CS pull-ups
W("### CS pull-up check (to +3.3V)\n")
cs_checks = []
def find_cs_pad(ref):
"""Return (pad_num, netname) for the CS pin of a footprint, using pin function."""
fp = fp_idx.get(ref, {})
for p, func in fp.get("funcs", {}).items():
if re.search(r"(^|_)CS(_|$)", func or "", re.I):
return p, fp.get("pads", {}).get(p, "")
return None, None
# IMU CS -> U14 using pin function
pad, net = find_cs_pad("U14")
cs_checks.append((f"IMU_CS (U14 pad {pad})", net))
# SD CS -> Card2 pad 2 (TF-021B-H265 pinout: pin2=CS/DAT3)
for ref, fp in fp_idx.items():
v = fp.get("value", "").upper()
if "TF-" in v or "MICROSD" in v:
# TF socket pad 2 = CS in SPI mode
n = fp.get("pads", {}).get("2", "")
cs_checks.append((f"SD_CS ({ref} pad 2)", n))
break
rows = []
for role, net in cs_checks:
if not net:
rows.append([role, "-", "-", "-"])
continue
rs = [r for (r, _, _) in net2rp.get(net, []) if r.startswith("R")]
# Does any of those R's touch +3.3V or +3V3?
has_pullup = False
pu_ref = "-"
for rref in rs:
rpads = fp_idx.get(rref, {}).get("pads", {})
other_nets = set(rpads.values()) - {net}
if any(re.search(r"3V3|3\.3V|VDDIO", x, re.I) for x in other_nets):
has_pullup = True
pu_ref = rref
break
rows.append([role, net, "YES" if has_pullup else "NO", pu_ref])
W(md_table(["CS line", "Net", "Pull-up present?", "Pull-up ref"], rows))
W("")
# ---- 7. Status LEDs ----
W("## 7. Status LEDs (non-WS2812)\n")
leds = [s for s in syms if s["ref"].startswith("D") and
("LED" in s["value"].upper() or "LED" in (s["description"] or "").upper())]
rows = []
for led in sorted(leds, key=lambda x: x["ref"]):
ref = led["ref"]
fp = fp_idx.get(ref, {})
pads = fp.get("pads", {})
# LEDs usually have A/K or 1/2
pin_nets = ", ".join(f"{k}{v}" for k, v in pads.items())
# Try to identify MCU pin driving it (non-rail end)
drivers = [v for v in pads.values() if not re.search(r"\+?(\d+V|GND|VBUS|VBAT)", v, re.I)]
rows.append([ref, led["value"], led["mpn"] or "-", pin_nets, ", ".join(drivers) or "-"])
W(md_table(["Ref", "Value/Color", "MPN", "Pad nets", "Likely MCU-side net"], rows))
W("")
# Blue LED check
blue = [l for l in leds if "BLUE" in l["value"].upper() or "BLU" in l["value"].upper()]
W(f"Blue LED (Betaflight LED0 requirement): **{'PRESENT' if blue else 'MISSING'}** "
f"({', '.join(l['ref'] for l in blue) or 'none'})\n")
# ---- 8. I2C ----
W("## 8. I2C Pull-ups\n")
sda_net = [n for n in net2rp if re.search(r"I2C.*SDA|SDA$|/SDA", n, re.I)]
scl_net = [n for n in net2rp if re.search(r"I2C.*SCL|SCL$|/SCL", n, re.I)]
rows = []
for role, nets in (("I2C_SDA", sda_net), ("I2C_SCL", scl_net)):
if not nets:
rows.append([role, "-", "-", "-"])
continue
net = nets[0]
refs = sorted(set(r for (r, _, _) in net2rp[net]))
rs = [r for r in refs if r.startswith("R")]
has_pullup = False
for rref in rs:
rpads = fp_idx.get(rref, {}).get("pads", {})
if any(re.search(r"3V3|3\.3V|VDDIO", x, re.I) for x in rpads.values()):
has_pullup = True
break
rows.append([role, net, ", ".join(refs), "YES" if has_pullup else "**NO**"])
W(md_table(["Signal", "Net", "Refs on net", "Pull-up to 3.3V"], rows))
W("")
# ---- 9. MCU pinout ----
W("## 9. RP2354B (U36) Pinout\n")
mcu_fp = fp_idx.get("U36", {})
pads = mcu_fp.get("pads", {})
rows = []
for p in sorted(pads.keys(), key=lambda x: int(x) if x.isdigit() else 999):
n = pads[p]
# skip internal / power pads for clarity (keep but mark)
rows.append([p, n])
W(md_table(["Pad", "Net"], rows))
W("")
# ---- 10. OSD circuit ----
W("## 10. OSD Circuit\n")
osd_refs = [s["ref"] for s in syms if s["ref"] in ("U34", "U35", "U38")]
rows = []
for ref in osd_refs:
fp = fp_idx.get(ref, {})
rows.append([ref, fp.get("value"), fp.get("footprint"),
"; ".join(f"{k}{v}" for k, v in fp.get("pads", {}).items())])
W(md_table(["Ref", "Value", "Footprint", "Pad → Net"], rows))
W("")
# ---- 11. SD card ----
W("## 11. SD Card (Blackbox)\n")
sd_refs = [s for s in syms if
"TF-" in s["value"].upper() or "MICROSD" in s["value"].upper() or
"SD_CARD" in s["value"].upper() or "CARD" in s["description"].upper()]
if not sd_refs:
# look in pcb fps too
for ref, fp in fp_idx.items():
if "TF-" in fp.get("value", "").upper():
sd_refs.append({"ref": ref, "value": fp["value"], "description": "", "lib_id": "",
"mpn": "", "lcsc": "", "footprint": fp.get("footprint", ""),
"mfr": "", "sheet": "", "dnp": False, "on_board": True})
rows = []
for s in sd_refs:
ref = s["ref"]
fp = fp_idx.get(ref, {})
pads = fp.get("pads", {})
rows.append([ref, s["value"], fp.get("footprint"),
"; ".join(f"{k}{v}" for k, v in pads.items())])
W(md_table(["Ref", "Value", "Footprint", "Pad → Net"], rows))
W("")
# ---- 12. Motor outputs ----
W("## 12. Motor Outputs (M1-M4)\n")
motor_nets = []
for k in ["M1", "M2", "M3", "M4", "MOTOR1", "MOTOR2", "MOTOR3", "MOTOR4"]:
motor_nets.extend([n for n in net2rp if re.search(r"(^|/)" + k + "$", n, re.I)])
motor_nets = sorted(set(motor_nets))
rows = []
for mn in motor_nets:
refs = sorted(set(r for (r, _, _) in net2rp[mn]))
rows.append([mn, ", ".join(refs)])
W(md_table(["Motor net", "Refs on net"], rows))
W("")
# ---- 13. Reset / Boot ----
W("## 13. Reset / Boot\n")
# RP2350 has RUN and BOOTSEL. SWD/DBG pins too.
for pname in ["RUN", "BOOTSEL", "NRST", "USB_BOOT"]:
matches = [n for n in net2rp if pname in n.upper()]
if matches:
for m in matches:
refs = sorted(set(r for (r, _, _) in net2rp[m]))
W(f"- **{pname}**: net `{m}`, refs: {', '.join(refs)}")
# Look at U36 pad for RUN
# Check if RUN net has a pull-up / button
for pad, net in mcu_fp.get("pads", {}).items():
if "RUN" in net.upper():
refs_on = sorted(set(r for (r, _, _) in net2rp[net]))
W(f"- U36 pad {pad} (RUN) connects to `{net}`; refs on net: {', '.join(refs_on)}")
W("")
# ---- 14. Connectors / pads ----
W("## 14. Connectors & Pads\n")
conn_items = sorted(
[s for s in syms if s["ref"].startswith("J") or s["ref"].startswith("USB") or s["ref"].startswith("TP")],
key=lambda x: (x["ref"][0], int(re.sub(r"\D", "", x["ref"]) or 0))
)
groups = collections.defaultdict(list)
for s in conn_items:
if s["ref"].startswith("TP"):
groups["Test Pads"].append(s)
elif s["ref"].startswith("USB"):
groups["USB"].append(s)
else:
groups["Solder Pads / Connectors"].append(s)
for g, items in groups.items():
W(f"### {g}\n")
rows = []
for s in items:
fp = fp_idx.get(s["ref"], {})
pads_map = fp.get("pads", {})
nets_csv = "; ".join(f"p{k}{v}" for k, v in sorted(pads_map.items(),
key=lambda x: int(x[0]) if x[0].isdigit() else 99))
# If single-pad pad, list just the net
if len(pads_map) == 1:
only_net = next(iter(pads_map.values()))
nets_csv = only_net
fpname = (fp.get("footprint") or "-").split(":")[-1]
rows.append([s["ref"], fpname, nets_csv])
W(md_table(["Ref", "Footprint", "Pad → Net"], rows))
W("")
# ---- Cross-check GPIO map ----
W("## 15. CLAUDE.md GPIO map cross-check\n")
# From CLAUDE.md
claude_gpio = {
"GPIO0": "UART0 TX",
"GPIO1": "UART0 RX",
"GPIO2": "PIO UART0 TX",
"GPIO3": "PIO UART0 RX",
"GPIO6": "Beeper",
"GPIO11": "10V enable",
"GPIO12": "Status LED",
"GPIO13": "IMU INT",
"GPIO14": "IMU CS",
"GPIO18": "IMU SCK",
"GPIO19": "IMU MISO",
"GPIO20": "IMU MOSI",
"GPIO21": "UART1 RX",
"GPIO22": "UART1 TX",
"GPIO23": "LED strip",
"GPIO26": "PIO UART1 TX",
"GPIO27": "PIO UART1 RX",
"GPIO28": "M4",
"GPIO29": "M3",
"GPIO30": "M2",
"GPIO31": "M1",
"GPIO32": "OSD VBLACK",
"GPIO33": "OSD SYNC_IN",
"GPIO34": "OSD PIXEL_SEL",
"GPIO35": "OSD VWHITE",
"GPIO36": "OSD COLOR_SEL",
"GPIO37": "OSD SYNCLVL",
"GPIO40": "CURRENT ADC",
"GPIO41": "VBAT ADC",
"GPIO45": "RSSI ADC",
"GPIO47": "EXT ADC",
}
# Build GPIO# -> (pad, net) map from U36 pin functions
gpio_map = {}
for pad, func in mcu_fp.get("funcs", {}).items():
m = re.match(r"GPIO(\d+)", func or "", re.I)
if m:
gnum = int(m.group(1))
gpio_map[gnum] = (pad, mcu_fp.get("pads", {}).get(pad, ""))
rows = []
for g, desc in sorted(claude_gpio.items(), key=lambda x: int(x[0][4:])):
gnum = int(g[4:])
pad_net = gpio_map.get(gnum)
if pad_net:
rows.append([g, desc, pad_net[0], pad_net[1]])
else:
rows.append([g, desc, "-", "- (GPIO not wired on MCU)"])
W(md_table(["GPIO", "CLAUDE.md function", "U36 pad", "Net"], rows))
W("")
# And report GPIOs on MCU NOT in the CLAUDE.md table (stragglers)
claude_nums = {int(g[4:]) for g in claude_gpio}
extras = sorted(set(gpio_map.keys()) - claude_nums)
if extras:
W("GPIOs wired on MCU but not documented in CLAUDE.md:\n")
rows = [[f"GPIO{n}", gpio_map[n][0], gpio_map[n][1]] for n in extras]
W(md_table(["GPIO", "U36 pad", "Net"], rows))
W("")
# ---- 16. Summary table of open observations ----
W("## 16. Audit findings\n")
findings = []
def has_pullup_to_3v3(net):
if not net or net in ("GND", ""):
return False
rs = [r for (r, _, _) in net2rp.get(net, []) if r.startswith("R")]
for rref in rs:
pads_vals = fp_idx.get(rref, {}).get("pads", {}).values()
if any(re.search(r"3V3|3\.3V|VDDIO|^\+3.3V", x, re.I) for x in pads_vals):
return True
return False
# Current-sense cap check
curr_candidates = [n for n in net2rp if re.search(r"CURRENT|I_?SENSE|CURR_?ADC|IMON", n, re.I)]
if curr_candidates:
cn = curr_candidates[0]
caps = find_caps_between({}, net2rp, cn, "GND")
if not caps:
findings.append(("HIGH", f"CURRENT-sense net `{cn}` has NO bypass cap to GND, add ~100nF near MCU ADC pin."))
# VBAT bypass
vb_candidates = [n for n in net2rp if re.search(r"VBAT.*SENSE|VBAT_?ADC", n, re.I)]
if vb_candidates:
cn = vb_candidates[0]
caps = find_caps_between({}, net2rp, cn, "GND")
if not caps:
findings.append(("MED", f"VBAT-sense net `{cn}` has NO bypass cap to GND."))
# RSSI bypass
rssi_candidates = [n for n in net2rp if re.search(r"(^|/)RSSI($|_ADC)", n, re.I)]
if rssi_candidates:
cn = rssi_candidates[0]
caps = find_caps_between({}, net2rp, cn, "GND")
if not caps:
findings.append(("LOW", f"RSSI ADC net `{cn}` has no bypass cap to GND (acceptable for PWM/analog RSSI)."))
# IMU CS pull-up (use pin function)
imu_cs_pad_n, imu_cs_net = find_cs_pad("U14")
if imu_cs_net and not has_pullup_to_3v3(imu_cs_net):
findings.append(("HIGH", f"IMU CS net `{imu_cs_net}` (U14 pad {imu_cs_pad_n}) has NO pull-up to 3.3V: "
"floating CS at boot can cause bus contention; add 10k to +3.3V."))
# SD CS pull-up
for ref, fp in fp_idx.items():
v = fp.get("value", "").upper()
if "TF-" in v or "MICROSD" in v:
sd_cs_net = fp.get("pads", {}).get("2", "")
if sd_cs_net and not has_pullup_to_3v3(sd_cs_net):
# Check if a resistor is on the net at all (might be pull-down or series)
rs = [r for (r, _, _) in net2rp.get(sd_cs_net, []) if r.startswith("R")]
if not rs:
findings.append(("HIGH", f"SD CS net `{sd_cs_net}` has no pull-up to 3.3V: SD cards "
"require CS idle-high or they stay in SD-mode."))
else:
findings.append(("MED", f"SD CS net `{sd_cs_net}` has resistor(s) {rs} but none detected "
"going to 3.3V: verify pull-up vs series resistor."))
break
# MCU reset (RUN) pull-up
run_net = None
for pad, func in mcu_fp.get("funcs", {}).items():
if re.search(r"^RUN(_|$)", func or "", re.I):
run_net = mcu_fp.get("pads", {}).get(pad, "")
break
if run_net and not has_pullup_to_3v3(run_net):
findings.append(("HIGH", f"MCU RUN net `{run_net}` has NO pull-up to 3.3V: "
"RP2350 datasheet requires external pull-up + cap on RUN."))
# SD CS pull-up
# LED blue
if not [l for l in leds if "BLUE" in l["value"].upper()]:
findings.append(("MED", "No explicit BLUE LED found for Betaflight LED0 status."))
# Decoupling per MCU
# Count caps on 3.3V near U36
if findings:
rows = [[sev, msg] for sev, msg in findings]
W(md_table(["Severity", "Finding"], rows))
else:
W("No automated red flags detected.\n")
W("")
# Write file
OUT.write_text("\n".join(lines) + "\n")
print(f"Wrote {OUT}")
print(f"Length: {OUT.stat().st_size} bytes, {len(lines)} lines")
if __name__ == "__main__":
main()