pixalynx/nrf9151-gps-tracker

A compact 44×32 mm, 4-layer nRF9151 LTE-M/NB-IoT/GNSS tracker PCB with nPM1300 LiPo/USB-C power, nano-SIM/eSIM switching, nRF7002 Wi‑Fi scanning, accelerometer, antennas, buzzer, LEDs, SOS button, and SWD/debug connectors.

Version
0.1.4
License
unset
Stars
0

scripts/validate.ts

/** Design assertions against the built Circuit JSON (run `npx tsci build index.circuit.tsx` first).
 * They check the netlist tscircuit actually produced, so a wiring mistake in the TSX shows up here even if the
 * schematic looks right, plus copper coverage once the board is routed. Not a substitute for bring-up.
 * Usage: bun scripts/validate.ts [--routed] */
import assert from "node:assert/strict"
import { existsSync, readFileSync } from "node:fs"

type El = Record<string, any>
const path = "dist/index/circuit.json"
assert(existsSync(path), `${path} missing: run npx tsci build index.circuit.tsx`)
const json: El[] = JSON.parse(readFileSync(path, "utf8"))
const routed = process.argv.includes("--routed")

class Netlist {
  portNet = new Map<string, string>()
  netPorts = new Map<string, Set<string>>()
  constructor(json: El[]) {
    const comp = new Map(json.filter((e) => e.type === "source_component").map((e) => [e.source_component_id, e.name]))
    const ports = new Map(json.filter((e) => e.type === "source_port").map((e) => [e.source_port_id, e]))
    const nets = new Map(json.filter((e) => e.type === "source_net").map((e) => [e.source_net_id, e.name]))
    const keyName = new Map<string, string>()
    for (const t of json.filter((e) => e.type === "source_trace")) {
      const netNames = (t.connected_source_net_ids ?? []).map((id: string) => nets.get(id)).filter(Boolean)
      for (const pid of t.connected_source_port_ids ?? []) {
        const key = ports.get(pid)?.subcircuit_connectivity_map_key
        if (netNames.length && key && !keyName.has(key)) keyName.set(key, netNames[0])
      }
    }
    for (const p of ports.values()) {
      const key = p.subcircuit_connectivity_map_key
      const connected = json.some((t) => t.type === "source_trace" && (t.connected_source_port_ids ?? []).includes(p.source_port_id))
      if (!key || !connected) continue
      const name = keyName.get(key) ?? `~${key}`
      const c = comp.get(p.source_component_id)
      this.portNet.set(`${c}.${p.name}`, name)
      for (const h of p.port_hints ?? []) this.portNet.set(`${c}.${h}`, name)
      if (!this.netPorts.has(name)) this.netPorts.set(name, new Set())
      this.netPorts.get(name)!.add(`${c}.${p.name}`)
    }
  }
  net(sel: string) { return this.portNet.get(sel) ?? null }
  same(a: string, b: string) { const x = this.net(a); return x !== null && x === this.net(b) }
  size(sel: string) { const x = this.net(sel); return x ? this.netPorts.get(x)!.size : 0 }
}
let checks = 0
const failures: string[] = []
const ok = (v: unknown, msg: string) => { checks++; if (!v) failures.push(msg) }
const n = new Netlist(json)
const gnd = (sel: string) => n.net(sel) === "GND"

// ------------------------------------------------------------ nRF9151
ok(n.net("U1.VDD") === "VDD_NRF" && n.same("FB1.B", "U1.VDD") && n.net("FB1.A") === "VSYS", "nRF9151 VDD fed from VSYS through FB1")
ok(["C1", "C2", "C3"].every((c) => n.net(`${c}.pin1`) === "VSYS" && gnd(`${c}.pin2`)), "C1/C2 1 uF + C3 47 uF on the source side of the bead (Nordic reference)")
ok(n.net("C4.pin1") === "VDD_NRF" && gnd("C4.pin2"), "C4 10 uF between the bead and VDD (Nordic reference)")
ok(n.same("R1.pin2", "U1.ENABLE") && n.net("R1.pin1") === "VDD_NRF", "ENABLE to VDD through 10k")
ok(n.net("U1.VDD_GPIO") === "V1V8", "VDD_GPIO on the 1.8 V rail")
ok(n.same("U1.DEC0", "C7.pin2") && n.size("U1.DEC0") === 2, "DEC0 carries exactly its 4.7 uF")
ok(n.same("U1.nRESET", "C8.pin1") && n.same("U1.nRESET", "R2.pin2") && n.size("U1.nRESET") === 3, "nRESET: C8 + R2, no pull-up")
ok(n.net("U1.SIM_DET") === null, "SIM_DET floating (Nordic)")
for (let p = 81; p <= 104; p++) ok(n.net(`U1.RESERVED_${p}`) === null, `RESERVED_${p} unconnected`)
ok(["MAGPIO0", "MAGPIO1", "MAGPIO2", "SDATA", "SCLK", "VIO", "COEX1", "COEX2", "AUX"].every((p) => n.net(`U1.${p}`) === null), "RFFE / unused COEX / AUX open")
ok(n.net("R5.pin1") === "COEX0" && gnd("R5.pin2") && n.net("U1.COEX0") === "COEX0", "COEX0 has its mandatory 100k pull-down")
ok(n.same("R4.pin2", "U2.VEN") && n.net("R4.pin1") === "COEX0", "LNA enable from COEX0 through 1k")
ok(n.same("U2.AMP_IN", "L2.pin1") && n.same("U2.FILT_OUT", "L2.pin2"), "9.1 nH LNA inter-stage inductor")
ok(n.same("U2.RF_OUT", "C12.pin2") && n.same("C12.pin1", "U1.GPS"), "LNA output to GPS through the DC block")
ok(n.same("U2.RF_IN", "C13.pin1") && n.same("C13.pin2", "J2.SIG"), "GNSS U.FL to the LNA input")
ok(n.same("U1.ANT", "C10.pin2") && n.same("C10.pin2", "R3.pin2") && n.same("R3.pin1", "J1.SIG"), "LTE pi-match to J1")
ok(n.net("FB2.A") === "V1V8" && n.same("FB2.B", "U2.VCC"), "LNA supply from 1.8 V through FB2")
ok(n.same("L3.pin1", "J2.SIG") && n.net("L3.pin2") === "VANT" && n.net("U3.LSOUT1") === "VANT", "active-antenna bias-T from load switch 1")

// ------------------------------------------------------------ SIM switch, nano-SIM, eSIM
ok(n.net("U7.VCC") === "V1V8" && gnd("U7.GND"), "switch powered from 1.8 V")
ok(n.net("U7.S") === "SIM_SEL" && n.net("U1.P0_25") === "SIM_SEL" && gnd("R11.pin2") && n.net("R11.pin1") === "SIM_SEL", "SIM_SEL on P0.25 with a pull-down (nano-SIM default)")
ok(n.same("U7.Z1", "U1.SIM_1V8") && n.same("U7.Z2", "U1.SIM_CLK") && n.same("U7.Z3", "U1.SIM_IO") && n.same("U7.Z4", "U1.SIM_RST"), "SiP SIM port on the common side, VCC on the 0.5 ohm pole")
ok(n.same("U7.Y0_1", "J4.VCC") && n.same("U7.Y0_2", "U4.CLK_IN") && n.same("U7.Y0_3", "U4.DATA2_IN") && n.same("U7.Y0_4", "U4.DATA1_IN"), "Y0 (S low) -> TPD3F303 -> nano-SIM")
ok(n.same("U4.DATA1_OUT", "J4.RST") && n.same("U4.CLK_OUT", "J4.CLK") && n.same("U4.DATA2_OUT", "J4.IO") && n.same("U4.VCC", "J4.VCC"), "filtered lines on the socket contacts")
ok(n.same("U7.Y1_1", "U8.VCC") && n.same("U7.Y1_2", "U8.CLK") && n.same("U7.Y1_3", "U8.IO") && n.same("U7.Y1_4", "U8.RST") && gnd("U8.GND"), "Y1 (S high) -> MFF2 eSIM")
ok(n.net("J4.VPP") === null && ["C6", "C8", "C4"].every((c) => n.net(`U8.${c}`) === null), "VPP / C4 / C8 unused")
ok(n.same("C9.pin2", "U1.SIM_1V8") && n.same("C32.pin1", "J4.VCC") && n.same("C34.pin1", "U8.VCC"), "SIM supply capacitors")

// ------------------------------------------------------------ nPM1300 power
ok(n.net("U3.VBAT") === "VBAT" && n.net("J6.BAT_P") === "VBAT" && gnd("J6.BAT_N"), "JST pin 1 = BAT+ to VBAT")
ok(n.net("U3.VBUS") === "VBUS" && n.net("J5.VBUS1") === "VBUS" && n.net("J5.VBUS2") === "VBUS", "USB-C VBUS to the PMIC")
ok(n.same("U3.CC1", "J5.CC1") && n.same("U3.CC2", "J5.CC2") && n.size("U3.CC1") === 2 && n.size("U3.CC2") === 2, "CC1/CC2 straight to the PMIC (internal Rd)")
ok(n.same("U3.VBUSOUT", "C23.pin1") && n.size("U3.VBUSOUT") === 2, "VBUSOUT has its mandatory 1 uF")
ok(n.same("U3.VSET1", "R6.pin1") && gnd("R6.pin2"), "VSET1 47k -> BUCK1 1.8 V")
ok(gnd("U3.VSET2") && n.net("U3.VOUT2") === "VSYS" && n.net("U3.SW2") === null && gnd("U3.PVSS2"), "BUCK2 disabled per Nordic config 3")
ok(n.same("U3.SW1", "L1.pin1") && n.net("L1.pin2") === "V1V8" && n.net("U3.VOUT1") === "V1V8", "BUCK1 inductor and feedback")
ok(n.net("U3.PVDD") === "VSYS" && n.net("C24.pin1") === "VSYS" && n.net("U3.VSYS") === "VSYS", "PVDD from VSYS")
ok(n.net("U3.LSIN1") === "V3V3" && n.net("U3.LSIN2") === "V1V8" && n.net("U3.LSOUT2") === "WIFI_IOVDD", "load switches: VANT from 3.3 V, Wi-Fi IOVDD from 1.8 V")
ok(n.same("U3.NTC", "RT1.A") && gnd("RT1.B"), "on-board 10k NTC for JEITA")
ok(n.net("U3.SDA") === "I2C_SDA" && n.net("U3.SCL") === "I2C_SCL" && n.net("U3.VDDIO") === "V1V8", "PMIC TWI on the 1.8 V bus")
ok(n.net("U3.GPIO0") === "PMIC_INT" && n.net("U1.P0_03") === "PMIC_INT", "PMIC interrupt to P0.03")
ok(n.net("U3.SHPHLD") === "SHPHLD" && n.net("SW1.A") === "SHPHLD" && n.size("U3.SHPHLD") === 2, "SOS button on SHPHLD only (no SiP GPIO)")
ok(n.same("D1.cathode", "U3.LED1") && n.same("D2.cathode", "U3.LED2") && n.net("D1.anode") === "VSYS", "LEDs on the PMIC sinks")
ok(n.net("U9.VIN") === "VSYS" && n.net("U9.VOUT") === "V3V3" && n.net("U9.EN") === "V3V3_EN" && n.net("U1.P0_02") === "V3V3_EN" && gnd("R13.pin2"), "RT9080 3.3 V LDO enabled from P0.02, off by default")

// ------------------------------------------------------------ nRF7002
for (const p of ["VBAT", "AFEVBAT", "BUCKVBAT", "BUCKVBATS"]) ok(n.net(`U5.${p}`) === "V3V3", `nRF7002 ${p} on the 3.3 V rail`)
ok(n.same("U5.BUCKOUT", "L5.pin1") && n.net("L5.pin2") === "W_VDD_BUCK" && n.net("U5.PWRBUCKVDD") === "W_VDD_BUCK" && n.net("U5.RFBUCKVDD") === "W_VDD_BUCK", "internal buck through L5")
ok(["PAVDD0", "PAVDD1", "PALDO"].every((p) => n.net(`U5.${p}`) === "W_PALDO"), "PAVDD0/1 tied to PALDO")
ok(n.same("U5.OTPVDD", "U5.PWRIOVDD"), "OTPVDD from PWRIOVDD")
for (const [pin, cap] of [["SXLDO", "C54"], ["RFVDD", "C53"], ["XOLDO", "C55"], ["AFELDO", "C56"], ["DIGVDD", "C50"], ["BUCKVMID", "C48"]]) ok(n.same(`U5.${pin}`, `${cap}.pin1`) && n.size(`U5.${pin}`) === 2 && gnd(`${cap}.pin2`), `${pin} has its own capacitor ${cap}`)
ok(n.net("U5.IOVDD") === "WIFI_IOVDD", "IOVDD from the PMIC load switch")
ok(n.same("U5.XOP", "X1.X1") && n.same("U5.XON", "X1.X2") && n.size("U5.XOP") === 2 && n.size("U5.XON") === 2, "40 MHz crystal, no load caps")
ok(n.same("U5.TXRF0", "FL1.LOW_BAND") && n.same("U5.TXRF1", "FL1.HIGH_BAND") && n.same("FL1.COM", "J3.SIG"), "2.4 / 5 GHz ports through the diplexer to J3")
ok(n.same("U5.SPI_CLK", "U1.P0_28") && n.same("U5.SPI_MOSI", "U1.P0_29") && n.same("U5.SPI_MISO", "U1.P0_30") && n.same("U5.SPI_SS", "U1.P0_31"), "SPI to P0.28-P0.31")
ok(n.same("U5.HOST_IRQ", "U1.P0_00") && n.same("U5.BUCKEN", "U1.P0_01") && gnd("R12.pin2") && n.same("R12.pin1", "U5.BUCKEN"), "HOST_IRQ P0.00, BUCKEN P0.01 with pull-down")
ok(["RSV2", "RSV3", "RSV4", "RSV5", "QSPI_DATA2", "QSPI_DATA3", "COEX_STATUS0", "COEX_REQ", "SW_CTRL1"].every((p) => gnd(`U5.${p}`)), "nRF7002 Errata [9]: pins 2-5, 39-42, 45 grounded")
ok(["RSV6", "NC21", "NC22", "NC23", "NC24", "COEX_GRANT", "SW_CTRL0"].every((p) => n.net(`U5.${p}`) === null), "nRF7002 pins 6, 21-24, 43, 44 left open")

// ------------------------------------------------------------ UI, motion, debug
ok(n.net("U6.VDD") === "V1V8" && n.net("U6.SCL") === "I2C_SCL" && n.net("U6.SDA") === "I2C_SDA" && gnd("U6.SDO") && n.net("U6.CS") === "V1V8", "LIS2DW12 on I2C at 0x18")
ok(n.same("U6.INT1", "U1.P0_10") && n.same("U6.INT2", "U1.P0_11"), "accelerometer interrupts on P0.10 / P0.11")
ok(n.same("U1.P0_08", "U6.SDA") && n.same("U1.P0_09", "U6.SCL") && n.net("R8.pin2") === "V1V8" && n.net("R9.pin2") === "V1V8", "I2C on P0.08 / P0.09 with 10k pull-ups")
ok(n.net("BZ1.POS") === "V3V3" && n.same("BZ1.NEG", "Q1.D") && n.same("D3.A", "Q1.D") && n.net("D3.K") === "V3V3" && gnd("Q1.S"), "buzzer low-side switch with flyback diode")
ok(n.same("Q1.G", "U1.P0_04") && gnd("R10.pin2"), "buzzer PWM on P0.04 with gate pull-down")
ok(n.same("J7.SWDIO", "U1.SWDIO") && n.same("J7.SWCLK", "U1.SWDCLK") && n.same("J7.NRESET", "R2.pin1") && n.net("J7.VREF") === "V1V8" && gnd("J7.GND"), "Tag-Connect SWD pinout")

// ------------------------------------------------------------ build health
const errors = json.filter((e) => typeof e.type === "string" && e.type.endsWith("_error"))
ok(errors.length === 0, `no errors in the Circuit JSON (found ${errors.length}: ${errors.slice(0, 5).map((e) => e.type + " " + (e.message ?? "")).join(" | ")})`)
if (routed) {
  // with routingDisabled core reports no unconnected ports, so the physical checks are the project scripts:
  // connectivity through drawn copper, vias and pour islands; clearance incl. copper inside a foreign pour; peg holes
  const run = (args: string[]) => Bun.spawnSync(["python3", ...args], { cwd: `${import.meta.dir}/..` })
  const conn = run(["scripts/check-connectivity.py", "dist/index/circuit.json"])
  ok(conn.exitCode === 0, `every net joined by copper (${conn.stdout.toString().trim().split("\n").slice(-3).join(" | ")})`)
  const clr = run(["scripts/check-clearance.py", "dist/index/circuit.json"]).stdout.toString()
  ok(/; 0 clearance findings/.test(clr), `copper clearance >= 0.1 mm (${clr.trim().split("\n")[0]})`)
  const holes = run(["scripts/check-holes.py"]).stdout.toString()
  ok(/holes: 0 conflict/.test(holes), `no drill through another part's land (${holes.trim().split("\n").slice(-1)[0]})`)
  ok(json.some((e) => e.type === "pcb_trace"), "board has copper")
}

if (failures.length) { console.error(`FAILED ${failures.length}/${checks}:\n  - ${failures.join("\n  - ")}`); process.exit(1) }
console.log(`all ${checks} design checks passed${routed ? " (routed)" : ""}`)