From 15c33d650b310b7d513d092b579f232d31c018be Mon Sep 17 00:00:00 2001 From: znetsixe <73483679+znetsixe@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:16:41 +0100 Subject: [PATCH] updates --- datasets/assetData/measurement.json | 18 +-- index.js | 2 + src/configs/assetApiConfig.js | 15 +++ src/configs/measurement.json | 19 +++ src/helper/assetUtils.js | 192 +++++++++++++++++++++++++++- src/menu/asset.js | 98 +++++++++++++- 6 files changed, 332 insertions(+), 12 deletions(-) create mode 100644 src/configs/assetApiConfig.js diff --git a/datasets/assetData/measurement.json b/datasets/assetData/measurement.json index 04e34ad..7d1374e 100644 --- a/datasets/assetData/measurement.json +++ b/datasets/assetData/measurement.json @@ -11,39 +11,39 @@ "id": "temperature", "name": "Temperature", "models": [ - { "id": "vega-temp-10", "name": "VegaTemp 10", "units": ["degC", "degF"] }, - { "id": "vega-temp-20", "name": "VegaTemp 20", "units": ["degC", "degF"] } + { "id": "vega-temp-10", "name": "VegaTemp 10", "units": ["degC", "degF"], "product_model_id": 1001, "product_model_uuid": "vega-temp-10" }, + { "id": "vega-temp-20", "name": "VegaTemp 20", "units": ["degC", "degF"], "product_model_id": 1002, "product_model_uuid": "vega-temp-20" } ] }, { "id": "pressure", "name": "Pressure", "models": [ - { "id": "vega-pressure-10", "name": "VegaPressure 10", "units": ["bar", "mbar", "psi"] }, - { "id": "vega-pressure-20", "name": "VegaPressure 20", "units": ["bar", "mbar", "psi"] } + { "id": "vega-pressure-10", "name": "VegaPressure 10", "units": ["bar", "mbar", "psi"], "product_model_id": 1003, "product_model_uuid": "vega-pressure-10" }, + { "id": "vega-pressure-20", "name": "VegaPressure 20", "units": ["bar", "mbar", "psi"], "product_model_id": 1004, "product_model_uuid": "vega-pressure-20" } ] }, { "id": "flow", "name": "Flow", "models": [ - { "id": "vega-flow-10", "name": "VegaFlow 10", "units": ["m3/h", "gpm", "l/min"] }, - { "id": "vega-flow-20", "name": "VegaFlow 20", "units": ["m3/h", "gpm", "l/min"] } + { "id": "vega-flow-10", "name": "VegaFlow 10", "units": ["m3/h", "gpm", "l/min"], "product_model_id": 1005, "product_model_uuid": "vega-flow-10" }, + { "id": "vega-flow-20", "name": "VegaFlow 20", "units": ["m3/h", "gpm", "l/min"], "product_model_id": 1006, "product_model_uuid": "vega-flow-20" } ] }, { "id": "level", "name": "Level", "models": [ - { "id": "vega-level-10", "name": "VegaLevel 10", "units": ["m", "ft", "mm"] }, - { "id": "vega-level-20", "name": "VegaLevel 20", "units": ["m", "ft", "mm"] } + { "id": "vega-level-10", "name": "VegaLevel 10", "units": ["m", "ft", "mm"], "product_model_id": 1007, "product_model_uuid": "vega-level-10" }, + { "id": "vega-level-20", "name": "VegaLevel 20", "units": ["m", "ft", "mm"], "product_model_id": 1008, "product_model_uuid": "vega-level-20" } ] }, { "id": "oxygen", "name": "Quantity (oxygen)", "models": [ - { "id": "vega-oxy-10", "name": "VegaOxySense 10", "units": ["g/m3", "mol/m3"] } + { "id": "vega-oxy-10", "name": "VegaOxySense 10", "units": ["g/m3", "mol/m3"], "product_model_id": 1009, "product_model_uuid": "vega-oxy-10" } ] } ] diff --git a/index.js b/index.js index 97dffa3..281fe51 100644 --- a/index.js +++ b/index.js @@ -15,6 +15,7 @@ const configUtils = require('./src/helper/configUtils.js'); const assertions = require('./src/helper/assertionUtils.js') const coolprop = require('./src/coolprop-node/src/index.js'); const gravity = require('./src/helper/gravity.js') +const assetApiConfig = require('./src/configs/assetApiConfig.js'); // Domain-specific modules const { MeasurementContainer } = require('./src/measurements/index.js'); @@ -34,6 +35,7 @@ module.exports = { predict, interpolation, configManager, + assetApiConfig, outputUtils, configUtils, logger, diff --git a/src/configs/assetApiConfig.js b/src/configs/assetApiConfig.js new file mode 100644 index 0000000..bb2290e --- /dev/null +++ b/src/configs/assetApiConfig.js @@ -0,0 +1,15 @@ +const BASE_URL = 'http://localhost:8000'; +const AUTHORIZATION = '4a49332a-fc3e-11f0-bf0a-9457f8d645d9'; +const CSRF_TOKEN = 'dcWLY6luSVuQu4mIlKNCGlk3i9VzG9n3p2pxihcm'; + +module.exports = { + baseUrl: BASE_URL, + registerPath: '/assets/store', + updatePath: (tag) => `/assets/${encodeURIComponent(tag)}/edit`, + headers: { + accept: 'application/json', + Authorization: AUTHORIZATION, + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': CSRF_TOKEN + } +}; diff --git a/src/configs/measurement.json b/src/configs/measurement.json index 5c45d96..b674587 100644 --- a/src/configs/measurement.json +++ b/src/configs/measurement.json @@ -117,6 +117,14 @@ "description": "Asset tag code which is a unique identifier for this asset. May be null if not assigned." } }, + "tagNumber": { + "default": null, + "rules": { + "type": "string", + "nullable": true, + "description": "Asset tag number assigned by the asset registry. May be null if not assigned." + } + }, "geoLocation": { "default": { "x": 0, @@ -166,6 +174,10 @@ { "value": "sensor", "description": "A device that detects or measures a physical property and responds to it (e.g. temperature sensor)." + }, + { + "value": "measurement", + "description": "Measurement software category used by the asset menu for this node." } ] } @@ -208,6 +220,13 @@ } } }, + "assetRegistration": { + "profileId": 1, + "locationId": 1, + "processId": 1, + "status": "actief", + "childAssets": [] + }, "scaling": { "enabled": { "default": false, diff --git a/src/helper/assetUtils.js b/src/helper/assetUtils.js index 2155549..957864f 100644 --- a/src/helper/assetUtils.js +++ b/src/helper/assetUtils.js @@ -1,3 +1,191 @@ -export function getAssetVariables() { +const http = require('node:http'); +const https = require('node:https'); +const { URL } = require('node:url'); +const { assetCategoryManager } = require('../../datasets/assetData'); -} \ No newline at end of file +function toNumber(value, fallback = 1) { + const result = Number(value); + return Number.isFinite(result) && result > 0 ? result : fallback; +} + +function toArray(value = []) { + if (Array.isArray(value)) { + return value.filter((item) => typeof item !== 'undefined' && item !== null); + } + if (typeof value === 'string' && value.trim()) { + return [value.trim()]; + } + if (typeof value === 'number') { + return [value]; + } + return []; +} + +function findModelMetadata(selection = {}) { + if (!selection) { + return null; + } + + const categoryKey = selection.softwareType || 'measurement'; + if (!assetCategoryManager.hasCategory(categoryKey)) { + return null; + } + + const suppliers = assetCategoryManager.getCategory(categoryKey).suppliers || []; + const supplierMatch = (entry, value) => { + if (!entry || !value) return false; + const key = value.toString().toLowerCase(); + return ( + (entry.id && entry.id.toLowerCase() === key) || + (entry.name && entry.name.toLowerCase() === key) + ); + }; + + const supplier = suppliers.find((item) => supplierMatch(item, selection.supplier)); + const types = supplier?.types || []; + const type = types.find((item) => supplierMatch(item, selection.assetType)); + const models = type?.models || []; + const model = models.find((item) => supplierMatch(item, selection.model)); + + return model || null; +} + +function buildAssetPayload({ assetSelection = {}, registrationDefaults = {} }) { + const defaults = { + profileId: 1, + locationId: 1, + processId: 1, + status: 'actief', + childAssets: [], + ...registrationDefaults + }; + + const metadata = assetSelection.modelMetadata || findModelMetadata(assetSelection) || {}; + const rawName = assetSelection.assetName || assetSelection.name || assetSelection.assetType || assetSelection.model; + const assetName = (rawName || 'Measurement asset').toString(); + const assetDescription = (assetSelection.assetDescription || assetSelection.description || assetName).toString(); + + const payload = { + profile_id: toNumber(defaults.profileId, 1), + location_id: toNumber(defaults.locationId, 1), + process_id: toNumber(defaults.processId, 1), + asset_name: assetName, + asset_description: assetDescription, + asset_status: (assetSelection.assetStatus || defaults.status || 'actief').toString(), + product_model_id: metadata.product_model_id || metadata.id || assetSelection.model || null, + product_model_uuid: metadata.product_model_uuid || metadata.uuid || metadata.id || assetSelection.model || null, + child_assets: toArray(defaults.childAssets) + }; + + const tagNumber = typeof assetSelection.tagNumber === 'string' && assetSelection.tagNumber.trim() + ? assetSelection.tagNumber.trim() + : null; + + return { + payload, + tagNumber, + isUpdate: Boolean(tagNumber) + }; +} + +function normalizeHeaders(headers = {}, body = '') { + const normalized = { ...headers }; + if (!Object.prototype.hasOwnProperty.call(normalized, 'Content-Length')) { + normalized['Content-Length'] = Buffer.byteLength(body); + } + return normalized; +} + +function prepareUrl(baseUrl = '', path = '') { + const trimmedBase = (baseUrl || '').replace(/\/+$/g, '').replace(/\\/g, '/'); + const trimmedPath = path.startsWith('/') ? path : `/${path}`; + if (!trimmedBase) { + return trimmedPath; + } + return `${trimmedBase}${trimmedPath}`; +} + +function sendHttpRequest(url, method, headers = {}, body = '') { + const parsedUrl = new URL(url, 'http://localhost'); + const agent = parsedUrl.protocol === 'https:' ? https : http; + const requestOptions = { + method, + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: `${parsedUrl.pathname}${parsedUrl.search}`, + headers: normalizeHeaders(headers, body) + }; + + return new Promise((resolve, reject) => { + const req = agent.request(requestOptions, (res) => { + let raw = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { raw += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, body: raw })); + }); + + req.on('error', reject); + if (body) { + req.write(body); + } + req.end(); + }); +} + +function parseApiResponse(raw, status) { + try { + const parsed = JSON.parse(raw); + return { + success: parsed.success === true, + data: parsed.data || null, + message: parsed.message || (status >= 400 ? `HTTP ${status}` : 'Result returned') + }; + } catch (error) { + return { + success: false, + data: raw, + message: `Unable to decode asset API response: ${error.message}` + }; + } +} + +async function syncAsset({ assetSelection = {}, registrationDefaults = {}, apiConfig = {}, nodeContext = {} }) { + const { payload, tagNumber, isUpdate } = buildAssetPayload({ assetSelection, registrationDefaults }); + if (!apiConfig || !apiConfig.baseUrl) { + const message = 'Asset API configuration is missing'; + console.warn('[assetUtils] ' + message, { nodeContext }); + return { success: false, data: null, message }; + } + + const path = isUpdate && tagNumber && typeof apiConfig.updatePath === 'function' + ? apiConfig.updatePath(tagNumber) + : apiConfig.registerPath; + const url = prepareUrl(apiConfig.baseUrl, path); + const method = isUpdate ? 'PUT' : 'POST'; + const headers = apiConfig.headers || {}; + + console.info('[assetUtils] Sending asset update', { nodeContext, method, url }); + + try { + const response = await sendHttpRequest(url, method, headers, JSON.stringify(payload)); + const parsed = parseApiResponse(response.body, response.status); + return { + success: parsed.success, + data: parsed.data, + message: parsed.message + }; + } catch (error) { + console.error('[assetUtils] Asset API request failed', error, { nodeContext }); + return { + success: false, + data: null, + message: `Asset API request error: ${error.message}` + }; + } +} + +module.exports = { + syncAsset, + buildAssetPayload, + findModelMetadata +}; \ No newline at end of file diff --git a/src/menu/asset.js b/src/menu/asset.js index c3f17c2..40592a2 100644 --- a/src/menu/asset.js +++ b/src/menu/asset.js @@ -75,6 +75,7 @@ class AssetMenu { const htmlCode = this.getHtmlInjectionCode(nodeName); const dataCode = this.getDataInjectionCode(nodeName); const eventsCode = this.getEventInjectionCode(nodeName); + const syncCode = this.getSyncInjectionCode(nodeName); const saveCode = this.getSaveInjectionCode(nodeName); return ` @@ -85,6 +86,7 @@ class AssetMenu { ${htmlCode} ${dataCode} ${eventsCode} + ${syncCode} ${saveCode} window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) { @@ -285,6 +287,9 @@ class AssetMenu { const activeModel = models.find( (model) => (model.id || model.name) === node.model ); + if (activeModel) { + node.modelMetadata = activeModel; + } populate( elems.unit, activeModel ? activeModel.units || [] : [], @@ -292,6 +297,7 @@ class AssetMenu { (unit) => ({ value: unit, label: unit }), activeModel ? 'Select...' : activeType ? 'Awaiting Model Selection' : 'Awaiting Type Selection' ); + this.setAssetTagNumber(node, node.assetTagNumber || ''); }; `; } @@ -381,6 +387,7 @@ class AssetMenu { (type) => ({ value: type.id || type.name, label: type.name }), supplier ? 'Select...' : 'Awaiting Supplier Selection' ); + node.modelMetadata = null; populate(elems.model, [], '', undefined, 'Awaiting Type Selection'); populate(elems.unit, [], '', undefined, 'Awaiting Type Selection'); }); @@ -405,6 +412,7 @@ class AssetMenu { (model) => ({ value: model.id || model.name, label: model.name }), type ? 'Select...' : 'Awaiting Type Selection' ); + node.modelMetadata = null; populate( elems.unit, [], @@ -431,6 +439,7 @@ class AssetMenu { (item) => (item.id || item.name) === elems.model.value ) : null; + node.modelMetadata = model; populate( elems.unit, model ? model.units || [] : [], @@ -443,6 +452,84 @@ class AssetMenu { `; } + getSyncInjectionCode(nodeName) { + return ` + // Asset synchronization helpers for ${nodeName} + window.EVOLV.nodes.${nodeName}.assetMenu.setAssetTagNumber = function(node, tag) { + const normalized = tag ? tag.toString() : ''; + const input = document.getElementById('node-input-assetTagNumber'); + const hint = document.getElementById('node-input-assetTagNumber-hint'); + if (input) { + input.value = normalized; + } + if (hint) { + hint.textContent = normalized ? 'Assigned tag ' + normalized : 'Not registered yet'; + } + if (node) { + node.assetTagNumber = normalized; + } + }; + + window.EVOLV.nodes.${nodeName}.assetMenu.buildSyncRequest = function(node) { + const tagInput = document.getElementById('node-input-assetTagNumber'); + const candidateTag = tagInput && tagInput.value ? tagInput.value.trim() : ''; + const fallbackTag = node && node.assetTagNumber ? node.assetTagNumber : ''; + const registrationDefaults = + (window.EVOLV.nodes.${nodeName}.config && window.EVOLV.nodes.${nodeName}.config.assetRegistration) || {}; + const displayName = node && node.name ? node.name : node && node.id ? node.id : '${nodeName}'; + return { + asset: { + tagNumber: candidateTag || fallbackTag, + supplier: node && node.supplier ? node.supplier : '', + assetType: node && node.assetType ? node.assetType : '', + model: node && node.model ? node.model : '', + unit: node && node.unit ? node.unit : '', + assetName: displayName, + assetDescription: displayName, + assetStatus: registrationDefaults.status || 'actief', + modelMetadata: node && node.modelMetadata ? node.modelMetadata : null + }, + nodeId: node && node.id ? node.id : null, + nodeName: node && node.type ? node.type : '${nodeName}' + }; + }; + + window.EVOLV.nodes.${nodeName}.assetMenu.syncAsset = function(node) { + const payload = this.buildSyncRequest(node); + const redSettings = window.RED && window.RED.settings; + const adminRoot = redSettings ? redSettings.httpAdminRoot : ''; + const trimmedRoot = adminRoot && adminRoot.endsWith('/') ? adminRoot.slice(0, -1) : adminRoot || ''; + const prefix = trimmedRoot || ''; + const endpoint = (prefix || '') + '/${nodeName}/asset-reg'; + fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + .then((res) => + res.json().catch((err) => { + console.warn('[AssetMenu] asset sync response is not JSON', err); + return { success: false, message: err.message || 'Invalid API response' }; + }) + ) + .then((result) => { + if (result && result.success) { + const newTag = (result.data && result.data.asset_tag_number) || payload.asset.tagNumber || ''; + this.setAssetTagNumber(node, newTag); + if (window.RED && typeof window.RED.notify === 'function') { + window.RED.notify('Asset synced: ' + (newTag || 'no tag'), 'info'); + } + } else { + console.warn('[AssetMenu] asset sync failed', result && result.message); + } + }) + .catch((error) => { + console.error('[AssetMenu] asset sync error', error); + }); + }; + `; + } + getHtmlTemplate() { return ` @@ -464,6 +551,11 @@ class AssetMenu { +