app
AI Flow: JLCPCB
Public Made by Adomby adom
The whole JLCPCB order, from gerbers to assembled boards in hand: proven part swaps, design rules, rotation checks, quote and order.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
//! adom-aiflow-jlcpcb: order a finished board from JLCPCB (PCB + assembly) with every step proven and recorded:
//! parts JLCPCB stocks (substitutes proven from both datasheets), the manufacturing package from the desktop's own
//! KiCad, every part's placement rotation checked against JLCPCB's own footprint, the placement preview captured,
//! the quote, the human's OK before any money moves, the order, the delivered boards, and the rotation corrections
//! shared on the parts' wiki pages so the next board gets them right the first time.
mod bridge;
mod csvio;
mod export;
mod flow;
mod kicad;
mod ledger;
mod out;
mod rotation;
mod swap;
use clap::{Parser, Subcommand};
use out::{err, ok};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
/// The folder this flow keeps inside the project (state.json, run.jsonl, clips/, fab/, preview/, evidence).
const FLOW_DIR: &str = "jlcpcb-flow";
#[derive(Parser)]
#[command(name = "adom-aiflow-jlcpcb", version, about = "Order a finished board from JLCPCB: proven part swaps, the manufacturing package, the rotation check, the placement preview, quote, the human's OK, order, delivery, and shared rotation corrections")]
struct Cli {
/// The name of the AI conversation calling (required for commands that change state)
#[arg(long, global = true)]
ai_thread: Option<String>,
/// Project folder (default: the nearest one above the current folder that has jlcpcb-flow/)
#[arg(long, global = true)]
project: Option<String>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Start on a finished board: `init --release <board-release.json>` or `init --board <B.kicad_pcb> --bom <csv>`, with `--target <desktop>` (the box with KiCad and the browser)
Init { #[arg(long)] release: Option<String>, #[arg(long)] board: Option<String>, #[arg(long)] bom: Option<String>, #[arg(long)] target: Option<String> },
/// What to do now: the first step whose artifacts are missing, and what the AI must do in it
Next,
/// Every step with its state
Steps,
/// The run ledger, one line per command
Ledger,
/// `clip start --name <step> --hwnd <window>` / `clip stop`: record one desktop window for the video
Clip { what: String, #[arg(long)] name: Option<String>, #[arg(long)] hwnd: Option<i64> },
/// Prove a substitute part from both datasheets: `swap --ref C6 --facts swaps/C6.json [--apply]` (`--apply` rewrites the BOM rows)
Swap { #[arg(long = "ref")] reference: String, #[arg(long)] facts: String, #[arg(long)] apply: bool },
/// Close sourcing: every placed part has an LCSC number; `sourcing --why "<what was swapped and why the rest stay>"`
Sourcing { #[arg(long)] why: String },
/// Check the board against JLCPCB's design rules (adom/pcb-design-rules, picked by copper layer count) with the desktop's KiCad DRC, on a copy: the design's own rules file is never touched
Rules { #[arg(long, default_value = "adom/pcb-design-rules")] from: String },
/// The manufacturing package plus the rotation check: gerber zip, JLCPCB BOM and CPL (corrected), manifest, rotation record
Export { #[arg(long)] rotations: Option<String>, #[arg(long)] no_rotation_check: bool, /// export although the JLCPCB rules check has not passed (say why in the ledger)
#[arg(long)] skip_rules: Option<String>, #[arg(long, default_value_t = 0.15)] tol: f64 },
/// A JLCPCB placement-preview screenshot: `preview --png <file> --refs U1,L1` (pin 1 visible on each named part)
Preview { #[arg(long)] png: String, #[arg(long)] refs: String, #[arg(long)] note: Option<String> },
/// The quote JLCPCB shows before checkout: `quote --total 108.38 [--currency USD] [--quote-id <id>] [--note ...]`
Quote { #[arg(long)] total: f64, #[arg(long, default_value = "USD")] currency: String, #[arg(long)] quote_id: Option<String>, #[arg(long)] note: Option<String> },
/// The human's OK to pay, in their own words: `approve --by <name> --amount <total> --words "<what they said>"`
Approve { #[arg(long)] by: String, #[arg(long)] amount: f64, #[arg(long)] words: String },
/// The order went in: `ordered --order <JLCPCB order number>`
Ordered { #[arg(long)] order: String },
/// The boards came back: `delivered --boards 5 --working 5 [--note ...]`
Delivered { #[arg(long)] boards: u64, #[arg(long)] working: u64, #[arg(long)] note: Option<String> },
/// Share a part's rotation correction on its wiki component page: `share --ref U1 --page <owner/slug> [--screenshot <png>] [--dry-run]`; `share --done --why "..."` closes the step
Share { #[arg(long = "ref")] reference: Option<String>, #[arg(long)] page: Option<String>, #[arg(long)] screenshot: Option<String>, #[arg(long)] dry_run: bool, #[arg(long)] done: bool, #[arg(long)] why: Option<String> },
/// Write jlcpcb-flow/report.md: what was ordered, the evidence, and what the next stage must know
Report,
}
pub fn sha256_file(p: &Path) -> String {
std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(&b))).unwrap_or_default()
}
fn thread(cli: &Cli) -> String {
cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state: pass --ai-thread \"<your thread name>\"", &[]))
}
fn project(cli: &Cli) -> ledger::Project {
let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-jlcpcb init --release <board-release.json> --target <desktop> --ai-thread <you>".into()]))
}
fn read_json(p: &Path) -> Value {
std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)
}
fn write_json(p: &Path, v: &Value) {
if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); }
std::fs::write(p, serde_json::to_string_pretty(v).unwrap() + "\n").unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));
}
fn board_of(p: &ledger::Project) -> PathBuf { PathBuf::from(p.state["release"]["board"]["path"].as_str().unwrap_or("")) }
fn bom_of(p: &ledger::Project) -> PathBuf { PathBuf::from(p.state["release"]["bom"]["path"].as_str().unwrap_or("")) }
fn bridge(p: &ledger::Project, th: &str) -> bridge::Bridge {
let target = p.state["target"].as_str().map(str::to_string).unwrap_or_else(|| err("no desktop target: init with --target <box>", &[]));
bridge::Bridge { target, ai_thread: th.to_string() }
}
/// Refuse when the board changed after the flow started: everything downstream would describe a different board.
fn same_board(p: &ledger::Project) {
let b = board_of(p);
let want = p.state["release"]["board"]["sha256"].as_str().unwrap_or("");
if !want.is_empty() && sha256_file(&b) != want {
err(&format!("{} changed since init (sha256 differs)", b.display()), &["Run init again on the new board (a new release); the export, preview and quote must all describe the board you order.".into()]);
}
}
/// Parts the placement preview must show with pin 1 visible: every corrected, hand-set or footprint-less part.
fn flagged(p: &ledger::Project) -> Vec<String> {
let man = latest_manifest(p).map(|m| read_json(&m)).unwrap_or(Value::Null);
let rec = man["rotationCheck"]["file"].as_str().map(|f| read_json(Path::new(f))).unwrap_or(Value::Null);
let mut out: Vec<String> = rec["parts"].as_array().into_iter().flatten()
.filter(|r| r["delta"].as_i64().unwrap_or(0) != 0 || matches!(r["status"].as_str(), Some("manual") | Some("no-jlc-footprint")))
.filter_map(|r| r["ref"].as_str().map(str::to_string)).collect();
out.sort_by_key(|r| kicad::ref_key(r));
out
}
fn latest_manifest(p: &ledger::Project) -> Option<PathBuf> {
std::fs::read_dir(p.path("fab")).ok()?.flatten().map(|e| e.path()).find(|x| x.to_string_lossy().ends_with("-fab-manifest.json"))
}
fn after_hint(p: &ledger::Project, fl: &flow::Flow) -> String {
flow::current(fl, &p.flow_dir).map(|s| format!("next step: {}: {}", s.name, s.todo)).unwrap_or("every step has its artifacts: the flow is done".into())
}
fn main() {
let cli = Cli::parse();
let fl = flow::load();
match &cli.cmd {
Cmd::Init { release, board, bom, target } => {
let th = thread(&cli);
let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
let mut rel: Value = match release {
Some(r) => serde_json::from_str(&std::fs::read_to_string(r).unwrap_or_else(|e| err(&format!("{r}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{r}: not JSON: {e}"), &[])),
None => {
let (Some(b), Some(m)) = (board, bom) else { err("init needs --release <board-release.json>, or --board and --bom", &["The board flow (adom-aiflow) writes board-release.json when it finishes (schema adom/board-release@1).".into()]) };
json!({"schema": "adom/board-release@1", "board": {"path": b, "sha256": ""}, "bom": {"path": m, "sha256": ""}})
}
};
for k in ["board", "bom"] {
let raw = rel[k]["path"].as_str().unwrap_or("").to_string();
let path = Path::new(&raw).canonicalize().unwrap_or_else(|_| err(&format!("the release's {k} is not here: {raw}"), &[]));
let want = rel[k]["sha256"].as_str().unwrap_or("").to_string();
let have = sha256_file(&path);
if !want.is_empty() && want != have { err(&format!("{} changed since the release (sha256 differs)", path.display()), &["Finish the board again so the release names the board you mean to order.".into()]); }
rel[k] = json!({"path": path.display().to_string(), "sha256": have});
}
let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "release": rel, "target": target, "started": ledger::now()}))
.unwrap_or_else(|e| err(&format!("cannot write {FLOW_DIR}/: {e}"), &[]));
p.log(&th, "init", json!({"release": release, "board": rel["board"], "bom": rel["bom"]}));
ok(&format!("JLCPCB flow started in {} for {}", p.flow_dir.display(), rel["board"]["path"].as_str().unwrap_or("")), &[after_hint(&p, &fl),
if target.is_none() { "No --target: the export, rotation check and clips need the desktop with KiCad 10 and pup (init again with --target <box>).".into() } else { String::new() }]);
}
Cmd::Next => {
let p = project(&cli);
match flow::current(&fl, &p.flow_dir) {
Some(s) => ok(&format!("step {}", s.name), &[s.todo.clone()]),
None => ok("every step has its artifacts: the flow is done", &[]),
}
}
Cmd::Steps => {
let p = project(&cli);
let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone());
for s in &fl.steps {
let done = s.done.iter().all(|x| flow::exists(&p.flow_dir, x));
println!("{} {}", if done { "done" } else if Some(&s.name) == cur.as_ref() { "NOW " } else { " " }, s.name);
}
}
Cmd::Ledger => { let p = project(&cli); print!("{}", std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default()); }
Cmd::Clip { what, name, hwnd } => {
let th = thread(&cli);
let mut p = project(&cli);
let br = bridge(&p, &th);
match what.as_str() {
"start" => {
let (Some(n), Some(h)) = (name, hwnd) else { err("clip start needs --name <step> --hwnd <window>", &["Find the window with `adom-bridge desktop_list_windows`. Drive the browser with pup (pup_eval, pup_input_dispatch), never the OS mouse, so the clip shows no moving cursor.".into()]) };
if p.state["clip"].is_object() { err("a clip is already recording: clip stop first", &[]); }
let id = br.record_window_start(*h, &format!("{FLOW_DIR}: record step {n}")).unwrap_or_else(|r| err(&format!("recording did not start: {r}"), &[]));
p.state["clip"] = json!({"id": id, "name": n, "hwnd": h, "started": ledger::now()});
p.save().ok(); p.log(&th, "clip-start", p.state["clip"].clone());
ok(&format!("recording {n} (window {h})"), &[]);
}
"stop" => {
let c = p.state["clip"].clone();
let id = c["id"].as_str().unwrap_or_else(|| err("no clip is recording", &[]));
let dir = p.path("clips"); std::fs::create_dir_all(&dir).ok();
let s = br.record_stop(id, &dir);
p.state["clip"] = Value::Null; p.save().ok();
p.log(&th, "clip-stop", json!({"name": c["name"], "remote": s.remote, "local": s.local, "pulled": s.pulled}));
if !s.pulled { err(&format!("the clip did not land locally: {}", s.reply), &["Pull it with pull_file before the desktop cleans its recordings folder.".into()]); }
ok(&format!("clip {} saved to {}", c["name"].as_str().unwrap_or(""), s.local.unwrap().display()), &[]);
}
_ => err("clip start|stop", &[]),
}
}
Cmd::Swap { reference, facts, apply } => {
let th = thread(&cli);
let p = project(&cli);
let fpath = PathBuf::from(facts);
let mut f = read_json(&fpath);
if f.is_null() { err(&format!("{facts}: missing or not JSON"), &["Write the facts first: both datasheets read, every number with its source (see `adom-aiflow-jlcpcb swap --help` and the skill).".into()]); }
if f.get("ref").is_none() { f["ref"] = json!(reference); }
let board = board_of(&p);
let fps = kicad::footprints(&std::fs::read_to_string(&board).unwrap_or_default());
let mut rows = swap::check(&f, fps.get(reference.as_str()), true);
for r in rows.iter_mut() {
if let Some(w) = f["waive"][&r.check].as_str() { if !r.pass { r.info = true; r.detail = format!("{}; WAIVED: {w}", r.detail); } }
}
let pass = !rows.is_empty() && rows.iter().all(|r| r.pass || r.info);
let cand = &f["candidate"];
print!("{} -> {} ({})\n{}", f["original"]["mpn"].as_str().unwrap_or(""), cand["mpn"].as_str().unwrap_or(""), cand["lcsc"].as_str().unwrap_or("no LCSC"), swap::print(&rows));
println!("verdict: {}", if pass { "PASS" } else { "FAIL" });
let out = fpath.parent().unwrap_or(Path::new(".")).join(format!("{reference}.result.json"));
let mut res = json!({"ref": reference, "original": f["original"]["mpn"], "candidate": cand["mpn"], "lcsc": cand["lcsc"], "verdict": if pass { "PASS" } else { "FAIL" },
"checks": rows.iter().map(|r| json!({"check": r.check, "pass": r.pass, "detail": r.detail, "info": r.info})).collect::<Vec<_>>(),
"checkedAt": ledger::now(), "facts": facts, "notes": f["notes"]});
if *apply {
if !pass { write_json(&out, &res); p.log(&th, "swap", json!({"ref": reference, "verdict": "FAIL", "result": out})); err("not applied: the swap failed its checks", &[]); }
let changed = swap::apply(&bom_of(&p), &f).unwrap_or_else(|e| err(&format!("BOM not rewritten: {e}"), &[]));
res["bomRows"] = json!(changed);
println!("BOM rows now {}: {}", cand["mpn"].as_str().unwrap_or(""), changed.join(", "));
}
write_json(&out, &res);
p.log(&th, "swap", json!({"ref": reference, "verdict": res["verdict"], "applied": apply, "result": out}));
if !pass { err(&format!("swap {reference}: FAIL (see the checks above); evidence {}", out.display()), &["Fix the facts only with what the datasheets say; a deliberate exception goes in \"waive\" with the reason.".into()]); }
ok(&format!("swap {reference} verified, evidence {}", out.display()), &[if *apply { String::new() } else { "Re-run with --apply to rewrite the BOM rows.".into() }]);
}
Cmd::Sourcing { why } => {
let th = thread(&cli);
let p = project(&cli);
let bom = csvio::Table::read(&bom_of(&p)).unwrap_or_else(|e| err(&format!("BOM: {e}"), &[]));
let fps = kicad::footprints(&std::fs::read_to_string(board_of(&p)).unwrap_or_default());
let split = regex::Regex::new(r"[ ,;]+").unwrap();
let re_c = regex::Regex::new(r"^C\d+$").unwrap();
let mut missing = Vec::new();
let mut covered = std::collections::BTreeSet::new();
for row in &bom.rows {
let fit = bom.get(row, "fit").trim().to_lowercase();
let l = { let l = bom.get(row, "lcsc").trim(); if l.is_empty() { bom.get(row, "vendor_pn").trim() } else { l } };
for rf in split.split(bom.get(row, "ref")).filter(|s| !s.is_empty()) {
covered.insert(rf.to_string());
if !rotation::SKIP_FIT.contains(&fit.as_str()) && !re_c.is_match(l) { missing.push(rf.to_string()); }
}
}
let mut absent: Vec<String> = fps.keys().filter(|r| !covered.contains(*r) && !r.starts_with('#')).cloned().collect();
absent.sort_by_key(|r| kicad::ref_key(r));
if !missing.is_empty() || !absent.is_empty() {
err(&format!("sourcing is not closed: {}{}", if missing.is_empty() { String::new() } else { format!("no LCSC number for {}", missing.join(", ")) },
if absent.is_empty() { String::new() } else { format!("{}board references with no BOM row: {}", if missing.is_empty() { "" } else { "; " }, absent.join(", ")) }),
&["Search in Hydrogen (`adom-parts-search show \"<spec>\" --basic`), prove each substitute with `swap`, or mark parts fitted after assembly fit=post.".into()]);
}
let swaps: Vec<Value> = std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default().lines().filter_map(|l| serde_json::from_str::<Value>(l).ok())
.filter(|e| e["ev"] == "swap" && e["data"]["applied"] == true).map(|e| e["data"].clone()).collect();
write_json(&p.path("sourcing.json"), &json!({"closed": ledger::now(), "bom": bom_of(&p).display().to_string(), "bomSha256": sha256_file(&bom_of(&p)), "why": why, "swapsApplied": swaps}));
p.log(&th, "sourcing", json!({"why": why, "swaps": swaps.len()}));
ok(&format!("sourcing closed: every placed part has an LCSC number ({} swaps applied)", swaps.len()), &[after_hint(&p, &fl)]);
}
Cmd::Rules { from } => {
let th = thread(&cli);
let p = project(&cli);
same_board(&p);
let board = board_of(&p);
let n = export::copper_layers(&std::fs::read_to_string(&board).unwrap_or_default()).max(2);
let file = format!("kicad/jlcpcb-{n}L.kicad_dru");
let o = std::process::Command::new("adom-wiki").args(["repo", "show", from, &file]).output().unwrap_or_else(|e| err(&format!("adom-wiki: {e}"), &[]));
let dru = String::from_utf8_lossy(&o.stdout).to_string();
if !o.status.success() || !dru.contains("(version") { err(&format!("no {file} on {from}"), &[format!("JLCPCB rules live on the wiki page {from} (kicad/jlcpcb-2L|4L|6L.kicad_dru); a {n}-layer board needs its file there.")]); }
// the board's own rules first, JLCPCB's limits last (KiCad gives later rules priority), so a design's
// deliberate overrides stand and only JLCPCB's minimums are judged on top of them
let own = std::fs::read_to_string(board.with_extension("kicad_dru")).unwrap_or_default();
let strip = |t: &str| t.lines().filter(|l| !l.trim_start().starts_with("(version")).collect::<Vec<_>>().join("\n");
let combined = format!("(version 1)\n\n# the board's own rules\n{}\n\n# JLCPCB's limits ({from}/{file}), last so they take priority\n{}\n", strip(&own), strip(&dru));
let br = bridge(&p, &th);
let rep = export::drc_with_rules(&br, &board, &combined).unwrap_or_else(|e| err(&format!("rules check: {e}"), &[]));
let viol: Vec<Value> = rep["violations"].as_array().cloned().unwrap_or_default();
let errors: Vec<&Value> = viol.iter().filter(|v| v["severity"] == "error").collect();
let unconnected = rep["unconnected_items"].as_array().map(|a| a.len()).unwrap_or(0);
let mut kinds: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for v in &errors { *kinds.entry(v["type"].as_str().unwrap_or("?").to_string()).or_default() += 1; }
let pass = errors.is_empty() && unconnected == 0;
write_json(&p.path("rules.json"), &json!({"pass": pass, "rules": format!("{from}/{file}"), "ownRules": if own.is_empty() { Value::Null } else { json!(board.with_extension("kicad_dru").display().to_string()) }, "rulesSha256": format!("{:x}", Sha256::digest(dru.as_bytes())), "copperLayers": n,
"errors": errors.len(), "errorKinds": kinds, "warnings": viol.len() - errors.len(), "unconnected": unconnected, "boardSha256": sha256_file(&board), "at": ledger::now(),
"violations": errors.iter().take(50).map(|v| json!({"type": v["type"], "description": v["description"], "items": v["items"].as_array().map(|a| a.iter().map(|i| i["description"].clone()).collect::<Vec<_>>())})).collect::<Vec<_>>()}));
p.log(&th, "rules", json!({"pass": pass, "rules": file, "errors": errors.len(), "unconnected": unconnected}));
if !pass {
err(&format!("the board breaks JLCPCB's {n}-layer rules: {} errors ({}), {unconnected} unconnected; details in jlcpcb-flow/rules.json", errors.len(), kinds.iter().map(|(k, c)| format!("{k} x{c}")).collect::<Vec<_>>().join(", ")),
&["Fix the board in the board flow (adom-aiflow) and finish it again, then init this flow on the new release. A JLCPCB upgrade (smaller drills, tighter spacing) is a cost decision for the human.".into()]);
}
ok(&format!("the board meets JLCPCB's {n}-layer rules ({from}/{file}): 0 errors, 0 unconnected, {} warnings", viol.len()), &[after_hint(&p, &fl)]);
}
Cmd::Export { rotations, no_rotation_check, tol, skip_rules } => {
let th = thread(&cli);
let p = project(&cli);
same_board(&p);
let rules = read_json(&p.path("rules.json"));
let rules_ok = rules["pass"] == true && rules["boardSha256"].as_str() == Some(sha256_file(&board_of(&p)).as_str());
match (rules_ok, skip_rules) {
(true, _) => {}
(false, Some(why)) => p.log(&th, "rules-skipped", json!({"why": why})),
(false, None) => err("the board has not passed JLCPCB's design rules yet: run `rules`", &["`rules` runs KiCad DRC with JLCPCB's rules from adom/pcb-design-rules on a copy of the board. To export anyway, pass --skip-rules \"<why>\" (it goes in the ledger).".into()]),
}
let br = bridge(&p, &th);
let ov: Map<String, Value> = rotations.as_ref().map(|r| read_json(Path::new(r))).and_then(|v| v.as_object().cloned()).unwrap_or_default()
.into_iter().filter(|(k, _)| !k.starts_with('_')).collect();
let out = p.path("fab");
let pkg = export::export(&br, &board_of(&p), &bom_of(&p), &out, &ov).unwrap_or_else(|e| { p.log(&th, "export-failed", json!({"why": e})); err(&format!("export: {e}"), &[]) });
println!("OK: {}", pkg.summary);
let mut hints = vec![];
if !*no_rotation_check {
let bom = csvio::Table::read(&bom_of(&p)).unwrap_or_else(|e| err(&format!("BOM: {e}"), &[]));
let c = rotation::check(&br, &board_of(&p), &bom, &ov, *tol);
let rec_path = out.join(format!("{}-jlc-rotation.json", pkg.base));
write_json(&rec_path, &c.record);
let applied = c.failed.is_empty();
if applied { rotation::apply(&pkg.cpl, &c.record).unwrap_or_else(|e| err(&format!("CPL not corrected: {e}"), &[])); }
println!("{}", rotation::summary(&c, applied));
for (r, w) in &c.failed { println!(" FAIL {r}: {w}"); }
for (r, w) in &c.warned { println!(" WARN {r}: {w}"); }
let parts = c.record["parts"].as_array().cloned().unwrap_or_default();
let mut man = read_json(&pkg.manifest);
man["rotationCheck"] = json!({"file": rec_path.display().to_string(), "applied": applied, "parts": parts.len(),
"corrected": parts.iter().filter(|r| r["delta"].as_i64().unwrap_or(0) != 0).map(|r| (r["ref"].as_str().unwrap_or("").to_string(), json!(rotation::signed(r["delta"].as_i64().unwrap_or(0))))).collect::<Map<_, _>>(),
"failed": c.failed.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>(),
"noJlcFootprint": parts.iter().filter(|r| matches!(r["status"].as_str(), Some("manual") | Some("no-jlc-footprint"))).filter_map(|r| r["ref"].as_str()).collect::<Vec<_>>()});
man["cplJlcpcb"]["sha256"] = json!(sha256_file(&pkg.cpl));
write_json(&pkg.manifest, &man);
p.log(&th, "export", json!({"manifest": pkg.manifest, "rotationCheck": man["rotationCheck"]}));
if !applied { err("the JLCPCB rotation check failed (see above); the CPL was NOT corrected", &["A part whose pads fit no rotation of JLCPCB's footprint has the wrong footprint or LCSC number, or is mirrored: fix the board or the BOM, never the CPL by hand.".into()]); }
let fl_refs = flagged(&p);
if !fl_refs.is_empty() { hints.push(format!("In JLCPCB's placement preview, look at pin 1 on {} and save a screenshot of each with `preview --png <file> --refs <refs>`.", fl_refs.join(", "))); }
} else {
p.log(&th, "export", json!({"manifest": pkg.manifest, "rotationCheck": "skipped"}));
hints.push("The rotation check was skipped: every part's pin 1 must then be checked by eye in JLCPCB's preview.".into());
}
hints.push(after_hint(&p, &fl));
ok(&format!("JLCPCB package in {}", out.display()), &hints);
}
Cmd::Preview { png, refs, note } => {
let th = thread(&cli);
let p = project(&cli);
let src = Path::new(png);
if !src.is_file() { err(&format!("{png}: no such file"), &[]); }
let name = format!("{}-{}.png", refs.replace([',', ' '], "-"), &sha256_file(src)[..8]);
let dest = p.path("preview").join(&name);
std::fs::create_dir_all(dest.parent().unwrap()).ok();
std::fs::copy(src, &dest).unwrap_or_else(|e| err(&format!("{e}"), &[]));
let mut doc = read_json(&p.path("preview.json"));
if doc.is_null() { doc = json!({"shots": []}); }
let list: Vec<String> = refs.split([',', ' ']).filter(|s| !s.is_empty()).map(str::to_string).collect();
doc["shots"].as_array_mut().unwrap().push(json!({"file": format!("preview/{name}"), "refs": list, "note": note, "at": ledger::now()}));
let seen: std::collections::BTreeSet<String> = doc["shots"].as_array().unwrap().iter().flat_map(|s| s["refs"].as_array().cloned().unwrap_or_default()).filter_map(|r| r.as_str().map(str::to_string)).collect();
let left: Vec<String> = flagged(&p).into_iter().filter(|r| !seen.contains(r)).collect();
doc["missing"] = json!(left);
write_json(&p.path("preview.json"), &doc);
p.log(&th, "preview", json!({"file": dest, "refs": list}));
ok(&format!("preview saved for {}", list.join(", ")), &[if left.is_empty() { "Every flagged part has its preview: get the quote next.".into() } else { format!("Still to capture in the preview: {}", left.join(", ")) }]);
}
Cmd::Quote { total, currency, quote_id, note } => {
let th = thread(&cli);
let p = project(&cli);
same_board(&p);
let doc = read_json(&p.path("preview.json"));
let seen: std::collections::BTreeSet<String> = doc["shots"].as_array().into_iter().flatten().flat_map(|s| s["refs"].as_array().cloned().unwrap_or_default()).filter_map(|r| r.as_str().map(str::to_string)).collect();
let left: Vec<String> = flagged(&p).into_iter().filter(|r| !seen.contains(r)).collect();
if !left.is_empty() { err(&format!("no placement-preview screenshot yet for {}", left.join(", ")), &["These parts were corrected or have no JLCPCB footprint: look at pin 1 on each in JLCPCB's preview and save it with `preview` before quoting.".into()]); }
write_json(&p.path("quote.json"), &json!({"total": total, "currency": currency, "quoteId": quote_id, "note": note, "at": ledger::now(), "boardSha256": sha256_file(&board_of(&p))}));
p.log(&th, "quote", json!({"total": total, "currency": currency, "quoteId": quote_id}));
ok(&format!("quote {total:.2} {currency} recorded"), &["Show the human the total and what it buys, and wait for their explicit OK in their own words before anything is paid. Then record it with `approve`.".into()]);
}
Cmd::Approve { by, amount, words } => {
let th = thread(&cli);
let p = project(&cli);
let q = read_json(&p.path("quote.json"));
let total = q["total"].as_f64().unwrap_or_else(|| err("no quote yet: `quote` first", &[]));
if (total - amount).abs() > 0.005 { err(&format!("the approval is for {amount:.2} but the quote is {total:.2}"), &["Approval covers one amount. Show the human the current total and ask again.".into()]); }
if words.trim().len() < 2 { err("record the human's own words", &[]); }
write_json(&p.path("approval.json"), &json!({"by": by, "amount": amount, "currency": q["currency"], "words": words, "aiThread": th, "at": ledger::now()}));
p.log(&th, "approve", json!({"by": by, "amount": amount, "words": words}));
ok(&format!("{by} approved {amount:.2} {}", q["currency"].as_str().unwrap_or("")), &["Place the order and pay at JLCPCB (record the checkout clip privately: it shows addresses), then `ordered --order <number>`.".into()]);
}
Cmd::Ordered { order } => {
let th = thread(&cli);
let p = project(&cli);
let (q, a) = (read_json(&p.path("quote.json")), read_json(&p.path("approval.json")));
if a.is_null() { err("no recorded approval: nothing may be paid without the human's OK (`approve`)", &[]); }
if (q["total"].as_f64().unwrap_or(-1.0) - a["amount"].as_f64().unwrap_or(-2.0)).abs() > 0.005 { err("the approval does not match the quote", &[]); }
write_json(&p.path("order.json"), &json!({"order": order, "total": q["total"], "currency": q["currency"], "approvedBy": a["by"], "at": ledger::now(), "boardSha256": sha256_file(&board_of(&p))}));
p.log(&th, "ordered", json!({"order": order}));
ok(&format!("order {order} recorded"), &["When the boards arrive and have been tried: `delivered --boards N --working N`.".into()]);
}
Cmd::Delivered { boards, working, note } => {
let th = thread(&cli);
let p = project(&cli);
if working > boards { err("more working boards than boards", &[]); }
let order = read_json(&p.path("order.json"));
write_json(&p.path("delivered.json"), &json!({"boards": boards, "working": working, "note": note, "order": order["order"], "at": ledger::now()}));
p.log(&th, "delivered", json!({"boards": boards, "working": working}));
ok(&format!("{working} of {boards} boards work"), &[if working == boards { "Every board works: the rotation corrections are now proven on real boards. Share them (`share`).".into() } else { "Some boards fail: find out why before sharing any rotation correction as proven (a part turned the wrong way shows up here).".into() }]);
}
Cmd::Share { reference, page, screenshot, dry_run, done, why } => {
let th = thread(&cli);
let p = project(&cli);
if *done {
let why = why.clone().unwrap_or_else(|| err("share --done needs --why \"<what was shared, and why the rest was not>\"", &[]));
write_json(&p.path("shared.json"), &json!({"why": why, "at": ledger::now()}));
p.log(&th, "share-done", json!({"why": why}));
return ok("sharing closed", &[after_hint(&p, &fl)]);
}
let (Some(rf), Some(pg)) = (reference, page) else { err("share needs --ref <REF> --page <owner/slug> (or --done --why ...)", &["Share only on pages you own or were asked to update; on anyone else's page, open an issue with the record instead.".into()]) };
let man = latest_manifest(&p).map(|m| read_json(&m)).unwrap_or(Value::Null);
let rec = man["rotationCheck"]["file"].as_str().map(|f| read_json(Path::new(f))).unwrap_or_else(|| err("no rotation record: run `export` first", &[]));
let delivered = read_json(&p.path("delivered.json"));
let boards = delivered["working"].as_u64().filter(|n| *n > 0 && delivered["working"] == delivered["boards"]);
let order = delivered["order"].as_str().map(str::to_string);
let ev = rotation::Evidence { screenshot: screenshot.as_ref().map(Path::new), boards, order: order.as_deref() };
match rotation::publish(&rec, rf, pg, &ev, *dry_run) {
Ok(msg) => { if !dry_run { p.log(&th, "share", json!({"ref": rf, "page": pg, "screenshot": screenshot, "boards": boards})); } print!("{}", if *dry_run { format!("{msg}") } else { format!("OK: {msg}\n") }); }
Err(e) => err(&format!("share {rf}: {e}"), &[]),
}
}
Cmd::Report => {
let th = thread(&cli);
let p = project(&cli);
let man = latest_manifest(&p).map(|m| read_json(&m)).unwrap_or(Value::Null);
let (q, a, o, d) = (read_json(&p.path("quote.json")), read_json(&p.path("approval.json")), read_json(&p.path("order.json")), read_json(&p.path("delivered.json")));
let src = read_json(&p.path("sourcing.json"));
let corrected = man["rotationCheck"]["corrected"].as_object().map(|m| m.iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(", ")).unwrap_or_default();
let text = format!("# JLCPCB order report\n\nBoard: `{}` (sha256 {})\n\n| Step | Result |\n|---|---|\n| Sourcing | {} swaps applied; {} |\n| Package | {} BOM lines, {} placed parts; left out: {} |\n| Rotation check | {} parts; corrected: {}; no JLCPCB footprint: {} |\n| Quote | {} {} |\n| Approval | {} said \"{}\" |\n| Order | {} |\n| Delivered | {} of {} boards work |\n",
board_of(&p).display(), &sha256_file(&board_of(&p))[..12],
src["swapsApplied"].as_array().map(|a| a.len()).unwrap_or(0), src["why"].as_str().unwrap_or("not closed"),
man["bomJlcpcb"]["lines"], man["cplJlcpcb"]["parts"], man["leftOut"]["fitPost"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" ")).unwrap_or_default(),
man["rotationCheck"]["parts"], if corrected.is_empty() { "none".into() } else { corrected }, man["rotationCheck"]["noJlcFootprint"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" ")).unwrap_or_default(),
q["total"], q["currency"].as_str().unwrap_or(""), a["by"].as_str().unwrap_or("nobody yet"), a["words"].as_str().unwrap_or(""),
o["order"].as_str().unwrap_or("not placed"), d["working"], d["boards"]);
std::fs::write(p.path("report.md"), &text).unwrap_or_else(|e| err(&format!("{e}"), &[]));
p.log(&th, "report", json!({}));
print!("{text}");
ok(&format!("report written to {}", p.path("report.md").display()), &[]);
}
}
}