app
adom-schematic-viewer
Public Made by Adomby adom
Interactive schematic viewer: your EDA's own render plus per-symbol highlighting and live MPN, stock and price on hover
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
//! adom-schematic-viewer live app server — the embeddable live schematic view.
//! GET / (UI), /viewer.js, /favicon.svg, /state, POST /load (.kicad_sch),
//! GET /enrich, POST /shutdown. Same app-creator shape as the layout viewer.
use anyhow::Result;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};
#[derive(Default, Clone)]
pub struct AppState { pub name: String, pub svg: String, pub meta_json: String }
static STATE: Mutex<Option<AppState>> = Mutex::new(None);
static ENRICH_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
static ENRICH_PENDING: Mutex<Option<HashSet<String>>> = Mutex::new(None);
const APP_HTML: &str = include_str!("app.html");
const VIEWER_JS: &str = include_str!("viewer.js");
const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#00e6dc" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="1.5"/><path d="M7 9h3v6H7zM14 12h3M14 12l-2-2M14 12l-2 2"/></svg>"##;
pub fn set_state(s: AppState) { *STATE.lock().unwrap() = Some(s); }
pub fn load_sch(name: &str, sch: &str) -> Result<()> {
let (svg, meta) = crate::kicad_sch::render_sheet(sch)?;
let lcscs: Vec<(String, String)> = meta.components.iter()
.filter(|c| !c.lcsc.is_empty() || !c.value.is_empty())
.map(|c| (c.value.clone(), c.lcsc.clone())).collect();
set_state(AppState { name: name.into(), svg, meta_json: serde_json::to_string(&meta)? });
if !lcscs.is_empty() {
std::thread::spawn(move || { for (v, l) in lcscs { let _ = enrich_fill(&v, &l); } });
}
Ok(())
}
fn enrich_fill(value: &str, lcsc: &str) -> String {
let key = format!("{value}|{lcsc}");
let out = crate::enrich::lookup(value, lcsc);
if let Some(m) = ENRICH_CACHE.lock().unwrap().as_mut() { m.insert(key.clone(), out.clone()); }
if let Some(p) = ENRICH_PENDING.lock().unwrap().as_mut() { p.remove(&key); }
out
}
fn state_json() -> String {
match &*STATE.lock().unwrap() {
Some(s) => format!(r#"{{"name":{},"svg":{},"meta":{}}}"#,
serde_json::to_string(&s.name).unwrap(), serde_json::to_string(&s.svg).unwrap(),
if s.meta_json.is_empty() { "{}" } else { &s.meta_json }),
None => "{}".into(),
}
}
pub fn run(port: u16) -> Result<()> {
*ENRICH_CACHE.lock().unwrap() = Some(HashMap::new());
*ENRICH_PENDING.lock().unwrap() = Some(HashSet::new());
let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;
eprintln!("[adom-schematic-viewer] serving on http://127.0.0.1:{port}");
register_port(port);
for mut req in server.incoming_requests() {
let raw = req.url().to_string();
let url = raw.split('?').next().unwrap_or("/").to_string();
let query = raw.splitn(2, '?').nth(1).unwrap_or("").to_string();
let method = req.method().clone();
let mut body = String::new();
if matches!(method, Method::Post) { let _ = req.as_reader().read_to_string(&mut body); }
let resp = route(&method, &url, &query, &body);
let _ = req.respond(resp);
if url == "/shutdown" && matches!(method, Method::Post) { break; }
}
unregister_port(port);
Ok(())
}
fn route(method: &Method, url: &str, query: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
match (method, url) {
(Method::Get, "/") | (Method::Get, "/index.html") => resp(APP_HTML, "text/html; charset=utf-8"),
(Method::Get, "/viewer.js") => resp(VIEWER_JS, "application/javascript"),
(Method::Get, "/favicon.svg") => resp(FAVICON, "image/svg+xml"),
(Method::Get, "/state") => json_resp(&state_json()),
(Method::Get, "/enrich") => json_resp(&enrich(query)),
(Method::Post, "/load") => {
if let Ok(v) = serde_json::from_str::<Value>(body) {
if let Some(sch) = v.get("kicadSch").and_then(|x| x.as_str()) {
let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("sheet").to_string();
return match load_sch(&name, sch) { Ok(()) => json_resp("{\"ok\":true}"), Err(e) => json_err(422, &e.to_string()) };
}
}
json_resp("{\"ok\":true}")
}
(Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),
_ => not_found(),
}
}
fn enrich(query: &str) -> String {
let params = parse_query(query);
let value = params.get("mpn").cloned().unwrap_or_default();
let lcsc = params.get("lcsc").cloned().unwrap_or_default();
if value.is_empty() && lcsc.is_empty() { return "{}".into(); }
let key = format!("{value}|{lcsc}");
if let Some(hit) = ENRICH_CACHE.lock().unwrap().as_ref().and_then(|m| m.get(&key).cloned()) { return hit; }
let already = { let mut g = ENRICH_PENDING.lock().unwrap(); let set = g.get_or_insert_with(Default::default); !set.insert(key.clone()) };
if !already { std::thread::spawn(move || { let _ = enrich_fill(&value, &lcsc); }); }
"{\"pending\":true}".into()
}
fn parse_query(q: &str) -> HashMap<String, String> {
q.split('&').filter_map(|kv| { let mut it = kv.splitn(2, '='); Some((it.next()?.to_string(), urldecode(it.next().unwrap_or("")))) }).collect()
}
fn urldecode(s: &str) -> String {
let b = s.as_bytes(); let mut out = String::new(); let mut i = 0;
while i < b.len() {
match b[i] {
b'%' if i + 2 < b.len() => { if let Ok(c) = u8::from_str_radix(&s[i + 1..i + 3], 16) { out.push(c as char); i += 3; } else { out.push('%'); i += 1; } }
b'+' => { out.push(' '); i += 1; }
c => { out.push(c as char); i += 1; }
}
}
out
}
fn register_port(port: u16) {
let body = format!("{{\"port\":{port},\"action\":\"register\",\"name\":\"adom-schematic-viewer\",\"reason\":\"Interactive schematic view\",\"expose\":\"internal\"}}");
std::thread::spawn(move || hd_post(&body));
}
fn unregister_port(port: u16) { hd_post(&format!("{{\"port\":{port},\"action\":\"unregister\"}}")); }
fn hd_post(body: &str) {
use std::io::Write;
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
let addr = match ("host.docker.internal", 47084u16).to_socket_addrs().ok().and_then(|mut a| a.next()) { Some(a) => a, None => return };
if let Ok(mut s) = TcpStream::connect_timeout(&addr, Duration::from_millis(800)) {
let _ = s.set_write_timeout(Some(Duration::from_millis(800)));
let req = format!("POST /port-forward HTTP/1.1\r\nHost: host.docker.internal\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body);
let _ = s.write_all(req.as_bytes());
}
}
fn hdr(ct: &str) -> Header { Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap() }
fn resp(s: &str, ct: &str) -> Response<std::io::Cursor<Vec<u8>>> { Response::from_string(s).with_header(hdr(ct)) }
fn json_resp(s: &str) -> Response<std::io::Cursor<Vec<u8>>> { Response::from_string(s).with_header(hdr("application/json")) }
fn not_found() -> Response<std::io::Cursor<Vec<u8>>> { Response::from_string("{\"error\":\"not found\"}").with_status_code(404).with_header(hdr("application/json")) }
fn json_err(code: u16, msg: &str) -> Response<std::io::Cursor<Vec<u8>>> { Response::from_string(json!({ "ok": false, "error": msg }).to_string()).with_status_code(code).with_header(hdr("application/json")) }