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:
2026-04-24 12:51:21 -04:00
commit f32203b630
26 changed files with 5667 additions and 0 deletions
View File
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
import os
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "wiring.db")
DATABASE_URL = f"sqlite:///{os.path.normpath(DB_PATH)}"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+90
View File
@@ -0,0 +1,90 @@
import os
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text, inspect as sa_inspect
from .database import Base, engine, SessionLocal
from .routers import bundles, connectors, diagrams, devices, export, octopart, wires
Base.metadata.create_all(bind=engine)
# Lightweight column migrations (SQLite ALTER TABLE IF NOT EXISTS unsupported)
def _migrate():
import sys
# ── Column additions ────────────────────────────────────────────────────────
try:
with engine.connect() as conn:
def _cols(table):
return {c["name"] for c in sa_inspect(engine).get_columns(table)}
if "waypoints" not in _cols("wires"):
conn.execute(text("ALTER TABLE wires ADD COLUMN waypoints TEXT DEFAULT '[]'"))
conn.commit()
if "sheet_id" not in _cols("devices"):
conn.execute(text("ALTER TABLE devices ADD COLUMN sheet_id INTEGER REFERENCES sheets(id)"))
conn.commit()
if "sheet_id" not in _cols("wires"):
conn.execute(text("ALTER TABLE wires ADD COLUMN sheet_id INTEGER REFERENCES sheets(id)"))
conn.commit()
if "twisted_pair" not in _cols("wires"):
conn.execute(text("ALTER TABLE wires ADD COLUMN twisted_pair INTEGER DEFAULT 0"))
conn.commit()
if "twist_pitch" not in _cols("wires"):
conn.execute(text("ALTER TABLE wires ADD COLUMN twist_pitch REAL DEFAULT 16.0"))
conn.commit()
if "shielded" not in _cols("wires"):
conn.execute(text("ALTER TABLE wires ADD COLUMN shielded INTEGER DEFAULT 0"))
conn.commit()
if "bundle_id" not in _cols("wires"):
conn.execute(text("ALTER TABLE wires ADD COLUMN bundle_id INTEGER REFERENCES wire_bundles(id)"))
conn.commit()
if "show_size_label" not in _cols("wires"):
conn.execute(text("ALTER TABLE wires ADD COLUMN show_size_label INTEGER DEFAULT 0"))
conn.commit()
except Exception as e:
print(f"[migrate] column error: {e}", file=sys.stderr)
_migrate()
app = FastAPI(title="Wiring Diagram Maker", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(bundles.router, prefix="/api/bundles")
app.include_router(diagrams.router, prefix="/api/diagrams")
app.include_router(devices.router, prefix="/api/devices")
app.include_router(wires.router, prefix="/api/wires")
app.include_router(octopart.router, prefix="/api/octopart")
app.include_router(export.router, prefix="/api/export")
app.include_router(connectors.router, prefix="/api/connectors")
FRONTEND_DIR = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..", "frontend")
)
if os.path.isdir(FRONTEND_DIR):
for _sub in ("css", "js"):
_d = os.path.join(FRONTEND_DIR, _sub)
if os.path.isdir(_d):
app.mount(f"/{_sub}", StaticFiles(directory=_d), name=_sub)
_index = os.path.join(FRONTEND_DIR, "index.html")
@app.get("/")
async def _root():
return FileResponse(_index)
@app.exception_handler(404)
async def _spa_fallback(request: Request, exc):
if request.url.path.startswith("/api"):
return JSONResponse(status_code=404, content={"detail": "Not Found"})
return FileResponse(_index)
+103
View File
@@ -0,0 +1,103 @@
from sqlalchemy import Column, Integer, String, Float, JSON, ForeignKey, DateTime, Text, Boolean
from sqlalchemy.orm import relationship
from .database import Base
import datetime
class Diagram(Base):
__tablename__ = "diagrams"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, default="Untitled Diagram")
description = Column(Text, default="")
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow)
sheets = relationship("Sheet", back_populates="diagram", cascade="all, delete-orphan", order_by="Sheet.position")
devices = relationship("Device", back_populates="diagram", cascade="all, delete-orphan")
wires = relationship("Wire", back_populates="diagram", cascade="all, delete-orphan")
wire_bundles = relationship("WireBundle", back_populates="diagram", cascade="all, delete-orphan")
class WireBundle(Base):
__tablename__ = "wire_bundles"
id = Column(Integer, primary_key=True, index=True)
diagram_id = Column(Integer, ForeignKey("diagrams.id"), nullable=False)
label = Column(String, default="")
jacket_color = Column(String, default="#2a2a2a")
diagram = relationship("Diagram", back_populates="wire_bundles")
wires = relationship("Wire", back_populates="bundle")
class Sheet(Base):
__tablename__ = "sheets"
id = Column(Integer, primary_key=True, index=True)
diagram_id = Column(Integer, ForeignKey("diagrams.id", ondelete="CASCADE"), nullable=False)
name = Column(String, nullable=False, default="Sheet 1")
position = Column(Integer, default=0)
diagram = relationship("Diagram", back_populates="sheets")
devices = relationship("Device", back_populates="sheet", cascade="all, delete-orphan")
wires = relationship("Wire", back_populates="sheet", cascade="all, delete-orphan")
class Device(Base):
__tablename__ = "devices"
id = Column(Integer, primary_key=True, index=True)
diagram_id = Column(Integer, ForeignKey("diagrams.id"), nullable=False)
sheet_id = Column(Integer, ForeignKey("sheets.id", ondelete="CASCADE"), nullable=True)
device_type = Column(String, nullable=False)
label = Column(String, default="")
reference = Column(String, default="")
x = Column(Float, default=100)
y = Column(Float, default=100)
width = Column(Float, default=120)
height = Column(Float, default=60)
properties = Column(JSON, default={})
pins = Column(JSON, default=[])
diagram = relationship("Diagram", back_populates="devices")
sheet = relationship("Sheet", back_populates="devices")
class Wire(Base):
__tablename__ = "wires"
id = Column(Integer, primary_key=True, index=True)
diagram_id = Column(Integer, ForeignKey("diagrams.id"), nullable=False)
sheet_id = Column(Integer, ForeignKey("sheets.id", ondelete="CASCADE"), nullable=True)
label = Column(String, default="")
from_device_id = Column(Integer, ForeignKey("devices.id"), nullable=True)
from_pin = Column(String, default="")
to_device_id = Column(Integer, ForeignKey("devices.id"), nullable=True)
to_pin = Column(String, default="")
color_primary = Column(String, default="#FF0000")
color_stripe = Column(String, nullable=True)
gauge = Column(String, default="18 AWG")
length = Column(Float, nullable=True)
length_unit = Column(String, default="in")
notes = Column(Text, default="")
waypoints = Column(JSON, default=[])
twisted_pair = Column(Boolean, default=False)
twist_pitch = Column(Float, default=16.0)
shielded = Column(Boolean, default=False)
bundle_id = Column(Integer, ForeignKey("wire_bundles.id"), nullable=True)
show_size_label = Column(Boolean, default=False)
diagram = relationship("Diagram", back_populates="wires")
sheet = relationship("Sheet", back_populates="wires")
bundle = relationship("WireBundle", back_populates="wires", foreign_keys=[bundle_id])
class CustomConnector(Base):
__tablename__ = "custom_connectors"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
manufacturer = Column(String, default="")
part_number = Column(String, default="")
category = Column(String, default="Custom")
description = Column(String, default="")
pin_count = Column(Integer, nullable=False, default=2)
pin_labels = Column(JSON, default=[])
created_at = Column(DateTime, default=datetime.datetime.utcnow)
updated_at = Column(DateTime, default=datetime.datetime.utcnow)
View File
+45
View File
@@ -0,0 +1,45 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
router = APIRouter(tags=["bundles"])
@router.get("/diagram/{diagram_id}", response_model=List[schemas.WireBundleOut])
def list_bundles(diagram_id: int, db: Session = Depends(get_db)):
return db.query(models.WireBundle).filter(models.WireBundle.diagram_id == diagram_id).all()
@router.post("/", response_model=schemas.WireBundleOut, status_code=201)
def create_bundle(payload: schemas.WireBundleCreate, db: Session = Depends(get_db)):
bundle = models.WireBundle(**payload.model_dump())
db.add(bundle)
db.commit()
db.refresh(bundle)
return bundle
@router.patch("/{bundle_id}", response_model=schemas.WireBundleOut)
def update_bundle(bundle_id: int, payload: schemas.WireBundleUpdate, db: Session = Depends(get_db)):
bundle = db.get(models.WireBundle, bundle_id)
if not bundle:
raise HTTPException(404, "Bundle not found")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(bundle, k, v)
db.commit()
db.refresh(bundle)
return bundle
@router.delete("/{bundle_id}", status_code=204)
def delete_bundle(bundle_id: int, db: Session = Depends(get_db)):
bundle = db.get(models.WireBundle, bundle_id)
if not bundle:
raise HTTPException(404, "Bundle not found")
# Detach wires before deleting
for wire in bundle.wires:
wire.bundle_id = None
db.delete(bundle)
db.commit()
+47
View File
@@ -0,0 +1,47 @@
import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
router = APIRouter(tags=["connectors"])
@router.get("/", response_model=List[schemas.CustomConnector])
def list_connectors(db: Session = Depends(get_db)):
return db.query(models.CustomConnector).order_by(models.CustomConnector.category, models.CustomConnector.name).all()
@router.post("/", response_model=schemas.CustomConnector)
def create_connector(connector: schemas.CustomConnectorCreate, db: Session = Depends(get_db)):
db_conn = models.CustomConnector(**connector.model_dump())
db.add(db_conn)
db.commit()
db.refresh(db_conn)
return db_conn
@router.patch("/{connector_id}", response_model=schemas.CustomConnector)
def update_connector(connector_id: int, update: schemas.CustomConnectorUpdate, db: Session = Depends(get_db)):
conn = db.query(models.CustomConnector).filter(models.CustomConnector.id == connector_id).first()
if not conn:
raise HTTPException(status_code=404, detail="Connector not found")
for key, value in update.model_dump(exclude_none=True).items():
setattr(conn, key, value)
conn.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(conn)
return conn
@router.delete("/{connector_id}")
def delete_connector(connector_id: int, db: Session = Depends(get_db)):
conn = db.query(models.CustomConnector).filter(models.CustomConnector.id == connector_id).first()
if not conn:
raise HTTPException(status_code=404, detail="Connector not found")
db.delete(conn)
db.commit()
return {"ok": True}
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
import datetime
router = APIRouter(tags=["devices"])
@router.post("/", response_model=schemas.Device)
def create_device(device: schemas.DeviceCreate, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == device.diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
db_device = models.Device(**device.model_dump())
db.add(db_device)
diagram.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(db_device)
return db_device
@router.patch("/{device_id}", response_model=schemas.Device)
def update_device(device_id: int, update: schemas.DeviceUpdate, db: Session = Depends(get_db)):
device = db.query(models.Device).filter(models.Device.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="Device not found")
for key, value in update.model_dump(exclude_none=True).items():
setattr(device, key, value)
diagram = db.query(models.Diagram).filter(models.Diagram.id == device.diagram_id).first()
if diagram:
diagram.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(device)
return device
@router.delete("/{device_id}")
def delete_device(device_id: int, db: Session = Depends(get_db)):
device = db.query(models.Device).filter(models.Device.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="Device not found")
db.delete(device)
db.commit()
return {"ok": True}
+53
View File
@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
from .. import models, schemas
from ..database import get_db
import datetime
router = APIRouter(tags=["diagrams"])
@router.get("/", response_model=List[schemas.DiagramSummary])
def list_diagrams(db: Session = Depends(get_db)):
return db.query(models.Diagram).order_by(models.Diagram.updated_at.desc()).all()
@router.post("/", response_model=schemas.DiagramSummary)
def create_diagram(diagram: schemas.DiagramCreate, db: Session = Depends(get_db)):
db_diagram = models.Diagram(**diagram.model_dump())
db.add(db_diagram)
db.commit()
db.refresh(db_diagram)
return db_diagram
@router.get("/{diagram_id}", response_model=schemas.DiagramFull)
def get_diagram(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
return diagram
@router.patch("/{diagram_id}", response_model=schemas.DiagramSummary)
def update_diagram(diagram_id: int, update: schemas.DiagramUpdate, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
for key, value in update.model_dump(exclude_none=True).items():
setattr(diagram, key, value)
diagram.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(diagram)
return diagram
@router.delete("/{diagram_id}")
def delete_diagram(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
db.delete(diagram)
db.commit()
return {"ok": True}
+316
View File
@@ -0,0 +1,316 @@
import csv
import io
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from sqlalchemy.orm import Session
from .. import models
from ..database import get_db
router = APIRouter(tags=["export"])
@router.get("/{diagram_id}/bom/csv")
def export_bom_csv(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["=== DEVICES ==="])
writer.writerow(["Reference", "Type", "Label", "Part Number", "Manufacturer", "Description", "Datasheet"])
for device in diagram.devices:
props = device.properties or {}
desc = props.get("description", "")
if device.device_type == "cable" and not desc:
desc = f"{props.get('conductorCount', len(props.get('conductors', [])))} conductors"
writer.writerow([
device.reference, device.device_type, device.label,
props.get("partNumber", ""), props.get("manufacturer", ""),
desc, props.get("datasheetUrl", ""),
])
writer.writerow([])
writer.writerow(["=== WIRES ==="])
writer.writerow(["ID", "Label", "From Device", "From Pin", "To Device", "To Pin",
"Primary Color", "Stripe Color", "Gauge", "Length", "Unit", "Shielded", "Notes"])
device_map = {d.id: d for d in diagram.devices}
for wire in diagram.wires:
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
writer.writerow([
wire.id, wire.label,
f"{fd.reference} ({fd.label})" if fd else "",
wire.from_pin,
f"{td.reference} ({td.label})" if td else "",
wire.to_pin,
wire.color_primary, wire.color_stripe or "",
wire.gauge, wire.length or "", wire.length_unit if wire.length else "",
"YES" if wire.shielded else "", wire.notes,
])
output.seek(0)
filename = f"{diagram.name.replace(' ', '_')}_BOM.csv"
return StreamingResponse(
io.BytesIO(output.getvalue().encode()),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.get("/{diagram_id}/assembly/txt")
def export_assembly_txt(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
device_map = {d.id: d for d in diagram.devices}
lines = [
f"ASSEMBLY INSTRUCTIONS: {diagram.name}",
"=" * 60, "",
]
if diagram.description:
lines += [f"Description: {diagram.description}", ""]
lines += ["STEP 1: PREPARE DEVICES", "-" * 40]
for i, d in enumerate(diagram.devices, 1):
props = d.properties or {}
lines.append(f" {i}. {d.reference or '?'}{d.label} "
f"(Type: {d.device_type}, P/N: {props.get('partNumber', 'N/A')})")
lines.append("")
# Group wires by which cable device (if any) they pass through
cable_devices = {d.id: d for d in diagram.devices if d.device_type == "cable"}
wire_cable_map = {} # wire_id -> cable_device_id
for wire in diagram.wires:
if wire.from_device_id in cable_devices or wire.to_device_id in cable_devices:
cable_id = wire.from_device_id if wire.from_device_id in cable_devices else wire.to_device_id
wire_cable_map[wire.id] = cable_id
lines += ["STEP 2: WIRE CONNECTIONS", "-" * 40]
step = 1
for wire in diagram.wires:
if wire.id in wire_cable_map:
continue # handled in cables step
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
from_str = f"{fd.reference or fd.label} Pin {wire.from_pin}" if fd else "unconnected"
to_str = f"{td.reference or td.label} Pin {wire.to_pin}" if td else "unconnected"
color_str = wire.color_primary
if wire.color_stripe:
color_str += f" / {wire.color_stripe} stripe"
length_str = f"{wire.length} {wire.length_unit}" if wire.length else "TBD"
twisted = " [TWISTED PAIR]" if wire.twisted_pair else ""
shielded = " [SHIELDED]" if wire.shielded else ""
lines += [
f" {step}. {color_str} {wire.gauge} {length_str}{twisted}{shielded}",
f" From: {from_str}",
f" To: {to_str}",
]
if wire.label:
lines.append(f" Label: {wire.label}")
if wire.notes:
lines.append(f" Notes: {wire.notes}")
lines.append("")
step += 1
if cable_devices:
lines += ["", "STEP 3: CABLE ASSEMBLIES", "-" * 40]
for cable_dev in cable_devices.values():
props = cable_dev.properties or {}
conductors = props.get("conductors", [])
pn = props.get("partNumber", "N/A")
lines += [
f" Cable: {cable_dev.reference or '?'}{cable_dev.label} (P/N: {pn}, {len(conductors)} conductors)",
]
cable_wires = [w for w in diagram.wires if wire_cable_map.get(w.id) == cable_dev.id]
for wire in cable_wires:
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
other_dev = td if wire.from_device_id == cable_dev.id else fd
other_pin = wire.to_pin if wire.from_device_id == cable_dev.id else wire.from_pin
cable_pin = wire.from_pin if wire.from_device_id == cable_dev.id else wire.to_pin
length_str = f"{wire.length} {wire.length_unit}" if wire.length else "TBD"
lines.append(
f" Pin {cable_pin} ({wire.color_primary}) → "
f"{other_dev.reference or other_dev.label if other_dev else '?'} Pin {other_pin} {length_str}"
)
lines.append("")
content = "\n".join(lines)
filename = f"{diagram.name.replace(' ', '_')}_Assembly.txt"
return StreamingResponse(
io.BytesIO(content.encode()),
media_type="text/plain",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.get("/{diagram_id}/formboard", response_class=HTMLResponse)
def export_formboard(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
device_map = {d.id: d for d in diagram.devices}
def to_mm(length, unit):
factors = {"mm": 1.0, "cm": 10.0, "in": 25.4, "ft": 304.8}
return length * factors.get(unit or "in", 25.4)
wires_with_length = []
wires_no_length = []
for wire in diagram.wires:
if wire.length and wire.length > 0:
mm = to_mm(wire.length, wire.length_unit)
wires_with_length.append((wire, mm))
else:
wires_no_length.append(wire)
wires_with_length.sort(key=lambda t: t[1], reverse=True)
def wire_label(wire):
fd = device_map.get(wire.from_device_id)
td = device_map.get(wire.to_device_id)
frm = f"{fd.reference or fd.label}/{wire.from_pin}" if fd else "?"
to = f"{td.reference or td.label}/{wire.to_pin}" if td else "?"
return f"{frm}{to}" + (f" [{wire.label}]" if wire.label else "")
def stripe_style(wire):
if wire.color_stripe:
c1, c2 = wire.color_primary or "#cc0000", wire.color_stripe
return f"background: repeating-linear-gradient(45deg, {c1}, {c1} 4mm, {c2} 4mm, {c2} 8mm);"
return f"background: {wire.color_primary or '#cc0000'};"
rows_html = ""
for wire, mm in wires_with_length:
badges = ""
if wire.twisted_pair:
badges += '<span class="badge tw">TWISTED</span>'
if wire.shielded:
badges += '<span class="badge sh">SHIELDED</span>'
txt_color = "#000" if (wire.color_primary or "#000") in ("#FFD700","#DDDDDD","#FF8C00","#dddddd") else "#fff"
rows_html += f"""
<tr>
<td class="lbl">{wire_label(wire)}</td>
<td class="gauge">{wire.gauge or ""}</td>
<td class="len-val">{wire.length:.1f} {wire.length_unit or "in"} ({mm:.0f} mm)</td>
<td class="bar-cell">
<div class="wire-bar" style="width:{mm:.1f}mm;{stripe_style(wire)}color:{txt_color}">
<span class="bar-text">{mm:.0f} mm</span>
</div>
</td>
<td>{badges}</td>
</tr>"""
no_len_rows = ""
for wire in wires_no_length:
no_len_rows += f"<li>{wire_label(wire)}{wire.gauge or 'gauge?'}</li>"
no_len_section = ""
if wires_no_length:
no_len_section = f"""
<h2>Wires without length</h2>
<ul class="no-len">{no_len_rows}</ul>"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Formboard — {diagram.name}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: sans-serif; font-size: 11pt; padding: 10mm; background: #fff; color: #111; }}
h1 {{ font-size: 16pt; margin-bottom: 4mm; }}
.meta {{ font-size: 9pt; color: #555; margin-bottom: 6mm; }}
.scale-bar-wrap {{ margin-bottom: 8mm; }}
.scale-bar {{ display: inline-block; width: 100mm; height: 5mm; background: #333; }}
.scale-bar-lbl {{ font-size: 9pt; color: #555; margin-left: 3mm; }}
table {{ border-collapse: collapse; width: 100%; margin-bottom: 8mm; }}
th {{ text-align: left; font-size: 9pt; color: #555; border-bottom: 0.5mm solid #ccc; padding: 1mm 2mm; }}
td {{ padding: 1.5mm 2mm; vertical-align: middle; border-bottom: 0.2mm solid #e8e8e8; }}
.lbl {{ font-size: 9pt; max-width: 60mm; word-break: break-all; }}
.gauge {{ font-size: 9pt; white-space: nowrap; }}
.len-val {{ font-size: 9pt; white-space: nowrap; }}
.bar-cell {{ width: 99%; }}
.wire-bar {{
height: 5mm; min-width: 5mm; border-radius: 1mm;
display: flex; align-items: center; padding: 0 2mm;
print-color-adjust: exact; -webkit-print-color-adjust: exact;
}}
.bar-text {{ font-size: 7pt; white-space: nowrap; }}
.badge {{ font-size: 7pt; padding: 0.5mm 1.5mm; border-radius: 1mm; margin-left: 1mm; white-space: nowrap; }}
.tw {{ background: #d0e8ff; color: #003; }}
.sh {{ background: #e8e8e8; color: #333; }}
.no-len {{ font-size: 9pt; padding-left: 5mm; margin-bottom: 6mm; }}
.no-len li {{ margin-bottom: 1mm; }}
h2 {{ font-size: 12pt; margin: 6mm 0 3mm; }}
@media print {{
body {{ padding: 5mm; }}
.no-print {{ display: none; }}
}}
</style>
</head>
<body>
<h1>📐 Formboard — {diagram.name}</h1>
<p class="meta">Wire bars are printed at true 1:1 scale. Print at 100% (no scaling) for accurate lengths.</p>
<div class="scale-bar-wrap no-print">
<strong>100 mm scale reference:</strong><br>
<div class="scale-bar"></div><span class="scale-bar-lbl">← this bar is exactly 100 mm when printed at 100%</span>
</div>
<table>
<thead><tr>
<th>Wire</th><th>Gauge</th><th>Length</th><th>Scale (1:1)</th><th>Flags</th>
</tr></thead>
<tbody>{rows_html}</tbody>
</table>
{no_len_section}
<p class="meta no-print" style="margin-top:8mm">Print → More settings → Scale: 100% → No headers/footers</p>
</body>
</html>"""
return HTMLResponse(content=html)
@router.get("/{diagram_id}/json")
def export_json(diagram_id: int, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
data = {
"id": diagram.id,
"name": diagram.name,
"description": diagram.description,
"devices": [
{
"id": d.id, "device_type": d.device_type, "label": d.label,
"reference": d.reference, "x": d.x, "y": d.y,
"width": d.width, "height": d.height,
"properties": d.properties, "pins": d.pins,
}
for d in diagram.devices
],
"wires": [
{
"id": w.id, "label": w.label,
"from_device_id": w.from_device_id, "from_pin": w.from_pin,
"to_device_id": w.to_device_id, "to_pin": w.to_pin,
"color_primary": w.color_primary, "color_stripe": w.color_stripe,
"gauge": w.gauge, "length": w.length, "length_unit": w.length_unit,
"notes": w.notes,
}
for w in diagram.wires
],
}
filename = f"{diagram.name.replace(' ', '_')}.json"
return StreamingResponse(
io.BytesIO(json.dumps(data, indent=2).encode()),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
+109
View File
@@ -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
+51
View File
@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
router = APIRouter(tags=["sheets"])
@router.post("/", response_model=schemas.SheetOut)
def create_sheet(sheet: schemas.SheetCreate, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == sheet.diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
db_sheet = models.Sheet(**sheet.model_dump())
db.add(db_sheet)
db.commit()
db.refresh(db_sheet)
return db_sheet
@router.get("/{sheet_id}", response_model=schemas.SheetOut)
def get_sheet(sheet_id: int, db: Session = Depends(get_db)):
sheet = db.query(models.Sheet).filter(models.Sheet.id == sheet_id).first()
if not sheet:
raise HTTPException(status_code=404, detail="Sheet not found")
return sheet
@router.patch("/{sheet_id}", response_model=schemas.SheetOut)
def update_sheet(sheet_id: int, update: schemas.SheetUpdate, db: Session = Depends(get_db)):
sheet = db.query(models.Sheet).filter(models.Sheet.id == sheet_id).first()
if not sheet:
raise HTTPException(status_code=404, detail="Sheet not found")
for key, value in update.model_dump(exclude_none=True).items():
setattr(sheet, key, value)
db.commit()
db.refresh(sheet)
return sheet
@router.delete("/{sheet_id}")
def delete_sheet(sheet_id: int, db: Session = Depends(get_db)):
sheet = db.query(models.Sheet).filter(models.Sheet.id == sheet_id).first()
if not sheet:
raise HTTPException(status_code=404, detail="Sheet not found")
sheet_count = db.query(models.Sheet).filter(models.Sheet.diagram_id == sheet.diagram_id).count()
if sheet_count <= 1:
raise HTTPException(status_code=400, detail="Cannot delete the last sheet")
db.delete(sheet)
db.commit()
return {"ok": True}
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
import datetime
router = APIRouter(tags=["wires"])
@router.post("/", response_model=schemas.Wire)
def create_wire(wire: schemas.WireCreate, db: Session = Depends(get_db)):
diagram = db.query(models.Diagram).filter(models.Diagram.id == wire.diagram_id).first()
if not diagram:
raise HTTPException(status_code=404, detail="Diagram not found")
db_wire = models.Wire(**wire.model_dump())
db.add(db_wire)
diagram.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(db_wire)
return db_wire
@router.patch("/{wire_id}", response_model=schemas.Wire)
def update_wire(wire_id: int, update: schemas.WireUpdate, db: Session = Depends(get_db)):
wire = db.query(models.Wire).filter(models.Wire.id == wire_id).first()
if not wire:
raise HTTPException(status_code=404, detail="Wire not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(wire, key, value)
diagram = db.query(models.Diagram).filter(models.Diagram.id == wire.diagram_id).first()
if diagram:
diagram.updated_at = datetime.datetime.utcnow()
db.commit()
db.refresh(wire)
return wire
@router.delete("/{wire_id}")
def delete_wire(wire_id: int, db: Session = Depends(get_db)):
wire = db.query(models.Wire).filter(models.Wire.id == wire_id).first()
if not wire:
raise HTTPException(status_code=404, detail="Wire not found")
db.delete(wire)
db.commit()
return {"ok": True}
+180
View File
@@ -0,0 +1,180 @@
from pydantic import BaseModel, field_validator
from typing import Optional, List, Any, Dict
from datetime import datetime
class DeviceBase(BaseModel):
device_type: str
label: str = ""
reference: str = ""
x: float = 100
y: float = 100
width: float = 120
height: float = 60
properties: Dict[str, Any] = {}
pins: List[Dict[str, Any]] = []
class DeviceCreate(DeviceBase):
diagram_id: int
class DeviceUpdate(BaseModel):
label: Optional[str] = None
reference: Optional[str] = None
x: Optional[float] = None
y: Optional[float] = None
width: Optional[float] = None
height: Optional[float] = None
properties: Optional[Dict[str, Any]] = None
pins: Optional[List[Dict[str, Any]]] = None
class Device(DeviceBase):
id: int
diagram_id: int
model_config = {"from_attributes": True}
class WireBase(BaseModel):
label: str = ""
from_device_id: Optional[int] = None
from_pin: str = ""
to_device_id: Optional[int] = None
to_pin: str = ""
color_primary: str = "#FF0000"
color_stripe: Optional[str] = None
gauge: str = "18 AWG"
length: Optional[float] = None
length_unit: str = "in"
notes: str = ""
waypoints: List[Dict[str, Any]] = []
twisted_pair: bool = False
twist_pitch: float = 16.0
shielded: bool = False
bundle_id: Optional[int] = None
show_size_label: bool = False
@field_validator("waypoints", mode="before")
@classmethod
def _coerce_waypoints(cls, v):
return v if v is not None else []
class WireCreate(WireBase):
diagram_id: int
class WireUpdate(BaseModel):
label: Optional[str] = None
from_device_id: Optional[int] = None
from_pin: Optional[str] = None
to_device_id: Optional[int] = None
to_pin: Optional[str] = None
color_primary: Optional[str] = None
color_stripe: Optional[str] = None
gauge: Optional[str] = None
length: Optional[float] = None
length_unit: Optional[str] = None
notes: Optional[str] = None
waypoints: Optional[List[Dict[str, Any]]] = None
twisted_pair: Optional[bool] = None
twist_pitch: Optional[float] = None
shielded: Optional[bool] = None
bundle_id: Optional[int] = None
show_size_label: Optional[bool] = None
class Wire(WireBase):
id: int
diagram_id: int
model_config = {"from_attributes": True}
# ── WireBundle ────────────────────────────────────────────────────────────────
class WireBundleBase(BaseModel):
label: str = ""
jacket_color: str = "#2a2a2a"
class WireBundleCreate(WireBundleBase):
diagram_id: int
class WireBundleUpdate(BaseModel):
label: Optional[str] = None
jacket_color: Optional[str] = None
class WireBundleOut(WireBundleBase):
id: int
diagram_id: int
model_config = {"from_attributes": True}
# ── Diagram ───────────────────────────────────────────────────────────────────
class DiagramBase(BaseModel):
name: str = "Untitled Diagram"
description: str = ""
class DiagramCreate(DiagramBase):
pass
class DiagramUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class DiagramSummary(DiagramBase):
id: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class DiagramFull(DiagramSummary):
devices: List[Device] = []
wires: List[Wire] = []
wire_bundles: List[WireBundleOut] = []
# ── CustomConnector ───────────────────────────────────────────────────────────
class CustomConnectorBase(BaseModel):
name: str
manufacturer: str = ""
part_number: str = ""
category: str = "Custom"
description: str = ""
pin_count: int
pin_labels: List[str] = []
class CustomConnectorCreate(CustomConnectorBase):
pass
class CustomConnectorUpdate(BaseModel):
name: Optional[str] = None
manufacturer: Optional[str] = None
part_number: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
pin_count: Optional[int] = None
pin_labels: Optional[List[str]] = None
class CustomConnector(CustomConnectorBase):
id: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}