- Rewrite generateDeviceManifest() to read DESC_IP sheet from
PLC Data Generator/{PROJECT}/{MCM}_DESC_IP_MERGED.xlsx
- Rewrite generateProjectManifest() to discover projects from same files
- Key devices-manifest by {PROJECT}_{MCM} to avoid cross-project collisions
- Add fill.paint binding for standalone photoeye path elements
- Add fill.paint binding for button circle sub-elements
- Add s_str_ prefix to path/rect element names for consistency
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
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<string, { excelPath: string; pdfPath: string | null }>();
|
|
|
|
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<string, { id: string; svg: string; zone: string }[]> = {};
|
|
|
|
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<string>();
|
|
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(),
|
|
],
|
|
});
|