diff --git a/server/api/projects/index.js b/server/api/projects/index.js index 64df386fa..60dbcd853 100644 --- a/server/api/projects/index.js +++ b/server/api/projects/index.js @@ -8,6 +8,9 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const { normalizeRelativePath, resolveWithin } = require('../path-helper'); +const multer = require('multer'); +const { parseTpyFile } = require('../runtime/devices/adsclient/tpy-parser'); +const upload = multer({ storage: multer.memoryStorage() }); var runtime; var secureFnc; @@ -21,7 +24,7 @@ module.exports = { }, app: function () { var prjApp = express(); - prjApp.use(function(req,res,next) { + prjApp.use(function (req, res, next) { if (!runtime.project) { res.status(404).end(); } else { @@ -30,10 +33,45 @@ module.exports = { }); /** + * POST /api/ads/import-tpy + * Parse a Beckhoff TwinCAT .tpy file and return extracted tags + */ + prjApp.post('/api/ads/import-tpy', secureFnc, upload.single('tpyFile'), async (req, res) => { + const permission = checkGroupsFnc(req); + if (!authJwt.haveAdminPermission(permission)) { + return res.status(401).json({ error: 'unauthorized_error', message: 'Unauthorized!' }); + } + + if (!req.file) { + return res.status(400).json({ error: 'missing_file', message: 'No .tpy file uploaded' }); + } + + if (!req.file.originalname.endsWith('.tpy')) { + return res.status(400).json({ error: 'invalid_file', message: 'File must be a .tpy file' }); + } + + try { + const xmlContent = req.file.buffer.toString('utf8'); + const tags = await parseTpyFile(xmlContent); + + runtime.logger.info(`Imported ${tags.length} tags from .tpy file: ${req.file.originalname}`); + + res.json({ + success: true, + filename: req.file.originalname, + count: tags.length, + tags: tags + }); + } catch (err) { + runtime.logger.error(`Failed to parse .tpy file: ${err.message}`); + res.status(400).json({ error: 'parse_error', message: err.message }); + } + }); + /** * GET Project data * Take from project storage and reply */ - prjApp.get("/api/project", secureFnc, function(req, res) { + prjApp.get("/api/project", secureFnc, function (req, res) { const permission = checkGroupsFnc(req); runtime.project.getProject(req.userId, permission).then(result => { // res.header("Access-Control-Allow-Origin", "*"); @@ -44,14 +82,14 @@ module.exports = { res.status(404).end(); runtime.logger.error("api get project: Not Found!"); } - }).catch(function(err) { + }).catch(function (err) { if (err && err.code) { if (err.code !== 'ERR_HTTP_HEADERS_SENT') { - res.status(400).json({error:err.code, message: err.message}); + res.status(400).json({ error: err.code, message: err.message }); runtime.logger.error("api get project: " + err.message); } } else { - res.status(400).json({error:"unexpected_error", message: err}); + res.status(400).json({ error: "unexpected_error", message: err }); runtime.logger.error("api get project: " + err); } }); @@ -61,24 +99,24 @@ module.exports = { * POST Project data * Set to project storage */ - prjApp.post("/api/project", secureFnc, function(req, res, next) { + prjApp.post("/api/project", secureFnc, function (req, res, next) { const permission = checkGroupsFnc(req); if (res.statusCode === 403) { runtime.logger.error("api post project: Tocken Expired"); } else if (!authJwt.haveAdminPermission(permission)) { - res.status(401).json({error:"unauthorized_error", message: "Unauthorized!"}); + res.status(401).json({ error: "unauthorized_error", message: "Unauthorized!" }); runtime.logger.error("api post project: Unauthorized"); } else { - runtime.project.setProject(req.body).then(function(data) { - runtime.restart(true).then(function(result) { + runtime.project.setProject(req.body).then(function (data) { + runtime.restart(true).then(function (result) { res.end(); }); - }).catch(function(err) { + }).catch(function (err) { if (err && err.code) { - res.status(400).json({error:err.code, message: err.message}); + res.status(400).json({ error: err.code, message: err.message }); runtime.logger.error("api post project: " + err.message); } else { - res.status(400).json({error:"unexpected_error", message: err}); + res.status(400).json({ error: "unexpected_error", message: err }); runtime.logger.error("api post project: " + err); } }); @@ -89,24 +127,24 @@ module.exports = { * POST Single Project data * Set the value (general/view/device/...) to project storage */ - prjApp.post("/api/projectData", secureFnc, function(req, res, next) { + prjApp.post("/api/projectData", secureFnc, function (req, res, next) { const permission = checkGroupsFnc(req); if (res.statusCode === 403) { runtime.logger.error("api post projectData: Tocken Expired"); } else if (!authJwt.haveAdminPermission(permission)) { - res.status(401).json({error:"unauthorized_error", message: "Unauthorized!"}); + res.status(401).json({ error: "unauthorized_error", message: "Unauthorized!" }); runtime.logger.error("api post projectData: Unauthorized"); } else { runtime.project.setProjectData(req.body.cmd, req.body.data).then(setres => { runtime.update(req.body.cmd, req.body.data).then(result => { res.end(); }); - }).catch(function(err) { + }).catch(function (err) { if (err && err.code) { - res.status(400).json({error:err.code, message: err.message}); + res.status(400).json({ error: err.code, message: err.message }); runtime.logger.error("api post projectData: " + err.message); } else { - res.status(400).json({error:"unexpected_error", message: err}); + res.status(400).json({ error: "unexpected_error", message: err }); runtime.logger.error("api post projectData: " + err); } }); @@ -133,12 +171,12 @@ module.exports = { * GET Device property like security * Take from project storage and reply */ - prjApp.get("/api/device", secureFnc, function(req, res) { + prjApp.get("/api/device", secureFnc, function (req, res) { const permission = checkGroupsFnc(req); if (res.statusCode === 403) { runtime.logger.error("api get device: Tocken Expired"); } else if (!authJwt.haveAdminPermission(permission)) { - res.status(401).json({error:"unauthorized_error", message: "Unauthorized!"}); + res.status(401).json({ error: "unauthorized_error", message: "Unauthorized!" }); runtime.logger.error("api get device: Unauthorized"); } else { runtime.project.getDeviceProperty(req.query).then(result => { @@ -149,12 +187,12 @@ module.exports = { } else { res.end(); } - }).catch(function(err) { + }).catch(function (err) { if (err && err.code) { - res.status(400).json({error:err.code, message: err.message}); + res.status(400).json({ error: err.code, message: err.message }); runtime.logger.error("api get device: " + err.message); } else { - res.status(400).json({error:"unexpected_error", message: err}); + res.status(400).json({ error: "unexpected_error", message: err }); runtime.logger.error("api get device: " + err); } }); @@ -165,22 +203,22 @@ module.exports = { * POST Device property * Set to project storage */ - prjApp.post("/api/device", secureFnc, function(req, res, next) { + prjApp.post("/api/device", secureFnc, function (req, res, next) { const permission = checkGroupsFnc(req); if (res.statusCode === 403) { runtime.logger.error("api post device: Tocken Expired"); } else if (!authJwt.haveAdminPermission(permission)) { - res.status(401).json({error:"unauthorized_error", message: "Unauthorized!"}); + res.status(401).json({ error: "unauthorized_error", message: "Unauthorized!" }); runtime.logger.error("api post device: Unauthorized"); } else { - runtime.project.setDeviceProperty(req.body.params).then(function(data) { + runtime.project.setDeviceProperty(req.body.params).then(function (data) { res.end(); - }).catch(function(err) { + }).catch(function (err) { if (err && err.code) { - res.status(400).json({error:err.code, message: err.message}); + res.status(400).json({ error: err.code, message: err.message }); runtime.logger.error("api post device: " + err.message); } else { - res.status(400).json({error:"unexpected_error", message: err}); + res.status(400).json({ error: "unexpected_error", message: err }); runtime.logger.error("api post device: " + err); } }); @@ -197,7 +235,7 @@ module.exports = { runtime.logger.error("api get device: Tocken Expired"); return; } else if (!authJwt.haveAdminPermission(permission)) { - res.status(401).json({error:"unauthorized_error", message: "Unauthorized!"}); + res.status(401).json({ error: "unauthorized_error", message: "Unauthorized!" }); runtime.logger.error("api get device: Unauthorized"); return; } @@ -213,17 +251,17 @@ module.exports = { const safeFullPath = normalizeRelativePath(file.fullPath || rawFileName); const relativePath = safeFullPath || safeFileName; if (!relativePath) { - res.status(400).json({error:"invalid_path", message: "Invalid upload path."}); + res.status(400).json({ error: "invalid_path", message: "Invalid upload path." }); return; } if (file.type !== 'svg') { basedata = file.data.replace(/^data:.*,/, ''); - encoding = {encoding: 'base64'}; + encoding = { encoding: 'base64' }; } const resolvedUpload = resolveWithin(runtime.settings.uploadFileDir, relativePath); if (!resolvedUpload) { - res.status(400).json({error:"invalid_path", message: "Invalid upload path."}); + res.status(400).json({ error: "invalid_path", message: "Invalid upload path." }); return; } let filePath = resolvedUpload.resolvedTarget; @@ -233,18 +271,18 @@ module.exports = { : runtime.settings.appDir; const normalizedDestination = normalizeRelativePath(destination); if (!normalizedDestination) { - res.status(400).json({error:"invalid_destination", message: "Invalid destination path."}); + res.status(400).json({ error: "invalid_destination", message: "Invalid destination path." }); return; } const resolvedDestination = resolveWithin(baseDir, `_${normalizedDestination}`); if (!resolvedDestination) { - res.status(400).json({error:"invalid_destination", message: "Invalid destination path."}); + res.status(400).json({ error: "invalid_destination", message: "Invalid destination path." }); return; } const destinationDir = resolvedDestination.resolvedTarget; const resolvedFile = resolveWithin(destinationDir, relativePath); if (!resolvedFile) { - res.status(400).json({error:"invalid_path", message: "Invalid upload path."}); + res.status(400).json({ error: "invalid_path", message: "Invalid upload path." }); return; } filePath = resolvedFile.resolvedTarget; @@ -254,14 +292,14 @@ module.exports = { } } fs.writeFileSync(filePath, basedata, encoding); - let result = {'location': '/' + runtime.settings.httpUploadFileStatic + '/' + relativePath }; + let result = { 'location': '/' + runtime.settings.httpUploadFileStatic + '/' + relativePath }; res.json(result); } catch (err) { if (err && err.code) { - res.status(400).json({error: err.code, message: err.message}); + res.status(400).json({ error: err.code, message: err.message }); runtime.logger.error("api upload: " + err.message); } else { - res.status(400).json({error:"unexpected_error", message: err}); + res.status(400).json({ error: "unexpected_error", message: err }); runtime.logger.error("api upload: " + err); } } diff --git a/server/package-lock.json b/server/package-lock.json index 010790393..358e87e53 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "fuxa-server", - "version": "1.3.0-2700", + "version": "1.3.0-2727", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fuxa-server", - "version": "1.3.0-2700", + "version": "1.3.0-2727", "license": "MIT", "dependencies": { "@influxdata/influxdb-client": "1.25.0", @@ -45,6 +45,7 @@ "swagger-ui-express": "^5.0.1", "winston": "3.7.2", "ws": "8.18.0", + "xml2js": "^0.6.2", "yamljs": "^0.3.0" }, "devDependencies": { @@ -9214,6 +9215,7 @@ "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" diff --git a/server/package.json b/server/package.json index 29867e3bf..b8cf908ed 100644 --- a/server/package.json +++ b/server/package.json @@ -51,6 +51,7 @@ "swagger-ui-express": "^5.0.1", "winston": "3.7.2", "ws": "8.18.0", + "xml2js": "^0.6.2", "yamljs": "^0.3.0" }, "overrides": { diff --git a/server/runtime/devices/adsclient/tpy-parser.js b/server/runtime/devices/adsclient/tpy-parser.js new file mode 100644 index 000000000..7c90e7d39 --- /dev/null +++ b/server/runtime/devices/adsclient/tpy-parser.js @@ -0,0 +1,149 @@ +/** + * 'adsclient/tpy-parser': Parser for Beckhoff TwinCAT .tpy symbol files + * Supports TwinCAT 2 and TwinCAT 3 .tpy XML format + */ + +'use strict'; + +const xml2js = require('xml2js'); + +/** + * Map TwinCAT data types to FUXA tag types + */ +const TYPE_MAP = { + 'BOOL': 'boolean', + 'BYTE': 'number', + 'WORD': 'number', + 'DWORD': 'number', + 'SINT': 'number', + 'USINT': 'number', + 'INT': 'number', + 'UINT': 'number', + 'DINT': 'number', + 'UDINT': 'number', + 'LINT': 'number', + 'ULINT': 'number', + 'REAL': 'number', + 'LREAL': 'number', + 'STRING': 'string', + 'TIME': 'number', + 'DATE': 'number', + 'TOD': 'number', + 'DT': 'number', +}; + +/** + * Parse a .tpy XML file content and extract symbols as FUXA tags + * @param {string} xmlContent - The raw XML string from the .tpy file + * @returns {Promise} - Array of tag objects { name, type, address } + */ +const parseTpyFile = (xmlContent) => { + return new Promise((resolve, reject) => { + xml2js.parseString(xmlContent, { explicitArray: false }, (err, result) => { + if (err) { + return reject(new Error(`Failed to parse .tpy file: ${err.message}`)); + } + + try { + const tags = []; + + // TwinCAT 2 format: TypeList > DataTypes > Symbol + // TwinCAT 3 format: TcModuleClass > DataTypes > Symbol + var symbolList = []; + + // Try TwinCAT 3 format first + if (result && result.TcModuleClass && result.TcModuleClass.Symbol) { + symbolList = result.TcModuleClass.Symbol; + } + // Try TwinCAT 2 format + else if (result && result.TypeList && result.TypeList.Symbol) { + symbolList = result.TypeList.Symbol; + } + // Try nested DataTypes format + else if (result && result.TcModuleClass && result.TcModuleClass.DataTypes) { + symbolList = _extractFromDataTypes(result.TcModuleClass.DataTypes); + } + + // Normalize to array + if (!Array.isArray(symbolList)) { + symbolList = [symbolList]; + } + + // Extract each symbol + for (var i = 0; i < symbolList.length; i++) { + var symbol = symbolList[i]; + if (!symbol) continue; + + var name = symbol.Name || symbol.name || symbol.$ && symbol.$.Name; + var rawType = symbol.Type || symbol.type || symbol.$ && symbol.$.Type || 'UNKNOWN'; + var iGroup = symbol.IGroup || symbol.igroup || '0'; + var iOffset = symbol.IOffset || symbol.ioffset || '0'; + + if (!name) continue; + + // Skip ARRAY and STRUCT types for v1 (scalar only) + if (rawType.toUpperCase().startsWith('ARRAY') || + rawType.toUpperCase().startsWith('STRUCT')) { + continue; + } + + // Map TwinCAT type to FUXA type + var fuxaType = TYPE_MAP[rawType.toUpperCase()] || 'string'; + + tags.push({ + name: name, + type: fuxaType, + address: name, // ADS uses symbol name as address + rawType: rawType, // Keep original TwinCAT type for reference + iGroup: parseInt(iGroup), + iOffset: parseInt(iOffset), + }); + } + + resolve(tags); + } catch (parseErr) { + reject(new Error(`Failed to extract symbols: ${parseErr.message}`)); + } + }); + }); +}; + +/** + * Extract symbols from nested DataTypes structure (TwinCAT 3) + * @param {object} dataTypes + * @returns {Array} + */ +const _extractFromDataTypes = (dataTypes) => { + var symbols = []; + + if (!dataTypes) return symbols; + + var dataTypeList = dataTypes.DataType; + if (!dataTypeList) return symbols; + + if (!Array.isArray(dataTypeList)) { + dataTypeList = [dataTypeList]; + } + + for (var i = 0; i < dataTypeList.length; i++) { + var dt = dataTypeList[i]; + if (dt && dt.SubItem) { + var subItems = Array.isArray(dt.SubItem) ? dt.SubItem : [dt.SubItem]; + for (var j = 0; j < subItems.length; j++) { + var subItem = subItems[j]; + if (subItem && subItem.Name) { + symbols.push({ + Name: (dt.Name ? dt.Name + '.' : '') + subItem.Name, + Type: subItem.Type || 'UNKNOWN', + IGroup: subItem.IGroup || '0', + IOffset: subItem.IOffset || '0', + }); + } + } + } + } + + return symbols; +}; + +module.exports = { parseTpyFile }; \ No newline at end of file diff --git a/server/test/tpy-parser.test.js b/server/test/tpy-parser.test.js new file mode 100644 index 000000000..5f0e81a7b --- /dev/null +++ b/server/test/tpy-parser.test.js @@ -0,0 +1,134 @@ +/** + * Tests for adsclient/tpy-parser.js + */ + +'use strict'; + +const { parseTpyFile } = require('../runtime/devices/adsclient/tpy-parser'); + +// Sample TwinCAT 2 .tpy XML +const TC2_SAMPLE = ` + + + MAIN.bMotorRunning + BOOL + 16448 + 0 + + + MAIN.rSetpoint + REAL + 16448 + 4 + + + MAIN.nSpeed + INT + 16448 + 8 + + + MAIN.sStatus + STRING + 16448 + 12 + +`; + +// Sample TwinCAT 3 .tpy XML +const TC3_SAMPLE = ` + + + MAIN.bMotorRunning + BOOL + 16448 + 0 + + + MAIN.rTemperature + LREAL + 16448 + 4 + + + MAIN.nCount + DINT + 16448 + 12 + +`; + +// Invalid XML +const INVALID_XML = `this is not xml at all`; + +// Empty symbol list +const EMPTY_SAMPLE = ` + +`; + +async function runTests() { + let passed = 0; + let failed = 0; + + const assert = (condition, message) => { + if (condition) { + console.log(` ✅ PASS: ${message}`); + passed++; + } else { + console.error(` ❌ FAIL: ${message}`); + failed++; + } + }; + + console.log('\n--- TwinCAT 2 Format ---'); + try { + const tags = await parseTpyFile(TC2_SAMPLE); + assert(tags.length === 4, `Should extract 4 tags (got ${tags.length})`); + assert(tags[0].name === 'MAIN.bMotorRunning', `First tag name should be MAIN.bMotorRunning`); + assert(tags[0].type === 'boolean', `BOOL should map to boolean type`); + assert(tags[0].address === 'MAIN.bMotorRunning', `Address should equal name for ADS`); + assert(tags[1].type === 'number', `REAL should map to number type`); + assert(tags[2].type === 'number', `INT should map to number type`); + assert(tags[3].type === 'string', `STRING should map to string type`); + assert(tags[0].iGroup === 16448, `IGroup should be parsed as integer`); + assert(tags[0].iOffset === 0, `IOffset should be parsed as integer`); + } catch (err) { + console.error(` ❌ TC2 test threw error: ${err.message}`); + failed++; + } + + console.log('\n--- TwinCAT 3 Format ---'); + try { + const tags = await parseTpyFile(TC3_SAMPLE); + assert(tags.length === 3, `Should extract 3 tags (got ${tags.length})`); + assert(tags[0].name === 'MAIN.bMotorRunning', `First tag name should be MAIN.bMotorRunning`); + assert(tags[1].type === 'number', `LREAL should map to number type`); + assert(tags[2].type === 'number', `DINT should map to number type`); + } catch (err) { + console.error(` ❌ TC3 test threw error: ${err.message}`); + failed++; + } + + console.log('\n--- Invalid XML ---'); + try { + await parseTpyFile(INVALID_XML); + console.error(` ❌ FAIL: Should throw error for invalid XML`); + failed++; + } catch (err) { + assert(err.message.includes('Failed to parse'), `Should throw parse error for invalid XML`); + } + + console.log('\n--- Empty Symbol List ---'); + try { + const tags = await parseTpyFile(EMPTY_SAMPLE); + assert(tags.length === 0, `Should return empty array for empty symbol list`); + } catch (err) { + console.error(` ❌ Empty test threw error: ${err.message}`); + failed++; + } + + console.log(`\n--- Results: ${passed} passed, ${failed} failed ---\n`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); \ No newline at end of file