app
[DEPRECATED] Chip Fetcher → adom-chip-fetcher
Public Made by Adomby adom
DEPRECATED — use adom/adom-chip-fetcher. No longer maintained.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222
use anyhow::{Context, Result};
use std::io::Read;
use std::path::PathBuf;
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};
const INDEX_HTML: &str = include_str!("ui/index.html");
// Single-slot activity state: (raw JSON payload, epoch-seconds it was set).
// Updated via POST /api/activity, read via GET. Used by the viewer to
// highlight the chip currently being worked on. The timestamp lets the
// dashboard distinguish "currently working" from a stale heartbeat that no
// process ever cleared (heartbeats are write-only stage labels with no
// terminal state, so the last one sticks indefinitely without this).
static ACTIVITY: Mutex<Option<(String, u64)>> = Mutex::new(None);
fn now_epoch() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn run(port: u16) -> Result<()> {
let addr = format!("0.0.0.0:{port}");
let server = Server::http(&addr).map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;
println!("adom-chip-fetcher viewer listening on http://{addr}");
println!("(open in Hydrogen via `adom-chip-fetcher app`, or visit in any browser)");
for mut request in server.incoming_requests() {
let raw_url = request.url().to_string();
let no_qs = raw_url.split('?').next().unwrap_or(&raw_url);
// Strip code-server proxy prefix (e.g. /proxy/8786/) so routes work behind the proxy
let url = if let Some(rest) = no_qs.strip_prefix(&format!("/proxy/{port}")) {
if rest.is_empty() { "/".to_string() } else { rest.to_string() }
} else {
no_qs.to_string()
};
let method = request.method().clone();
let response = match (&method, url.as_str()) {
(Method::Get, "/") | (Method::Get, "/index.html") => html_response(INDEX_HTML),
(Method::Get, "/api/library") => library_json(),
(Method::Get, "/api/sources") => sources_json(),
(Method::Get, "/api/validate") => validate_json(),
(Method::Get, "/api/projects") => projects_json(),
(Method::Get, "/api/activity") => activity_get(),
(Method::Post, "/api/activity") => {
let mut body = String::new();
let _ = request.as_reader().read_to_string(&mut body);
activity_set(&body)
}
(Method::Get, "/api/credentials") => credentials_list(),
(Method::Post, "/api/credentials") => {
let mut body = String::new();
let _ = request.as_reader().read_to_string(&mut body);
credentials_set(&body)
}
(Method::Delete, path) if path.starts_with("/api/credentials/") => {
let host = path.trim_start_matches("/api/credentials/");
credentials_delete(host)
}
(Method::Get, path) if path.starts_with("/api/chip/") => {
let mpn = path.trim_start_matches("/api/chip/");
chip_json(mpn)
}
(Method::Post, "/api/reveal") => {
let mut body = String::new();
let _ = request.as_reader().read_to_string(&mut body);
reveal_in_vscode(&body)
}
(Method::Post, "/api/open") => {
let mut body = String::new();
let _ = request.as_reader().read_to_string(&mut body);
open_in_app(&body)
}
(Method::Post, "/api/orientation") => {
let mut body = String::new();
let _ = request.as_reader().read_to_string(&mut body);
set_orientation(&body)
}
(Method::Post, "/api/deliver") => {
let mut body = String::new();
let _ = request.as_reader().read_to_string(&mut body);
deliver_chip(&body)
}
(Method::Get, path) if path.starts_with("/asset/") => {
serve_asset(path.trim_start_matches("/asset/"))
}
(Method::Get, path) if path.starts_with("/thumb/") => {
serve_thumb(path.trim_start_matches("/thumb/"))
}
(Method::Get, path) if path.starts_with("/svg/") => {
serve_authoritative_svg(path.trim_start_matches("/svg/"))
}
(Method::Post, path) if path.starts_with("/api/validate/") => {
let mpn = path.trim_start_matches("/api/validate/").to_string();
spawn_subprocess_action("validate", &["validate", &mpn], &mpn)
}
(Method::Post, path) if path.starts_with("/api/thumbnails/") => {
let mpn = path.trim_start_matches("/api/thumbnails/").to_string();
spawn_subprocess_action("thumbnails", &["thumbnails", "--mpn", &mpn, "--force"], &mpn)
}
(Method::Post, path) if path.starts_with("/api/refetch/") => {
let mpn = path.trim_start_matches("/api/refetch/").to_string();
let qs = raw_url.split('?').nth(1).unwrap_or("");
let mut format = String::new();
let mut source = String::new();
for p in qs.split('&') {
if let Some(v) = p.strip_prefix("format=") { format = v.to_string(); }
if let Some(v) = p.strip_prefix("source=") { source = v.to_string(); }
}
if format.is_empty() || source.is_empty() {
error_response(400, "{\"error\":\"need ?format=X&source=Y\"}".into())
} else {
spawn_subprocess_action(
"refetch",
&["refetch", &mpn, "--format", &format, "--source", &source],
&mpn,
)
}
}
_ => not_found(),
};
let _ = request.respond(response);
}
Ok(())
}
fn activity_get() -> Response<std::io::Cursor<Vec<u8>>> {
let entry = ACTIVITY.lock().ok().and_then(|v| v.clone());
let json = match entry {
Some((body, ts)) => {
let age = now_epoch().saturating_sub(ts);
// Merge a server-computed age into the stored payload so the
// dashboard can tell "currently working" from a stale heartbeat.
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(serde_json::Value::Object(mut m)) => {
m.insert("_age_seconds".into(), serde_json::json!(age));
m.insert("_received_at".into(), serde_json::json!(ts));
serde_json::Value::Object(m).to_string()
}
_ => body,
}
}
None => "{}".to_string(),
};
json_response(json)
}
fn activity_set(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
if serde_json::from_str::<serde_json::Value>(body).is_err() {
return error_response(400, "{\"error\":\"invalid json\"}".to_string());
}
if let Ok(mut slot) = ACTIVITY.lock() {
*slot = Some((body.to_string(), now_epoch()));
}
json_response("{\"ok\":true}".to_string())
}
fn credentials_list() -> Response<std::io::Cursor<Vec<u8>>> {
match crate::creds::list_public() {
Ok(creds) => {
let body = serde_json::json!({"credentials": creds}).to_string();
json_response(body)
}
Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),
}
}
fn credentials_set(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let parsed: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),
};
let host = parsed.get("host").and_then(|v| v.as_str()).unwrap_or("");
let username = parsed.get("username").and_then(|v| v.as_str()).unwrap_or("");
let password = parsed.get("password").and_then(|v| v.as_str()).unwrap_or("");
if host.is_empty() || username.is_empty() || password.is_empty() {
return error_response(400, "{\"error\":\"host, username, password all required\"}".to_string());
}
match crate::creds::set(host, username, password) {
Ok(_) => json_response("{\"ok\":true}".to_string()),
Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),
}
}
fn credentials_delete(host: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let host = urldecode(host);
match crate::creds::delete(&host) {
Ok(removed) => json_response(format!("{{\"ok\":true,\"removed\":{removed}}}")),
Err(e) => error_response(500, format!("{{\"error\":{:?}}}", e.to_string())),
}
}
// POST /api/open {"mpn":"<X>","kind":"chip3d|symbol|footprint|datasheet"}
// All four open in a NEW PUP TAB at a publicly-routable proxy URL —
// this matters because pup runs on the user's Windows machine, so a
// loopback URL (127.0.0.1) on the container is unreachable from there.
//
// We resolve the proxy URL from $ADOM_PROXY_HOST or $ADOM_CONTAINER_SLUG
// (set by `chip-fetcher dashboard ensure`). When neither is set we fall
// back to 127.0.0.1 so dev-running-on-localhost still works.
/// POST /api/deliver {mpn, target} — send the chip's CAD to a desktop EDA tool.
fn deliver_chip(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let parsed: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),
};
let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();
let target = parsed.get("target").and_then(|v| v.as_str()).unwrap_or("kicad").trim();
if mpn.is_empty() || mpn.contains('/') || mpn.contains("..") {
return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());
}
let dir = crate::library::root().join(mpn);
if !dir.exists() {
return error_response(404, format!("{{\"error\":\"no library/{mpn}/\"}}"));
}
json_response(crate::deliver::deliver(mpn, &dir, target).to_string())
}
fn open_in_app(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let parsed: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),
};
let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();
let kind = parsed.get("kind").and_then(|v| v.as_str()).unwrap_or("").trim();
let source = parsed.get("source").and_then(|v| v.as_str()).unwrap_or("").trim();
if mpn.is_empty() || mpn.contains('/') || mpn.contains("..") {
return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());
}
let dir = crate::library::root().join(mpn);
if !dir.exists() {
return error_response(404, format!("{{\"error\":\"no library/{mpn}/\"}}"));
}
// chipsmith opens in its own Hydrogen webview (STEP-native detection +
// footprint sign-off), loading the chip's whole library dir.
if kind == "chipsmith" {
use std::process::Stdio;
let r = std::process::Command::new("adom-chipsmith")
.args(["view-library"]).arg(&dir)
.stdout(Stdio::null()).stderr(Stdio::null()).stdin(Stdio::null())
.spawn();
return match r {
Ok(_) => json_response("{\"ok\":true,\"tool\":\"chipsmith\"}".to_string()),
Err(e) => error_response(500, format!("{{\"error\":\"adom-chipsmith unavailable: {e}\"}}")),
};
}
let base = pup_visible_base_url();
let target_url = match kind {
"chip3d-yup" => {
// Y-up is for ME / mech-CAD users (Fusion 360 / SolidWorks
// convention). adom-step uses Z-up internally, so we open the
// pre-rendered large Y-up PNG full-size in pup rather than
// wrestling adom-step's pose. Click "Z-up" thumb for the live
// interactive 3D viewer.
if !dir.join(format!("{mpn}-3d-iso-yup-lg.png")).exists() {
return error_response(404, format!("{{\"error\":\"no {mpn}-3d-iso-yup-lg.png — run `chip-thumbnailer scan`\"}}"));
}
format!("{base}/thumb/{mpn}/chip3d-yup-lg")
}
"chip3d" => {
let step = dir.join(format!("{mpn}.step"));
if !step.exists() {
return error_response(404, format!("{{\"error\":\"no {mpn}.step\"}}"));
}
// Real adom-step viewer integration:
// 1. Ensure server on :8851 (spawn detached if not running).
// 2. POST the STEP bytes to /api/load so the server switches
// to this chip — adom-step is single-file-per-server.
// 3. Open the public proxy URL of port 8851 in a new pup tab.
ensure_adom_step_server();
if !load_step_into_adom_step(&step, mpn) {
println!("WARN: adom-step /api/load failed for {mpn}; falling back to asset URL");
return open_pup_tab(&format!("{base}/asset/{mpn}/{mpn}.step"), "fallback");
}
// Cache-bust the URL so a fresh load happens even if the user already
// has an adom-step tab open from a previous click.
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("{}?reload={ts}&title={}", proxy_url_for_port(ADOM_STEP_PORT), urlencode(mpn))
}
"datasheet" => {
let pdf = dir.join(format!("{mpn}.pdf"));
if !pdf.exists() {
return error_response(404, format!("{{\"error\":\"no {mpn}.pdf\"}}"));
}
ensure_adom_pdf_viewer_server();
let asset_url = format!("{base}/asset/{mpn}/{mpn}.pdf");
format!(
"{}v?src={}&title={}%20datasheet",
proxy_url_for_port(ADOM_PDF_VIEWER_PORT),
urlencode(&asset_url),
urlencode(mpn)
)
}
"symbol" => {
let sym = dir.join(format!("{mpn}.kicad_sym"));
if !sym.exists() {
return error_response(404, format!("{{\"error\":\"no {mpn}.kicad_sym\"}}"));
}
// Launch the standalone adom-symbol live app (no gallia, no Node):
// 1. Ensure `adom-symbol serve` is up on its port.
// 2. POST the raw .kicad_sym to /load — the app renders it
// server-side via service-kicad and updates its state.
// 3. Open the app's public proxy URL in a pup tab.
ensure_adom_symbol_server();
// Folder mode: adom-symbol discovers ds2sf + manufacturer + iso and
// renders the auto-grouped default with a layout toolbar.
if !load_dir_into_app(&dir, mpn, ADOM_SYMBOL_PORT, source) {
println!("WARN: adom-symbol /load (dir) failed for {mpn}; falling back to raw SVG");
return open_pup_tab(&format!("{base}/thumb/{mpn}/symbol"), "fallback-svg");
}
format!("{}?reload={}&title={}", proxy_url_for_port(ADOM_SYMBOL_PORT), cache_bust(), urlencode(mpn))
}
"footprint" => {
let md = dir.join(format!("{mpn}.kicad_mod"));
if !md.exists() {
return error_response(404, format!("{{\"error\":\"no {mpn}.kicad_mod\"}}"));
}
// Launch the standalone adom-footprint live app (no gallia, no
// Node): ensure `adom-footprint serve`, POST the raw .kicad_mod to
// /load (app renders via service-kicad), open its proxy URL.
ensure_adom_footprint_server();
if !load_kicad_into_app(&md, mpn, ADOM_FOOTPRINT_PORT, "kicadMod") {
println!("WARN: adom-footprint /load failed for {mpn}; falling back to raw SVG");
return open_pup_tab(&format!("{base}/thumb/{mpn}/footprint"), "fallback-svg");
}
format!("{}?reload={}&title={}", proxy_url_for_port(ADOM_FOOTPRINT_PORT), cache_bust(), urlencode(mpn))
}
"lbr" => {
let sym = dir.join(format!("{mpn}.kicad_sym"));
let md = dir.join(format!("{mpn}.kicad_mod"));
if !sym.exists() && !md.exists() {
return error_response(404, format!("{{\"error\":\"no {mpn}.kicad_sym or .kicad_mod to build an .lbr\"}}"));
}
// Launch the standalone adom-lbr live app (no gallia, no Node):
// 1. Ensure `adom-lbr serve` is up.
// 2. Generate a true EAGLE .lbr from the chip's symbol+footprint
// and POST it to /load (the app lints + displays it).
// 3. Open the app's public proxy URL in a pup tab.
ensure_adom_lbr_server();
if !load_lbr_into_app(&dir, mpn, ADOM_LBR_PORT) {
println!("WARN: adom-lbr generate/load failed for {mpn}");
return error_response(500, format!("{{\"error\":\"could not build .lbr for {mpn}\"}}"));
}
format!("{}?reload={}&title={}", proxy_url_for_port(ADOM_LBR_PORT), cache_bust(), urlencode(mpn))
}
_ => return error_response(400, format!("{{\"error\":\"unknown kind {:?} (want chip3d|symbol|footprint|lbr|datasheet)\"}}", kind)),
};
// Return the app's public URL and let the CLIENT open it with window.open —
// that opens a tab in whatever browser the user is actually viewing
// chip-fetcher in (their phone, a pup window, a Hydrogen webview), which is
// the only universally-correct surface. (The old server-side
// `adom-desktop browser_open_tab` always opened on the LAPTOP, so a phone
// user saw nothing.) `desktopBridge` is reported so the client can name the
// method and gate Send-to.
json_response(format!(
"{{\"ok\":true,\"url\":{:?},\"desktopBridge\":{}}}",
target_url, desktop_bridge_available()
))
}
/// Whether an Adom Desktop client is connected to this container's bridge.
/// Cached briefly (the probe shells out). Used only to label the open method +
/// gate Send-to — the actual app open happens client-side via window.open.
fn desktop_bridge_available() -> bool {
use std::sync::Mutex;
use std::time::{Duration, Instant};
static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
if let Some((t, v)) = *CACHE.lock().unwrap() {
if t.elapsed() < Duration::from_secs(15) {
return v;
}
}
let avail = std::process::Command::new("timeout")
.args(["3", "adom-desktop", "status"])
.output()
.ok()
.map(|o| {
serde_json::from_str::<serde_json::Value>(&String::from_utf8_lossy(&o.stdout))
.ok()
.and_then(|v| v.get("clients").and_then(|c| c.as_u64()))
.map(|n| n > 0)
.unwrap_or(false)
})
.unwrap_or(false);
*CACHE.lock().unwrap() = Some((Instant::now(), avail));
avail
}
/// Fallback open (e.g. raw SVG when the live app load failed): return the URL for
/// the client to window.open — same client-side strategy as the main path.
fn open_pup_tab(url: &str, _label: &str) -> Response<std::io::Cursor<Vec<u8>>> {
json_response(format!("{{\"ok\":true,\"url\":{:?},\"desktopBridge\":{}}}", url, desktop_bridge_available()))
}
// Dedicated chip-fetcher-owned ports for the spawned helper apps. Using
// non-default ports avoids collisions with any pre-existing adom-step /
// adom-pdf-viewer instances the user may have started for other workflows.
const ADOM_STEP_PORT: u16 = 8857;
const ADOM_PDF_VIEWER_PORT: u16 = 8985;
const ADOM_SYMBOL_PORT: u16 = 8781;
const ADOM_FOOTPRINT_PORT: u16 = 8783;
const ADOM_LBR_PORT: u16 = 8784;
fn port_listening(port: u16) -> bool {
let url = format!("http://127.0.0.1:{port}/");
std::process::Command::new("curl")
.args(["-fs", "-m", "1", "-o", "/dev/null", &url])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn ensure_adom_step_server() {
if port_listening(ADOM_STEP_PORT) { return; }
// Need a placeholder file to start the server. We'll use any STEP from
// the library — the next /api/load POST will replace it. If the library
// is empty, skip; the load call below will surface the error.
let any_step = std::fs::read_dir(crate::library::root())
.ok()
.and_then(|rd| rd.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.find_map(|e| {
let mpn = e.file_name().to_string_lossy().to_string();
let p = e.path().join(format!("{mpn}.step"));
if p.is_file() { Some(p) } else { None }
}));
let Some(any) = any_step else { return; };
use std::process::Stdio;
let _ = std::process::Command::new("adom-step")
.args(["view", "--no-open", "--port", &ADOM_STEP_PORT.to_string()])
.arg(&any)
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn();
// Wait briefly for it to come up.
for _ in 0..30 {
if port_listening(ADOM_STEP_PORT) { break; }
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
fn load_step_into_adom_step(step: &std::path::Path, mpn: &str) -> bool {
let bytes = match std::fs::read(step) { Ok(b) => b, Err(_) => return false };
let Some(tmp) = write_tmp_file(&bytes) else { return false };
// curl -X POST --data-binary @<tmp> -H 'X-Filename: <MPN>.step' http://127.0.0.1:8851/api/load
let url = format!("http://127.0.0.1:{ADOM_STEP_PORT}/api/load");
let filename = format!("{mpn}.step");
let header = format!("X-Filename: {filename}");
let data_arg = format!("@{}", tmp.display());
let out = std::process::Command::new("curl")
.args([
"-fs", "-m", "60", "-X", "POST",
"-H", &header,
"--data-binary", &data_arg,
&url,
])
.output();
let _ = std::fs::remove_file(&tmp);
matches!(out, Ok(o) if o.status.success())
}
fn write_tmp_file(bytes: &[u8]) -> Option<std::path::PathBuf> {
use std::io::Write;
let path = std::env::temp_dir().join(format!("cf-load-{}.bin", std::process::id()));
let mut f = std::fs::File::create(&path).ok()?;
f.write_all(bytes).ok()?;
Some(path)
}
/// Generate (or refresh) <MPN>-symbol-viewer.html via the local Node
/// helper. Idempotent at the script level — caller checks file existence
/// first and only invokes this on first click. Returns true on success.
// POST /api/orientation {"mpn":"<X>","axis":"z"|"y"}
// Records the user's choice of up-axis for this chip. Persists to
// library/<MPN>/info.json's chosen_up_axis field. Downstream tools
// (adom-step / adom-chiplinter / chip3d previewers) read this field and
// apply the right rotation when consuming the STEP, so the user only
// has to pick once per chip.
//
// When the user picks the SAME axis as chip-thumbnailer's inferred axis,
// we still record it explicitly — that way "user has confirmed" is
// visible in the data, vs. "still relying on inference."
fn set_orientation(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let parsed: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),
};
let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();
let axis = parsed.get("axis").and_then(|v| v.as_str()).unwrap_or("").trim();
if mpn.is_empty() || mpn.contains('/') || mpn.contains("..") {
return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());
}
let valid = ["z", "y", "x", "-x", "-y", "-z", "xup", "xdown", "yup", "ydown", "zup", "zdown"];
if !valid.contains(&axis) {
return error_response(400, "{\"error\":\"axis must be 'z' or 'y'\"}".to_string());
}
let info_path = crate::library::root().join(mpn).join("info.json");
if !info_path.exists() {
return error_response(404, format!("{{\"error\":\"no info.json for {mpn}\"}}"));
}
let raw = match std::fs::read_to_string(&info_path) {
Ok(s) => s,
Err(e) => return error_response(500, format!("{{\"error\":\"read info.json: {e}\"}}")),
};
let mut info: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => return error_response(500, format!("{{\"error\":\"parse info.json: {e}\"}}")),
};
info["chosen_up_axis"] = serde_json::Value::String(axis.to_string());
info["chosen_up_axis_at"] = serde_json::Value::String(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_default(),
);
let pretty = match serde_json::to_string_pretty(&info) {
Ok(s) => s,
Err(e) => return error_response(500, format!("{{\"error\":\"serialize: {e}\"}}")),
};
if let Err(e) = std::fs::write(&info_path, pretty) {
return error_response(500, format!("{{\"error\":\"write info.json: {e}\"}}"));
}
json_response(format!("{{\"ok\":true,\"mpn\":{mpn:?},\"axis\":{axis:?}}}"))
}
/// Millisecond timestamp for cache-busting an already-open app tab so a fresh
/// /load is reflected even if the user clicked the same kind before.
fn cache_bust() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
/// Spawn `adom-symbol serve` on its port if it isn't already listening.
fn ensure_adom_symbol_server() {
ensure_app_server("adom-symbol", ADOM_SYMBOL_PORT);
}
/// Spawn `adom-footprint serve` on its port if it isn't already listening.
fn ensure_adom_footprint_server() {
ensure_app_server("adom-footprint", ADOM_FOOTPRINT_PORT);
}
/// Spawn `adom-lbr serve` on its port if it isn't already listening.
fn ensure_adom_lbr_server() {
ensure_app_server("adom-lbr", ADOM_LBR_PORT);
}
/// Build a true EAGLE `.lbr` for this chip (symbol + footprint when both exist)
/// via the adom-lbr CLI, then POST the XML to adom-lbr's `/load`. Returns false
/// if generation or the load failed.
fn load_lbr_into_app(dir: &std::path::Path, mpn: &str, port: u16) -> bool {
let sym = dir.join(format!("{mpn}.kicad_sym"));
let md = dir.join(format!("{mpn}.kicad_mod"));
let out_lbr = dir.join(format!("{mpn}.lbr"));
let mut args: Vec<String> = vec!["generate".into()];
if sym.is_file() {
args.push("--sym".into());
args.push(sym.to_string_lossy().to_string());
}
if md.is_file() {
args.push("--fp".into());
args.push(md.to_string_lossy().to_string());
}
if !sym.is_file() && !md.is_file() {
return false;
}
args.push("--name".into());
args.push(mpn.to_string());
args.push("--output".into());
args.push(out_lbr.to_string_lossy().to_string());
let gen = std::process::Command::new("adom-lbr").args(&args).output();
// generate exits 2 on lint failure but still writes the file; accept any
// run that produced the .lbr.
if !matches!(gen, Ok(_)) || !out_lbr.is_file() {
return false;
}
let content = match std::fs::read_to_string(&out_lbr) {
Ok(c) => c,
Err(_) => return false,
};
let body = serde_json::json!({ "lbr": content }).to_string();
let Some(tmp) = write_tmp_file(body.as_bytes()) else { return false };
let url = format!("http://127.0.0.1:{port}/load");
let data_arg = format!("@{}", tmp.display());
let out = std::process::Command::new("curl")
.args(["-fs", "-m", "30", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", &data_arg, &url])
.output();
let _ = std::fs::remove_file(&tmp);
matches!(out, Ok(o) if o.status.success())
}
fn ensure_app_server(bin: &str, port: u16) {
if port_listening(port) {
return;
}
use std::process::Stdio;
let _ = std::process::Command::new(bin)
.args(["serve", "--port", &port.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn();
for _ in 0..30 {
if port_listening(port) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
/// POST a raw KiCad file to a standalone app's `/load` endpoint as JSON
/// `{ "<field>": "<file contents>", "symbolName"/"footprintName": "<mpn>" }`.
/// The app renders it server-side via service-kicad. `field` is "kicadSym" or
/// "kicadMod".
/// POST `{dir, mpn}` to an app's `/load` so it can discover all source files in
/// the chip's library folder (manufacturer .kicad_sym, ds2sf extraction, iso 3D).
fn load_dir_into_app(dir: &std::path::Path, mpn: &str, port: u16, source: &str) -> bool {
let body = serde_json::json!({ "dir": dir.to_string_lossy(), "mpn": mpn, "source": source }).to_string();
let Some(tmp) = write_tmp_file(body.as_bytes()) else { return false };
let url = format!("http://127.0.0.1:{port}/load");
let data_arg = format!("@{}", tmp.display());
let out = std::process::Command::new("curl")
.args(["-fs", "-m", "90", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", &data_arg, &url])
.output();
let _ = std::fs::remove_file(&tmp);
matches!(out, Ok(o) if o.status.success())
}
fn load_kicad_into_app(path: &std::path::Path, mpn: &str, port: u16, field: &str) -> bool {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
let name_key = if field == "kicadSym" { "symbolName" } else { "footprintName" };
let body = serde_json::json!({ field: content, name_key: mpn }).to_string();
let Some(tmp) = write_tmp_file(body.as_bytes()) else { return false };
let url = format!("http://127.0.0.1:{port}/load");
let data_arg = format!("@{}", tmp.display());
let out = std::process::Command::new("curl")
.args([
"-fs", "-m", "70", "-X", "POST",
"-H", "Content-Type: application/json",
"--data-binary", &data_arg,
&url,
])
.output();
let _ = std::fs::remove_file(&tmp);
matches!(out, Ok(o) if o.status.success())
}
fn ensure_adom_pdf_viewer_server() {
if port_listening(ADOM_PDF_VIEWER_PORT) { return; }
use std::process::Stdio;
let _ = std::process::Command::new("node")
.args([
"/home/adom/project/adom-pdf-viewer/server.mjs",
"--port", &ADOM_PDF_VIEWER_PORT.to_string(),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null())
.spawn();
for _ in 0..30 {
if port_listening(ADOM_PDF_VIEWER_PORT) { break; }
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
/// Resolve the dashboard URL that pup (running on the user's Windows
/// machine) can actually reach. Reads $ADOM_PROXY_HOST or
/// $ADOM_CONTAINER_SLUG, set by `chip-fetcher dashboard ensure`.
fn pup_visible_base_url() -> String {
proxy_url_for_port(8786).trim_end_matches('/').to_string()
}
/// Same logic as pup_visible_base_url but for an arbitrary port — used so
/// adom-step (8851) and adom-pdf-viewer (8981) are reachable from pup
/// through the same wiki-proxy host pattern.
fn proxy_url_for_port(port: u16) -> String {
if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {
return format!("https://{host}/proxy/{port}/");
}
if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {
return format!("https://{slug}.adom.cloud/proxy/{port}/");
}
// Hydrogen / code-server exposes the public proxy template here, e.g.
// https://<slug>.adom.cloud/proxy/{{port}}/ — substitute the real port.
if let Ok(tpl) = std::env::var("VSCODE_PROXY_URI") {
if tpl.contains("{{port}}") {
return tpl.replace("{{port}}", &port.to_string());
}
}
format!("http://127.0.0.1:{port}/")
}
fn urlencode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b':' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
// POST /api/reveal {"mpn": "<MPN>"} → reveals library/<MPN>/ in VS Code Explorer.
// Empty mpn (or omitted) reveals the library root. Path is resolved against
// library::root() and rejected if it escapes via .. or contains a slash.
fn reveal_in_vscode(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let parsed: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(e) => return error_response(400, format!("{{\"error\":\"invalid json: {e}\"}}")),
};
let mpn = parsed.get("mpn").and_then(|v| v.as_str()).unwrap_or("").trim();
let target = if mpn.is_empty() {
crate::library::root()
} else {
if mpn.contains('/') || mpn.contains("..") || mpn.contains('\\') {
return error_response(400, "{\"error\":\"invalid mpn\"}".to_string());
}
let dir = crate::library::root().join(mpn);
if !dir.exists() {
return error_response(404, format!("{{\"error\":\"no library folder for mpn {:?}\"}}", mpn));
}
dir
};
let path_str = target.to_string_lossy().to_string();
match std::process::Command::new("adom-vscode")
.args(["reveal", &path_str])
.output()
{
Ok(out) if out.status.success() => {
json_response(format!("{{\"ok\":true,\"path\":{:?}}}", path_str))
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
error_response(500, format!("{{\"error\":\"adom-vscode failed\",\"stderr\":{:?}}}", stderr))
}
Err(e) => error_response(
500,
format!("{{\"error\":\"adom-vscode not runnable: {}\"}}", e),
),
}
}
fn urldecode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i+1..i+3]).unwrap_or(""), 16) {
out.push(b as char);
i += 3;
continue;
}
} else if bytes[i] == b'+' {
out.push(' ');
i += 1;
continue;
}
out.push(bytes[i] as char);
i += 1;
}
out
}
fn error_response(code: u16, body: String) -> Response<std::io::Cursor<Vec<u8>>> {
let h = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();
Response::from_string(body).with_status_code(code).with_header(h)
}
fn html_response(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let h = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap();
Response::from_string(body.to_string()).with_header(h)
}
fn json_response(body: String) -> Response<std::io::Cursor<Vec<u8>>> {
let h = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();
Response::from_string(body).with_header(h)
}
/// Brutal self-audit: for every chip (the active project if set), report ✓/✗ for
/// EVERY expected artifact — datasheet, 3D STEP, KiCad symbol+footprint, the card
/// thumbnails (symbol.svg / footprint.svg / 3d-iso.png), the Fusion/EAGLE .lbr,
/// and info.json. A chip is "complete" ONLY if every REQUIRED box is green. This
/// is the tab that stops anyone (human or AI) from calling it done when it isn't.
fn validate_json() -> Response<std::io::Cursor<Vec<u8>>> {
use serde_json::json;
let root = crate::library::root();
let active = crate::projects::get_active();
let project_mpns: Option<Vec<String>> = active.as_ref().map(|p| crate::projects::mpns_for_project(p));
let valid_pdf = |p: std::path::PathBuf| -> bool {
std::fs::File::open(&p).ok().and_then(|mut f| {
use std::io::Read;
let mut b = [0u8; 4];
f.read_exact(&mut b).ok().map(|_| &b == b"%PDF")
}).unwrap_or(false)
};
let valid_step = |p: std::path::PathBuf| -> bool {
std::fs::read_to_string(&p).ok().map(|s| s.contains("ISO-10303")).unwrap_or(false)
};
let nonempty = |p: std::path::PathBuf, min: u64| -> bool {
std::fs::metadata(&p).map(|m| m.len() >= min).unwrap_or(false)
};
let mut chips = Vec::new();
let (mut total, mut complete) = (0u32, 0u32);
if let Ok(rd) = std::fs::read_dir(&root) {
let mut dirs: Vec<_> = rd.filter_map(|e| e.ok()).filter(|e| e.path().is_dir()).collect();
dirs.sort_by_key(|e| e.file_name());
for e in dirs {
let mpn = e.file_name().to_string_lossy().to_string();
if let Some(ref pm) = project_mpns {
if !pm.iter().any(|m| m == &mpn) { continue; }
}
let d = e.path();
// REQUIRED — files a complete card MUST have
let pdf = valid_pdf(d.join(format!("{mpn}.pdf")));
let step = valid_step(d.join(format!("{mpn}.step")));
let sym = nonempty(d.join(format!("{mpn}.kicad_sym")), 50);
let foot = nonempty(d.join(format!("{mpn}.kicad_mod")), 50);
let sym_svg = nonempty(d.join(format!("{mpn}-symbol.svg")), 500);
let fp_svg = nonempty(d.join(format!("{mpn}-footprint.svg")), 500);
let thumb3d = nonempty(d.join(format!("{mpn}-3d-iso.png")), 2000);
// EXTRA — nice-to-have
let lbr = nonempty(d.join(format!("{mpn}.lbr")), 50);
let info = d.join("info.json").exists();
// Description: the human "what is this chip" line shown on the card +
// zoom. Tracked so a blank one is impossible to miss in the audit.
let has_desc = std::fs::read_to_string(d.join("info.json")).ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("description").and_then(|x| x.as_str()).map(|s| s.trim().len() >= 10))
.unwrap_or(false);
// adom-concur cross-source consensus: did the KiCad / EAGLE / Altium
// / ds2sf variants of this part AGREE? Surface concur's verdict + the
// per-axis agree/disagree tally so the audit shows consistency, not
// just file presence.
let consensus = std::fs::read_to_string(d.join(format!("{mpn}-concur.result.json"))).ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.map(|c| {
let axes = c.get("axes").and_then(|a| a.as_array()).cloned().unwrap_or_default();
let count = |v: &str| axes.iter().filter(|a| a.get("verdict").and_then(|x| x.as_str()) == Some(v)).count();
json!({
"status": c.get("status").and_then(|s| s.as_str()).unwrap_or("?"),
"sources": c.get("sources_compared").cloned().unwrap_or(json!([])),
"missing": c.get("sources_missing").cloned().unwrap_or(json!([])),
"agree": count("agree"), "disagree": count("disagree"), "total_axes": axes.len()
})
});
let required = [pdf, step, sym, foot, sym_svg, fp_svg, thumb3d];
let all = required.iter().all(|&b| b);
total += 1;
if all { complete += 1; }
chips.push(json!({
"mpn": mpn,
"complete": all,
"required": {
"datasheet_pdf": pdf, "step_3d": step,
"kicad_symbol": sym, "kicad_footprint": foot,
"thumb_symbol": sym_svg, "thumb_footprint": fp_svg, "thumb_3d": thumb3d
},
"extra": { "description": has_desc, "fusion_lbr": lbr, "info_json": info },
"consensus": consensus,
"sources": per_source_breakdown(&d, &mpn, step)
}));
}
}
let body = json!({
"project": active,
"summary": { "total": total, "complete": complete, "incomplete": total - complete },
"required_keys": ["datasheet_pdf","step_3d","kicad_symbol","kicad_footprint","thumb_symbol","thumb_footprint","thumb_3d"],
"extra_keys": ["description","fusion_lbr","info_json"],
"chips": chips
}).to_string();
json_response(body)
}
/// Per-source-library breakdown for the Validation tab's child rows: for each
/// library we downloaded (CSE / UltraLibrarian / SnapEDA / ds2sf), which of
/// symbol / footprint / 3D did we generate from IT. 3D is special: a library
/// that shipped its OWN STEP (`<mpn>.<method>.step`) gets a normal check; one
/// that didn't gets a check with an asterisk (we used the global chip STEP).
fn per_source_breakdown(d: &std::path::Path, mpn: &str, has_global_step: bool) -> serde_json::Value {
use serde_json::json;
let has = |f: String| d.join(f).is_file();
// The DISCOVERY source (where the CAD was found) labels the format lines —
// CSE / SnapEDA / UltraLibrarian — NOT the content_origin ("manufacturer"),
// because CSE is an aggregator, not the maker. Read it from file-provenance.
let prov = std::fs::read_to_string(d.join(format!("{mpn}-file-provenance.json"))).ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let disc = prov.as_ref().and_then(|p| p.as_object())
.and_then(|m| m.values().find_map(|e| e.get("discovery_source").and_then(|s| s.as_str())))
.unwrap_or("cse");
// Manufacturer name (for naming the maker's own site when it's truly direct).
let manufacturer = std::fs::read_to_string(d.join("info.json")).ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("manufacturer").and_then(|m| m.as_str()).map(String::from))
.unwrap_or_default();
// PROVENANCE LABELING RULE (John, 2026-06-23): label by WHERE it was actually
// downloaded, never the content_origin. ONLY the maker's OWN site counts as
// "the manufacturer" — and then NAME the site (ti.com / analog.com / st.com).
// The moment they hand off to UltraLibrarian / SnapMagic / Component Search
// Engine, that aggregator is the source — NOT the manufacturer.
let src_label = |disc: &str| crate::provenance::source_label(disc, &manufacturer);
let src = src_label(disc);
// Where the GLOBAL chip STEP came from — for the asterisk tooltip on lines
// whose own library had no 3D ("we swapped in the STEP we got from <X>").
let step_e = prov.as_ref().and_then(|p| p.get(format!("{mpn}.step")));
let step_disc = step_e.and_then(|e| e.get("discovery_source")).and_then(|s| s.as_str()).unwrap_or(disc);
let step_source = src_label(step_disc);
let step_url = step_e.and_then(|e| e.get("url")).and_then(|s| s.as_str())
.filter(|s| !s.is_empty()).map(String::from);
// Download link for a file (relative path under /asset/<mpn>/). UI prepends
// API_BASE. `original=true` = the raw archive/file we pulled off the internet
// (for the user to inspect); `false` = a specific file we extracted from it.
let dl = |name: &str, file: String, original: bool| json!({ "name": name, "file": file, "original": original });
// The original CSE bundle zip (the exact bytes downloaded) — shared by every
// EDA-format line, since CSE ships one archive with all formats inside.
let cse_zip = has(format!("{mpn}.cse-bundle.zip"));
let mut lines: Vec<serde_json::Value> = Vec::new();
// 1) Manufacturer DATASHEET → ds2sf extraction is its OWN source. The
// datasheet has no 3D, so its 3D cell is the global chip STEP (asterisk).
let ds_tried = has(format!("{mpn}-extraction.result.json"));
let ds_sym = has(format!("{mpn}-symbol.extracted.json")) || has(format!("{mpn}-symbol.ds2sf.svg"));
let ds_fp = has(format!("{mpn}-footprint.extracted.json")) || has(format!("{mpn}-footprint.ds2sf.svg"));
if ds_tried || ds_sym || ds_fp {
let mut files = vec![];
// The datasheet PDF IS the original download for this line.
if has(format!("{mpn}.pdf")) { files.push(dl("datasheet (PDF)", format!("{mpn}.pdf"), true)); }
// Why is ds2sf red? Surface the extractor's own reason so the ✗ explains itself.
let note = std::fs::read_to_string(d.join(format!("{mpn}-extraction.result.json"))).ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|r| {
let status = r.get("status").and_then(|s| s.as_str()).unwrap_or("");
let summary = r.get("summary").and_then(|s| s.as_str()).unwrap_or("");
if status == "ok" { None } else {
Some(format!("ds2sf could not extract (status: {status}). {summary}"))
}
});
// The datasheet is fetched from the maker's own lit server → name the site.
lines.push(json!({ "label": format!("PDF Datasheet from {}", src_label("manufacturer")),
"sym": ds_sym, "fp": ds_fp, "three_d": has_global_step, "three_d_from_lib": false,
"step_source": step_source, "step_url": step_url, "files": files, "note": note }));
}
// 2) One line PER EDA FORMAT in the downloaded bundle — each library has its
// own symbol + footprint, and (for CSE/UL/SnapEDA bundles) its own STEP.
// Each line carries download links to the EXACT files we used.
let three_lib = json!(has_global_step); // CSE/UL/SnapEDA bundles ship the STEP with each format
let orig_zip = |files: &mut Vec<serde_json::Value>| {
if cse_zip { files.push(dl("original bundle (.zip)", format!("{mpn}.cse-bundle.zip"), true)); }
};
let ki = (has(format!("{mpn}.kicad_sym")), has(format!("{mpn}.kicad_mod")));
if ki.0 || ki.1 {
let mut files = vec![];
if ki.0 { files.push(dl(".kicad_sym", format!("{mpn}.kicad_sym"), false)); }
if ki.1 { files.push(dl(".kicad_mod", format!("{mpn}.kicad_mod"), false)); }
if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }
orig_zip(&mut files);
lines.push(json!({ "label": format!("{src} - KiCad Library"), "sym": ki.0, "fp": ki.1,
"three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));
}
let al = (has(format!("{mpn}.SchLib")), has(format!("{mpn}.PcbLib")));
if al.0 || al.1 {
let mut files = vec![];
if al.0 { files.push(dl(".SchLib", format!("{mpn}.SchLib"), false)); }
if al.1 { files.push(dl(".PcbLib", format!("{mpn}.PcbLib"), false)); }
if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }
orig_zip(&mut files);
lines.push(json!({ "label": format!("{src} - Altium Library"), "sym": al.0, "fp": al.1,
"three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));
}
// EAGLE/Fusion .lbr is a single file holding BOTH the device (symbol) + package (footprint).
if has(format!("{mpn}.lbr")) {
let mut files = vec![dl(".lbr", format!("{mpn}.lbr"), false)];
if has_global_step { files.push(dl(".step", format!("{mpn}.step"), false)); }
orig_zip(&mut files);
lines.push(json!({ "label": format!("{src} - Fusion 360 Library"), "sym": true, "fp": true,
"three_d": has_global_step, "three_d_from_lib": three_lib, "files": files }));
}
json!(lines)
}
/// The CANONICAL sourcing-priority — the single source of truth for "what order
/// chip-fetcher searches the entire internet for electronics data." The
/// dashboard's Sourcing tab renders this verbatim, and the AI must follow it.
/// Three concerns, each with its own ordered ladder. ALWAYS manufacturer-first
/// for CAD; ALWAYS the Adom data tools (keys baked in) for metadata; the manual
/// CAD-crawl skills are the FALLBACK, in order, with LCSC dead last.
fn sources_json() -> Response<std::io::Cursor<Vec<u8>>> {
use serde_json::json;
let body = json!({
"concerns": [
{
"concern": "1 · Identity & metadata",
"what": "What the part IS — description, manufacturer, orderable MPN/package variants, stock, pricing, lead time, lifecycle (active/NRND/EOL).",
"rule": "ALWAYS query the Adom data tools first — their API keys are baked into their own service containers, so they ALWAYS work with NO key needed. They also resolve the orderable MPN (base ADS1263 → ADS1263IPWR) that CAD sites need.",
"steps": [
{"tier": "Adom tool", "name": "adom-parts-search", "detail": "THE umbrella — searches Mouser + DigiKey + JLCPCB IN PARALLEL, returns description, stock, pricing, lead time, lifecycle, image, datasheet URL, and every orderable package variant. Mouser-preferred (40-min drone delivery, Fort Worth).", "cmd": "adom-parts-search search <MPN>"},
{"tier": "Adom tool", "name": "adom-mouser", "detail": "Single-vendor deep lookup + richest stock/lifecycle. Backend keeps the key server-side (`adom-mouser serve`).", "cmd": "adom-mouser part <MPN>"},
{"tier": "Adom tool", "name": "adom-digikey", "detail": "DigiKey catalog — broadest coverage, parametric + lifecycle.", "cmd": "adom-digikey part <MPN>"},
{"tier": "Adom tool", "name": "adom-jlcpcb", "detail": "JLCPCB / LCSC catalog — assembly stock + basic/extended part class.", "cmd": "adom-jlcpcb part <MPN>"}
]
},
{
"concern": "2 · Datasheet (PDF)",
"what": "The manufacturer datasheet.",
"rule": "Manufacturer's own lit server first; Adom tools give the fallback link.",
"steps": [
{"tier": "Manufacturer", "name": "Manufacturer lit server", "detail": "e.g. ti.com/lit/ds/symlink/<part>.pdf, st.com, nxp.com. ALWAYS try first.", "cmd": "chip-fetcher pup-pdf-fetch <MPN> --pdf-url <url>"},
{"tier": "Adom tool", "name": "adom-parts-search datasheet_url", "detail": "Mouser/DigiKey expose a datasheet link when the mfr URL is unknown.", "cmd": "adom-parts-search search <MPN>"}
]
},
{
"concern": "3 · CAD bundle (STEP + symbol + footprint)",
"what": "The 3D STEP model + KiCad/Altium/Fusion symbol & footprint.",
"rule": "MANUFACTURER-FIRST, always. Exhaust the maker's own CAD/Resources before any aggregator. Then fall back DOWN this ladder IN ORDER — never skip ahead. LCSC is the LAST resort; surface 'no manufacturer CAD' to the user before reaching for it.",
"steps": [
{"tier": "Manufacturer", "name": "Manufacturer site", "detail": "vendor.com CAD / Design Resources tab — the authoritative STEP + footprint. ALWAYS first, even when confident.", "cmd": "chip-fetcher mfr-probe <MPN>"},
{"tier": "Aggregator skill", "name": "SnapMagic / SnapEDA", "detail": "Free CAD bundles, login-gated. Right after the manufacturer.", "skill": "playbooks/snapmagic.md"},
{"tier": "Aggregator skill", "name": "Mouser CSE (Component Search Engine / SamacSys)", "detail": "Mouser's ECAD Model / Library Loader. Same-origin XHR + Basic-auth; needs the ORDERABLE MPN (from step 1).", "cmd": "chip-fetcher cse <MPN> --mfr <name>", "skill": "playbooks/mouser-cse.md"},
{"tier": "Aggregator skill", "name": "DigiKey Footprints & Models", "detail": "DigiKey's /models/<id> page — mfr STEP + Ultra Librarian + SnapMagic.", "skill": "playbooks/digikey-models.md"},
{"tier": "Aggregator skill", "name": "Arrow", "detail": "Niche distributor CAD.", "skill": "playbooks/sourcing-ladder.md"},
{"tier": "Aggregator skill", "name": "UltraLibrarian / Component Search Engine", "detail": "Only when the manufacturer links there explicitly. Captcha or UL-Pro login flows.", "skill": "playbooks/ultra-librarian.md"},
{"tier": "Last resort", "name": "LCSC", "detail": "datasheet.lcsc.com — LAST RESORT. Avoid if possible; tell the user we couldn't find it on the manufacturer's site first.", "skill": "playbooks/sourcing-ladder.md"}
]
}
]
}).to_string();
json_response(body)
}
fn library_json() -> Response<std::io::Cursor<Vec<u8>>> {
match crate::inventory::scan() {
Ok(lib) => json_response(serde_json::to_string(&lib).unwrap_or_default()),
Err(e) => {
let h = Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();
Response::from_string(format!("{{\"error\":{:?}}}", e.to_string()))
.with_status_code(500)
.with_header(h)
}
}
}
fn projects_json() -> Response<std::io::Cursor<Vec<u8>>> {
let store = crate::projects::load();
json_response(serde_json::to_string(&store).unwrap_or_default())
}
fn chip_json(mpn: &str) -> Response<std::io::Cursor<Vec<u8>>> {
match crate::inventory::scan() {
Ok(lib) => match lib.chips.into_iter().find(|c| c.mpn == mpn) {
Some(c) => json_response(serde_json::to_string(&c).unwrap_or_default()),
None => not_found(),
},
Err(_) => not_found(),
}
}
fn serve_thumb(rel: &str) -> Response<std::io::Cursor<Vec<u8>>> {
if rel.contains("..") { return not_found(); }
let parts: Vec<&str> = rel.splitn(2, '/').collect();
if parts.len() != 2 { return not_found(); }
let mpn = parts[0];
let kind = parts[1];
// chip-thumbnailer's canonical naming. URL keeps short kinds for nice
// dashboard hrefs (/thumb/<MPN>/symbol|footprint|chip3d).
// Per-source variant thumbnails: symbol.<method> / footprint.<method>
// → <mpn>-symbol.<method>.svg (e.g. symbol.ds2sf, symbol.snapeda).
if let Some(rest) = kind.strip_prefix("symbol.").or_else(|| kind.strip_prefix("footprint.")) {
let pre = if kind.starts_with("symbol.") { "symbol" } else { "footprint" };
if rest.contains('/') || rest.contains("..") { return not_found(); }
let path = crate::library::root().join(mpn).join(format!("{mpn}-{pre}.{rest}.svg"));
return match std::fs::read(&path) {
Ok(b) => Response::from_data(b)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"image/svg+xml"[..]).unwrap())
.with_header(Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=60"[..]).unwrap()),
Err(_) => not_found(),
};
}
let (filename, ct) = match kind {
"symbol" => (format!("{mpn}-symbol.svg"), "image/svg+xml"),
"footprint" => (format!("{mpn}-footprint.svg"), "image/svg+xml"),
"chip3d" => (format!("{mpn}-3d-iso.png"), "image/png"),
"chip3d-yup" => (format!("{mpn}-3d-iso-yup.png"), "image/png"),
"chip3d-xup" => (format!("{mpn}-3d-iso-xup.png"), "image/png"),
"chip3d-xdown" => (format!("{mpn}-3d-iso-xdown.png"), "image/png"),
"chip3d-ydown" => (format!("{mpn}-3d-iso-ydown.png"), "image/png"),
"chip3d-zdown" => (format!("{mpn}-3d-iso-zdown.png"), "image/png"),
// Large variants
"chip3d-lg" => (format!("{mpn}-3d-iso-lg.png"), "image/png"),
"chip3d-yup-lg" => (format!("{mpn}-3d-iso-yup-lg.png"), "image/png"),
"chip3d-xup-lg" => (format!("{mpn}-3d-iso-xup-lg.png"), "image/png"),
"chip3d-xdown-lg" => (format!("{mpn}-3d-iso-xdown-lg.png"), "image/png"),
"chip3d-ydown-lg" => (format!("{mpn}-3d-iso-ydown-lg.png"), "image/png"),
"chip3d-zdown-lg" => (format!("{mpn}-3d-iso-zdown-lg.png"), "image/png"),
_ => return not_found(),
};
let path: PathBuf = crate::library::root().join(mpn).join(filename);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => return not_found(),
};
let h = Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap();
let cc = Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=60"[..]).unwrap();
Response::from_data(bytes).with_header(h).with_header(cc)
}
fn serve_asset(rel: &str) -> Response<std::io::Cursor<Vec<u8>>> {
if rel.contains("..") { return not_found(); }
let parts: Vec<&str> = rel.splitn(2, '/').collect();
if parts.len() != 2 { return not_found(); }
let path: PathBuf = crate::library::root().join(parts[0]).join(parts[1]);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(_) => return not_found(),
};
let ct = guess_content_type(&path);
let h = Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap();
Response::from_data(bytes).with_header(h)
}
fn guess_content_type(path: &PathBuf) -> &'static str {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
match ext.as_str() {
"pdf" => "application/pdf",
"step" | "stp" => "application/STEP",
"json" => "application/json",
"kicad_mod" | "kicad_sym" => "text/plain; charset=utf-8",
"html" => "text/html; charset=utf-8",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
_ => "application/octet-stream",
}
}
fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string("not found").with_status_code(404)
}
/// Serve `<MPN>-footprint.authoritative.svg` from library/<MPN>/. ds2sf
/// v0.5.1+ writes this; the dashboard renders it inline as the canonical
/// 2D footprint reference next to the chip-thumbnailer 3D thumbs.
fn serve_authoritative_svg(mpn: &str) -> Response<std::io::Cursor<Vec<u8>>> {
if mpn.contains("..") || mpn.contains('/') {
return not_found();
}
let path = crate::library::root().join(mpn).join(format!("{mpn}-footprint.authoritative.svg"));
match std::fs::read(&path) {
Ok(bytes) => Response::from_data(bytes)
.with_header("Content-Type: image/svg+xml".parse::<tiny_http::Header>().unwrap())
.with_status_code(200),
Err(_) => not_found(),
}
}
/// Spawn `chip-fetcher <args>` as a detached subprocess so the dashboard's
/// click returns immediately while the action runs in the background. The
/// dashboard polls `/api/library` to see the new state. Activity heartbeats
/// from the subprocess will surface mid-run progress automatically.
fn spawn_subprocess_action(
label: &str,
args: &[&str],
mpn: &str,
) -> Response<std::io::Cursor<Vec<u8>>> {
use std::process::{Command, Stdio};
// Self-invoke by absolute path — name-independent so the rename to
// adom-chip-fetcher (or any future rename) can't break it.
let exe = std::env::current_exe().unwrap_or_else(|_| "adom-chip-fetcher".into());
let mut cmd = Command::new(exe);
cmd.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null());
match cmd.spawn() {
Ok(_child) => {
let _ = crate::hints::heartbeat(mpn, &format!("dashboard kicked: {label}"));
json_response(format!(
"{{\"ok\":true,\"action\":\"{label}\",\"mpn\":{:?},\"hint\":\"polling /api/library shows new state\"}}",
mpn
))
}
Err(e) => error_response(500, format!("{{\"error\":\"could not spawn chip-fetcher {label}: {e}\"}}")),
}
}
pub fn open_in_hydrogen(port: u16) -> Result<()> {
let url = format!("http://127.0.0.1:{port}");
println!("opening {url} in Hydrogen webview");
let status = std::process::Command::new("adom-cli")
.args(["workspace", "tab-open", "--url", &url, "--title", "adom-chip-fetcher"])
.status()
.context("running adom-cli workspace tab-open")?;
if !status.success() {
anyhow::bail!("adom-cli workspace tab-open returned non-zero exit");
}
Ok(())
}