app
adom-layout-viewer
Public Made by Adomby adom
Interactive PCB layout viewer: your EDA's own render plus live net/trace highlighting and per-pad stock, price and wiki lookup
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
//! Component enrichment: MPN → { price, stock, tier, popularity, wiki_url }.
//!
//! Sources (sibling Adom CLIs, shelled with a hard timeout so a slow/absent CLI
//! never hangs the UI):
//! • popularity + wiki page — `adom-wiki discover search --query <mpn> --json`
//! (vouch_count / star_count → popularity; owner/slug → wiki_url)
//! • price + stock — `adom-parts-search search <mpn>` (max stock across
//! vendors; first real unit price; JLCPCB tier from flags)
//!
//! Because the vendor lookup is slow (~30 s, live APIs), the server PRE-WARMS the
//! cache for every board MPN in a background thread at load time; hover then hits
//! the cache. `lookup()` is the (slow) cache-fill; `/enrich` reads the result.
use serde_json::{json, Value};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
/// Run a command, capping wall-clock at `secs`. Returns stdout on success.
fn run_capped(bin: &str, args: &[&str], secs: u64) -> Option<String> {
let bin = bin.to_string();
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let out = Command::new(&bin).args(&args).stdin(Stdio::null()).output();
let _ = tx.send(out.ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).into_owned()));
});
rx.recv_timeout(Duration::from_secs(secs)).ok().flatten()
}
/// Strip any leading non-JSON preamble (parts-search prints an "OK: …" summary
/// line before its JSON) and parse.
fn parse_json_tail(s: &str) -> Option<Value> {
let start = s.find(['{', '['])?;
serde_json::from_str(&s[start..]).ok()
}
/// The slow path: hit both CLIs and assemble the enrichment record as a JSON
/// string. Safe to call off-thread; degrades field-by-field.
pub fn lookup(mpn: &str, _lcsc: &str) -> String {
let mut out = json!({});
let o = out.as_object_mut().unwrap();
// ── wiki page + popularity (vouches/score) ──
if let Some(txt) = run_capped("adom-wiki", &["discover", "search", "--query", mpn, "--limit", "5", "--json"], 12) {
if let Some(v) = parse_json_tail(&txt) {
if let Some(results) = v.get("data").and_then(|d| d.get("results")).and_then(|r| r.as_array()) {
// prefer an exact (case-insensitive) title/slug match, else the first component
let pick = results.iter().find(|r| r.get("title").and_then(|t| t.as_str()).map(|t| t.eq_ignore_ascii_case(mpn)).unwrap_or(false))
.or_else(|| results.iter().find(|r| r.get("type").and_then(|t| t.as_str()) == Some("component")))
.or_else(|| results.first());
if let Some(r) = pick {
let owner = r.get("owner").and_then(|x| x.as_str()).unwrap_or("");
let slug = r.get("slug").and_then(|x| x.as_str()).unwrap_or("");
if !slug.is_empty() {
o.insert("wiki_url".into(), json!(format!("https://wiki.adom.inc/{owner}/{slug}")));
}
let vouch = r.get("vouch_count").and_then(|x| x.as_i64()).unwrap_or(0);
let stars = r.get("star_count").and_then(|x| x.as_i64()).unwrap_or(0);
let installs = r.get("installs_30d").and_then(|x| x.as_i64()).unwrap_or(0);
o.insert("popularity".into(), json!(vouch.max(stars).max(installs)));
}
}
}
}
// ── price + stock (parts-search across vendors) ──
if let Some(txt) = run_capped("adom-parts-search", &["search", mpn, "--limit", "3"], 40) {
if let Some(v) = parse_json_tail(&txt) {
let mut best_stock: i64 = 0;
let mut price: Option<String> = None;
let mut tier: Option<String> = None;
for vendor in ["mouser", "digikey", "jlcpcb"] {
if let Some(hits) = v.get(vendor).and_then(|m| m.get("hits")).and_then(|h| h.as_array()) {
for h in hits {
best_stock = best_stock.max(h.get("stock").and_then(|s| s.as_i64()).unwrap_or(0));
if price.is_none() {
let up = h.get("unit_price").and_then(|p| p.as_f64())
.or_else(|| h.get("price_100").and_then(|p| p.as_f64()));
if let Some(p) = up { price = Some(format!("${:.3}", p)); }
}
if vendor == "jlcpcb" && tier.is_none() {
if let Some(flags) = h.get("flags").and_then(|f| f.as_array()) {
for f in flags {
if let Some(fs) = f.as_str() {
if ["Basic", "Preferred", "Extended"].contains(&fs) { tier = Some(fs.to_string()); }
}
}
}
}
}
}
}
o.insert("stock".into(), json!(best_stock));
if let Some(p) = price { o.insert("price".into(), json!(p)); }
if let Some(t) = tier { o.insert("tier".into(), json!(t)); }
}
}
if o.is_empty() { return "{\"error\":\"no data\"}".into(); }
out.to_string()
}