Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions web/scripts/drift/_bom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* The BOM as the drift checks read it. hardware/bom/bom_v3.9.csv is the
* source of truth (hard rule 2 in AGENTS.md): check:bom holds it against the
* build guide's parts tables, check:bom-schematic against the schematic PDF.
*/
import { read } from "./_lib";

export const BOM = "hardware/bom/bom_v3.9.csv";

export type Row = Record<string, string>;

// RFC 4180: a quoted cell may hold commas, line breaks and "" for a quote.
function parse(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"' && text[i + 1] === '"') cell += text[++i];
else if (c === '"') quoted = false;
else cell += c;
} else if (c === '"') quoted = true;
else if (c === "," || c === "\n") {
row.push(cell);
cell = "";
if (c === "\n") {
rows.push(row);
row = [];
}
} else if (c !== "\r") cell += c;
}
if (cell || row.length) rows.push([...row, cell]);
return rows;
}

/** The BOM's rows, each keyed by the CSV's header line. */
export function bom(): Row[] {
const [head, ...rows] = parse(read(BOM));
return rows.filter((r) => r.some(Boolean)).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""])));
}

/**
* The designators a ref cell names. "J1" is one, and "SW1-SW4" is four, with
* a hyphen or an en dash. Any other cell names none: "-" off the board, the
* guide's "—", and "U1 (sockets)", whose notes say it is not a refdes.
*/
export function designators(ref: string): string[] {
const m = ref.trim().match(/^([A-Z]+)(\d+)(?:\s*[-–]\s*([A-Z]+)(\d+))?$/);
if (!m) return [];
const [, prefix, from, prefixTo, to] = m;
if (!to) return [prefix + from];
if (prefixTo !== prefix || +to <= +from) throw new Error(`${ref}: a range keeps one prefix and counts up`);
return Array.from({ length: +to - +from + 1 }, (_, i) => `${prefix}${+from + i}`);
}
37 changes: 37 additions & 0 deletions web/scripts/drift/bom-schematic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* check:bom-schematic: the BOM's designators against the schematic's.
* hardware/pcb/schematic.pdf is KiCad output in stroke fonts, and pdftotext
* (poppler) reads every designator out of its text layer all the same, so
* this is strict from its first day.
*
* A token in the PDF is a designator when it has a designator's prefix: U,
* SW, J and C are on the board, R, D, Q, L and F are what a part added later
* would carry, and USB is what the v3.0 board's USB-C footprint was (hard
* rule 6). Pin names (S1, IO4) and ESP32 carry none of them. Without
* pdftotext this skips.
*/
import { execFileSync } from "node:child_process";
import path from "node:path";

import { BOM, bom, designators } from "./_bom";
import { ROOT, compare, done, skip } from "./_lib";

const PDF = "hardware/pcb/schematic.pdf";
const PREFIXES = ["U", "SW", "J", "C", "R", "D", "Q", "L", "F", "USB"];

let text: string | undefined;
try {
text = execFileSync("pdftotext", ["-layout", path.join(ROOT, PDF), "-"], { encoding: "utf8" });
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
skip("designators", "pdftotext is not installed (poppler-utils)");
}
if (text !== undefined) {
const token = new RegExp(`\\b(?:${PREFIXES.join("|")})\\d+\\b`, "g");
compare(
"designators",
{ label: BOM, items: bom().flatMap((r) => designators(r.ref)) },
{ label: PDF, items: new Set(text.match(token)) },
);
}
done();
78 changes: 78 additions & 0 deletions web/scripts/drift/bom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* check:bom: the BOM against the two parts tables in section 1 of
* BUILD_GUIDE.md. Hard rule 2 derives the guide's tables from the BOM, so a
* designator, a part or a quantity on one side only is drift.
*
* On the board a row is its designators, its quantity and its MPN. The names
* are worded apart ("Rotary encoder w/ switch" for "Rotary encoder with push
* switch") and are not compared. The sockets row has no designator: the BOM
* writes "U1 (sockets)" and the guide "—". Off the board there is neither, so
* the rows pair in order, and the guide's part keeps every word of the BOM's:
* "Sacrificial USB cable (it gets cut)" for "USB cable (sacrificial)". A
* hyphen and an en dash are the same in a quantity, so 6-12 is 6–12.
*/
import { BOM, bom, designators, type Row } from "./_bom";
import { compare, done, read } from "./_lib";

const GUIDE = "BUILD_GUIDE.md";

// Bold, code, emphasis and link targets out; a link keeps its text.
const plain = (s: string) =>
s
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[*`]/g, "")
.trim();
const dashes = (s: string) => s.replace(/[–—]/g, "-");

/** The first table under a heading of the guide, each row keyed by the table's header line. */
function table(heading: string, columns: string[]): Row[] {
const lines = read(GUIDE).split("\n");
const at = lines.findIndex((l) => l.trim() === heading);
if (at < 0) throw new Error(`${GUIDE} has no "${heading}"`);
const rows: string[][] = [];
for (const line of lines.slice(at + 1)) {
if (line.startsWith("|")) rows.push(line.trim().replace(/^\||\|$/g, "").split("|").map(plain));
else if (rows.length || line.startsWith("#")) break;
}
const [head, ...body] = rows.filter((r) => !r.every((c) => /^:?-+:?$/.test(c)));
for (const c of columns) if (!head?.includes(c)) throw new Error(`${GUIDE}, ${heading}: no "${c}" column`);
return body.map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""])));
}

const rows = bom();

// On the board: designators, quantity, MPN. The guide's MPN cell ends in the maker, "(Bourns)".
const ref = (cell: string) => {
const d = designators(dashes(cell));
return d.length > 1 ? `${d[0]}-${d[d.length - 1]}` : (d[0] ?? "—");
};
const part = (r: string, qty: string, mpn: string) => `${ref(r)}: ${dashes(qty)} × ${mpn}`;
compare(
"on the board",
{ label: BOM, items: rows.filter((r) => r.category === "pcb").map((r) => part(r.ref, r.qty, r.mpn)) },
{
label: GUIDE,
items: table("### On the board", ["Ref", "Qty", "MPN (Manufacturer)"]).map((g) =>
part(g.Ref, g.Qty, g["MPN (Manufacturer)"].replace(/\s*\([^)]*\)$/, "")),
),
},
);

// Off the board: quantity and part, in order. A guide row is named as the BOM's
// row in its place when it holds every word of that row's part.
const words = (s: string) => s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
const off = rows.filter((r) => r.category === "off-board");
console.log("off the board, rows pair in order, and a guide row with every word of its BOM row's part takes that name");
compare(
"off the board",
{ label: BOM, items: off.map((r) => `${dashes(r.qty)} × ${r.part}`) },
{
label: GUIDE,
items: table("### Off the board", ["Qty", "Part"]).map((g, i) => {
const has = new Set(words(g.Part));
const same = off[i] && words(off[i].part).every((w) => has.has(w));
return `${dashes(g.Qty)} × ${same ? off[i].part : g.Part}`;
}),
},
);
done();
Loading