app
Adom Chip Fetcher
Public Made by Adomby adom
Your whole parts library — manufacturer-grade chip CAD (symbol, footprint, 3D) one tap from your EDA tool.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
// Sourcing-ladder enforcement, programmatic.
//
// Single source of truth for:
// * the canonical priority order (Mfr → SnapMagic → Mouser → DigiKey →
// Arrow → CSE/UL → LCSC), used by `chip-fetcher sources` and the
// first-heartbeat HINT and the `next-source` subcommand.
// * MPN → manufacturer + canonical product-search URL, used by
// `chip-fetcher mfr-probe`.
// * fetched_via_chain helpers — reading, appending, querying which
// ladder step has been tried for a given MPN.
// * stage-label heuristics — parse a heartbeat stage string for
// source names so the chain auto-updates without the AI writing it.
//
// All of this lives in code (not skill prose) because the agent doesn't
// reliably remember rules. The binary remembers; the agent queries.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
/// One step in the sourcing ladder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Source {
/// Adom Wiki — reuse an already-published, Adom-vetted CAD bundle. FIRST on
/// the ladder: if the part exists on wiki.adom.inc we don't re-fetch from
/// external vendors at all.
AdomWiki,
Manufacturer,
SnapMagic,
Mouser,
DigiKey,
Arrow,
CseUl,
Lcsc,
}
impl Source {
pub fn as_slug(&self) -> &'static str {
match self {
Source::AdomWiki => "adom-wiki",
Source::Manufacturer => "manufacturer",
Source::SnapMagic => "snapmagic",
Source::Mouser => "mouser",
Source::DigiKey => "digikey",
Source::Arrow => "arrow",
Source::CseUl => "cse_ul",
Source::Lcsc => "lcsc",
}
}
pub fn ladder() -> &'static [Source] {
&[
Source::AdomWiki,
Source::Manufacturer,
Source::SnapMagic,
Source::Mouser,
Source::DigiKey,
Source::Arrow,
Source::CseUl,
Source::Lcsc,
]
}
pub fn next_after(&self) -> Option<Source> {
let lad = Source::ladder();
let idx = lad.iter().position(|s| s == self)?;
lad.get(idx + 1).copied()
}
}
// ---------- MPN → manufacturer + search URL ----------
/// What we know (or guess) about a chip's manufacturer from its MPN.
#[derive(Debug, Clone, Serialize)]
pub struct MfrGuess {
pub manufacturer: &'static str,
pub search_url: String,
/// True when the prefix match is unambiguous; false when we fell back
/// to a generic guess. The CLI surfaces this so the AI knows whether
/// to trust it or do its own probe.
pub confident: bool,
}
/// Look up the manufacturer + canonical product-search URL for an MPN.
/// Returns `None` only when we have no signal at all (very rare for
/// real-world EE parts; passive R/C MPNs are the usual gap). Always pairs
/// the manufacturer name with a directly-openable URL — the AI's job is
/// just to navigate there, not to figure out where there is.
pub fn guess_mfr(mpn: &str) -> Option<MfrGuess> {
let m = mpn.to_ascii_uppercase();
// Each entry: (prefix, manufacturer, search-URL template with {mpn}
// placeholder; if the URL contains no placeholder it's a direct search).
// Order matters — longer prefixes first so e.g. "STM32" matches before
// "ST" does.
let table: &[(&str, &str, &str)] = &[
// MCUs / SoCs
("STM32", "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),
("LSM", "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),
("VL5", "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),
("USBLC", "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),
("ESP32", "Espressif", "https://www.espressif.com/en/search?keys=%MPN%"),
("NRF", "Nordic Semi", "https://www.nordicsemi.com/Search?Keyword=%MPN%"),
("ATSAM", "Microchip", "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),
("ATME", "Microchip", "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),
("ATTI", "Microchip", "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),
("PIC", "Microchip", "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),
("MCP", "Microchip", "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),
("R7FA", "Renesas", "https://www.renesas.com/us/en/search?keywords=%MPN%"),
("DA14", "Renesas", "https://www.renesas.com/us/en/search?keywords=%MPN%"),
("LPC", "NXP", "https://www.nxp.com/search?q=%MPN%"),
("MCXN", "NXP", "https://www.nxp.com/search?q=%MPN%"),
("PCA9", "NXP", "https://www.nxp.com/search?q=%MPN%"),
("ICE40", "Lattice", "https://www.latticesemi.com/Search?searchTerm=%MPN%"),
// Power / analog
("TPS", "Texas Instruments", "https://www.ti.com/product/%MPN%"),
("ADS", "Texas Instruments", "https://www.ti.com/product/%MPN%"),
("INA", "Texas Instruments", "https://www.ti.com/product/%MPN%"),
("DRV", "Texas Instruments", "https://www.ti.com/product/%MPN%"),
("MCF", "Texas Instruments", "https://www.ti.com/product/%MPN%"),
("BQ", "Texas Instruments", "https://www.ti.com/product/%MPN%"),
("MSP", "Texas Instruments", "https://www.ti.com/product/%MPN%"),
("LM", "Texas Instruments", "https://www.ti.com/product/%MPN%"),
("LMV", "Texas Instruments", "https://www.ti.com/product/%MPN%"),
("AD7", "Analog Devices", "https://www.analog.com/en/search.html?q=%MPN%"),
("AD8", "Analog Devices", "https://www.analog.com/en/search.html?q=%MPN%"),
("LTC", "Analog Devices", "https://www.analog.com/en/search.html?q=%MPN%"),
("MAX", "Analog Devices", "https://www.analog.com/en/search.html?q=%MPN%"),
("AP21", "Diodes Inc.", "https://www.diodes.com/search/?q=%MPN%"),
("AP22", "Diodes Inc.", "https://www.diodes.com/search/?q=%MPN%"),
("DMG", "Diodes Inc.", "https://www.diodes.com/search/?q=%MPN%"),
("DMP", "Diodes Inc.", "https://www.diodes.com/search/?q=%MPN%"),
("DMN", "Diodes Inc.", "https://www.diodes.com/search/?q=%MPN%"),
// Sensors
("BME", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"),
("BMI", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"),
("BMP", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"),
("BMA", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"),
("BMG", "Bosch Sensortec", "https://www.bosch-sensortec.com/search.html?q=%MPN%"),
// Connectors — JST. CAD is EMAIL-GATED on jst-mfg.com (see
// playbooks/jst.md). Route every JST part to the global keyword
// search, which resolves the part to its series page (where the
// STEP/IGES download buttons + agreement form live). Do NOT hard-wire
// a series id — JST has 600+ series and a prefix can't pick one.
// jst.com (USA) is Cloudflare-walled vs pup; use the native browser.
// Housing prefixes (…R-/…P-) + board/SMT header prefixes (B*B-/S*B-/
// BM*B-/SM*B-) all funnel through search.
("PHR-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("XHP-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("ZHR-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("SHR-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("EHR-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("VHR-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("PAP-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("NHR-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("GHR-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("S2B-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("S3B-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("S4B-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("B2B-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("B3B-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("B4B-", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("SM", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("BM", "JST", "https://www.jst-mfg.com/search/?q=%MPN%"),
("USB4", "GCT", "https://gct.co/search?q=%MPN%"),
("USB3", "GCT", "https://gct.co/search?q=%MPN%"),
("HR9", "HanRun", "https://www.hanrun.com/"), // chinese-only mfr
("WAGO", "WAGO", "https://www.wago.com/global/search?text=%MPN%"),
// Crystals / passives
("ABM8", "Abracon", "https://abracon.com/search?searchQuery=%MPN%"),
("ABM3", "Abracon", "https://abracon.com/search?searchQuery=%MPN%"),
("CRCW", "Vishay", "https://www.vishay.com/search/?q=%MPN%"),
("VS", "Vishay", "https://www.vishay.com/search/?q=%MPN%"),
("VLM", "Vishay", "https://www.vishay.com/search/?q=%MPN%"),
("WSL", "Vishay", "https://www.vishay.com/search/?q=%MPN%"),
// Switches
("TL3", "E-Switch", "https://www.e-switch.com/?s=%MPN%"),
];
for (prefix, mfr, url_tmpl) in table {
if m.starts_with(prefix) {
let url = url_tmpl.replace("%MPN%", &urlencoding(mpn));
return Some(MfrGuess {
manufacturer: mfr,
search_url: url,
confident: true,
});
}
}
None
}
/// Normalize a chain-source slug. Accepts legacy variants ("cse",
/// "snapeda") and maps to the canonical slug used by Source::as_slug.
/// Without this, an entry with source="cse" doesn't satisfy a check for
/// "cse_ul" and the next-source state machine wrongly thinks cse_ul
/// hasn't been tried.
fn normalize_slug(s: &str) -> String {
match s.to_ascii_lowercase().as_str() {
"cse" | "ul" | "cseul" | "ultralibrarian" => "cse_ul".into(),
"snapeda" => "snapmagic".into(),
"mfr" => "manufacturer".into(),
"adom_wiki" | "adomwiki" | "wiki" | "adom" => "adom-wiki".into(),
other => other.to_string(),
}
}
fn urlencoding(s: &str) -> String {
// Tiny URL-encoder — enough for MPN strings. Avoids a dep.
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
// ---------- fetched_via_chain helpers ----------
/// One entry in info.json.fetched_via_chain. Each represents a step on the
/// ladder we tried, with outcome.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainEntry {
pub source: String, // slug from Source::as_slug
pub outcome: String, // "OK", "empty", "404", "blocked", "ladder_exhausted", etc.
pub note: Option<String>, // free-form context: "no English DS", "Chinese-only site"
pub at: u64, // unix seconds
}
/// Read `info.json.fetched_via_chain` for an MPN. Returns empty Vec if no
/// chain has been recorded yet (fresh stub).
pub fn read_chain(mpn: &str) -> Result<Vec<ChainEntry>> {
let path = info_path(mpn);
if !path.exists() {
return Ok(vec![]);
}
let s = std::fs::read_to_string(&path)?;
let v: Value = serde_json::from_str(&s).unwrap_or(Value::Null);
Ok(parse_chain(&v))
}
fn parse_chain(v: &Value) -> Vec<ChainEntry> {
let arr = match v.get("fetched_via_chain").and_then(|c| c.as_array()) {
Some(a) => a,
None => return vec![],
};
arr.iter().filter_map(|e| {
// Two formats supported: legacy "manufacturer:st.com:OK" string,
// or the structured object below. Old entries get parsed loosely.
if let Some(s) = e.as_str() {
let parts: Vec<&str> = s.splitn(3, ':').collect();
return Some(ChainEntry {
source: parts.first().map(|x| x.to_string()).unwrap_or_default(),
outcome: parts.get(2).map(|x| x.to_string()).unwrap_or_else(|| "?".into()),
note: parts.get(1).map(|x| x.to_string()),
at: 0,
});
}
serde_json::from_value::<ChainEntry>(e.clone()).ok()
}).collect()
}
/// Return the source slug from the last chain entry (most recent attempt).
/// Used by `fetch` to derive provenance for imported files.
pub fn last_chain_source(mpn: &str) -> Option<String> {
read_chain(mpn).ok()?.last().map(|e| e.source.clone())
}
/// Append an entry to info.json.fetched_via_chain, creating the file if
/// missing. Idempotent on (source, outcome) — won't double-log the same
/// step result. Returns true if a new entry was added.
pub fn append_chain(mpn: &str, source: Source, outcome: &str, note: Option<&str>) -> Result<bool> {
let path = info_path(mpn);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut info: Value = if path.exists() {
let s = std::fs::read_to_string(&path)?;
serde_json::from_str(&s).unwrap_or_else(|_| serde_json::json!({"mpn": mpn}))
} else {
serde_json::json!({"mpn": mpn})
};
let chain = parse_chain(&info);
let dup = chain.iter().any(|e| e.source == source.as_slug() && e.outcome == outcome);
if dup {
return Ok(false);
}
let new_entry = ChainEntry {
source: source.as_slug().to_string(),
outcome: outcome.to_string(),
note: note.map(|s| s.to_string()),
at: now_secs(),
};
let arr = info.get_mut("fetched_via_chain")
.and_then(|v| v.as_array_mut());
match arr {
Some(a) => a.push(serde_json::to_value(&new_entry)?),
None => {
info["fetched_via_chain"] = serde_json::Value::Array(
chain.iter().filter_map(|e| serde_json::to_value(e).ok())
.chain(std::iter::once(serde_json::to_value(&new_entry)?))
.collect()
);
}
}
std::fs::write(&path, serde_json::to_string_pretty(&info)?)?;
Ok(true)
}
/// Return the next ladder step that hasn't been tried (or has only been
/// tried with a transient outcome). Manufacturer always comes first, even
/// if the AI thinks it knows the part.
pub fn next_source(mpn: &str) -> Result<Option<Source>> {
let chain = read_chain(mpn)?;
let tried: std::collections::HashSet<String> = chain.iter()
// Anything other than the bare "attempted" marker (= "navigated to
// the page, no outcome yet") counts as tried. Legacy free-form
// outcomes from older sessions ("Chinese-only site...", "no English
// DS") still count.
.filter(|e| e.outcome != "attempted")
.map(|e| normalize_slug(&e.source))
.collect();
for step in Source::ladder() {
if !tried.contains(step.as_slug()) {
return Ok(Some(*step));
}
}
Ok(None) // ladder fully exhausted
}
// ---------- stage-label parsing ----------
/// Parse a heartbeat stage string for source-name signals. Returns
/// (source, outcome) if we can confidently extract one. The point is to
/// auto-record the chain on every heartbeat so the AI doesn't have to
/// remember to do it explicitly.
pub fn detect_source_attempt(stage: &str) -> Option<(Source, String)> {
let s = stage.to_ascii_lowercase();
// Try to read the source out first.
let source = if s.contains("manufacturer") || s.contains("mfr ") || s.contains(" mfr,")
|| s.contains("st.com") || s.contains("ti.com") || s.contains("nxp.com")
|| s.contains("nordic") || s.contains("microchip") || s.contains("ti ")
|| s.contains(".com") && s.contains("trying ") && !s.contains("snapeda") && !s.contains("snapmagic")
&& !s.contains("mouser") && !s.contains("digikey") && !s.contains("arrow")
&& !s.contains("lcsc")
{
Source::Manufacturer
} else if s.contains("snapmagic") || s.contains("snapeda") {
Source::SnapMagic
} else if s.contains("mouser") {
Source::Mouser
} else if s.contains("digikey") {
Source::DigiKey
} else if s.contains("arrow") {
Source::Arrow
} else if s.contains("cse") || s.contains("ultra librarian") || s.contains("ultralibrarian") {
Source::CseUl
} else if s.contains("lcsc") {
Source::Lcsc
} else {
return None;
};
// Now infer outcome heuristics.
let outcome = if s.contains("imported") || s.contains("download ok") || s.contains(" ok ") {
"OK"
} else if s.contains("empty") || s.contains("no result") || s.contains("not carried") || s.contains("not_carried") {
"empty"
} else if s.contains("404") || s.contains("not found") {
"404"
} else if s.contains("blocked") || s.contains("captcha") || s.contains("cdn") {
"blocked"
} else if s.contains("trying") || s.contains("probing") || s.contains("driving") || s.contains("falling to") {
// Just a navigation event, not yet an outcome — don't pollute the chain.
return None;
} else {
"attempted"
};
Some((source, outcome.to_string()))
}
// ---------- stage state machine ----------
/// Canonical fetch stages, in expected order. Used to validate stage
/// transitions in heartbeat and warn when the AI reports "imported"
/// without ever heartbeating "downloading".
pub fn stage_phase(stage: &str) -> Option<u8> {
let s = stage.to_ascii_lowercase();
if s.contains("queued") || s.contains("starting") { return Some(0); }
if s.contains("probing") || s.contains("navigating") || s.contains("trying") || s.contains("falling to") || s.contains("driving") { return Some(1); }
if s.contains("found") || s.contains("identified") { return Some(2); }
if s.contains("downloading") || s.contains("fetching") || s.contains("polling downloads") { return Some(3); }
if s.contains("pulling") || s.contains("from desktop") { return Some(4); }
if s.contains("importing") { return Some(5); }
if s.contains("imported") || s.contains("complete") || s.contains("done") { return Some(6); }
None
}
// ---------- internal helpers ----------
fn info_path(mpn: &str) -> PathBuf {
crate::library::root().join(mpn).join("info.json")
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}