From 00c8064a3b415b3ffb65a150943f0fc634273631 Mon Sep 17 00:00:00 2001 From: milesjunior4 Date: Mon, 2 Mar 2026 01:13:11 +0100 Subject: [PATCH 1/2] feat: add ADS .tpy symbol file import for Beckhoff TwinCAT tags --- cat | 4 + package-lock.json | 181 ++++++++++++++++++ package.json | 5 + server/api/projects/index.js | 114 +++++++---- server/package-lock.json | 6 +- server/package.json | 1 + .../runtime/devices/adsclient/tpy-parser.js | 149 ++++++++++++++ server/test/tpy-parser.test.js | 134 +++++++++++++ 8 files changed, 554 insertions(+), 40 deletions(-) create mode 100644 cat create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/runtime/devices/adsclient/tpy-parser.js create mode 100644 server/test/tpy-parser.test.js diff --git a/cat b/cat new file mode 100644 index 000000000..9e4e7808c --- /dev/null +++ b/cat @@ -0,0 +1,4 @@ + PID PPID PGID WINPID TTY UID STIME COMMAND + 1399 1 1399 2112 cons0 197609 00:47:11 /usr/bin/bash + 1544 1399 1543 20160 cons0 197609 00:47:29 /usr/bin/bash + 1543 1399 1543 21456 cons0 197609 00:47:29 /usr/bin/PS diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..d6cdb4a99 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,181 @@ +{ + "name": "FUXA", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "multer": "^2.1.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..4b64e633f --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "multer": "^2.1.0" + } +} 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 From fd37e7e0cafdf922cb8af9b803bc8a2d1cf7e537 Mon Sep 17 00:00:00 2001 From: milesjunior4 Date: Mon, 2 Mar 2026 01:26:00 +0100 Subject: [PATCH 2/2] chore: remove accidentally committed files --- cat | 4 - package-lock.json | 181 ---------------------------------------------- package.json | 5 -- 3 files changed, 190 deletions(-) delete mode 100644 cat delete mode 100644 package-lock.json delete mode 100644 package.json diff --git a/cat b/cat deleted file mode 100644 index 9e4e7808c..000000000 --- a/cat +++ /dev/null @@ -1,4 +0,0 @@ - PID PPID PGID WINPID TTY UID STIME COMMAND - 1399 1 1399 2112 cons0 197609 00:47:11 /usr/bin/bash - 1544 1399 1543 20160 cons0 197609 00:47:29 /usr/bin/bash - 1543 1399 1543 21456 cons0 197609 00:47:29 /usr/bin/PS diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index d6cdb4a99..000000000 --- a/package-lock.json +++ /dev/null @@ -1,181 +0,0 @@ -{ - "name": "FUXA", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "multer": "^2.1.0" - } - }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/multer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz", - "integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "type-is": "^1.6.18" - }, - "engines": { - "node": ">= 10.16.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 4b64e633f..000000000 --- a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "dependencies": { - "multer": "^2.1.0" - } -}