diff --git a/.env.development b/.env.development index 98b5134..51f4863 100644 --- a/.env.development +++ b/.env.development @@ -6,6 +6,6 @@ NEXT_PUBLIC_USE_MOCK_BACKEND_LOOP_START=false NEXT_PUBLIC_EXPORT_STATIC=false NEXT_PUBLIC_USE_CGI=false # App-Versionsnummer -NEXT_PUBLIC_APP_VERSION=1.6.835 +NEXT_PUBLIC_APP_VERSION=1.6.836 NEXT_PUBLIC_CPL_MODE=json # json (Entwicklungsumgebung) oder jsSimulatedProd (CPL ->CGI-Interface-Simulator) oder production (CPL-> CGI-Interface Platzhalter) diff --git a/.env.production b/.env.production index a4ff04e..4792396 100644 --- a/.env.production +++ b/.env.production @@ -5,5 +5,5 @@ NEXT_PUBLIC_CPL_API_PATH=/CPL NEXT_PUBLIC_EXPORT_STATIC=true NEXT_PUBLIC_USE_CGI=true # App-Versionsnummer -NEXT_PUBLIC_APP_VERSION=1.6.835 +NEXT_PUBLIC_APP_VERSION=1.6.836 NEXT_PUBLIC_CPL_MODE=production \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c85a199..d7d47da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [1.6.836] – 2025-09-04 + +- feat: local-cpl-sim.mjs kabelueberwachung + +--- ## [1.6.835] – 2025-09-04 - feat: local-cpl-sim.mjs Einstellungen done diff --git a/package-lock.json b/package-lock.json index 4346ac4..0ed066a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cpl-v4", - "version": "1.6.835", + "version": "1.6.836", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cpl-v4", - "version": "1.6.835", + "version": "1.6.836", "dependencies": { "@fontsource/roboto": "^5.1.0", "@headlessui/react": "^2.2.4", diff --git a/package.json b/package.json index 035c955..7ee0579 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cpl-v4", - "version": "1.6.835", + "version": "1.6.836", "private": true, "scripts": { "dev": "next dev -p 3000", diff --git a/scripts/local-cpl-sim.mjs b/scripts/local-cpl-sim.mjs index e3b3892..17afed5 100644 --- a/scripts/local-cpl-sim.mjs +++ b/scripts/local-cpl-sim.mjs @@ -10,6 +10,10 @@ import path from "path"; const PORT = process.env.PORT ? Number(process.env.PORT) : 3030; const ROOT = path.join(process.cwd(), "out"); +// Simple in-memory caches to speed up local dev +const textCache = new Map(); // key: absolute path, value: { mtimeMs, body, contentType } +let messagesAllCache = null; // cache parsed meldungen/messages_all.json + function exists(p) { try { fs.accessSync(p, fs.constants.F_OK); @@ -80,6 +84,17 @@ function tryServeSpecialMocks(res, relPath) { ); if (exists(mockPath)) return streamRaw(res, mockPath); } + // Analog Inputs JSON + if (rp === "CPL/SERVICE/analogInputs.json") { + const mockPath = path.join( + process.cwd(), + "mocks", + "device-cgi-simulator", + "SERVICE", + "analogInputsMockData.json" + ); + if (exists(mockPath)) return streamRaw(res, mockPath); + } // Kabelüberwachung main JS (kueData.js) if (rp === "CPL/SERVICE/kueData.js") { const mockPath = path.join( @@ -101,6 +116,26 @@ function tryServeSpecialMocks(res, relPath) { ); if (exists(mockPath)) return streamRaw(res, mockPath); } + // Kabelüberwachung Knotenpunkte via kueDataKnoten (case: Service) + if ( + (rp.startsWith("CPL/Service/kueDataKnoten/") || + rp.startsWith("CPL/SERVICE/kueDataKnoten/")) && + rp.endsWith(".js") + ) { + const m = rp.match(/kueDataKnoten\/kueData(\d+)\.js$/); + if (m) { + const slot = Number(m[1]); + const mockPath = path.join( + process.cwd(), + "mocks", + "device-cgi-simulator", + "SERVICE", + "knotenpunkte", + `slot${slot}.js` + ); + if (exists(mockPath)) return streamRaw(res, mockPath); + } + } if (rp === "CPL/SERVICE/systemVoltTemp.js") { const mockPath = path.join( process.cwd(), @@ -121,6 +156,17 @@ function tryServeSpecialMocks(res, relPath) { ); if (exists(mockPath)) return streamRaw(res, mockPath); } + // Some builds request last messages via Start.js + if (rp === "CPL/SERVICE/Start.js" || rp === "CPL/Service/Start.js") { + const mockPath = path.join( + process.cwd(), + "mocks", + "device-cgi-simulator", + "SERVICE", + "last20MessagesMockData.js" + ); + if (exists(mockPath)) return streamRaw(res, mockPath); + } if (rp === "CPL/meldungen/messages_all.json") { const mockPath = path.join( process.cwd(), @@ -150,13 +196,44 @@ function tryServeSpecialMocks(res, relPath) { ); if (exists(mockPath)) return streamRaw(res, mockPath); } + // Generic: any CPL/SERVICE/*.js -> try MockData.js or .js from mocks + const m = rp.match(/^CPL\/(SERVICE|Service)\/([A-Za-z0-9_-]+)\.js$/); + if (m) { + const name = m[2]; + const candidates = [ + path.join( + process.cwd(), + "mocks", + "device-cgi-simulator", + "SERVICE", + `${name}MockData.js` + ), + path.join( + process.cwd(), + "mocks", + "device-cgi-simulator", + "SERVICE", + `${name}.js` + ), + ]; + for (const p of candidates) { + if (exists(p)) return streamRaw(res, p); + } + } return false; } function streamRaw(res, filePath) { const ext = path.extname(filePath).toLowerCase(); const type = contentTypeByExt(ext); - res.writeHead(200, { "Content-Type": type }); + // cache headers for static assets + const headers = { "Content-Type": type }; + if (getRelFromRoot(filePath).startsWith("_next/")) { + headers["Cache-Control"] = "public, max-age=31536000, immutable"; + } else { + headers["Cache-Control"] = "public, max-age=60"; + } + res.writeHead(200, headers); fs.createReadStream(filePath).pipe(res); return true; } @@ -167,6 +244,7 @@ function resolvePlaceholders(filePath, content) { /CPL\/SERVICE\/(System\.(js|json)|system\.(js|json))$/i.test(rel); const serviceOpcua = /CPL\/SERVICE\/opcua\.(js|json)$/i.test(rel); const serviceDO = /CPL\/SERVICE\/digitalOutputs\.(js|json)$/i.test(rel); + const serviceDI = /CPL\/SERVICE\/digitalInputs\.(js|json)$/i.test(rel); let data = {}; let replacer = (m) => m; @@ -229,6 +307,47 @@ function resolvePlaceholders(filePath, content) { return String(data[prop]); return m; }; + } else if (serviceDI) { + const json = path.join( + process.cwd(), + "mocks", + "device-cgi-simulator", + "SERVICE", + "digitalInputsMockData.json" + ); + data = exists(json) ? readJsonSafe(json) : {}; + const getter = (arrName, idx) => { + const arr = data[arrName]; + return Array.isArray(arr) && idx >= 0 && idx < arr.length + ? String(arr[idx]) + : undefined; + }; + replacer = (m, key) => { + // Map DES/DEN/DEC/DEF/DEG/DEI/DEZ/DEA nn (80..83) to CSV lines + const m2 = key.match(/^(DES|DEN|DEC|DEF|DEG|DEI|DEZ|DEA)(\d{2})$/i); + if (m2) { + const prefix = m2[1].toUpperCase(); + const num = parseInt(m2[2], 10); + const idx = num - 80; // 80..83 => 0..3 + if (idx >= 0 && idx < 4) { + const mapName = { + DES: "win_de_state", + DEN: "win_de_label", + DEC: "win_de_counter", + DEF: "win_de_time_filter", + DEG: "win_de_weighting", + DEI: "win_de_invert", + DEZ: "win_de_counter_active", + DEA: "win_de_offline", + }[prefix]; + if (mapName) { + const v = getter(mapName, idx); + if (v !== undefined) return v; + } + } + } + return m; + }; } else if (serviceDO) { const json = path.join( process.cwd(), @@ -266,18 +385,44 @@ function resolvePlaceholders(filePath, content) { function sendFileWithReplace(res, filePath) { const ext = path.extname(filePath).toLowerCase(); const type = contentTypeByExt(ext); - if (isTextExt(ext)) { + const rel = getRelFromRoot(filePath); + const shouldReplace = + isTextExt(ext) && (ext === ".html" || /(^|\/)CPL\//.test(rel)); + + // Try cache first (only for text files) + if (shouldReplace) { try { + const stat = fs.statSync(filePath); + const cached = textCache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs) { + const headers = { "Content-Type": type, "Cache-Control": "no-cache" }; + res.writeHead(200, headers); + res.end(cached.body); + return; + } let content = fs.readFileSync(filePath, "utf8"); - content = resolvePlaceholders(filePath, content); - res.writeHead(200, { "Content-Type": type }); + // quick check to avoid heavy replace if no tokens + if (content.includes("<%=")) { + content = resolvePlaceholders(filePath, content); + } + const headers = { "Content-Type": type, "Cache-Control": "no-cache" }; + res.writeHead(200, headers); res.end(content); + textCache.set(filePath, { + mtimeMs: stat.mtimeMs, + body: content, + contentType: type, + }); return; } catch { // fall through to stream as binary if read fails } } - res.writeHead(200, { "Content-Type": type }); + const headers = { "Content-Type": type }; + if (rel.startsWith("_next/")) + headers["Cache-Control"] = "public, max-age=31536000, immutable"; + else headers["Cache-Control"] = "public, max-age=60"; + res.writeHead(200, headers); fs.createReadStream(filePath).pipe(res); } @@ -292,7 +437,49 @@ const server = http.createServer((req, res) => { const pathname = decodeURIComponent(url.pathname); const rawQuery = req.url.includes("?") ? req.url.split("?")[1] : ""; - // CPL? mapping: /CPL?/CPL/... -> /CPL/... + // Lightweight API for messages used in local mocks + if (pathname === "/api/cpl/messages") { + const fromDate = url.searchParams.get("fromDate"); + const toDate = url.searchParams.get("toDate"); + if (!fromDate || !toDate) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ error: "fromDate und toDate sind erforderlich" }) + ); + return; + } + try { + if (!messagesAllCache) { + const p = path.join( + process.cwd(), + "mocks", + "device-cgi-simulator", + "meldungen", + "messages_all.json" + ); + const raw = fs.readFileSync(p, "utf8"); + messagesAllCache = JSON.parse(raw); + } + const from = new Date(fromDate); + const to = new Date(toDate); + to.setHours(23, 59, 59, 999); + const filtered = messagesAllCache.filter((m) => { + const t = new Date(m.t); + return t >= from && t <= to; + }); + res.writeHead(200, { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + }); + res.end(JSON.stringify(filtered)); + } catch { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Interner Serverfehler" })); + } + return; + } + + // CPL? mapping: /CPL?/CPL/... -> /CPL/... and service commands if (pathname === "/CPL" && rawQuery) { const q = decodeURIComponent(rawQuery); if (q.startsWith("/")) { @@ -309,7 +496,45 @@ const server = http.createServer((req, res) => { } return notFound(res); } - // Non-file commands: just 200 OK + // Service commands: implement minimal subset for messages + // Example: Service/ae.ACP&MSS1=YYYY;MM;DD;YYYY;MM;DD;All + if (/^Service\/ae\.ACP/i.test(q) && q.includes("MSS1=")) { + try { + // Extract date parameters + const m = q.match(/MSS1=([^&]+)/); + const parts = m && m[1] ? m[1].split(";") : []; + if (parts.length >= 7) { + const [fy, fm, fd, ty, tm, td] = parts; + const from = new Date(`${fy}-${fm}-${fd}`); + const to = new Date(`${ty}-${tm}-${td}`); + to.setHours(23, 59, 59, 999); + if (!messagesAllCache) { + const p = path.join( + process.cwd(), + "mocks", + "device-cgi-simulator", + "meldungen", + "messages_all.json" + ); + const raw = fs.readFileSync(p, "utf8"); + messagesAllCache = JSON.parse(raw); + } + const filtered = messagesAllCache.filter((m) => { + const t = new Date(m.t); + return t >= from && t <= to; + }); + res.writeHead(200, { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + }); + res.end(JSON.stringify(filtered)); + return; + } + } catch { + // fall-through + } + } + // Other non-file commands: just 200 OK res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); res.end("OK"); return;