app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Publish 0.2.18
13 files changed
+672−14
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+16bin/adom-aiflowcrates/adom-aiflow/src/led_states.rs+168crates/adom-aiflow/src/main.rs+33−1crates/adom-aiflow/src/programming.rs+161crates/adom-aiflow/src/silk_polarity.rs+241package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+16skills/aiflow-molecule/SKILL.md+16skills/aiflow-silkscreen/SKILL.md+8Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.17"+version = "0.2.18" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.17"+version = "0.2.18" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.17"+version = "0.2.18" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.17"+version = "0.2.18" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.17"+version = "0.2.18" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.17"+version = "0.2.18" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.17"+version = "0.2.18" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.17"+version = "0.2.18" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.17"+version = "0.2.18" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.17"+version = "0.2.18" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.17"+version = "0.2.18" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+16@@ -555,3 +555,19 @@ Read `docs/native-board-binding.md` when the adopted board differs from the desk `spec.boardSink` declares a pedestal or plate on a board face for named tabless parts: a conservative via, mask, interface and sink series estimate in `analyze thermal`, or the same-input Fields solve with the named conducting block. Both record their assumption in the run summary and `finish`; other parts keep the still-air check. Read `docs/board-sink.md`. `outline check` reads only Edge.Cuts: board items and footprint Edge.Cuts items placed in board coordinates.++## LED on/off states (0.2.18)++Every LED on the board is described in `led-states.json` beside the board, and listed in board-release.json under `ledStates` (`{"path", "sha256"}`; `finish` writes it there when board-release.json already exists beside the board), so the probing stage can show each LED turning on when the probed state lights it:++```json+{"$schema": "adom/led-states@1", "leds": [{"ref": "D1", "mpn": "LTST-C191KGKT",+ "colour": {"name": "green", "dominant_nm": 571, "hex": "#7fc26b"},+ "anode_net": "LEDA", "cathode_net": "LED", "series": ["R1"], "current_mA": 1.3,+ "on_when": {"kind": "gpio", "mcu": "U1", "pin": "2", "port": "PC13", "active": "low"},+ "evidence": "<datasheet page / calc>"}]}+```++`on_when.kind` is `rail` (`net`, `min_v`: lit whenever that rail is up, a power LED), `gpio` (`mcu`, `pin`, `port`, `active`: `high` or `low`, firmware-driven) or `signal` (`net`, `active`: driven by another circuit). `current_mA` is computed from the rail, the LED's Vf and the series resistor. `anode_net` and `cathode_net` must be nets on the LED's own pads. `step schematic`, `step silkscreen` and `step finish` print the Hint; `finish` refuses the board while any LED has no complete entry.++For each LED part, add `led.json` to its wiki component page when you may edit that page: `{"$schema": "adom/led-part@1", "colour": {"name", "dominant_nm", "hex"}, "vf_V": {"typ", "max", "at_mA"}, "if_max_mA", "viewing_deg", "glb_lens_material": "<material or mesh name of the lens in the part's GLB>"}`. When you may not edit the page, open an issue on it carrying that record. Never edit someone else's page.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/led_states.rsadded+168@@ -0,0 +1,168 @@+//! LED on/off states (0.2.18): every LED on the board gets an entry in `led-states.json` next to the+//! board (schema `adom/led-states@1`), saying what colour it is, which nets it sits on, the current it+//! draws and when it is lit. The probing stage reads it (through board-release.json `ledStates`) to+//! show each LED turning on when the probed state would light it. `finish` refuses a board whose LEDs+//! are not all described.+//!+//! ```json+//! {"$schema": "adom/led-states@1", "leds": [{"ref": "D1", "mpn": "LTST-C191KGKT",+//! "colour": {"name": "green", "dominant_nm": 571, "hex": "#7fc26b"},+//! "anode_net": "LEDA", "cathode_net": "LED", "series": ["R1"], "current_mA": 1.3,+//! "on_when": {"kind": "gpio", "mcu": "U1", "pin": "2", "port": "PC13", "active": "low"},+//! "evidence": "<datasheet page / calc>"}]}+//! ```+//! `on_when.kind`: `rail` {net, min_v} (lit whenever that rail is up), `gpio` {mcu, pin, port,+//! active: high|low} (firmware-driven), `signal` {net, active} (driven by another circuit).+use aiflow_board::Board;+use serde_json::Value;+use std::path::{Path, PathBuf};++pub const SCHEMA: &str = "adom/led-states@1";+pub const PART_SCHEMA: &str = "adom/led-part@1";+pub const FILE: &str = "led-states.json";++fn prefix(r: &str) -> String { r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase() }+fn bare(net: &str) -> String { net.trim().trim_start_matches('/').to_string() }++/// The LEDs on a board: references LED*, or D*/CR* whose footprint library names an LED.+pub fn leds(b: &Board) -> Vec<String> {+ let mut v: Vec<String> = b.footprints.iter().filter(|(r, fp)| {+ let p = prefix(r);+ let lib = fp.lib.to_ascii_lowercase();+ !r.starts_with('#') && (p == "LED" || (matches!(p.as_str(), "D" | "CR") && lib.contains("led")))+ }).map(|(r, _)| r.clone()).collect();+ v.sort();+ v+}++/// `led-states.json` beside the board file.+pub fn path_for(board: &str) -> PathBuf { Path::new(board).parent().unwrap_or(Path::new(".")).join(FILE) }++fn s<'a>(v: &'a Value, k: &str) -> Option<&'a str> { v.get(k).and_then(|x| x.as_str()).filter(|x| !x.trim().is_empty()) }++/// What is wrong with one entry (empty when it is complete), checked against the LED's own pads.+fn entry_problems(e: &Value, b: &Board) -> Vec<String> {+ let r = s(e, "ref").unwrap_or("?");+ let mut p = Vec::new();+ if s(&e["colour"], "name").is_none() { p.push("colour.name".to_string()); }+ let nets: Vec<String> = b.pads_of(r).iter().map(|q| bare(&q.net)).collect();+ for k in ["anode_net", "cathode_net"] {+ match s(e, k) {+ None => p.push(k.to_string()),+ Some(n) if !nets.is_empty() && !nets.contains(&bare(n)) => p.push(format!("{k} {n:?} is not on {r}'s pads ({})", nets.join(", "))),+ _ => {}+ }+ }+ if e.get("current_mA").and_then(|x| x.as_f64()).map_or(true, |x| !(x > 0.0)) { p.push("current_mA (from the rail, Vf and the series resistor)".into()); }+ let w = &e["on_when"];+ match s(w, "kind") {+ Some("rail") => {+ if s(w, "net").is_none() { p.push("on_when.net".into()); }+ if w.get("min_v").and_then(|x| x.as_f64()).is_none() { p.push("on_when.min_v".into()); }+ }+ Some("gpio") => {+ for k in ["mcu", "pin"] { if s(w, k).is_none() && w.get(k).and_then(|x| x.as_u64()).is_none() { p.push(format!("on_when.{k}")); } }+ if !matches!(s(w, "active"), Some("high" | "low")) { p.push("on_when.active (high|low)".into()); }+ }+ Some("signal") => {+ if s(w, "net").is_none() { p.push("on_when.net".into()); }+ if !matches!(s(w, "active"), Some("high" | "low")) { p.push("on_when.active (high|low)".into()); }+ }+ _ => p.push("on_when.kind (rail|gpio|signal)".into()),+ }+ p+}++/// Check the board's `led-states.json`: Ok(None) when the board has no LED, Ok(Some(path)) when every+/// LED has a complete entry, Err(problems) otherwise.+pub fn check(board_path: &str, b: &Board) -> Result<Option<PathBuf>, Vec<String>> {+ let want = leds(b);+ if want.is_empty() { return Ok(None); }+ let path = path_for(board_path);+ let Ok(text) = std::fs::read_to_string(&path) else { return Err(vec![format!("no {} beside the board for {}", FILE, want.join(", "))]) };+ let v: Value = serde_json::from_str(&text).map_err(|e| vec![format!("{} is not JSON: {e}", path.display())])?;+ let mut problems = Vec::new();+ if v["$schema"].as_str() != Some(SCHEMA) { problems.push(format!("$schema must be {SCHEMA:?}")); }+ let entries = v["leds"].as_array().cloned().unwrap_or_default();+ for r in &want {+ match entries.iter().find(|e| e["ref"].as_str() == Some(r.as_str())) {+ None => problems.push(format!("{r} has no entry")),+ Some(e) => { let p = entry_problems(e, b); if !p.is_empty() { problems.push(format!("{r} lacks {}", p.join(", "))); } }+ }+ }+ if problems.is_empty() { Ok(Some(path)) } else { Err(problems) }+}++/// The finish-gate line for a board whose LED states are missing or incomplete.+pub fn gate_message(problems: &[String]) -> String {+ format!("LED states: {} (write led-states.json beside the board, schema {SCHEMA}, one entry per LED; skill adom-aiflow, LED on/off states)", problems.join("; "))+}++/// The hint for a board with LEDs (schematic, silkscreen and finish stages).+pub fn hint(board_path: &str, b: &Board) -> Option<String> {+ let want = leds(b);+ if want.is_empty() { return None; }+ let state = match check(board_path, b) { Ok(_) => "complete".to_string(), Err(p) => format!("open: {}", p.join("; ")) };+ Some(format!("LEDs on this board: {}. Describe each one's on/off state in {} beside the board ({state}): {{\"$schema\":\"{SCHEMA}\",\"leds\":[{{\"ref\",\"mpn\",\"colour\":{{\"name\",\"dominant_nm\",\"hex\"}},\"anode_net\",\"cathode_net\",\"series\":[\"R1\"],\"current_mA\" (from the rail, Vf and the series resistor),\"on_when\":{{\"kind\":\"rail\",\"net\",\"min_v\"}} | {{\"kind\":\"gpio\",\"mcu\",\"pin\",\"port\",\"active\":\"high|low\"}} | {{\"kind\":\"signal\",\"net\",\"active\"}},\"evidence\"}}]}} and list it in board-release.json under ledStates. `finish` refuses the board until every LED has an entry. For each LED part, add led.json ({{\"$schema\":\"{PART_SCHEMA}\",\"colour\":{{name,dominant_nm,hex}},\"vf_V\":{{typ,max,at_mA}},\"if_max_mA\",\"viewing_deg\",\"glb_lens_material\"}}) to its wiki component page when you may edit that page; otherwise open an issue on the page carrying that record. Never edit someone else's page.", want.join(", "), FILE))+}++/// After a passing finish: name the LED states in board-release.json beside the board, when one exists.+pub fn record_in_release(board_path: &str, states: &Path, sha256: &str) -> Option<PathBuf> {+ let rel = Path::new(board_path).parent().unwrap_or(Path::new(".")).join("board-release.json");+ let mut v: Value = serde_json::from_str(&std::fs::read_to_string(&rel).ok()?).ok()?;+ v.as_object_mut()?.insert("ledStates".into(), serde_json::json!({"path": states.display().to_string(), "sha256": sha256}));+ std::fs::write(&rel, serde_json::to_string_pretty(&v).ok()? + "\n").ok()?;+ Some(rel)+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;++ const BOARD: &str = r#"(kicad_pcb (version 20241229)+ (net 0 "") (net 1 "LEDA") (net 2 "/LED") (net 3 "+3V3")+ (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.1))+ (footprint "LED_SMD:LED_0603_1608Metric" (layer "F.Cu") (at 10 10)+ (property "Reference" "D1" (at 0 -2) (layer "F.SilkS"))+ (pad "1" smd rect (at -0.8 0) (size 0.8 0.8) (layers "F.Cu") (net 2 "/LED")) (pad "2" smd rect (at 0.8 0) (size 0.8 0.8) (layers "F.Cu") (net 1 "LEDA")))+ (footprint "Diode_SMD:D_SOD-123" (layer "F.Cu") (at 20 10)+ (property "Reference" "D2" (at 0 -2) (layer "F.SilkS"))+ (pad "1" smd rect (at -1.6 0) (size 0.9 1.2) (layers "F.Cu") (net 3 "+3V3")) (pad "2" smd rect (at 1.6 0) (size 0.9 1.2) (layers "F.Cu") (net 1 "LEDA")))+)"#;++ fn d1() -> Value {+ json!({"ref": "D1", "mpn": "LTST-C191KGKT", "colour": {"name": "green", "dominant_nm": 571, "hex": "#7fc26b"}, "anode_net": "LEDA", "cathode_net": "LED", "series": ["R1"], "current_mA": 1.3, "on_when": {"kind": "gpio", "mcu": "U1", "pin": "2", "port": "PC13", "active": "low"}, "evidence": "datasheet p3; (3.3-2.0)/1k"})+ }++ #[test]+ fn leds_need_complete_states_beside_the_board() {+ let dir = std::env::temp_dir().join(format!("aiflow-led-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let bp = dir.join("b.kicad_pcb").display().to_string();+ let b = Board::from_text(&bp, BOARD.to_string()).unwrap();+ assert_eq!(leds(&b), vec!["D1"]); // D2 is a plain diode+ let e = check(&bp, &b).unwrap_err();+ assert!(e[0].starts_with("no led-states.json beside the board for D1"), "{e:?}");+ assert!(hint(&bp, &b).unwrap().starts_with("LEDs on this board: D1. Describe each one's on/off state in led-states.json"));+ // an incomplete entry names what is missing, and a net that is not on the LED+ let mut bad = d1(); bad["cathode_net"] = json!("NOPE"); bad["on_when"] = json!({"kind": "gpio", "mcu": "U1", "pin": "2"});+ std::fs::write(dir.join(FILE), json!({"$schema": SCHEMA, "leds": [bad]}).to_string()).unwrap();+ let e = check(&bp, &b).unwrap_err();+ assert!(e[0].contains("cathode_net \"NOPE\" is not on D1's pads") && e[0].contains("on_when.active (high|low)"), "{e:?}");+ assert!(gate_message(&e).starts_with("LED states: D1 lacks"), "{}", gate_message(&e));+ // the capencoder D1: complete+ std::fs::write(dir.join(FILE), json!({"$schema": SCHEMA, "leds": [d1()]}).to_string()).unwrap();+ assert_eq!(check(&bp, &b).unwrap(), Some(dir.join(FILE)));+ // a rail LED needs its net and min_v+ let mut rail = d1(); rail["on_when"] = json!({"kind": "rail", "net": "+3V3"});+ std::fs::write(dir.join(FILE), json!({"$schema": SCHEMA, "leds": [rail]}).to_string()).unwrap();+ assert!(check(&bp, &b).unwrap_err()[0].contains("on_when.min_v"));+ // board-release.json gets ledStates when it exists+ std::fs::write(dir.join("board-release.json"), json!({"schema": "adom/board-release@1"}).to_string()).unwrap();+ record_in_release(&bp, &dir.join(FILE), "abc").unwrap();+ let rel: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("board-release.json")).unwrap()).unwrap();+ assert_eq!(rel["ledStates"]["sha256"], "abc");+ std::fs::remove_dir_all(&dir).ok();+ }+}
crates/adom-aiflow/src/main.rs+33−1@@ -26,6 +26,8 @@ mod silkproof; mod schematic_symbols; mod design_evidence; mod silk_polarity;+mod led_states;+mod programming; mod project_lib; /// The timeline dashboard (tools/timeline/index.html): it receives state over SSE from its run server.@@ -341,6 +343,25 @@ fn err(msg: &str, hints: &[String]) -> ! { std::process::exit(2) } +/// Hints that depend on this board at a step (0.2.18): power-input voltage labels and test-point+/// labels at silkscreen, LED on/off states at schematic, silkscreen and finish, and the programming+/// interface of a programmable board at schematic and placement.+fn board_step_hints(r: &Run, step: &str) -> Vec<String> {+ if !matches!(step, "schematic" | "placement" | "silkscreen" | "finish") { return Vec::new(); }+ let path = r.current_board();+ let Ok(b) = Board::load(&path) else { return Vec::new() };+ let spec = spec_soft(r);+ let mut out = Vec::new();+ if step == "silkscreen" {+ out.extend(silk_polarity::power_input_hint(&b));+ let profile = checks::rules_target(&spec, &r.data["fabRules"]).map(|t| t.0);+ out.extend(silk_polarity::test_point_hint(&b, profile.as_deref().and_then(silk_polarity::min_text_mm)));+ }+ if matches!(step, "schematic" | "silkscreen" | "finish") { out.extend(led_states::hint(&path, &b)); }+ if matches!(step, "schematic" | "placement") { out.extend(programming::hint(&b, spec["molecule"].is_object() || b.footprints.values().any(|f| checks::molecule_part(&f.lib).is_some()))); }+ out+}+ fn thread(cli: &Cli) -> String { cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", &["Re-run with --ai-thread \"<your thread name>\".".into()])) }@@ -2580,6 +2601,8 @@ fn main() { proof["inputSha256"] = json!(sha256_file(&input.display().to_string())); r.outcome("silkscreen", proof.clone()); r.save().unwrap();+ // 0.2.18: what the labels say, not only where they sit: power inputs without a voltage, test points without their net+ for w in silk_polarity::warnings(&b).iter().filter(|w| w.contains("power input") || w.contains("test point")) { eprintln!("Warning: {w}"); } ok(&format!("{}silkscreen step proof recorded: {} footprints and {} vias covered, {} labels", String::from_utf8_lossy(&result.stdout), proof["footprints"], proof["vias"], proof["labels"]), &["Geometry audit only; native DRC, label association and both native 2D/3D views remain required. Then `step 3d`.".into()]); return; }@@ -4020,6 +4043,9 @@ fn main() { if let Err(m) = schematic_symbols::gate(&r) { missing.push(m); } let silk_warnings = silk_polarity::warnings(&board_now()); for w in &silk_warnings { eprintln!("Warning: {w}"); }+ // 0.2.18: every LED's on/off state, and a programmable part's interface where the workcell reaches it+ let led_file = match led_states::check(&solved_board, &board_now()) { Ok(p) => p, Err(p) => { missing.push(led_states::gate_message(&p)); None } };+ if let Err(p) = programming::check(&solved_board, &board_now(), &spec_soft(&r)) { missing.push(programming::gate_message(&p)); } let spec_f = spec_of(&r); if let Some((p, layers, warning)) = checks::rules_target(&spec_f, &r.data["fabRules"]) { if let Some(w) = warning { eprintln!("Warning: {w}"); }@@ -4186,6 +4212,11 @@ fn main() { if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); } r.data["summary"]["projectLibraryParts"] = json!(lib_parts); r.data["summary"]["silkscreenWarnings"] = json!(silk_warnings);+ if let Some(lf) = &led_file {+ let sha = sha256_file(&lf.display().to_string());+ r.data["summary"]["ledStates"] = json!({"path": lf.display().to_string(), "sha256": sha});+ if let Some(rel) = led_states::record_in_release(&solved_board, lf, &sha) { eprintln!("ledStates recorded in {}", rel.display()); }+ } r.data["summary"]["notApplicable"] = json!(not_applicable); if nothing_to_source { r.data["summary"]["notApplicable"].as_array_mut().unwrap().push(json!("sourcing check: not applicable (no footprint in the BOM)")); } r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));@@ -5426,10 +5457,11 @@ fn main() { report_note = format!("Progress page: {url} ({})", notes.join("; ")); } let skill_lines = skill_hints::step_hints(&flow_full, name, &skill_home, skill_pkg.as_deref());+ let board_hints = board_step_hints(&r, name); let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default(); let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) }; let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };- ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[vec![hint, record_hint], skill_lines, vec![back_hint, report_note, clips_note]].concat());+ ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[vec![hint, record_hint], skill_lines, board_hints, vec![back_hint, report_note, clips_note]].concat()); print_next(&cli); } Cmd::ProjectPageCheck { project, live_json } => {
crates/adom-aiflow/src/programming.rsadded+161@@ -0,0 +1,161 @@+//! Programmable boards (0.2.18): a board carrying an MCU or an FPGA brings its programming interface+//! out where the workcell reaches it (machine contacts on a molecule, labelled test pads otherwise), so+//! a debug probe can flash it with no hand wiring. The record is `programming` in board-release.json+//! beside the board (or `programming` in the spec):+//!+//! ```json+//! {"interface": "swd" | "uart-boot" | "usb-dfu", "pins": {"SWDIO": "J8", "SWCLK": "J9", "NRST": "J10", "GND": "J7"},+//! "target": "<MCU MPN>", "boot": "<e.g. BOOT0 low via R2; JP1 bridges for DFU>"}+//! ```+use aiflow_board::Board;+use serde_json::Value;+use std::path::Path;++/// MPN / value stems of parts a host programs: microcontrollers and FPGAs.+const STEMS: &[&str] = &[+ "STM32", "STM8", "GD32", "CH32", "APM32", "AT32", "ATMEGA", "ATTINY", "ATXMEGA", "ATSAM", "SAMD", "SAML", "SAME", "AVR", "PIC1", "PIC2", "PIC3", "DSPIC",+ "RP2040", "RP2350", "ESP32", "ESP8266", "NRF51", "NRF52", "NRF53", "NRF91", "MSP430", "MSPM0", "EFM32", "EFR32", "EFM8", "LPC8", "LPC1", "LPC5",+ "MKL", "MK2", "MK6", "MIMXRT", "CY8C", "PSOC", "TM4C", "CC13", "CC26", "R7FA", "RA4", "RA6", "XMC", "PY32", "ICE40", "ICE5", "LFE5", "LCMXO", "XC7", "XC6S", "10M0", "5CE", "GW1N", "GW2A",+];++/// The pins each interface needs at a machine contact or a labelled test pad (aliases after `|`).+pub fn required(interface: &str) -> Option<&'static [&'static str]> {+ match interface {+ "swd" => Some(&["SWDIO", "SWCLK", "NRST", "GND"]),+ "uart-boot" => Some(&["TX", "RX", "GND"]),+ "usb-dfu" => Some(&["D+|DP|USB_DP", "D-|DM|USB_DM", "GND"]),+ _ => None,+ }+}++fn texts(fp: &aiflow_board::sx::Node) -> Vec<String> {+ let mut v = vec![fp.atom(1).unwrap_or("").to_string()];+ for p in fp.find_all("property") {+ let k = p.atom(1).unwrap_or("").to_ascii_lowercase();+ if k == "value" || k.contains("mpn") || k.contains("part number") || k.contains("manufacturer_part") || k == "pn" { if let Some(x) = p.atom(2) { v.push(x.to_string()); } }+ }+ v+}++/// The programmable parts on the board, as (reference, part number or value).+pub fn targets(b: &Board) -> Vec<(String, String)> {+ let mut out = Vec::new();+ for fp in b.root.find_all("footprint") {+ let Some(r) = aiflow_board::reference_of(fp) else { continue };+ if r.starts_with('#') || !r.to_ascii_uppercase().starts_with('U') && !r.to_ascii_uppercase().starts_with("IC") { continue; }+ let ts = texts(fp);+ if let Some(t) = ts.iter().skip(1).chain(ts.iter().take(1)).find(|t| { let u = t.to_ascii_uppercase(); let u = u.rsplit(':').next().unwrap_or(&u); STEMS.iter().any(|s| u.starts_with(s)) }) {+ out.push((r, t.rsplit(':').next().unwrap_or(t).to_string()));+ }+ }+ out.sort();+ out+}++/// The `programming` record: board-release.json beside the board first, then the spec.+pub fn record(board_path: &str, spec: &Value) -> Option<Value> {+ let rel = Path::new(board_path).parent().unwrap_or(Path::new(".")).join("board-release.json");+ let from_rel = std::fs::read_to_string(rel).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v["programming"].clone()).filter(|v| v.is_object());+ from_rel.or_else(|| Some(spec["programming"].clone()).filter(|v| v.is_object()))+}++/// Where the workcell can reach a reference: a machine contact or pin, or a test pad with a label.+fn reachable(b: &Board, reference: &str) -> Result<(), String> {+ let Some(fp) = b.footprints.get(reference) else { return Err(format!("{reference} is not on the board")) };+ if crate::checks::molecule_part(&fp.lib).is_some() { return Ok(()); }+ let (unlabelled, _) = crate::silk_polarity::test_point_labels(b);+ let tp = reference.to_ascii_uppercase().starts_with("TP") || fp.lib.to_ascii_lowercase().contains("test");+ if tp && !unlabelled.iter().any(|(r, _)| r == reference) { return Ok(()); }+ if tp { Err(format!("{reference} is a test pad with no silkscreen label")) } else { Err(format!("{reference} ({}) is neither a machine contact nor a labelled test pad", fp.lib.rsplit(':').next().unwrap_or(&fp.lib))) }+}++/// The finish gate: Ok when the board has no programmable part or its record is complete and every pin+/// is reachable; Err(problems) otherwise.+pub fn check(board_path: &str, b: &Board, spec: &Value) -> Result<(), Vec<String>> {+ let t = targets(b);+ if t.is_empty() { return Ok(()); }+ let names = t.iter().map(|(r, m)| format!("{r} {m}")).collect::<Vec<_>>().join(", ");+ let Some(rec) = record(board_path, spec) else { return Err(vec![format!("no `programming` entry in board-release.json for {names}")]) };+ let mut p = Vec::new();+ let iface = rec["interface"].as_str().unwrap_or("");+ let pins = rec["pins"].as_object().cloned().unwrap_or_default();+ match required(iface) {+ None => p.push(format!("interface {iface:?} is not swd, uart-boot or usb-dfu")),+ Some(req) => for need in req {+ let alias: Vec<&str> = need.split('|').collect();+ if !pins.keys().any(|k| alias.iter().any(|a| a.eq_ignore_ascii_case(k))) { p.push(format!("pins.{} missing", alias[0])); }+ },+ }+ for (name, r) in &pins {+ match r.as_str() { Some(r) => if let Err(e) = reachable(b, r) { p.push(format!("{name}: {e}")); }, None => p.push(format!("pins.{name} is not a reference")) }+ }+ if rec["target"].as_str().map_or(true, |s| s.trim().is_empty()) { p.push("target (the MPN being programmed)".into()); }+ if p.is_empty() { Ok(()) } else { Err(p) }+}++pub fn gate_message(problems: &[String]) -> String {+ format!("programming: {} (bring the programming interface out to machine contacts or labelled test pads and record `programming` in board-release.json; skill aiflow-molecule, Programmable boards)", problems.join("; "))+}++/// The schematic/placement hint for a board with a programmable part.+pub fn hint(b: &Board, molecule: bool) -> Option<String> {+ let t = targets(b);+ if t.is_empty() { return None; }+ let names = t.iter().map(|(r, m)| format!("{r} {m}")).collect::<Vec<_>>().join(", ");+ let reach = if molecule { "the board's machine contacts (the workcell's pins)" } else { "pads or test points with silkscreen labels (this board is not a molecule on machine pins)" };+ Some(format!("Programmable part(s): {names}. Bring the programming interface out to {reach} so the workcell's debug probe can flash it with no hand wiring: SWD = SWDIO, SWCLK, NRST and GND (plus a VTref/3V3 sense if the target needs one); or UART boot (TX, RX, GND, with BOOT0 set); or USB DFU (D+, D-, GND). Record it as `programming` in board-release.json: {{\"interface\":\"swd|uart-boot|usb-dfu\",\"pins\":{{\"SWDIO\":\"J8\",\"SWCLK\":\"J9\",\"NRST\":\"J10\",\"GND\":\"J7\"}},\"target\":\"<MCU MPN>\",\"boot\":\"<e.g. BOOT0 low via R2; JP1 bridges for DFU>\"}}. `finish` refuses the board without it, or when a pin is not on a machine contact or a labelled test pad."))+}++#[cfg(test)]+mod tests {+ use super::*;+ use serde_json::json;++ const BOARD: &str = r#"(kicad_pcb (version 20241229)+ (net 0 "") (net 1 "SWDIO") (net 2 "SWCLK") (net 3 "NRST") (net 4 "GND") (net 5 "SWO")+ (gr_rect (start 0 0) (end 50 20) (layer "Edge.Cuts") (width 0.1))+ (footprint "Package_QFP:LQFP-48_7x7mm_P0.5mm" (layer "F.Cu") (at 25 10)+ (property "Reference" "U1" (at 0 -5) (layer "F.SilkS")) (property "Value" "STM32G431CBT6" (at 0 5) (layer "F.Fab"))+ (pad "1" smd rect (at -3 0) (size 1 0.3) (layers "F.Cu") (net 1 "SWDIO")))+ (footprint "Package_TO_SOT_SMD:SOT-23-5" (layer "F.Cu") (at 40 10)+ (property "Reference" "U2" (at 0 -2) (layer "F.SilkS")) (property "Value" "AP2112K-3.3" (at 0 2) (layer "F.Fab"))+ (pad "1" smd rect (at 0 0) (size 1 0.6) (layers "F.Cu") (net 4 "GND")))+ (footprint "Molecule:MachineContactMedium" (layer "F.Cu") (at 4 4) (property "Reference" "J7" (at 0 -2) (layer "F.SilkS"))+ (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu") (net 4 "GND")))+ (footprint "Molecule:MachineContactMedium" (layer "F.Cu") (at 4 8) (property "Reference" "J8" (at 0 -2) (layer "F.SilkS"))+ (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu") (net 1 "SWDIO")))+ (footprint "Molecule:MachineContactMedium" (layer "F.Cu") (at 4 12) (property "Reference" "J9" (at 0 -2) (layer "F.SilkS"))+ (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu") (net 2 "SWCLK")))+ (footprint "TestPoint:TestPoint_Pad_D1.0mm" (layer "F.Cu") (at 4 16) (property "Reference" "TP1" (at 0 -2) (layer "F.SilkS"))+ (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 3 "NRST")))+ (footprint "Resistor_SMD:R_0603" (layer "F.Cu") (at 12 16) (property "Reference" "R5" (at 0 -2) (layer "F.SilkS"))+ (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 3 "NRST")))+)"#;++ #[test]+ fn programmable_boards_bring_their_interface_to_the_workcell() {+ let b = Board::from_text("/nonexistent/b.kicad_pcb", BOARD.to_string()).unwrap();+ assert_eq!(targets(&b), vec![("U1".to_string(), "STM32G431CBT6".to_string())]);+ let h = hint(&b, true).unwrap();+ assert!(h.starts_with("Programmable part(s): U1 STM32G431CBT6. Bring the programming interface out to the board's machine contacts"), "{h}");+ assert!(hint(&b, false).unwrap().contains("pads or test points with silkscreen labels"));+ let e = check("/nonexistent/b.kicad_pcb", &b, &json!({})).unwrap_err();+ assert_eq!(e, vec!["no `programming` entry in board-release.json for U1 STM32G431CBT6".to_string()]);+ // NRST on an unlabelled test pad, then on a plain resistor pad: not reachable+ let mut spec = json!({"programming": {"interface": "swd", "pins": {"SWDIO": "J8", "SWCLK": "J9", "NRST": "TP1", "GND": "J7"}, "target": "STM32G431CBT6", "boot": "BOOT0 low via R2; JP1 bridges for DFU"}});+ let e = check("/nonexistent/b.kicad_pcb", &b, &spec).unwrap_err();+ assert_eq!(e, vec!["NRST: TP1 is a test pad with no silkscreen label".to_string()]);+ spec["programming"]["pins"]["NRST"] = json!("R5");+ assert!(check("/nonexistent/b.kicad_pcb", &b, &spec).unwrap_err()[0].contains("R5 (R_0603) is neither a machine contact nor a labelled test pad"));+ // a label beside TP1 makes it reachable+ spec["programming"]["pins"]["NRST"] = json!("TP1");+ let labelled = BOARD.replace("\n)", "\n (gr_text \"NRST\" (at 5.5 16) (layer \"F.SilkS\") (effects (font (size 0.3 0.3))))\n)");+ let b2 = Board::from_text("/nonexistent/b.kicad_pcb", labelled).unwrap();+ assert_eq!(check("/nonexistent/b.kicad_pcb", &b2, &spec), Ok(()));+ // a missing pin and an unknown interface+ spec["programming"]["pins"].as_object_mut().unwrap().remove("GND");+ assert!(check("/nonexistent/b.kicad_pcb", &b2, &spec).unwrap_err().contains(&"pins.GND missing".to_string()));+ spec["programming"]["interface"] = json!("jtag");+ assert!(gate_message(&check("/nonexistent/b.kicad_pcb", &b2, &spec).unwrap_err()).starts_with("programming: interface \"jtag\" is not swd, uart-boot or usb-dfu"));+ }+}
crates/adom-aiflow/src/silk_polarity.rs+241@@ -5,6 +5,9 @@ //! A mark is any silkscreen circle, polygon or arc in the footprint (a pin-1 dot, triangle or notch), //! a `+`, `1`, `K` or `A` text, footprint silk lines whose length-weighted middle sits clearly on the pad-1 //! side (a diode's extended cathode line), or a board-level silkscreen item within 1.5 mm of pad 1.+//!+//! Power inputs (0.2.18): a connector, contact or pad on a VIN-like net (VIN, VBUS, VCC_IN, PWR_IN,+//! +5V_IN, V+ ...) whose silkscreen label states no voltage. `step silkscreen` names them in a Hint. use aiflow_board::{sx::Node, Board}; type Pt = (f64, f64);@@ -103,9 +106,164 @@ pub fn warnings(b: &Board) -> Vec<String> { let mut out = Vec::new(); if !fab_only.is_empty() { out.push(format!("{} reference(s) only on the fabrication layer, none on the silkscreen: {}. Put readable references on F.SilkS/B.SilkS (skill aiflow-silkscreen)", fab_only.len(), list(&fab_only))); } if !unmarked.is_empty() { out.push(format!("{} polarized part(s) with no silkscreen polarity or pin-1 mark: {}. Add a cathode bar, + sign or pin-1 dot beside the pad on the silkscreen (skill aiflow-silkscreen)", unmarked.len(), list(&unmarked))); }+ let (no_label, mismatch) = test_point_labels(b);+ if !no_label.is_empty() { out.push(format!("{} test point(s) with no silkscreen label within {TP_LABEL_MM} mm saying what they are for: {}. {} (skill aiflow-silkscreen)", no_label.len(), pairs(&no_label), TP_RULE)); }+ if !mismatch.is_empty() { out.push(format!("{} test point label(s) that do not match the pad's net: {}. Label each with its net or function, as the board connects it (skill aiflow-silkscreen)", mismatch.len(), mismatch.iter().take(12).map(|(r, n, t)| format!("{r} on {n} labelled {t:?}")).collect::<Vec<_>>().join(", "))); }+ let bare = power_inputs_without_voltage(b);+ if !bare.is_empty() { out.push(format!("{} power input(s) whose silkscreen label gives no voltage: {}. {} (skill aiflow-silkscreen)", bare.len(), pairs(&bare), VOLTAGE_RULE)); } out } +/// What a power input's label carries, and how to work it out (shared by the hint and the warning).+const VOLTAGE_RULE: &str = "Label each with the voltage it takes, nominal and allowed range (\"VIN 5 V (4.3-5.5 V)\"), worked out from the datasheets of the parts on that rail: the lowest input is the regulator's output plus its dropout plus any series diode's drop (or the lowest operating input of the parts it feeds directly), the highest is the smallest absolute-maximum input among them";++/// Connector-like references: pins, contacts, connectors, test pads, battery holders, bare pads.+fn connector_like(p: &str) -> bool { matches!(p, "J" | "P" | "CN" | "CON" | "X" | "XT" | "TP" | "BT" | "PAD" | "MC" | "E") }++/// A net that brings power onto the board: VIN, VBUS, VCC_IN, PWR_IN, +5V_IN, DC_IN, V+, VEXT, VSUPPLY.+pub fn is_power_input_net(net: &str) -> bool {+ let n = net.rsplit('/').next().unwrap_or(net).trim().to_ascii_uppercase();+ if n.is_empty() || n.starts_with("NET-") || n.starts_with("UNCONNECTED") { return false; }+ if matches!(n.as_str(), "V+" | "VEXT" | "VSUPPLY" | "VSUP" | "DCIN" | "PWRIN") { return true; }+ let t = n.trim_start_matches('+');+ if t.starts_with("VIN") || t.starts_with("VBUS") { return true; }+ let tokens: Vec<&str> = t.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()).collect();+ let powerish = |w: &str| w.starts_with('V') || matches!(w, "PWR" | "POWER" | "DC" | "SUPPLY" | "BAT" | "BATT") || (w.as_bytes()[0].is_ascii_digit() && w.contains('V'));+ tokens.len() >= 2 && tokens.iter().skip(1).any(|w| *w == "IN" || *w == "INPUT") && powerish(tokens[0])+}++/// Every connector, contact or pad on a power-input net, as (reference, net), sorted.+pub fn power_inputs(b: &Board) -> Vec<(String, String)> {+ let mut v: Vec<(String, String)> = b.pads.iter()+ .filter(|p| connector_like(&prefix(&p.reference)) && is_power_input_net(&p.net))+ .map(|p| (p.reference.clone(), p.net.rsplit('/').next().unwrap_or(&p.net).to_string()))+ .collect();+ v.sort(); v.dedup();+ v+}++/// A voltage figure in a label: "5 V", "12V", "4.3-5.5 V", "3V3", "500 mV".+fn has_voltage(text: &str) -> bool {+ static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();+ RE.get_or_init(|| regex_lite::Regex::new(r"(?i)\d+(?:[.,]\d+)?\s*m?V\b|\d+V\d").unwrap()).is_match(text)+}++/// The visible silkscreen texts that label a part: its footprint's own texts, and board texts whose+/// anchor is within 5 mm of one of its pads.+fn labels_of(b: &Board, reference: &str) -> Vec<String> {+ let mut out = Vec::new();+ for fp in b.root.find_all("footprint").filter(|fp| aiflow_board::reference_of(fp).as_deref() == Some(reference)) {+ for t in fp.nodes().filter(|t| matches!(t.tag.as_str(), "fp_text" | "property" | "fp_text_box") && on_silk(t) && !hidden(t)) {+ if let Some(s) = t.atom(2).or(t.atom(1)) { out.push(s.to_string()); }+ }+ }+ let pads = b.pads_of(reference);+ for n in b.root.nodes().filter(|n| matches!(n.tag.as_str(), "gr_text" | "gr_text_box") && on_silk(n) && !hidden(n)) {+ let Some(text) = n.atom(1) else { continue };+ let at = xy(n.find("at")).or_else(|| xy(n.find("start")));+ if at.map_or(false, |a| pads.iter().any(|q| ((a.0 - q.x).powi(2) + (a.1 - q.y).powi(2)).sqrt() <= 5.0)) { out.push(text.to_string()); }+ }+ out+}++/// Power inputs none of whose silkscreen labels states a voltage.+pub fn power_inputs_without_voltage(b: &Board) -> Vec<(String, String)> {+ power_inputs(b).into_iter().filter(|(r, _)| !labels_of(b, r).iter().any(|t| has_voltage(t))).collect()+}++fn pairs(v: &[(String, String)]) -> String {+ format!("{}{}", v.iter().take(12).map(|(r, n)| format!("{r} ({n})")).collect::<Vec<_>>().join(", "), if v.len() > 12 { ", ..." } else { "" })+}++/// How far a test point's label may sit from its pad's edge.+pub const TP_LABEL_MM: f64 = 2.0;++const TP_RULE: &str = "Put a small label beside each test point, on its face and clear of pads and other silk, naming its net or function (\"3V3\", \"PH1\", \"USB D+\", \"RX A OUT\") at the fab profile's minimum silkscreen text height (0.3 mm on the 3rd party fab rules; JLCPCB 0.8 mm)";++/// A test point: reference TP*, or a footprint whose library names a test point.+fn is_test_point(reference: &str, lib: &str) -> bool {+ let l = lib.to_ascii_lowercase();+ prefix(reference) == "TP" || l.contains("testpoint") || l.contains("test_point") || l.contains("testpad") || l.contains("test_pad")+}++fn norm(s: &str) -> String { s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_uppercase() }++/// A label that names the net: equal once case, separators and +/- signs are dropped, or one contains+/// the other (PH1 on /PH1_SENSE, USB D+ on USB_D+).+fn label_matches_net(label: &str, net: &str) -> bool {+ let (l, n) = (norm(label), norm(net.rsplit('/').next().unwrap_or(net)));+ !l.is_empty() && !n.is_empty() && (l == n || (l.len() >= 2 && n.contains(&l)) || (n.len() >= 2 && l.contains(&n)))+}++/// Visible silkscreen texts on a test point's face whose anchor is within TP_LABEL_MM of its pad edge+/// (its own footprint texts and board texts), leaving out reference designators.+fn texts_near(b: &Board, reference: &str, front: bool) -> Vec<String> {+ let pads = b.pads_of(reference);+ let face = if front { "F.SilkS" } else { "B.SilkS" };+ let near = |a: Pt| pads.iter().any(|q| ((a.0 - q.x).powi(2) + (a.1 - q.y).powi(2)).sqrt() <= q.half_long + TP_LABEL_MM);+ // a reference designator of a part on this board (TP3 beside its pad) is not a function label+ let is_ref = |t: &str| b.footprints.contains_key(t.trim());+ let mut out = Vec::new();+ for fp in b.root.find_all("footprint").filter(|fp| aiflow_board::reference_of(fp).as_deref() == Some(reference)) {+ for t in fp.nodes().filter(|t| matches!(t.tag.as_str(), "fp_text" | "property") && layer(t) == Some(face) && !hidden(t)) {+ if t.tag == "property" && matches!(t.atom(1), Some("Reference" | "Value" | "Footprint" | "Datasheet" | "Description")) { continue; }+ if t.tag == "fp_text" && matches!(t.atom(1), Some("reference" | "value")) { continue; }+ if let Some(x) = t.atom(2) { if !x.contains("${") && !is_ref(x) { out.push(x.to_string()); } }+ }+ }+ for n in b.root.nodes().filter(|n| matches!(n.tag.as_str(), "gr_text" | "gr_text_box") && layer(n) == Some(face) && !hidden(n)) {+ let Some(text) = n.atom(1) else { continue };+ if is_ref(text) { continue; }+ if xy(n.find("at")).or_else(|| xy(n.find("start"))).map_or(false, near) { out.push(text.to_string()); }+ }+ out+}++/// Test points with no label near them, as (reference, net), and labels that name another net, as+/// (reference, net, label). A pad on an unnamed net (Net-(...)) takes any label.+pub fn test_point_labels(b: &Board) -> (Vec<(String, String)>, Vec<(String, String, String)>) {+ let (mut none, mut wrong) = (Vec::new(), Vec::new());+ for (reference, fp) in &b.footprints {+ if reference.starts_with('#') || !is_test_point(reference, &fp.lib) { continue; }+ let pads = b.pads_of(reference);+ let Some(pad) = pads.first() else { continue };+ let net = pad.net.clone();+ let front = !fp.layer.starts_with('B');+ let texts = texts_near(b, reference, front);+ let shown = net.rsplit('/').next().unwrap_or(&net).to_string();+ if texts.is_empty() { none.push((reference.clone(), if shown.is_empty() { "no net".into() } else { shown })); continue; }+ let unnamed = net.is_empty() || net.to_ascii_lowercase().starts_with("net-") || net.to_ascii_lowercase().starts_with("unconnected");+ if !unnamed && !texts.iter().any(|t| label_matches_net(t, &net)) { wrong.push((reference.clone(), shown, texts.join(" / "))); }+ }+ (none, wrong)+}++/// The silkscreen-stage hint for a board whose test points are not labelled yet.+pub fn test_point_hint(b: &Board, text_mm: Option<f64>) -> Option<String> {+ let (none, wrong) = test_point_labels(b);+ if none.is_empty() && wrong.is_empty() { return None; }+ let mut s = String::new();+ if !none.is_empty() { s.push_str(&format!("Unlabelled test point(s): {}. ", pairs(&none))); }+ if !wrong.is_empty() { s.push_str(&format!("Test point label(s) not matching the net: {}. ", wrong.iter().take(12).map(|(r, n, t)| format!("{r} on {n} labelled {t:?}")).collect::<Vec<_>>().join(", "))); }+ s.push_str(TP_RULE);+ if let Some(h) = text_mm { s.push_str(&format!("; this run's fab profile: {h} mm")); }+ s.push_str(". `finish` warns on a test point with no label within 2 mm or one that names another net (skill aiflow-silkscreen).");+ Some(s)+}++/// The fab profile's minimum silkscreen text height, when the profile is known.+pub fn min_text_mm(profile: &str) -> Option<f64> {+ let p = profile.to_ascii_lowercase();+ if p == "fab" || p.starts_with("fab-") || p.contains("3rdpartyfab") { Some(0.3) } else if p.contains("jlc") { Some(0.8) } else { None }+}++/// The silkscreen-stage hint for a board with power inputs: names them and says what their labels carry.+pub fn power_input_hint(b: &Board) -> Option<String> {+ let v = power_inputs(b);+ if v.is_empty() { return None; }+ Some(format!("Power input(s) on this board: {}. {}; never just \"VIN\". `finish` warns on a power input whose label has no voltage (skill aiflow-silkscreen).", pairs(&v), VOLTAGE_RULE))+}+ #[cfg(test)] mod tests { use super::*;@@ -143,4 +301,87 @@ mod tests { let b = Board::from_text("t.kicad_pcb", fixed).unwrap(); assert!(warnings(&b).is_empty(), "{:?}", warnings(&b)); }++ const TP_BOARD: &str = r#"(kicad_pcb (version 20241229)+ (net 0 "") (net 1 "+3V3") (net 2 "/PH1") (net 3 "/USB_D+") (net 4 "Net-(TP4-Pad1)")+ (gr_rect (start 0 0) (end 50 20) (layer "Edge.Cuts") (width 0.1))+ (footprint "TestPoint:TestPoint_Pad_D1.0mm" (layer "F.Cu") (at 5 5)+ (property "Reference" "TP1" (at 0 -2) (layer "F.SilkS") (effects (font (size 0.3 0.3))))+ (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "+3V3")))+ (footprint "TestPoint:TestPoint_Pad_D1.0mm" (layer "F.Cu") (at 15 5)+ (property "Reference" "TP2" (at 0 -2) (layer "F.SilkS") (effects (font (size 0.3 0.3))))+ (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 2 "/PH1")))+ (footprint "TestPoint:TestPoint_Pad_D1.0mm" (layer "F.Cu") (at 25 5)+ (property "Reference" "TP3" (at 0 -2) (layer "F.SilkS") (effects (font (size 0.3 0.3))))+ (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 3 "/USB_D+")))+ (footprint "Custom:TestPad" (layer "F.Cu") (at 35 5)+ (property "Reference" "X9" (at 0 -2) (layer "F.SilkS") (effects (font (size 0.3 0.3))))+ (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu") (net 4 "Net-(TP4-Pad1)")))+ (gr_text "3V3" (at 6.5 5) (layer "F.SilkS") (effects (font (size 0.3 0.3))))+ (gr_text "GND" (at 16.5 5) (layer "F.SilkS") (effects (font (size 0.3 0.3))))+ (gr_text "USB D+" (at 25 9) (layer "F.SilkS") (effects (font (size 0.3 0.3))))+)"#;++ #[test]+ fn test_points_need_a_label_naming_their_net() {+ let b = Board::from_text("t.kicad_pcb", TP_BOARD.to_string()).unwrap();+ let (none, wrong) = test_point_labels(&b);+ // TP3's label is 3.5 mm from its pad edge: too far; X9 (a test-pad footprint) has none+ assert_eq!(none, vec![("TP3".to_string(), "USB_D+".to_string()), ("X9".to_string(), "Net-(TP4-Pad1)".to_string())]);+ assert_eq!(wrong, vec![("TP2".to_string(), "PH1".to_string(), "GND".to_string())]);+ let h = test_point_hint(&b, min_text_mm("adom-3rdpartyfab")).unwrap();+ assert!(h.starts_with("Unlabelled test point(s): TP3 (USB_D+), X9 (Net-(TP4-Pad1)). Test point label(s) not matching the net: TP2 on PH1 labelled \"GND\".") && h.contains("this run's fab profile: 0.3 mm"), "{h}");+ let w = warnings(&b);+ assert!(w.iter().any(|x| x.starts_with("2 test point(s) with no silkscreen label within 2 mm")), "{w:?}");+ assert!(w.iter().any(|x| x.starts_with("1 test point label(s) that do not match the pad's net: TP2 on PH1 labelled \"GND\"")), "{w:?}");+ let fixed = TP_BOARD.replace("\"GND\" (at 16.5 5)", "\"PH1\" (at 16.5 5)").replace("(at 25 9)", "(at 26.4 5)")+ .replace("\n)", "\n (gr_text \"ADC IN\" (at 35 6.4) (layer \"F.SilkS\") (effects (font (size 0.3 0.3))))\n)");+ let b = Board::from_text("t.kicad_pcb", fixed).unwrap();+ assert_eq!(test_point_labels(&b), (vec![], vec![]));+ assert!(test_point_hint(&b, None).is_none());+ assert_eq!((min_text_mm("jlcpcb-2L"), min_text_mm("oshpark")), (Some(0.8), None));+ assert!(label_matches_net("RX A OUT", "/RXA_OUT") && label_matches_net("3V3", "+3V3") && !label_matches_net("GND", "/PH1"));+ }++ #[test]+ fn power_input_net_names() {+ for n in ["VIN", "/VIN", "+VIN", "VIN_5V", "VBUS", "VCC_IN", "PWR_IN", "+5V_IN", "12V_IN", "DC_IN", "V+", "/sensor/VBAT_IN", "VEXT"] { assert!(is_power_input_net(n), "{n}"); }+ for n in ["GND", "+5V", "VOUT", "+3V3", "SDA", "Net-(J1-Pad1)", "unconnected-(J2-Pad1)", "IN", "EN_IN", "VCC"] { assert!(!is_power_input_net(n), "{n}"); }+ assert!(has_voltage("VIN 5 V (4.3-5.5 V)") && has_voltage("5V") && has_voltage("3V3") && has_voltage("12 V"));+ assert!(!has_voltage("VIN") && !has_voltage("J1") && !has_voltage("VBUS"));+ }++ const VIN_BOARD: &str = r#"(kicad_pcb (version 20241229)+ (net 0 "") (net 1 "/VIN") (net 2 "GND") (net 3 "VBUS")+ (gr_rect (start 0 0) (end 50 20) (layer "Edge.Cuts") (width 0.1))+ (footprint "Adom:MachineContact" (layer "F.Cu") (at 4 10)+ (property "Reference" "J1" (at 0 -2) (layer "F.SilkS") (effects (font (size 0.8 0.8))))+ (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu") (net 1 "/VIN")))+ (footprint "Adom:MachineContact" (layer "F.Cu") (at 4 14)+ (property "Reference" "J2" (at 0 -2) (layer "F.SilkS") (effects (font (size 0.8 0.8))))+ (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu") (net 2 "GND")))+ (footprint "Connector_USB:USB_C" (layer "F.Cu") (at 40 10)+ (property "Reference" "J3" (at 0 -4) (layer "F.SilkS") (effects (font (size 0.8 0.8))))+ (pad "A4" smd rect (at 0 0) (size 0.3 1) (layers "F.Cu") (net 3 "VBUS")))+ (gr_text "VIN" (at 6 10) (layer "F.SilkS") (effects (font (size 0.8 0.8))))+)"#;++ #[test]+ fn power_inputs_get_a_hint_and_warn_without_a_voltage() {+ let b = Board::from_text("t.kicad_pcb", VIN_BOARD.to_string()).unwrap();+ assert_eq!(power_inputs(&b), vec![("J1".to_string(), "VIN".to_string()), ("J3".to_string(), "VBUS".to_string())]);+ let h = power_input_hint(&b).unwrap();+ assert!(h.starts_with("Power input(s) on this board: J1 (VIN), J3 (VBUS).") && h.contains("VIN 5 V (4.3-5.5 V)") && h.contains("datasheets"), "{h}");+ let w = warnings(&b);+ assert!(w.iter().any(|x| x.starts_with("2 power input(s) whose silkscreen label gives no voltage: J1 (VIN), J3 (VBUS).")), "{w:?}");+ // a voltage in a nearby board text labels J1; a footprint text with one labels J3+ let fixed = VIN_BOARD.replace("(gr_text \"VIN\"", "(gr_text \"VIN 5 V (4.3-5.5 V)\"")+ .replace("(property \"Reference\" \"J3\" (at 0 -4) (layer \"F.SilkS\") (effects (font (size 0.8 0.8))))", "(property \"Reference\" \"J3\" (at 0 -4) (layer \"F.SilkS\") (effects (font (size 0.8 0.8)))) (fp_text user \"VBUS 5V\" (at 0 3) (layer \"F.SilkS\") (effects (font (size 0.8 0.8))))");+ let b = Board::from_text("t.kicad_pcb", fixed).unwrap();+ assert!(power_inputs_without_voltage(&b).is_empty(), "{:?}", power_inputs_without_voltage(&b));+ assert!(!warnings(&b).iter().any(|x| x.contains("power input")), "{:?}", warnings(&b));+ // a board with no power input: no hint+ let b = Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap();+ assert!(power_input_hint(&b).is_none());+ } }
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.17",+ "version": "0.2.18", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.17",+ "version": "0.2.18", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+16@@ -577,3 +577,19 @@ A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a `spec.boardSink` declares a pedestal or plate on a board face for named tabless parts: a conservative via, mask, interface and sink series estimate in `analyze thermal`, or the same-input Fields solve with the named conducting block. Both record their assumption in the run summary and `finish`; other parts keep the still-air check. Read `docs/board-sink.md`. `outline check` reads only Edge.Cuts: board items and footprint Edge.Cuts items placed in board coordinates.++## LED on/off states (0.2.18)++Every LED on the board is described in `led-states.json` beside the board, and listed in board-release.json under `ledStates` (`{"path", "sha256"}`; `finish` writes it there when board-release.json already exists beside the board), so the probing stage can show each LED turning on when the probed state lights it:++```json+{"$schema": "adom/led-states@1", "leds": [{"ref": "D1", "mpn": "LTST-C191KGKT",+ "colour": {"name": "green", "dominant_nm": 571, "hex": "#7fc26b"},+ "anode_net": "LEDA", "cathode_net": "LED", "series": ["R1"], "current_mA": 1.3,+ "on_when": {"kind": "gpio", "mcu": "U1", "pin": "2", "port": "PC13", "active": "low"},+ "evidence": "<datasheet page / calc>"}]}+```++`on_when.kind` is `rail` (`net`, `min_v`: lit whenever that rail is up, a power LED), `gpio` (`mcu`, `pin`, `port`, `active`: `high` or `low`, firmware-driven) or `signal` (`net`, `active`: driven by another circuit). `current_mA` is computed from the rail, the LED's Vf and the series resistor. `anode_net` and `cathode_net` must be nets on the LED's own pads. `step schematic`, `step silkscreen` and `step finish` print the Hint; `finish` refuses the board while any LED has no complete entry.++For each LED part, add `led.json` to its wiki component page when you may edit that page: `{"$schema": "adom/led-part@1", "colour": {"name", "dominant_nm", "hex"}, "vf_V": {"typ", "max", "at_mA"}, "if_max_mA", "viewing_deg", "glb_lens_material": "<material or mesh name of the lens in the part's GLB>"}`. When you may not edit the page, open an issue on it carrying that record. Never edit someone else's page.
skills/aiflow-molecule/SKILL.md+16@@ -178,3 +178,19 @@ Do not populate this map merely to silence a moved-pin error. First retain a bef ## The STEP export's own warnings (0.2.3) `adom-aiflow step-export` runs the native `kicad-cli pcb export step` with every layer (models, tracks, pads, zones, mask, silkscreen) and keeps its output, classified apart from the exit status: geometry warnings (`Wire self-interference check failed` with its location and the nearest footprints), model warnings and others. An export that exits 0 with geometry warnings is not a complete twin: `board-glb copper` in a run and `deliver` refuse until you review the STEP at those spots and record `step-export --ack --why "..."` (bound to that STEP file's hash). Never drop silkscreen, mask or zones to make a warning go away. `--log <saved output> --out <the STEP>` classifies an export run elsewhere.++## Programmable boards reach the workcell (0.2.18)++When the board carries an MCU or an FPGA (STM32, RP2040, ESP32, nRF52, ATmega, iCE40 ... or a flash a host must program), bring its programming interface out where the workcell's debug probe reaches it, so it can be flashed with no hand wiring: on a molecule, the machine contacts; otherwise pads or test points with silkscreen labels.++- SWD: SWDIO, SWCLK, NRST and GND (plus a VTref/3V3 sense if the target needs one);+- or UART boot: TX, RX, GND, with BOOT0 set by a resistor or jumper;+- or USB DFU: D+, D-, GND, with the boot strap.++Record it as `programming` in board-release.json beside the board (or `programming` in the spec):++```json+{"interface": "swd", "pins": {"SWDIO": "J8", "SWCLK": "J9", "NRST": "J10", "GND": "J7"}, "target": "STM32G431CBT6", "boot": "BOOT0 low via R2; JP1 bridges for DFU"}+```++`interface` is `swd`, `uart-boot` or `usb-dfu`; `pins` maps each interface signal to the reference that carries it; `target` is the MPN being programmed. `step schematic` and `step placement` print the Hint; `finish` refuses the board when a programmable part has no `programming` record, a required pin is missing, or a pin is neither a machine contact nor a labelled test pad.
skills/aiflow-silkscreen/SKILL.md+8@@ -141,3 +141,11 @@ Every silkscreen command reads a JSON file you write; lengths are mm in board co ## References and polarity on the silkscreen (0.2.3) `finish` warns when a reference lives only on F.Fab/B.Fab (none on the silkscreen), and when a polarized part (a diode, LED, polarized capacitor, battery or IC) has no silkscreen polarity or pin-1 mark (a cathode bar, a `+`, a pin-1 dot or triangle beside pad 1). Put readable references on F.SilkS or B.SilkS, mark polarity and pin 1, and label connectors by function. The warnings are in the finish summary (`silkscreenWarnings`).++## Power inputs say their voltage (0.2.18)++Every power input (a pin, machine contact, connector or pad on VIN, VBUS, VCC_IN, PWR_IN, +5V_IN, DC_IN, V+ or the like) carries its voltage on the silkscreen: the nominal and the allowed range, e.g. `VIN 5 V (4.3-5.5 V)`, never just `VIN`. Work it out from the datasheets of the parts on that rail: the lowest input is the regulator's output plus its dropout plus any series diode's drop (or the lowest operating input of the parts the rail feeds directly); the highest is the smallest absolute-maximum input among them. Write the working into the run's evidence. `step silkscreen` names the board's power inputs in a Hint; `silkscreen-audit --record` and `finish` print a Warning for each power input whose label (its footprint's own silk text, or a board text within 5 mm of its pad) has no voltage figure.++## Every test point is labelled (0.2.18)++Each test point (reference TP*, or a TestPoint/test-pad footprint) gets a small silkscreen label beside it saying what it is for: its net or function (`3V3`, `PH1`, `USB D+`, `RX A OUT`), on the same face, clear of pads and other silk. Use the fab profile's minimum silkscreen text height: 0.3 mm on the 3rd party fab rules, otherwise the profile's own minimum (JLCPCB 0.8 mm). Place the labels with the same layout and collision tools as every other label. `step silkscreen` lists the unlabelled test points in a Hint; `silkscreen-audit --record` and `finish` warn when a test point has no label within 2 mm of its pad edge, or when its label does not name its net (case, spaces, `_` and `+`/`-` are ignored, so `USB D+` matches `USB_D+`). A test pad that carries a programming pin must be labelled, or the programming gate refuses it.