Initial commit — wiring diagram maker with full feature set
- FastAPI + SQLite backend with routers for diagrams, devices, wires, bundles, connectors, export, and Octopart/Nexar search - Konva.js canvas: ortho/direct/curved routing, wire crossings, harness mode, snap-to-grid, multi-select, drag, resize, undo - Wire features: color/stripe, twisted pair helix, shielded glow, gauge size label, multi-core bundle grouping, length/unit tracking - Device library: connector, terminal block, cable, splice, label, group, relay, fuse, switch, component with custom connector support - Exports: BOM CSV, assembly TXT, JSON, formboard layout, PNG image - Auto-migration for schema changes via ALTER TABLE at startup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import os
|
||||
import time
|
||||
from typing import Tuple
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
router = APIRouter(tags=["octopart"])
|
||||
|
||||
NEXAR_TOKEN_URL = "https://identity.nexar.com/connect/token"
|
||||
NEXAR_API_URL = "https://api.nexar.com/graphql"
|
||||
|
||||
_token_cache: dict = {"token": None, "expires_at": 0.0}
|
||||
|
||||
_SEARCH_GQL = """
|
||||
query PartSearch($q: String!, $limit: Int!) {
|
||||
supSearchMpn(q: $q, limit: $limit) {
|
||||
results {
|
||||
part {
|
||||
mpn
|
||||
manufacturer { name }
|
||||
shortDescription
|
||||
bestDatasheet { url }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _credentials() -> Tuple[str, str]:
|
||||
return (
|
||||
os.getenv("NEXAR_CLIENT_ID", ""),
|
||||
os.getenv("NEXAR_CLIENT_SECRET", ""),
|
||||
)
|
||||
|
||||
|
||||
async def _get_token() -> str:
|
||||
client_id, client_secret = _credentials()
|
||||
if not client_id or not client_secret:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Nexar/Octopart credentials not configured — set NEXAR_CLIENT_ID and NEXAR_CLIENT_SECRET environment variables",
|
||||
)
|
||||
|
||||
now = time.time()
|
||||
if _token_cache["token"] and now < _token_cache["expires_at"] - 60:
|
||||
return _token_cache["token"] # type: ignore[return-value]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
NEXAR_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail=f"Nexar auth failed: {resp.text[:200]}")
|
||||
|
||||
body = resp.json()
|
||||
_token_cache["token"] = body["access_token"]
|
||||
_token_cache["expires_at"] = now + body.get("expires_in", 3600)
|
||||
return _token_cache["token"] # type: ignore[return-value]
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def octopart_status():
|
||||
client_id, client_secret = _credentials()
|
||||
return {"configured": bool(client_id and client_secret)}
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def octopart_search(
|
||||
q: str = Query(..., min_length=1),
|
||||
limit: int = Query(8, ge=1, le=20),
|
||||
):
|
||||
token = await _get_token()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
NEXAR_API_URL,
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
json={"query": _SEARCH_GQL, "variables": {"q": q, "limit": limit}},
|
||||
timeout=12,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail=f"Nexar API error {resp.status_code}")
|
||||
|
||||
body = resp.json()
|
||||
if "errors" in body:
|
||||
raise HTTPException(status_code=502, detail=str(body["errors"])[:300])
|
||||
|
||||
results = body.get("data", {}).get("supSearchMpn", {}).get("results", [])
|
||||
out = []
|
||||
for r in results:
|
||||
part = r.get("part")
|
||||
if not part:
|
||||
continue
|
||||
out.append({
|
||||
"mpn": part.get("mpn", ""),
|
||||
"manufacturer": (part.get("manufacturer") or {}).get("name", ""),
|
||||
"description": part.get("shortDescription") or "",
|
||||
"datasheet_url": (part.get("bestDatasheet") or {}).get("url"),
|
||||
})
|
||||
return out
|
||||
Reference in New Issue
Block a user