@protoboardai/ext-release 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +290 -2
- package/evals/release-after-hand-edit/case.json +59 -0
- package/lib/bom.ts +444 -0
- package/lib/collect.ts +100 -0
- package/lib/config.ts +45 -0
- package/lib/cutlist.ts +70 -0
- package/lib/documents.ts +287 -0
- package/lib/glb.ts +85 -0
- package/lib/helpers.ts +49 -0
- package/lib/html.ts +126 -0
- package/lib/manifest.ts +159 -0
- package/lib/offers.ts +123 -0
- package/lib/preconditions.ts +141 -0
- package/lib/product.ts +64 -0
- package/lib/release.ts +279 -0
- package/lib/render.ts +203 -0
- package/lib/sections/baseline.ts +164 -0
- package/lib/sections/bom.ts +101 -0
- package/lib/sections/model.ts +78 -0
- package/lib/sections/testplan.ts +76 -0
- package/lib/sections/tests.ts +630 -0
- package/lib/sections/workorder.ts +369 -0
- package/lib/testing.ts +43 -0
- package/lib/types.ts +251 -0
- package/lib/units.ts +141 -0
- package/lib/util.ts +31 -0
- package/ops/release.ts +21 -0
- package/ops/sections.ts +23 -0
- package/package.json +41 -4
- package/protoboard-extension.json +44 -0
- package/skills/release/SKILL.md +191 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test plan of one unit, generated from the model (PK5; basic for v0.1):
|
|
3
|
+
*
|
|
4
|
+
* FAI first-article inspection of each part made for the unit: overall
|
|
5
|
+
* size from the CAD output, thickness, hole patterns and features
|
|
6
|
+
* from its interfaces, boards and cables against their files;
|
|
7
|
+
* JT one check per assembly step: seating and torque for mates,
|
|
8
|
+
* continuity and shorts for soldered or plugged connections;
|
|
9
|
+
* BU bring-up of the unit's electronics: power-up current-limited,
|
|
10
|
+
* each rail at its expected voltage, current against the model's
|
|
11
|
+
* supply budget (and the project's power budget document when it has
|
|
12
|
+
* one), programming and boot, bus scans with the expected addresses,
|
|
13
|
+
* a smoke test per signal link. A board in the build (its own unit or
|
|
14
|
+
* a sub-unit) is read through its nets (design voltages, what its edge
|
|
15
|
+
* exports) and its firmware map (core boardFirmware: the controller's
|
|
16
|
+
* USB port and the buses on the board, which links between parts may
|
|
17
|
+
* not show);
|
|
18
|
+
* FN functional checks, one per requirement of docs/brief.md (root unit);
|
|
19
|
+
*
|
|
20
|
+
* and the records to capture. The work order names the test that follows
|
|
21
|
+
* each operation by these ids, so both are built from the same plan.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import type { InterfaceDef, ModuleDef } from "@deltarobotics/uhd";
|
|
26
|
+
import { checkSystem, i2cAddresses, netVoltageRange, systemLinks, unconnectedOf, type LinkResult } from "@deltarobotics/uhd";
|
|
27
|
+
import { PRODUCT_FILE, boardFirmware, boardOptions, briefRequirements, readProduct, type BoardFirmware, type CapabilityBinding, type Project } from "@protoboardai/core";
|
|
28
|
+
import type { PlannedStep, UnitPlan } from "@protoboardai/ext-docs/plan";
|
|
29
|
+
import { unitModules, fileGroup } from "../collect.ts";
|
|
30
|
+
import { cutList, isCable } from "../cutlist.ts";
|
|
31
|
+
import { glbBox } from "../glb.ts";
|
|
32
|
+
import type { Unit } from "../types.ts";
|
|
33
|
+
import { num } from "../util.ts";
|
|
34
|
+
import { unitBom } from "../bom.ts";
|
|
35
|
+
|
|
36
|
+
export type TestGroup = "fai" | "joint" | "bringup" | "function";
|
|
37
|
+
|
|
38
|
+
export interface TestItem {
|
|
39
|
+
id: string;
|
|
40
|
+
group: TestGroup;
|
|
41
|
+
/** What is checked ("Overall size", "Rail 3V3"). */
|
|
42
|
+
check: string;
|
|
43
|
+
/** The part, link or requirement it concerns. */
|
|
44
|
+
subject: string;
|
|
45
|
+
how: string;
|
|
46
|
+
pass: string;
|
|
47
|
+
/** What to write down. */
|
|
48
|
+
record: string;
|
|
49
|
+
/** Model reference (module, link, interface, requirement id). */
|
|
50
|
+
trace?: string;
|
|
51
|
+
/** Bring-up: "power" checks need no firmware (before power, power-up, rails, current); "running" ones need it loaded. */
|
|
52
|
+
phase?: "power" | "running";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UnitTests {
|
|
56
|
+
items: TestItem[];
|
|
57
|
+
/** FAI ids by module id. */
|
|
58
|
+
fai: Map<string, string[]>;
|
|
59
|
+
/** JT id by plan step number. */
|
|
60
|
+
joint: Map<number, string>;
|
|
61
|
+
/** Bring-up ids, in order. */
|
|
62
|
+
bringup: string[];
|
|
63
|
+
/** Requirements source, when the root unit has one. */
|
|
64
|
+
requirements?: string;
|
|
65
|
+
/** Power budget document, when the project has one. */
|
|
66
|
+
powerBudget?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const PREFIX: Record<TestGroup, string> = { fai: "FAI", joint: "JT", bringup: "BU", function: "FN" };
|
|
70
|
+
|
|
71
|
+
const param = (i: InterfaceDef, id: string) => i.parameters?.find((p) => p.id === id);
|
|
72
|
+
const hasRole = (i: InterfaceDef, type: string, role: string) => i.protocols.some((p) => p.type === type && p.roles?.includes(role));
|
|
73
|
+
const fmtV = (p?: { value?: number; range?: [number, number] }) => {
|
|
74
|
+
if (!p) return undefined;
|
|
75
|
+
const r = p.range && p.range[0] !== p.range[1] ? p.range : undefined;
|
|
76
|
+
if (typeof p.value === "number") return r ? `${num(p.value)} V (${num(r[0])}–${num(r[1])} V)` : `${num(p.value)} V`;
|
|
77
|
+
if (r) return `${num(r[0])}–${num(r[1])} V`;
|
|
78
|
+
return p.range ? `${num(p.range[0])} V` : undefined;
|
|
79
|
+
};
|
|
80
|
+
const amps = (p?: { value?: number; unit?: string }) => (p && typeof p.value === "number" ? (p.unit === "mA" ? p.value / 1000 : p.value) : undefined);
|
|
81
|
+
|
|
82
|
+
export function unitTests(p: Project, u: Unit, plan: UnitPlan): UnitTests {
|
|
83
|
+
const items: TestItem[] = [];
|
|
84
|
+
const count: Record<TestGroup, number> = { fai: 0, joint: 0, bringup: 0, function: 0 };
|
|
85
|
+
const add = (group: TestGroup, t: Omit<TestItem, "id" | "group">) => {
|
|
86
|
+
const id = `${PREFIX[group]}-${++count[group]}`;
|
|
87
|
+
items.push({ id, group, ...t });
|
|
88
|
+
return id;
|
|
89
|
+
};
|
|
90
|
+
const out: UnitTests = { items, fai: new Map(), joint: new Map(), bringup: [] };
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------- FAI
|
|
93
|
+
for (const b of unitModules(p, u)) {
|
|
94
|
+
const def = p.definitions.get(b.module!)!.def;
|
|
95
|
+
const ids: string[] = [];
|
|
96
|
+
for (const t of faiChecks(p, b, def)) ids.push(add("fai", t));
|
|
97
|
+
out.fai.set(def.id, ids);
|
|
98
|
+
}
|
|
99
|
+
for (const r of unitBom(p, u).filter((r) => r.kind === "cable" && r.action === "fabricate" && !out.fai.has(r.definition))) {
|
|
100
|
+
const def = p.lookup(r.definition)!;
|
|
101
|
+
out.fai.set(def.id, cableChecks(def).map((t) => add("fai", t)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------- joints
|
|
105
|
+
for (const s of plan.steps) out.joint.set(s.n, add("joint", jointCheck(s)));
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------- bring-up
|
|
108
|
+
out.powerBudget = ["docs/power-budget.md", "docs/power-budget.json"].find((f) => existsSync(join(p.root, f)));
|
|
109
|
+
const bring: Check[] = bringUp(p, u, out.powerBudget);
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------- the project's own steps (release.checks)
|
|
112
|
+
const mine = (p.config.release?.checks ?? []).filter((c) => (c.unit ? [u.entry, u.id, u.definition, ...u.instances].includes(c.unit) : u.kind === "root"));
|
|
113
|
+
for (const c of mine.filter((c) => (c.group ?? "bringup") === "bringup")) bring.push({ check: c.check, subject: c.subject, how: c.how, pass: c.pass, record: c.record, ...(c.trace ? { trace: c.trace } : {}), phase: c.phase ?? "running" });
|
|
114
|
+
// numbered in the order the bench runs them: every check that needs only power, then those that need the firmware
|
|
115
|
+
for (const t of [...bring.filter((t) => t.phase === "power"), ...bring.filter((t) => t.phase !== "power")]) out.bringup.push(add("bringup", t));
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------- functions (root)
|
|
118
|
+
if (u.kind === "root") {
|
|
119
|
+
const brief = ["docs/brief.md", "docs/requirements.md"].find((f) => existsSync(join(p.root, f)));
|
|
120
|
+
// the project's structured statement (docs/product.json, PB-935) is the source when it states requirements; else the brief
|
|
121
|
+
const product = readProduct(p.root);
|
|
122
|
+
if (product.requirementsFrom === PRODUCT_FILE) {
|
|
123
|
+
out.requirements = PRODUCT_FILE;
|
|
124
|
+
for (const c of mine.filter((c) => c.group === "function")) add("function", { check: c.check, subject: c.subject, how: c.how, pass: c.pass, record: c.record, ...(c.trace ? { trace: c.trace } : {}) });
|
|
125
|
+
for (const r of product.requirements)
|
|
126
|
+
add("function", { check: r.id ?? "Requirement", subject: r.text, how: r.how ?? "Demonstrate on the built product", pass: r.pass ?? "the requirement is met as stated", record: r.record ?? "pass or fail, with a note or photo", trace: `${PRODUCT_FILE}${r.id ? ` ${r.id}` : ""}` });
|
|
127
|
+
} else if (brief) {
|
|
128
|
+
out.requirements = brief;
|
|
129
|
+
for (const c of mine.filter((c) => c.group === "function")) add("function", { check: c.check, subject: c.subject, how: c.how, pass: c.pass, record: c.record, ...(c.trace ? { trace: c.trace } : {}) });
|
|
130
|
+
for (const r of briefRequirements(readFileSync(join(p.root, brief), "utf8"))) add("function", { check: r.id ?? "Requirement", subject: r.text, how: r.how, pass: r.pass, record: r.record, trace: `${brief}${r.id ? ` ${r.id}` : ""}` });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// First-article inspection
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
type Check = Omit<TestItem, "id" | "group">;
|
|
141
|
+
|
|
142
|
+
function faiChecks(p: Project, b: CapabilityBinding, def: ModuleDef): Check[] {
|
|
143
|
+
const group = fileGroup(p, b);
|
|
144
|
+
const name = def.name ?? def.id;
|
|
145
|
+
const out: Check[] = [];
|
|
146
|
+
if (group === "harnesses") return cableChecks(def);
|
|
147
|
+
if (group === "firmware") return [];
|
|
148
|
+
if (group === "boards") {
|
|
149
|
+
const c = b.constraints as { layers?: number; thickness_mm?: number; mounting_holes?: unknown[] };
|
|
150
|
+
out.push({ check: "Bare board", subject: name, how: "Inspect against the Gerber files and the fabrication order", pass: `outline and ${c.mounting_holes?.length ?? 0} mounting hole(s) as the files; ${c.layers ?? "?"} layers, ${c.thickness_mm ?? "?"} mm; finish and solder mask as ordered`, record: "thickness measured; deviations", trace: def.id });
|
|
151
|
+
out.push({ check: "Assembled board", subject: name, how: "Visual (or AOI) against pnp.csv and bom.csv; X-ray for leadless packages where available", pass: "every component present, in its place and orientation; no bridges, tombstones or insufficient joints", record: "defects found and reworked", trace: def.id });
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
const box = glbBox(join(p.root, b.out, "body.glb"));
|
|
155
|
+
const m = readJson<{ manufacturing?: { thickness_mm?: Record<string, number> }; features?: Record<string, unknown> }>(join(p.root, b.out, "body.manifest.json"));
|
|
156
|
+
if (box) out.push({ check: "Overall size", subject: name, how: "Calipers, on a flat surface", pass: `${num(box.size[0], 1)} × ${num(box.size[1], 1)} × ${num(box.size[2], 1)} mm (CAD bounding box) within the fabrication order's tolerance`, record: "the three measured sizes", trace: `${b.out}/body.glb` });
|
|
157
|
+
for (const [body, t] of Object.entries(m?.manufacturing?.thickness_mm ?? {})) out.push({ check: "Thickness", subject: `${name}: ${body}`, how: "Micrometer or calipers, at three points", pass: `${t} mm, as the stock ordered`, record: "three readings", trace: `${b.out}/body.manifest.json` });
|
|
158
|
+
for (const i of def.interfaces.filter((x) => x.protocols.some((pr) => pr.type === "bolt_pattern"))) {
|
|
159
|
+
const spacing = param(i, "hole_spacing");
|
|
160
|
+
const spacingY = param(i, "hole_spacing_y");
|
|
161
|
+
const holes = param(i, "hole_count")?.value;
|
|
162
|
+
const d = param(i, "fastener_diameter");
|
|
163
|
+
const t = (i.traits ?? []).find((x) => x.type === "bolt_pattern")?.params as { shape?: string; fastener?: string; threaded?: boolean } | undefined;
|
|
164
|
+
const sp = (x?: { value?: number; range?: [number, number] }) => (x?.value !== undefined ? `${num(x.value)}` : x?.range ? `${num(x.range[0])}–${num(x.range[1])}` : "?");
|
|
165
|
+
const pattern = t?.shape === "circle" ? `on a Ø${sp(spacing)} mm circle` : t?.shape === "rectangle" ? `${sp(spacing)} × ${sp(spacingY)} mm` : `${sp(spacing)} mm square`;
|
|
166
|
+
out.push({
|
|
167
|
+
check: "Hole pattern",
|
|
168
|
+
subject: `${name}: ${i.name ?? i.id}`,
|
|
169
|
+
how: `Calipers or a pin gauge; a ${t?.fastener ?? (d?.value ? `M${d.value}` : "")} fastener through each hole`,
|
|
170
|
+
pass: `${holes ?? "?"} holes ${pattern}; the fastener ${t?.threaded ? "threads in by hand" : "passes freely"}`,
|
|
171
|
+
record: "spacing measured; holes that do not pass",
|
|
172
|
+
trace: `${def.id}:${i.id}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const seals = def.interfaces.filter((i) => (i.traits ?? []).some((x) => x.type === "enclosure_feature" && (x.params as { kind?: string })?.kind === "seal_gland"));
|
|
176
|
+
for (const i of seals) out.push({ check: "Seal gland", subject: `${name}: ${i.name ?? i.id}`, how: "Inspect the groove; fit the O-ring", pass: "groove clean and unbroken; the O-ring seats without twist or stretch marks", record: "pass or fail", trace: `${def.id}:${i.id}` });
|
|
177
|
+
const features = Object.keys(m?.features ?? {}).filter((f) => !def.interfaces.some((i) => i.id === f && i.protocols.some((pr) => pr.type === "bolt_pattern")));
|
|
178
|
+
if (features.length) out.push({ check: "Features present", subject: name, how: "Visual, against the CAD figure", pass: `${features.map((f) => def.interfaces.find((i) => i.id === f)?.name ?? f).join(", ")}`, record: "missing or damaged features", trace: `${b.out}/body.manifest.json` });
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function cableChecks(def: ModuleDef): Check[] {
|
|
183
|
+
const name = def.name ?? def.id;
|
|
184
|
+
const rows = isCable(def) ? cutList(def) : [];
|
|
185
|
+
const len = rows[0]?.length_mm;
|
|
186
|
+
const out: Check[] = [];
|
|
187
|
+
if (len) out.push({ check: "Length", subject: name, how: "Rule, end to end", pass: `${len} mm installed length${rows[0]?.strip_mm ? `, ${rows[0].strip_mm} mm stripped at each soldered end` : ""}`, record: "measured length", trace: def.id });
|
|
188
|
+
if (rows.length) out.push({ check: "Conductor order", subject: name, how: `Against the cut list (files/harnesses/${def.id}/cut-list.csv); a continuity tester end to end`, pass: `${rows.length} conductor(s), each continuous to its position${rows.some((r) => r.colour) ? `; colours ${rows.map((r) => r.colour).filter(Boolean).join(", ")}` : ""}; no short between any two`, record: "pass or fail per conductor", trace: def.id });
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Joints
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
function jointCheck(s: PlannedStep): Check {
|
|
197
|
+
const trace = s.trace.join("; ");
|
|
198
|
+
const variant = s.perBuild ? "; each build has the variant the step lists for its position" : "";
|
|
199
|
+
if (s.kind === "mate")
|
|
200
|
+
return { check: "Seated and torqued", subject: s.title, how: s.torque ? "Torque driver set to the step's torque; witness mark each fastener" : "Visual and by hand", pass: `every part seats without force and without a gap${s.torque ? `; fasteners at ${s.torque}` : ""}${variant}`, record: s.torque ? "torque driver setting; fasteners marked" : "pass or fail", trace };
|
|
201
|
+
if (s.kind === "prepare")
|
|
202
|
+
return { check: "Solder joints", subject: s.title, how: "Visual (magnifier); a square against each strip", pass: "every pin soldered, joints wetted and concave, no bridges between neighbouring pins; each strip flush and square to the board", record: "pass or fail; pins reworked", trace };
|
|
203
|
+
if (s.kind === "place") return { check: "Placed and secured", subject: s.title, how: "Visual and by hand", pass: "the part sits where the figure shows and cannot shift", record: "pass or fail", trace };
|
|
204
|
+
// the same count as the step's actions: conductors (pin rows of the step's table), in links
|
|
205
|
+
const one = /^1 /.test(s.extent);
|
|
206
|
+
return {
|
|
207
|
+
check: s.solder ? "Solder joints and continuity" : "Connectors and continuity",
|
|
208
|
+
subject: s.title,
|
|
209
|
+
how: `${s.solder ? "Visual (magnifier); " : "Check each connector is fully seated and latched; "}multimeter continuity for ${one ? "the" : "each of the"} ${s.extent} in the step's table, and between neighbouring pads`,
|
|
210
|
+
pass: `${s.solder ? "joints wetted and concave, no bridges; " : ""}each ${s.extent.includes("conductor") ? "conductor" : "connection"} continuous end to end, no short to a neighbour${s.connections.some((c) => c.crossover) ? "; crossovers as the table marks them" : ""}`,
|
|
211
|
+
record: `pass or fail per ${s.extent.includes("conductor") ? "conductor" : "connection"}`,
|
|
212
|
+
trace,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Bring-up
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
interface Rail {
|
|
221
|
+
name: string;
|
|
222
|
+
where: string;
|
|
223
|
+
voltage: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Leaf modules of one build with their instance paths (sub-units included: they are part of what is powered). */
|
|
227
|
+
function leaves(p: Project, u: Unit): { path: string; def: ModuleDef }[] {
|
|
228
|
+
const at = u.kind === "root" ? "" : u.representative;
|
|
229
|
+
return p.instances
|
|
230
|
+
.filter((i) => i.path !== "" && (at === "" || i.path === at || i.path.startsWith(at + "/")))
|
|
231
|
+
.map((i) => ({ path: i.path, def: p.lookup(i.definition)! }))
|
|
232
|
+
.filter((x) => x.def && !x.def.children?.length);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Interfaces the instance at `path` leaves unconnected on purpose (UHD
|
|
237
|
+
* ChildModuleRef.unconnected), with why: the bench does not power them.
|
|
238
|
+
*/
|
|
239
|
+
function unconnectedAt(p: Project, path: string): Map<string, string> {
|
|
240
|
+
const cut = path.lastIndexOf("/");
|
|
241
|
+
const parent = cut < 0 ? "" : path.slice(0, cut);
|
|
242
|
+
const def = parent === "" ? p.rootDef : p.lookup(p.instances.find((i) => i.path === parent)?.definition ?? "");
|
|
243
|
+
const ref = def?.children?.find((c) => c.id === path.slice(cut + 1));
|
|
244
|
+
return ref ? unconnectedOf(ref) : new Map();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Links of one build: of the unit's module and of every module inside it that declares links, with paths from the system root. */
|
|
248
|
+
function buildLinks(p: Project, u: Unit): { link: LinkResult; prefix: string }[] {
|
|
249
|
+
const at = u.kind === "root" ? "" : u.representative;
|
|
250
|
+
const out: { link: LinkResult; prefix: string }[] = [];
|
|
251
|
+
const visit = (def: ModuleDef, prefix: string) => {
|
|
252
|
+
if (def.kind === "harness") return;
|
|
253
|
+
if (def.links?.length) {
|
|
254
|
+
try {
|
|
255
|
+
for (const l of systemLinks(def, p.lookup)) out.push({ link: l, prefix });
|
|
256
|
+
} catch {
|
|
257
|
+
// an unresolvable link is a check error, reported by protoboard check
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const c of def.children ?? []) {
|
|
261
|
+
const d = p.lookup(c.moduleDefId);
|
|
262
|
+
if (d) visit(d, prefix ? `${prefix}/${c.id}` : c.id);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const root = u.kind === "root" ? p.rootDef! : p.lookup(u.definition)!;
|
|
266
|
+
visit(root, at);
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const endName = (p: Project, prefix: string, ownerPath: string[]) => {
|
|
271
|
+
const path = [prefix, ...ownerPath].filter(Boolean).join("/");
|
|
272
|
+
const inst = p.instances.find((i) => i.path === path);
|
|
273
|
+
return inst ? (p.lookup(inst.definition)?.name ?? path) : path;
|
|
274
|
+
};
|
|
275
|
+
const at = (prefix: string, ownerPath: string[]) => [prefix, ...ownerPath].filter(Boolean).join("/");
|
|
276
|
+
|
|
277
|
+
const PROGRAM = /^(swd|jtag|usb|usb_[a-z_]+|dfu)$/;
|
|
278
|
+
const SMOKE: Record<string, string> = {
|
|
279
|
+
uart: "data arrives from the peer at the configured baud rate",
|
|
280
|
+
crsf: "receiver frames arrive (link-quality and channel values update)",
|
|
281
|
+
sbus: "channel values update",
|
|
282
|
+
spi: "read the device's ID register; it matches the datasheet value",
|
|
283
|
+
i2c: "the device answers at its address and its ID register reads back",
|
|
284
|
+
pwm: "the output follows a commanded duty cycle",
|
|
285
|
+
dshot: "props off: each motor turns at minimum throttle, in its position",
|
|
286
|
+
bldc_3phase: "props off: the motor turns smoothly at minimum throttle",
|
|
287
|
+
analog: "the reading is plausible against a meter",
|
|
288
|
+
gpio: "the line toggles when driven",
|
|
289
|
+
can: "frames are received from the peer",
|
|
290
|
+
rf: "the link is established",
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
function bringUp(p: Project, u: Unit, budgetDoc?: string): Check[] {
|
|
294
|
+
const parts = leaves(p, u);
|
|
295
|
+
const out: Check[] = [];
|
|
296
|
+
// every board powered in this build: its own, folded in, or a sub-unit's (built before, powered again here)
|
|
297
|
+
const within = u.kind === "root" ? () => true : (i: string) => i === u.representative || i.startsWith(u.representative + "/");
|
|
298
|
+
const boards = p.capabilities.filter((b) => !b.problem && b.module && b.capability === "circuit" && b.instances.some(within));
|
|
299
|
+
// every board of the product: a part on a board that another unit builds was brought up there
|
|
300
|
+
const allBoards = p.capabilities.filter((b) => !b.problem && b.capability === "circuit" && b.module);
|
|
301
|
+
const links = buildLinks(p, u).filter((x) => x.link.state !== "incompatible");
|
|
302
|
+
const batteryDefs = new Set(parts.filter((x) => x.def.interfaces.some((i) => hasRole(i, "power", "output") && param(i, "cell_count"))).map((x) => x.def.id));
|
|
303
|
+
const battery = batteryDefs.size > 0;
|
|
304
|
+
|
|
305
|
+
// ---- what feeds each board at its edge: nets with a design voltage that an exported part's pins sit on and nothing on the board drives
|
|
306
|
+
const edge = boards.map((b) => {
|
|
307
|
+
const def = p.definitions.get(b.module!)!.def;
|
|
308
|
+
const exported = new Set((def.exports ?? []).map((e) => ("child" in e.from ? e.from.child : "")));
|
|
309
|
+
const nets = def.interfaces.filter((i) => i.protocols.some((x) => x.type === "net") && netVoltageRange(i));
|
|
310
|
+
const members = (net: string) =>
|
|
311
|
+
(def.links ?? []).flatMap((l) => (["a", "b"] as const).filter((k) => { const o = l[k === "a" ? "b" : "a"]; return "self" in o && o.interfaceId === net && "child" in l[k]; }).map((k) => l[k] as { child: string; interfaceId: string }));
|
|
312
|
+
const output = (m: { child: string; interfaceId: string }) => {
|
|
313
|
+
const ref = def.children?.find((c) => c.id === m.child);
|
|
314
|
+
const i = ref ? p.lookup(ref.moduleDefId)?.interfaces.find((x) => x.id === m.interfaceId) : undefined;
|
|
315
|
+
return !!i && hasRole(i, "power", "output");
|
|
316
|
+
};
|
|
317
|
+
return { b, def, inputs: nets.filter((n) => members(n.id).some((m) => exported.has(m.child)) && !members(n.id).some(output)) };
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ---- rails: board nets with a design voltage, and regulated outputs of the modules
|
|
321
|
+
const rails: Rail[] = [];
|
|
322
|
+
for (const { def, inputs: edgeInputs } of edge) {
|
|
323
|
+
for (const n of def.interfaces.filter((i) => i.protocols.some((x) => x.type === "net"))) {
|
|
324
|
+
const r = netVoltageRange(n);
|
|
325
|
+
// a board input fed from outside the build (USB VBUS while a pack powers the product) is not a rail of this test
|
|
326
|
+
if (battery && edgeInputs.includes(n)) continue;
|
|
327
|
+
if (r && r[1] > 0) rails.push({ name: n.name ?? n.id, where: `${def.name ?? def.id} (any pin on net ${n.name ?? n.id})`, voltage: r[0] === r[1] ? tolerance(r[0]) : `${num(r[0])}–${num(r[1])} V` });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// parts on a board: the board's nets carry the design voltages, and only what the board exports is reachable
|
|
331
|
+
const boardOf = (path: string) => allBoards.find((b) => b.instances.some((i) => path.startsWith(i + "/")));
|
|
332
|
+
const reachable = (path: string, id: string) => {
|
|
333
|
+
const b = boardOf(path);
|
|
334
|
+
if (!b) return true;
|
|
335
|
+
const def = p.definitions.get(b.module!)!.def;
|
|
336
|
+
const child = path.slice(path.lastIndexOf("/") + 1);
|
|
337
|
+
return (def.exports ?? []).some((e) => "child" in e.from && e.from.child === child && e.from.interfaceId === id);
|
|
338
|
+
};
|
|
339
|
+
const sources: { path: string; def: ModuleDef; iface: InterfaceDef }[] = [];
|
|
340
|
+
// an input is fed when a link in the build reaches it, directly or through a port that binds it
|
|
341
|
+
const fed = new Set<string>();
|
|
342
|
+
for (const l of links)
|
|
343
|
+
for (const e of [l.link.a, l.link.b]) {
|
|
344
|
+
const base = at(l.prefix, e.ownerPath);
|
|
345
|
+
fed.add(`${base}:${e.iface.id}`);
|
|
346
|
+
for (const pr of e.iface.profiles ?? []) for (const v of Object.values(pr.bindings ?? {})) if (typeof v === "string") fed.add(`${base}:${v}`);
|
|
347
|
+
}
|
|
348
|
+
// rails that read an input left unconnected on purpose (a motor driver's outputs, its supply terminal
|
|
349
|
+
// open in the product): they are measured with a temporary supply on that input, connected first
|
|
350
|
+
const temporary = new Map<string, { input: string; what: string; volts: ReturnType<typeof supplyVolts>; reason: string; rails: Rail[] }>();
|
|
351
|
+
const words = (x: string) => new Set(x.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2));
|
|
352
|
+
for (const x of parts) {
|
|
353
|
+
// parts on a board are read through its nets; a battery is what the bench supply stands in for
|
|
354
|
+
if (boardOf(x.path) || batteryDefs.has(x.def.id)) continue;
|
|
355
|
+
for (const i of x.def.interfaces) {
|
|
356
|
+
if (!hasRole(i, "power", "output") || param(i, "cell_count")) continue;
|
|
357
|
+
const pv = param(i, "voltage") as { value?: number; range?: [number, number] } | undefined;
|
|
358
|
+
const v = fmtV(pv as never);
|
|
359
|
+
if (!v) continue;
|
|
360
|
+
sources.push({ path: x.path, def: x.def, iface: i });
|
|
361
|
+
// an output stated only by the same range as one of the part's inputs passes that input through (an ESC's
|
|
362
|
+
// battery voltage to the FC): it reads the supply's voltage, not anywhere in the range
|
|
363
|
+
// several inputs may state that range: one the build feeds, else the one whose name shares most with the output's
|
|
364
|
+
const marks = unconnectedAt(p, x.path);
|
|
365
|
+
const about = words(`${i.id} ${i.name ?? ""} ${(i.traits ?? []).map((t) => String((t.params as { description?: string; note?: string } | undefined)?.description ?? (t.params as { note?: string } | undefined)?.note ?? "")).join(" ")}`);
|
|
366
|
+
const overlap = (j: InterfaceDef) => [...words(`${j.id} ${j.name ?? ""}`)].filter((w) => about.has(w)).length;
|
|
367
|
+
const candidates =
|
|
368
|
+
pv?.value === undefined && pv?.range
|
|
369
|
+
? x.def.interfaces.filter((j) => j !== i && hasRole(j, "power", "input") && (param(j, "voltage") as { range?: [number, number] } | undefined)?.range?.join() === pv.range!.join())
|
|
370
|
+
: [];
|
|
371
|
+
const isFed = (j: InterfaceDef) => fed.has(`${x.path}:${j.id}`) || !marks.has(j.id);
|
|
372
|
+
const through = [...candidates].sort((a, b) => Number(fed.has(`${x.path}:${b.id}`)) - Number(fed.has(`${x.path}:${a.id}`)) || overlap(b) - overlap(a))[0];
|
|
373
|
+
const rail: Rail = {
|
|
374
|
+
name: i.name ?? i.id,
|
|
375
|
+
where: `${x.def.name ?? x.def.id} (${x.path}:${i.id})`,
|
|
376
|
+
voltage: through ? `the supply voltage set at power-up, read on ${through.name ?? through.id} (passed through; the part accepts ${v})` : v,
|
|
377
|
+
};
|
|
378
|
+
if (through && !candidates.some(isFed)) {
|
|
379
|
+
const key = `${x.path}:${through.id}`;
|
|
380
|
+
const t = temporary.get(key) ?? { input: key, what: `${x.def.name ?? x.def.id} ${through.name ?? through.id} (${key})`, volts: supplyVolts(param(through, "voltage") as never), reason: marks.get(through.id)!, rails: [] };
|
|
381
|
+
rail.voltage = `the temporary supply's voltage, read on ${through.name ?? through.id} (passed through; the part accepts ${v})`;
|
|
382
|
+
t.rails.push(rail);
|
|
383
|
+
temporary.set(key, t);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
rails.push(rail);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// ---- inputs: battery packs in the build, else power inputs nothing in the build feeds
|
|
390
|
+
const batteries = parts.flatMap((x) => x.def.interfaces.filter((i) => hasRole(i, "power", "output") && param(i, "cell_count")).map((i) => ({ x, i })));
|
|
391
|
+
// a board is fed through its edge (above)
|
|
392
|
+
const boardInputs = edge.flatMap(({ def, inputs: ns }) =>
|
|
393
|
+
ns.map((n) => {
|
|
394
|
+
const r = netVoltageRange(n)!;
|
|
395
|
+
return { what: `into ${def.name ?? def.id} net ${n.name ?? n.id}, at its edge connector`, ...supplyVolts({ value: r[0] === r[1] ? r[0] : undefined, range: r }), pack: false };
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
const inputs: SupplyInput[] = batteries.length
|
|
399
|
+
? batteries.map(({ x, i }) => ({ what: `in place of ${x.def.name ?? x.def.id} (${x.path})`, ...supplyVolts(param(i, "voltage") as never), pack: true }))
|
|
400
|
+
: [
|
|
401
|
+
...boardInputs,
|
|
402
|
+
// an input left unconnected on purpose (UHD ChildModuleRef.unconnected) gets no bench supply
|
|
403
|
+
...parts.filter((x) => !boardOf(x.path)).flatMap((x) => {
|
|
404
|
+
const marks = unconnectedAt(p, x.path);
|
|
405
|
+
return x.def.interfaces.filter((i) => hasRole(i, "power", "input") && !fed.has(`${x.path}:${i.id}`) && !marks.has(i.id) && fmtV(param(i, "voltage") as never)).map((i) => ({ what: `into ${x.def.name ?? x.def.id} ${i.name ?? i.id} (${x.path}:${i.id})`, ...supplyVolts(param(i, "voltage") as never), pack: false }));
|
|
406
|
+
}),
|
|
407
|
+
];
|
|
408
|
+
// what an input stated only as a range sees in the product: the pack that powers the product
|
|
409
|
+
const pack = u.kind === "root" ? undefined : productPack(p);
|
|
410
|
+
// firmware maps of the boards: the controller's USB port and the buses on the board
|
|
411
|
+
const maps = edge.map((x) => ({ ...x, fw: firmwareMap(p, x.b, x.def) }));
|
|
412
|
+
const usbBoards = maps.filter((m) => m.fw?.usb && m.fw.controller && (m.def.exports ?? []).some((e) => "child" in e.from && m.fw!.usb!.peers.includes(e.from.child)));
|
|
413
|
+
const kindOf = (i: InterfaceDef) => (i.protocols.some((pr) => /^usb/.test(pr.type)) ? "usb" : "probe");
|
|
414
|
+
const programAll = parts.flatMap((x) =>
|
|
415
|
+
x.def.interfaces
|
|
416
|
+
.filter((i) => (i.protocols.some((pr) => PROGRAM.test(pr.type)) || /^(swd|jtag)/.test(i.id)) && i.exposed !== false && reachable(x.path, i.id))
|
|
417
|
+
// a board whose controller's USB reaches its edge is programmed there (one check below), not through each
|
|
418
|
+
// connector pin; a debug probe port (SWD, JTAG) keeps its own check (a controller with no bootloader yet is
|
|
419
|
+
// first programmed through it)
|
|
420
|
+
.filter((i) => kindOf(i) !== "usb" || !usbBoards.some((m) => boardOf(x.path) === m.b))
|
|
421
|
+
.map((i) => ({ x, i })),
|
|
422
|
+
);
|
|
423
|
+
// the rest: one USB check and one probe check per part (a connector's composite and its pins are the same port)
|
|
424
|
+
const program = [...new Map(programAll.sort((a, b) => (b.i.slots?.length ?? 0) - (a.i.slots?.length ?? 0)).map((e) => [`${e.x.path}:${kindOf(e.i)}`, e])).values()];
|
|
425
|
+
// and a board's USB connector covers the controller's USB behind it, when the firmware map does not show that path
|
|
426
|
+
const usbOn = new Set(program.filter((e) => kindOf(e.i) === "usb").map((e) => boardOf(e.x.path)?.module).filter(Boolean));
|
|
427
|
+
for (let k = program.length - 1; k >= 0; k--) {
|
|
428
|
+
const e = program[k];
|
|
429
|
+
if (kindOf(e.i) === "usb" && !(e.x.def.categories ?? []).some((c) => c.startsWith("connector")) && usbOn.has(boardOf(e.x.path)?.module) && program.some((o) => o !== e && kindOf(o.i) === "usb" && boardOf(o.x.path)?.module === boardOf(e.x.path)?.module)) program.splice(k, 1);
|
|
430
|
+
}
|
|
431
|
+
const buses = links.filter((l) => l.link.protocol === "i2c");
|
|
432
|
+
const boardBuses = maps.flatMap((m) => (m.fw?.buses ?? []).filter((bus) => bus.protocol === "i2c" && bus.devices.length).map((bus) => ({ m, bus })));
|
|
433
|
+
const signals = links.filter((l) => !["power", "net", "i2c", "connector", "mechanical", "bolt_pattern", "shaft"].includes(l.link.protocol) && l.link.a.iface.domain !== "mechanical" && !l.link.derived?.nets?.length);
|
|
434
|
+
if (!rails.length && !temporary.size && !inputs.length && !program.length && !buses.length && !usbBoards.length && !boardBuses.length) return out;
|
|
435
|
+
|
|
436
|
+
out.push({ check: "Before power", subject: u.name, how: "Visual; multimeter resistance from each rail to ground, power off", pass: `no solder debris or damage; no rail shorted to ground${rails.length ? ` (${rails.map((r) => r.name).join(", ")})` : ""}`, record: "resistance of each rail to ground", phase: "power" });
|
|
437
|
+
for (const i of inputs) out.push({ check: "Power up", subject: u.name, how: `Bench supply ${i.what}, current limit set low: start at a low voltage and raise slowly to ${target(i, pack)}`, pass: "no current limit hit, no heat, no smell; the supply current settles", record: "input voltage and idle current", phase: "power" });
|
|
438
|
+
for (const r of rails) out.push({ check: `Rail ${r.name}`, subject: r.where, how: "Multimeter to ground, powered", pass: r.voltage, record: "measured voltage", phase: "power" });
|
|
439
|
+
// the checks that need an input the product leaves open: a temporary supply on it first, removed at the end
|
|
440
|
+
for (const t of temporary.values()) {
|
|
441
|
+
out.push({
|
|
442
|
+
check: "Temporary supply",
|
|
443
|
+
subject: t.what,
|
|
444
|
+
how: `Connect a second current-limited bench supply to ${t.what}, set to ${t.volts.nominal !== undefined ? `${num(t.volts.nominal)} V` : t.volts.range ? `a voltage within its ${num(t.volts.range[0])}–${num(t.volts.range[1])} V range (the supply the product is meant to get)` : "its design voltage"}, for the checks that follow; disconnect it when the bring-up is done (unconnected in the product: ${t.reason})`,
|
|
445
|
+
pass: "no current limit hit, no heat, no smell; the supply current settles",
|
|
446
|
+
record: "supply voltage and idle current",
|
|
447
|
+
trace: t.input,
|
|
448
|
+
phase: "power",
|
|
449
|
+
});
|
|
450
|
+
for (const r of t.rails) out.push({ check: `Rail ${r.name}`, subject: r.where, how: "Multimeter to ground, powered, with the temporary supply on", pass: r.voltage, record: "measured voltage", phase: "power" });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---- current against the model's supply budget (and the project's power budget)
|
|
454
|
+
const def = u.kind === "root" ? p.rootDef! : p.lookup(u.definition)!;
|
|
455
|
+
const budget: string[] = [];
|
|
456
|
+
const budgetAll: [ModuleDef, string][] = [[def, ""], ...boards.map((b) => [p.definitions.get(b.module!)!.def, `${b.module} `] as [ModuleDef, string])];
|
|
457
|
+
const budgetDefs = budgetAll.filter(([d], k) => budgetAll.findIndex(([x]) => x.id === d.id) === k);
|
|
458
|
+
for (const [d, label] of budgetDefs) {
|
|
459
|
+
try {
|
|
460
|
+
for (const x of checkSystem(d, p.lookup).diagnostics) {
|
|
461
|
+
const det = x.details as { capacityW?: number; unknownLoads?: string[] } | undefined;
|
|
462
|
+
if (x.rule !== "supply_budget" || typeof det?.capacityW !== "number") continue;
|
|
463
|
+
const unknown = det.unknownLoads?.length ?? 0;
|
|
464
|
+
// with no load that states a draw, the rail's rating is what the measurement is held to
|
|
465
|
+
const rating = /\(([\d.]+) V × ([\d.]+) A/.exec(x.message);
|
|
466
|
+
const ref = x.message.split(": ")[0];
|
|
467
|
+
if (!((det as { loadW?: number }).loadW ?? 0) && rating) budget.push(`${label}${ref}: under its ${rating[2]} A rating at ${rating[1]} V (no load on it states a draw)`);
|
|
468
|
+
else budget.push(`${label}${x.message.split("; ")[0].replace(/\.$/, "")}${unknown ? `; ${unknown} load(s) state no draw` : ""}`);
|
|
469
|
+
}
|
|
470
|
+
} catch {
|
|
471
|
+
// a link that does not resolve is a check error, reported by protoboard check
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const sourceRating = sources.map((s) => ({ s, a: amps(param(s.iface, "max_current") as never) })).filter((x) => x.a !== undefined);
|
|
475
|
+
if (budget.length || budgetDoc || sourceRating.length)
|
|
476
|
+
out.push({
|
|
477
|
+
check: "Current against the power budget",
|
|
478
|
+
subject: u.name,
|
|
479
|
+
how: `Read the supply current idle, then with each load active${budgetDoc ? `; compare with ${budgetDoc}` : ""}`,
|
|
480
|
+
pass: [
|
|
481
|
+
...budget.map((m) => m.replace(/\.$/, "")),
|
|
482
|
+
...(!budget.length ? sourceRating.map(({ s, a }) => `${s.iface.name ?? s.iface.id} of ${s.def.name ?? s.def.id}: below its ${num(a!)} A rating`) : []),
|
|
483
|
+
...(budgetDoc && !budget.length && !sourceRating.length ? [`within ${budgetDoc}`] : []),
|
|
484
|
+
].join("; "),
|
|
485
|
+
record: "idle and loaded current, per rail where it can be measured",
|
|
486
|
+
phase: "power",
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ---- programming and boot: a board's controller over the USB its edge exports
|
|
490
|
+
for (const m of usbBoards) {
|
|
491
|
+
const fw = m.fw!;
|
|
492
|
+
const ctl = p.lookup(fw.controller!.part);
|
|
493
|
+
const port = ctl?.interfaces.find((i) => i.id === fw.usb!.interface);
|
|
494
|
+
const conn = (m.def.exports ?? []).find((e) => "child" in e.from && fw.usb!.peers.includes(e.from.child))!;
|
|
495
|
+
out.push({
|
|
496
|
+
check: "USB, programming and boot",
|
|
497
|
+
subject: `${m.def.name ?? m.def.id} ${conn.name ?? conn.id}`,
|
|
498
|
+
how: `Connect ${conn.name ?? conn.id} to a computer; load the firmware over ${port?.name ?? fw.usb!.interface} of the ${fw.controller!.name}`,
|
|
499
|
+
pass: `${port?.name ?? "the controller's USB port"} enumerates; the firmware loads, verifies and boots; its console connects`,
|
|
500
|
+
record: "firmware name and version loaded",
|
|
501
|
+
trace: `${m.def.id}:${fw.controller!.instance}:${fw.usb!.interface}`,
|
|
502
|
+
phase: "running",
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
for (const { x, i } of program) {
|
|
506
|
+
const usb = i.protocols.some((pr) => /^usb/.test(pr.type));
|
|
507
|
+
out.push({
|
|
508
|
+
check: usb ? "USB and boot" : "Programming and boot",
|
|
509
|
+
subject: `${x.def.name ?? x.def.id} ${i.name ?? i.id}`,
|
|
510
|
+
how: usb ? "Connect USB to a computer" : `Debug probe on ${i.name ?? i.id}; read the target's ID, load the firmware`,
|
|
511
|
+
pass: usb ? "the device enumerates; firmware boots (its configurator or console connects)" : "the probe reads the target; the firmware loads, verifies and boots",
|
|
512
|
+
record: "firmware name and version loaded",
|
|
513
|
+
trace: `${x.path}:${i.id}`,
|
|
514
|
+
phase: "running",
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---- bus scans: expected I2C addresses from the parts
|
|
519
|
+
const byMaster = new Map<string, { name: string; devices: string[] }>();
|
|
520
|
+
for (const { link: l, prefix } of buses) {
|
|
521
|
+
const [m, s] = hasRole(l.a.iface, "i2c", "master") ? [l.a, l.b] : [l.b, l.a];
|
|
522
|
+
const key = `${at(prefix, m.ownerPath)}:${m.iface.id}`;
|
|
523
|
+
// every address the device answers at: its own, then the all-call and sub-addresses it states (a PCA9685's 0x70)
|
|
524
|
+
const addrs = i2cAddresses(s.iface);
|
|
525
|
+
const hex = (a: number) => `0x${a.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
526
|
+
const e = byMaster.get(key) ?? { name: `${endName(p, prefix, m.ownerPath)} ${m.iface.name ?? m.iface.id}`, devices: [] };
|
|
527
|
+
const who = endName(p, prefix, s.ownerPath);
|
|
528
|
+
if (!addrs.length) e.devices.push(`address not in the model (${who})`);
|
|
529
|
+
for (const a of addrs) e.devices.push(`${hex(a.address)} (${who}${a.name === "address" ? "" : `, ${a.name.replace(/_/g, "-")} address`})`);
|
|
530
|
+
byMaster.set(key, e);
|
|
531
|
+
}
|
|
532
|
+
for (const [key, b] of byMaster) out.push({ check: "Bus scan", subject: b.name, how: "I2C scan from the controller's firmware or a bus adapter", pass: `answers at ${b.devices.join(", ")}; nothing else unexpected`, record: "addresses found", trace: key, phase: "running" });
|
|
533
|
+
// buses on a board that links between parts do not show (a controller whose I2C is routed through its pin matrix): from the firmware map
|
|
534
|
+
for (const { m, bus } of boardBuses) {
|
|
535
|
+
// already scanned from the links (a controller part that states its I2C)
|
|
536
|
+
const ctlPaths = m.b.instances.filter(within).map((i) => `${i}/${m.fw!.controller!.instance}:`);
|
|
537
|
+
if ([...byMaster.keys()].some((k) => ctlPaths.some((c) => k.startsWith(c)))) continue;
|
|
538
|
+
const pins = bus.signals.map((x) => `${x.role.toUpperCase()} net ${x.net}`).join(", ");
|
|
539
|
+
const hex = (a?: number) => (a !== undefined ? `0x${a.toString(16).toUpperCase().padStart(2, "0")}` : "address not in the model");
|
|
540
|
+
out.push({
|
|
541
|
+
check: "Bus scan",
|
|
542
|
+
subject: `${m.def.name ?? m.def.id} ${bus.id.toUpperCase()} (${pins})`,
|
|
543
|
+
how: `I2C scan from the ${m.fw!.controller!.name}'s firmware or a bus adapter on the test pads`,
|
|
544
|
+
pass: `answers at ${bus.devices.map((d) => `${hex(d.address)} (${d.name})`).join(", ")}; nothing else unexpected`,
|
|
545
|
+
record: "addresses found",
|
|
546
|
+
trace: `${m.def.id}:${bus.id}`,
|
|
547
|
+
phase: "running",
|
|
548
|
+
});
|
|
549
|
+
for (const d of bus.devices)
|
|
550
|
+
out.push({ check: "Smoke test: i2c", subject: `${d.name} (${m.def.id} ${d.instance}, ${hex(d.address)})`, how: "With the firmware running", pass: SMOKE.i2c, record: "pass or fail; ID register value", trace: `${m.def.id}:${d.instance}`, phase: "running" });
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---- one smoke test per signal link
|
|
554
|
+
const seen = new Set<string>();
|
|
555
|
+
for (const { link: l, prefix } of signals) {
|
|
556
|
+
// a composite port (many conductors of several kinds) is covered by the checks of what it carries
|
|
557
|
+
if (!SMOKE[l.protocol] && l.a.iface.slots?.length) continue;
|
|
558
|
+
const key = `${l.link.name ?? l.link.id}`;
|
|
559
|
+
if (seen.has(`${prefix}/${key}`)) continue;
|
|
560
|
+
seen.add(`${prefix}/${key}`);
|
|
561
|
+
out.push({
|
|
562
|
+
check: `Smoke test: ${l.protocol}`,
|
|
563
|
+
subject: `${l.link.name ?? l.link.id}: ${endName(p, prefix, l.a.ownerPath)} ↔ ${endName(p, prefix, l.b.ownerPath)}`,
|
|
564
|
+
how: "With the firmware running",
|
|
565
|
+
pass: SMOKE[l.protocol] ?? "the peer responds as its datasheet says",
|
|
566
|
+
record: "pass or fail",
|
|
567
|
+
trace: [prefix, l.link.id].filter(Boolean).join("/"),
|
|
568
|
+
phase: "running",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** A single design voltage as a pass band: ±5 % unless the model gives a range (an assumption, stated). */
|
|
575
|
+
const tolerance = (v: number) => `${num(v)} V ± 5 % (${num(v * 0.95, 2)}–${num(v * 1.05, 2)} V; tolerance assumed)`;
|
|
576
|
+
|
|
577
|
+
/** The board's firmware map (core), with the controller its firmware config names; undefined for a board with no controller. */
|
|
578
|
+
function firmwareMap(p: Project, b: CapabilityBinding, def: ModuleDef): BoardFirmware | undefined {
|
|
579
|
+
const fwb = p.capabilities.find((x) => x.capability === "firmware" && x.module === b.module && !x.problem);
|
|
580
|
+
const controller = (fwb?.constraints as { controller?: string } | undefined)?.controller;
|
|
581
|
+
try {
|
|
582
|
+
const fw = boardFirmware(def, p.lookup, { ...(controller ? { controller } : {}), board: boardOptions(p) });
|
|
583
|
+
return fw.controller ? fw : undefined;
|
|
584
|
+
} catch {
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
interface SupplyInput {
|
|
590
|
+
what: string;
|
|
591
|
+
/** Nominal voltage, when the model states one. */
|
|
592
|
+
nominal?: number;
|
|
593
|
+
/** The range it runs over or accepts. */
|
|
594
|
+
range?: [number, number];
|
|
595
|
+
/** A battery pack the bench supply stands in for. */
|
|
596
|
+
pack: boolean;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const supplyVolts = (v?: { value?: number; range?: [number, number] }) => ({
|
|
600
|
+
...(typeof v?.value === "number" ? { nominal: v.value } : {}),
|
|
601
|
+
...(v?.range && v.range[0] !== v.range[1] ? { range: v.range } : {}),
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
/** The pack that powers the whole product: its name and nominal voltage. */
|
|
605
|
+
function productPack(p: Project): { name: string; v: number } | undefined {
|
|
606
|
+
for (const i of p.instances) {
|
|
607
|
+
const def = p.lookup(i.definition);
|
|
608
|
+
const out = def?.interfaces.find((x) => hasRole(x, "power", "output") && param(x, "cell_count"));
|
|
609
|
+
const v = out ? param(out, "voltage")?.value : undefined;
|
|
610
|
+
if (def && typeof v === "number") return { name: def.name ?? def.id, v };
|
|
611
|
+
}
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Where the bench supply ends up: the nominal voltage, else the pack's voltage the input sees in the product, else its range. */
|
|
616
|
+
function target(i: SupplyInput, pack?: { name: string; v: number }): string {
|
|
617
|
+
const r = i.range ? `${num(i.range[0])}–${num(i.range[1])} V` : "";
|
|
618
|
+
if (i.nominal !== undefined) return `${num(i.nominal)} V${r ? ` (${i.pack ? "the pack runs" : "it accepts"} ${r})` : ""}`;
|
|
619
|
+
if (i.range && pack && pack.v >= i.range[0] && pack.v <= i.range[1]) return `${num(pack.v)} V, the voltage of the ${pack.name} that feeds it in the product (it accepts ${r})`;
|
|
620
|
+
return i.range ? `the voltage it sees in the product, within its ${r} range` : "its design voltage";
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function readJson<T>(f: string): T | undefined {
|
|
624
|
+
if (!existsSync(f)) return undefined;
|
|
625
|
+
try {
|
|
626
|
+
return JSON.parse(readFileSync(f, "utf8")) as T;
|
|
627
|
+
} catch {
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
}
|