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,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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}"},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
sqlalchemy>=2.0.0
|
||||
pydantic>=2.0.0
|
||||
aiofiles>=23.0.0
|
||||
openpyxl>=3.1.0
|
||||
httpx>=0.27.0
|
||||
@@ -0,0 +1,4 @@
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("app.main:app", host="127.0.0.1", port=8000, reload=False)
|
||||
Reference in New Issue
Block a user