import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; import fs from 'fs'; import path from 'path'; // @ts-ignore — xlsx has no type declarations import XLSX from 'xlsx'; /** Detect WSL environment and return the correct Ignition base path. */ function getIgnitionBasePath(): string { const winPath = 'C:/Program Files/Inductive Automation/Ignition/data/projects'; try { const procVersion = fs.readFileSync('/proc/version', 'utf-8').toLowerCase(); if (procVersion.includes('microsoft')) { return '/mnt/c/Program Files/Inductive Automation/Ignition/data/projects'; } } catch { /* not Linux / no /proc/version */ } return winPath; } /** Return the PowerShell executable name (WSL needs .exe suffix). */ function getPowerShellCmd(): string { try { const procVersion = fs.readFileSync('/proc/version', 'utf-8').toLowerCase(); if (procVersion.includes('microsoft')) return 'powershell.exe'; } catch { /* not Linux */ } return 'powershell'; } /** Resolve the PLC Data Generator directory (two levels up from svelte-app/) */ function getPlcDataGenDir(): string { return path.resolve('..', '..', 'PLC Data Generator'); } /** Regex to extract project and MCM from DESC_IP_MERGED filenames */ const DESC_IP_REGEX = /^([A-Z0-9]+)_(MCM\d+)_DESC_IP_MERGED\.xlsx$/; function generateProjectManifest() { const staticDir = path.resolve('static'); const projectesDir = path.join(staticDir, 'projectes'); if (!fs.existsSync(projectesDir)) fs.mkdirSync(projectesDir, { recursive: true }); const plcDataGenDir = getPlcDataGenDir(); if (!fs.existsSync(plcDataGenDir)) return; const projects: { name: string; mcms: { name: string; excelPath: string; pdfPath: string | null }[] }[] = []; for (const projectName of fs.readdirSync(plcDataGenDir)) { const projectDir = path.join(plcDataGenDir, projectName); if (!fs.statSync(projectDir).isDirectory()) continue; // Find DESC_IP_MERGED files in this project directory const mergedFiles = fs.readdirSync(projectDir).filter((f: string) => DESC_IP_REGEX.test(f)); if (mergedFiles.length === 0) continue; const mcmMap = new Map(); for (const f of mergedFiles) { const match = f.match(DESC_IP_REGEX); if (!match) continue; const mcm = match[2]; mcmMap.set(mcm, { excelPath: path.join(projectDir, f), pdfPath: null }); } // Check for PDFs in static/projectes/{PROJECT}/pdf/ (if they exist) const pdfDir = path.join(projectesDir, projectName, 'pdf'); if (fs.existsSync(pdfDir)) { const PDF_MCM_REGEX = /SYSDL[_ ]+(MCM\d+)/i; for (const f of fs.readdirSync(pdfDir)) { if (!f.endsWith('.pdf')) continue; const match = f.match(PDF_MCM_REGEX); if (!match) continue; const mcm = match[1]; if (mcmMap.has(mcm)) { mcmMap.get(mcm)!.pdfPath = `/projectes/${projectName}/pdf/${f}`; } } } if (mcmMap.size > 0) { const mcms = [...mcmMap.entries()] .map(([name, paths]) => ({ name, ...paths })) .sort((a, b) => a.name.localeCompare(b.name)); projects.push({ name: projectName, mcms }); } } projects.sort((a, b) => a.name.localeCompare(b.name)); fs.writeFileSync( path.join(projectesDir, 'manifest.json'), JSON.stringify(projects, null, 2) ); } /** Classify a device name to a symbol ID, or null to skip */ function classifyDevice(dev: string): string | null { // Skip if (/ENSH|ENW/.test(dev)) return null; if (/VFD_DI?SC/.test(dev)) return null; if (/_LT$/.test(dev)) return null; if (/EPC\d+_\d+/.test(dev)) return null; if (/BCN\d+_[HBWG]$/.test(dev)) return null; if (dev === 'SPARE') return null; if (/ESTOP/.test(dev)) return null; if (/SCALE/.test(dev)) return null; if (/_STO\d*$/.test(dev)) return null; if (/PRX\d*$/.test(dev)) return null; if (/LRPE/.test(dev)) return null; // Match if (/EPC\d*$/.test(dev)) return 'epc'; if (/[TLJF]PE\d*$/.test(dev)) return 'photoeye'; if (/BDS\d+_[RS]$/.test(dev)) return 'photoeye'; if (/TS\d+_[RS]$/.test(dev)) return 'photoeye'; if (/JR\d*_PB$/.test(dev)) return 'jam_reset'; if (/SS\d+_S[TP]*PB$/.test(dev)) return 'start_stop'; if (/S\d+_PB$/.test(dev) && !/^SS/.test(dev)) return 'start'; if (/EN\d+_PB$/.test(dev)) return 'chute_enable'; if (/PR\d+_PB$/.test(dev)) return 'package_release'; if (/SOL\d*$/.test(dev)) return 'solenoid'; if (/DIV\d+_LS/.test(dev)) return 'diverter'; if (/BCN\d*$/.test(dev) || /BCN\d+_[AR]$/.test(dev)) return 'beacon'; if (/^PDP\d+_CB/.test(dev)) return 'pdp'; if (/^PDP\d+_PMM/.test(dev)) return 'pdp'; if (/FIO[HM]?\d*$/.test(dev) || /SIO\d*$/.test(dev)) return 'fio_sio_fioh'; if (/PS\d*$/.test(dev) && !/^PS\d/.test(dev)) return 'pressure_sensor'; return null; } /** Extract zone prefix from device name, e.g. "NCP1_1_TPE1" -> "NCP1_1" */ function extractZone(dev: string): string { // PDP devices: PDP04_CB1 -> PDP04 const pdpM = dev.match(/^(PDP\d+)/); if (pdpM) return pdpM[1]; // Standard: ZONE_NUM_REST const m = dev.match(/^([A-Z]+\d*_\d+[A-Z]?)/); return m ? m[1] : 'OTHER'; } function generateDeviceManifest() { const plcDataGenDir = getPlcDataGenDir(); const staticDir = path.resolve('static', 'projectes'); if (!fs.existsSync(plcDataGenDir)) return; if (!fs.existsSync(staticDir)) fs.mkdirSync(staticDir, { recursive: true }); const manifest: Record = {}; for (const projectName of fs.readdirSync(plcDataGenDir)) { const projectDir = path.join(plcDataGenDir, projectName); if (!fs.statSync(projectDir).isDirectory()) continue; const mergedFiles = fs.readdirSync(projectDir).filter((f: string) => DESC_IP_REGEX.test(f)); for (const fileName of mergedFiles) { const fileMatch = fileName.match(DESC_IP_REGEX); if (!fileMatch) continue; const mcm = fileMatch[2]; // e.g. MCM09 let wb: XLSX.WorkBook; try { wb = XLSX.readFile(path.join(projectDir, fileName)); } catch { continue; } const ws = wb.Sheets['DESC_IP']; if (!ws) continue; const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) as string[][]; if (rows.length < 2) continue; // Column indices: TAGNAME=0, DESCA=2, DPM=6, DEVICE_TYPE=9 const COL_TAGNAME = 0; const COL_DESCA = 2; const COL_DPM = 6; const COL_DEVICE_TYPE = 9; const seen = new Set(); const devices: { id: string; svg: string; zone: string }[] = []; for (let i = 1; i < rows.length; i++) { const tagname = String(rows[i][COL_TAGNAME] || '').trim(); const desca = String(rows[i][COL_DESCA] || '').trim(); const dpm = String(rows[i][COL_DPM] || '').trim(); const deviceType = String(rows[i][COL_DEVICE_TYPE] || '').trim(); // Extract VFDs from TAGNAME where DEVICE_TYPE=APF if (deviceType === 'APF' && tagname && !seen.has(tagname)) { seen.add(tagname); devices.push({ id: tagname, svg: 'conveyor', zone: extractZone(tagname) }); } // Extract FIOMs from TAGNAME where DEVICE_TYPE=FIOM if (deviceType === 'FIOM' && tagname && !seen.has(tagname)) { seen.add(tagname); devices.push({ id: tagname, svg: 'fio_sio_fioh', zone: extractZone(tagname) }); } // Extract DPMs from DPM column (only actual DPM names, not part numbers) if (dpm && /DPM\d*$/i.test(dpm) && !seen.has(dpm)) { seen.add(dpm); devices.push({ id: dpm, svg: 'dpm', zone: extractZone(dpm) }); } // Extract individual devices from DESCA column if (!desca || desca === 'SPARE' || seen.has(desca)) continue; seen.add(desca); const svg = classifyDevice(desca); if (!svg) continue; const zone = extractZone(desca); // Consolidate PDP: only add once per PDP number if (svg === 'pdp') { const pdpId = zone; // e.g. PDP04 if (devices.some(d => d.id === pdpId)) continue; devices.push({ id: pdpId, svg, zone }); continue; } // Consolidate beacon: BCN1_A + BCN1_H -> one BCN1 per zone if (svg === 'beacon') { const bcnBase = desca.replace(/_[ARHBWG]$/, ''); if (devices.some(d => d.id === bcnBase)) continue; devices.push({ id: bcnBase, svg, zone }); continue; } devices.push({ id: desca, svg, zone }); } // Always include the MCM symbol itself if (!devices.some(d => d.id === mcm)) { devices.push({ id: mcm, svg: 'mcm', zone: mcm }); } // Sort by zone then id devices.sort((a, b) => a.zone.localeCompare(b.zone) || a.id.localeCompare(b.id)); manifest[`${projectName}_${mcm}`] = devices; } } fs.writeFileSync( path.join(staticDir, 'devices-manifest.json'), JSON.stringify(manifest, null, 2) ); } export default defineConfig({ plugins: [ { name: 'generate-project-manifest', buildStart() { generateProjectManifest(); generateDeviceManifest(); }, configureServer(server) { generateProjectManifest(); generateDeviceManifest(); // Re-generate on file changes in static/projectes server.watcher.add(path.resolve('static/projectes')); server.watcher.on('add', (p) => { if (p.includes('projectes') && !p.endsWith('manifest.json')) { generateProjectManifest(); } }); }, }, { name: 'ignition-deploy', configureServer(server) { server.middlewares.use('/api/deploy-ignition', async (req, res) => { if (req.method !== 'POST') { res.statusCode = 405; res.end('Method not allowed'); return; } let body = ''; req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); req.on('end', () => { try { const { projectName, viewName, viewPath, viewJson, resourceJson } = JSON.parse(body); const ignitionBase = getIgnitionBasePath(); const viewSubPath = viewPath ? `${viewPath}/${viewName}` : viewName; const viewDir = path.join(ignitionBase, projectName, 'com.inductiveautomation.perspective/views', viewSubPath); fs.mkdirSync(viewDir, { recursive: true }); fs.writeFileSync(path.join(viewDir, 'view.json'), viewJson); fs.writeFileSync(path.join(viewDir, 'resource.json'), resourceJson); // Write deploy payload for gateway message handler to pick up const deployPayload = path.join(viewDir, '.deploy-pending'); fs.writeFileSync(deployPayload, JSON.stringify({ projectName, viewName })); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ ok: true, path: viewDir })); } catch (err: any) { res.statusCode = 500; res.end(JSON.stringify({ error: err.message })); } }); }); server.middlewares.use('/api/ignition-scan', async (req, res) => { if (req.method !== 'POST') { res.statusCode = 405; res.end('Method not allowed'); return; } let body = ''; req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); req.on('end', () => { try { const { projectName } = JSON.parse(body); const { execSync } = require('child_process'); // Use PowerShell to call Ignition's sendRequest via the gateway's Jython // This triggers the "scanProject" message handler we set up in Designer const script = ` $body = '{"messageType":"scanProject","payload":{},"scope":"G","project":"${projectName}"}' try { Invoke-WebRequest -Uri 'http://localhost:8088/system/gateway-scripts' -Method POST -Body $body -ContentType 'application/json' -UseBasicParsing -TimeoutSec 5 } catch {} `; try { execSync(`${getPowerShellCmd()} -Command "${script.replace(/\n/g, ' ')}"`, { timeout: 10000 }); } catch {} res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ ok: true })); } catch (err: any) { res.statusCode = 500; res.end(JSON.stringify({ error: err.message })); } }); }); }, }, sveltekit(), ], });