app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
/**
* 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);
});