← Commit history

Sync current source (open-source, up to date)

John Lauer ·9382b2ba17 ·3mo ago ·parent 57ffa42
6 files changed +694
docs/screenshot-kicad.pngadded
⋯ 1 unchanged line ⋯
docs/screenshot-viewer.pngadded
⋯ 1 unchanged line ⋯
service/README.mdadded+20
@@ -0,0 +1,20 @@+# adom-symbol service++Node.js HTTP service for symbol creation, preview, lint, validation, and delivery.++**Port:** 8781++## Endpoints++| Method | Path | Description |+|---|---|---|+| POST | /sym/create | Create symbol from structured pin data |+| POST | /sym/preview | Generate/refresh viewer |+| POST | /sym/lint | Lint symbol |+| POST | /sym/validate | Validate symbol folder |+| POST | /sym/deliver | Deliver to KiCad/Fusion/Altium |+| GET | /sym/samples | List sample symbols |+| GET | /viewer/:name | Serve interactive viewer |+| GET | /health | Service health |+| POST | /cmd/push | AI-drivable UI command |+| GET | /cmd/poll | Poll pending UI commands |
service/cache/Device--R.kicad_symadded+128
@@ -0,0 +1,128 @@+(kicad_symbol_lib+  (version 20231120)+  (generator "kicad-service")+  (symbol "R"+    (pin_numbers+     (hide yes)+    )+    (pin_names+     (offset 0)+    )+    (exclude_from_sim no)+    (in_bom yes)+    (on_board yes)+    (property "Reference" "R"+     (at 2.032 0 90)+     (effects+      (font+       (size 1.27 1.27)+      )+     )+    )+    (property "Value" "R"+     (at 0 0 90)+     (effects+      (font+       (size 1.27 1.27)+      )+     )+    )+    (property "Footprint" ""+     (at -1.778 0 90)+     (effects+      (font+       (size 1.27 1.27)+      )+      (hide yes)+     )+    )+    (property "Datasheet" "~"+     (at 0 0 0)+     (effects+      (font+       (size 1.27 1.27)+      )+      (hide yes)+     )+    )+    (property "Description" "Resistor"+     (at 0 0 0)+     (effects+      (font+       (size 1.27 1.27)+      )+      (hide yes)+     )+    )+    (property "ki_keywords" "R res resistor"+     (at 0 0 0)+     (effects+      (font+       (size 1.27 1.27)+      )+      (hide yes)+     )+    )+    (property "ki_fp_filters" "R_*"+     (at 0 0 0)+     (effects+      (font+       (size 1.27 1.27)+      )+      (hide yes)+     )+    )+    (symbol "R_0_1"+     (rectangle+      (start -1.016 -2.54)+      (end 1.016 2.54)+      (stroke+       (width 0.254)+       (type default)+      )+      (fill+       (type none)+      )+     )+    )+    (symbol "R_1_1"+     (pin passive line+      (at 0 3.81 270)+      (length 1.27)+      (name "~"+       (effects+        (font+         (size 1.27 1.27)+        )+       )+      )+      (number "1"+       (effects+        (font+         (size 1.27 1.27)+        )+       )+      )+     )+     (pin passive line+      (at 0 -3.81 90)+      (length 1.27)+      (name "~"+       (effects+        (font+         (size 1.27 1.27)+        )+       )+      )+      (number "2"+       (effects+        (font+         (size 1.27 1.27)+        )+       )+      )+     )+    )+    (embedded_fonts no)+   )+)
service/server.jsadded+536
@@ -0,0 +1,536 @@+/**+ * Symbol Creator HTTP API Service+ *+ * Provides a structured API for creating KiCad/Fusion 360 schematic symbols.+ * Handles .kicad_sym generation, SVG export, branded viewer HTML, lint, validate,+ * and delivery to desktop EDA tools.+ *+ * Port: 8781+ *+ * All endpoints accept POST with JSON body.+ */++import { createServer } from 'http';+import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, readFile as readFileCb } from 'fs';+import { readFile } from 'fs/promises';+import { join, dirname } from 'path';+import { fileURLToPath } from 'url';+import { exec } from 'child_process';++const __dirname = dirname(fileURLToPath(import.meta.url));+const SAMPLES_DIR = join(__dirname, 'samples');++import { generateICSym, generateDiscreteSym, warmBaselineCache } from './lib/sym-gen.js';+import { generateBrandedViewer } from './lib/viewer-gen.js';+import { generateMetadata } from './lib/metadata-gen.js';+import { generateReadme } from './lib/readme-gen.js';+import { generateLbr } from './lib/lbr-gen.js';+import { symExportSvg, symSearch } from '../viewer/kicad-api-client.js';+import { lintKicadSymbol } from '../viewer/kicad-lint.js';+import { validateSymbolFolder } from '../viewer/kicad-validate.js';++const PORT = parseInt(process.env.PORT || '8781', 10);+const SYMBOLS_DIR = '/home/adom/project/project-content/schematics/symbols';++// In-memory cache of SymView HTML keyed by symbol name, served via GET /viewer/:name+const viewerCache = new Map();++// Build the public URL for this service using the VS Code proxy pattern+const VSCODE_PROXY_URI = process.env.VSCODE_PROXY_URI || '';++// ── Helpers ──────────────────────────────────────────────────────────++function parseBody(req) {+  return new Promise((resolve, reject) => {+    let data = '';+    req.on('data', chunk => data += chunk);+    req.on('end', () => {+      if (!data) return resolve({});+      try { resolve(JSON.parse(data)); }+      catch (e) { reject(new Error('Invalid JSON')); }+    });+    req.on('error', reject);+  });+}++function json(res, status, data) {+  res.writeHead(status, { 'Content-Type': 'application/json' });+  res.end(JSON.stringify(data));+}++async function pushToViewer(html, name) {+  // Cache the HTML so the GET /viewer/:name route can serve it+  viewerCache.set(name, html);++  // Also write it to disk at the canonical location+  const folder = join(SYMBOLS_DIR, name);+  mkdirSync(folder, { recursive: true });+  const htmlPath = join(folder, `${name}-viewer.html`);+  writeFileSync(htmlPath, html);++  // Build the proxy URL and open / refresh a Hydrogen webview tab+  if (VSCODE_PROXY_URI) {+    const base = VSCODE_PROXY_URI.replace('{{port}}', String(PORT)).replace(/\/$/, '');+    const proxyUrl = `${base}/viewer/${encodeURIComponent(name)}`;+    const cmd = `adom-cli hydrogen webview open-or-refresh --name "adom-symbol: ${name}" --url "${proxyUrl}"`;+    await new Promise((resolve) => {+      exec(cmd, (err) => {+        if (err) console.error(`[symbol-creator] hydrogen webview open-or-refresh failed: ${err.message}`);+        resolve();+      });+    });+  } else {+    console.warn('[symbol-creator] VSCODE_PROXY_URI not set — skipping Hydrogen webview open');+  }+}++function symbolDir(symbolName) {+  return join(SYMBOLS_DIR, symbolName);+}++// ── Route handlers ──────────────────────────────────────────────────++/**+ * POST /sym/create+ *+ * Create a complete symbol package from structured data.+ */+async function handleCreate(body) {+  const {+    symbolName,+    manufacturer = '',+    package: pkg = '',+    description = '',+    datasheetUrl = '',+    category = 'ic',       // 'ic' or 'discrete'+    pins = [],+    groups = [],+    discreteBaseline,      // { library, symbol } for discrete+    referencePrefix,+    pinOverrides = [],+    preferences = {},+  } = body;++  if (!symbolName) throw new Error('symbolName is required');+  if (pins.length === 0) throw new Error('pins array is required');++  const folder = symbolDir(symbolName);+  mkdirSync(folder, { recursive: true });++  const files = [];++  // 1. Generate .kicad_sym+  let symContent;+  if (category === 'discrete' && discreteBaseline) {+    symContent = await generateDiscreteSym({+      symbolName, manufacturer, package: pkg, description, datasheetUrl,+      referencePrefix, discreteBaseline, pinOverrides,+    });+  } else {+    // Determine reference prefix+    const refPrefix = referencePrefix || (category === 'discrete' ? 'D' : 'U');+    symContent = generateICSym({+      symbolName, manufacturer, package: pkg, description, datasheetUrl,+      referencePrefix: refPrefix, pins, groups, preferences,+    });+  }++  const symPath = join(folder, `${symbolName}.kicad_sym`);+  writeFileSync(symPath, symContent);+  files.push(symPath);++  // 2. Generate metadata JSON+  const metadata = generateMetadata({+    symbolName, manufacturer, package: pkg, description, datasheetUrl, pins, groups,+  });+  const metaPath = join(folder, `${symbolName}-metadata.json`);+  writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + '\n');+  files.push(metaPath);++  // 3. Generate README+  const readme = generateReadme({+    symbolName, manufacturer, package: pkg, description, datasheetUrl, pins, groups,+  });+  const readmePath = join(folder, 'README.md');+  writeFileSync(readmePath, readme);+  files.push(readmePath);++  // 4. Export SVG via KiCad service+  let svgContent;+  try {+    svgContent = await symExportSvg(symPath, symbolName);+    const svgPath = join(folder, `${symbolName}.svg`);+    writeFileSync(svgPath, svgContent);+    files.push(svgPath);+  } catch (err) {+    // SVG export failed — continue without it+    console.error(`[symbol-creator] SVG export failed: ${err.message}`);+  }++  // 5. Generate branded viewer HTML+  let viewerHtml;+  try {+    viewerHtml = await generateBrandedViewer(symPath, symbolName, {+      pinDescriptions: metadata.pinDescriptions,+      groupDescriptions: metadata.groupDescriptions,+      datasheetUrl,+      manufacturer,+    });+    const viewerPath = join(folder, `${symbolName}-viewer.html`);+    writeFileSync(viewerPath, viewerHtml);+    files.push(viewerPath);+  } catch (err) {+    console.error(`[symbol-creator] Viewer generation failed: ${err.message}`);+  }++  // 6. Run lint+  let lintResult = { passed: true, errors: [], warnings: [] };+  try {+    lintResult = await lintKicadSymbol(symPath, symbolName);+  } catch (err) {+    lintResult = { passed: false, errors: [{ check: 'lint-error', message: err.message }], warnings: [] };+  }++  // 7. Run validate+  let validateResult = { passed: true, errors: [], warnings: [] };+  try {+    validateResult = await validateSymbolFolder(folder, symbolName);+  } catch (err) {+    validateResult = { passed: false, errors: [{ check: 'validate-error', message: err.message }], warnings: [] };+  }++  // 8. Open in Hydrogen webview+  if (viewerHtml) {+    await pushToViewer(viewerHtml, symbolName);+  }++  return {+    folder,+    files,+    lint: lintResult,+    validate: validateResult,+  };+}++/**+ * POST /sym/preview+ *+ * (Re-)preview an existing symbol in a Hydrogen webview tab.+ */+async function handlePreview(body) {+  const { symbolName, folderPath } = body;+  const folder = folderPath || symbolDir(symbolName);+  if (!symbolName) throw new Error('symbolName is required');++  const symPath = join(folder, `${symbolName}.kicad_sym`);+  if (!existsSync(symPath)) throw new Error(`Symbol file not found: ${symPath}`);++  // Load metadata+  const metaPath = join(folder, `${symbolName}-metadata.json`);+  let pinDescriptions = {};+  let groupDescriptions = {};+  let datasheetUrl = '';+  let manufacturer = '';+  if (existsSync(metaPath)) {+    const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));+    pinDescriptions = meta.pinDescriptions || {};+    groupDescriptions = meta.groupDescriptions || {};+    datasheetUrl = meta.datasheetUrl || '';+    manufacturer = meta.manufacturer || '';+  }++  // Generate viewer HTML+  const viewerHtml = await generateBrandedViewer(symPath, symbolName, {+    pinDescriptions,+    groupDescriptions,+    datasheetUrl,+    manufacturer,+  });++  // Save viewer HTML+  const viewerPath = join(folder, `${symbolName}-viewer.html`);+  writeFileSync(viewerPath, viewerHtml);++  // Push to viewer+  await pushToViewer(viewerHtml, symbolName);++  return { folder, viewerPath, sent: true };+}++/**+ * POST /sym/lint+ */+async function handleLint(body) {+  const { symbolName, folderPath } = body;+  const folder = folderPath || symbolDir(symbolName);+  if (!symbolName) throw new Error('symbolName is required');++  const symPath = join(folder, `${symbolName}.kicad_sym`);+  return lintKicadSymbol(symPath, symbolName);+}++/**+ * POST /sym/validate+ */+async function handleValidate(body) {+  const { symbolName, folderPath } = body;+  const folder = folderPath || symbolDir(symbolName);+  if (!symbolName) throw new Error('symbolName is required');++  return validateSymbolFolder(folder, symbolName);+}++/**+ * POST /sym/search+ */+async function handleSearch(body) {+  const { query, limit = 20 } = body;+  if (!query) throw new Error('query is required');++  return symSearch(query, limit);+}++/**+ * POST /sym/deliver+ */+async function handleDeliver(body) {+  const { symbolName, target = 'kicad', folderPath } = body;+  const folder = folderPath || symbolDir(symbolName);+  if (!symbolName) throw new Error('symbolName is required');++  const symPath = join(folder, `${symbolName}.kicad_sym`);+  if (!existsSync(symPath)) throw new Error(`Symbol file not found: ${symPath}`);++  // Final lint + validate+  const lintResult = await lintKicadSymbol(symPath, symbolName);+  const validateResult = await validateSymbolFolder(folder, symbolName);++  if (!lintResult.passed || !validateResult.passed) {+    return {+      success: false,+      reason: 'lint or validation failed',+      lint: lintResult,+      validate: validateResult,+    };+  }++  if (target === 'fusion360') {+    // Generate .lbr+    const content = readFileSync(symPath, 'utf-8');+    const metaPath = join(folder, `${symbolName}-metadata.json`);+    const meta = existsSync(metaPath) ? JSON.parse(readFileSync(metaPath, 'utf-8')) : {};++    // Parse pins from the .kicad_sym for LBR conversion+    const pinRegex = /\(pin\s+(\w+)\s+\w+\s+\(at\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\)\s+\(length\s+([-\d.]+)\)\s+\(name\s+"([^"]*)"[^)]*\)\s+\(number\s+"([^"]*)"[^)]*\)\)/g;+    const lbrPins = [];+    let m;+    while ((m = pinRegex.exec(content)) !== null) {+      lbrPins.push({+        type: m[1], x: parseFloat(m[2]), y: parseFloat(m[3]),+        angle: parseInt(m[4]), length: parseFloat(m[5]),+        name: m[6], number: m[7],+      });+    }++    // Parse body rectangle+    const rectMatch = content.match(/\(rectangle\s+\(start\s+([-\d.]+)\s+([-\d.]+)\)\s+\(end\s+([-\d.]+)\s+([-\d.]+)\)/);+    const body = rectMatch ? {+      left: parseFloat(rectMatch[1]), top: parseFloat(rectMatch[2]),+      right: parseFloat(rectMatch[3]), bottom: parseFloat(rectMatch[4]),+    } : null;++    const lbrContent = generateLbr({+      symbolName,+      description: meta.description || '',+      referencePrefix: 'U',+      pins: lbrPins,+      body,+    });++    const lbrPath = join(folder, `${symbolName}.lbr`);+    writeFileSync(lbrPath, lbrContent);++    return {+      success: true,+      target: 'fusion360',+      lbrPath,+      lint: lintResult,+      validate: validateResult,+    };+  }++  // KiCad delivery — return the file path for Conduit+  return {+    success: true,+    target: 'kicad',+    symPath,+    lint: lintResult,+    validate: validateResult,+  };+}++/**+ * GET /sym/samples+ *+ * List available sample symbols bundled with the service.+ */+function handleSamplesList() {+  if (!existsSync(SAMPLES_DIR)) return [];+  const entries = readdirSync(SAMPLES_DIR, { withFileTypes: true });+  return entries+    .filter(e => e.isDirectory() && existsSync(join(SAMPLES_DIR, e.name, `${e.name}.kicad_sym`)))+    .map(e => {+      const metaPath = join(SAMPLES_DIR, e.name, `${e.name}-metadata.json`);+      let description = '';+      let manufacturer = '';+      if (existsSync(metaPath)) {+        const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));+        description = meta.description || '';+        manufacturer = meta.manufacturer || '';+      }+      return { name: e.name, description, manufacturer };+    });+}++/**+ * POST /sym/sample+ *+ * Preview a sample symbol. Generates the viewer HTML from the bundled+ * sample artifacts and opens it in a Hydrogen webview tab.+ */+async function handleSample(body) {+  const { symbolName } = body;+  if (!symbolName) throw new Error('symbolName is required');++  const sampleFolder = join(SAMPLES_DIR, symbolName);+  const symPath = join(sampleFolder, `${symbolName}.kicad_sym`);+  if (!existsSync(symPath)) {+    const available = handleSamplesList().map(s => s.name);+    throw new Error(`Sample "${symbolName}" not found. Available: ${available.join(', ')}`);+  }++  // Load metadata+  const metaPath = join(sampleFolder, `${symbolName}-metadata.json`);+  let pinDescriptions = {};+  let groupDescriptions = {};+  let datasheetUrl = '';+  let manufacturer = '';+  if (existsSync(metaPath)) {+    const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));+    pinDescriptions = meta.pinDescriptions || {};+    groupDescriptions = meta.groupDescriptions || {};+    datasheetUrl = meta.datasheetUrl || '';+    manufacturer = meta.manufacturer || '';+  }++  // Generate viewer HTML from sample files+  const viewerHtml = await generateBrandedViewer(symPath, symbolName, {+    pinDescriptions,+    groupDescriptions,+    datasheetUrl,+    manufacturer,+  });++  // Push to viewer+  await pushToViewer(viewerHtml, `${symbolName} (sample)`);++  return { symbolName, folder: sampleFolder, sent: true };+}++// ── HTTP Server ──────────────────────────────────────────────────────++const routes = {+  '/sym/create': handleCreate,+  '/sym/preview': handlePreview,+  '/sym/lint': handleLint,+  '/sym/validate': handleValidate,+  '/sym/search': handleSearch,+  '/sym/deliver': handleDeliver,+  '/sym/sample': handleSample,+};++const server = createServer(async (req, res) => {+  // CORS+  res.setHeader('Access-Control-Allow-Origin', '*');+  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');+  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');++  if (req.method === 'OPTIONS') {+    res.writeHead(204);+    res.end();+    return;+  }++  // Health check+  if (req.url === '/health' || req.url === '/') {+    return json(res, 200, {+      ok: true,+      service: 'symbol-creator',+      version: '0.2.0',+      uptime: Math.floor(process.uptime()),+    });+  }++  // GET /sym/samples — list available sample symbols+  if (req.url === '/sym/samples' && req.method === 'GET') {+    return json(res, 200, handleSamplesList());+  }++  // GET /viewer/:name — serve cached SymView HTML for a symbol+  const viewerMatch = req.url.match(/^\/viewer\/([^/?]+)(\?.*)?$/);+  if (viewerMatch && req.method === 'GET') {+    const name = decodeURIComponent(viewerMatch[1]);+    const cached = viewerCache.get(name);+    if (cached) {+      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });+      res.end(cached);+      return;+    }+    // Fall back to reading from disk if not in memory cache+    const htmlPath = join(SYMBOLS_DIR, name, `${name}-viewer.html`);+    if (existsSync(htmlPath)) {+      const html = readFileSync(htmlPath, 'utf-8');+      viewerCache.set(name, html);+      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });+      res.end(html);+      return;+    }+    return json(res, 404, { error: `No viewer found for symbol: ${name}` });+  }++  // Route dispatch+  const handler = routes[req.url];+  if (!handler) {+    return json(res, 404, { error: `Unknown route: ${req.url}` });+  }++  if (req.method !== 'POST') {+    return json(res, 405, { error: 'POST required' });+  }++  try {+    const body = await parseBody(req);+    const result = await handler(body);+    json(res, 200, result);+  } catch (err) {+    console.error(`[symbol-creator] ${req.url} error:`, err);+    json(res, 500, { error: err.message });+  }+});++server.listen(PORT, '0.0.0.0', () => {+  console.log(`[symbol-creator] HTTP API listening on 0.0.0.0:${PORT}`);+  console.log(`[symbol-creator] Symbols directory: ${SYMBOLS_DIR}`);+  // Pre-warm baseline cache in background (non-blocking)+  warmBaselineCache().then(stats => {+    console.log(`[symbol-creator] Baseline cache: ${stats.cached} cached, ${stats.fetched} fetched, ${stats.failed} failed (${stats.total} total)`);+  }).catch(err => {+    console.error(`[symbol-creator] Cache warm failed: ${err.message}`);+  });+});++process.on('SIGINT', () => {+  console.log('[symbol-creator] Shutting down...');+  server.close();+  process.exit(0);+});
service/service.jsonadded+10
@@ -0,0 +1,10 @@+{+  "name": "symbol-creator",+  "service": "local",+  "description": "Symbol Creator — generates KiCad symbols, branded viewers, and all artifacts",+  "port": 8781,+  "health": "http",+  "start": "node server.js",+  "cwd": ".",+  "log": "/tmp/symbol-creator.log"+}