This commit is contained in:
znetsixe
2026-01-29 09:16:41 +01:00
parent a536c6ed5e
commit 15c33d650b
6 changed files with 332 additions and 12 deletions

View File

@@ -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" }
]
}
]

View File

@@ -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,

View File

@@ -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
}
};

View File

@@ -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,

View File

@@ -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');
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
};

View File

@@ -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 `
<!-- Asset Properties -->
@@ -464,6 +551,11 @@ class AssetMenu {
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
<select id="node-input-unit" style="width:70%;"></select>
</div>
<div class="form-row">
<label for="node-input-assetTagNumber"><i class="fa fa-hashtag"></i> Asset Tag</label>
<input type="text" id="node-input-assetTagNumber" readonly style="width:70%;" />
<div class="form-tips" id="node-input-assetTagNumber-hint">Not registered yet</div>
</div>
<hr />
`;
}
@@ -505,7 +597,7 @@ class AssetMenu {
node.category = resolveCategoryKey();
const fields = ['supplier', 'assetType', 'model', 'unit'];
const fields = ['supplier', 'assetType', 'model', 'unit', 'assetTagNumber'];
const errors = [];
fields.forEach((field) => {
@@ -528,6 +620,10 @@ class AssetMenu {
}, {});
console.log('[AssetMenu] save result:', saved);
if (errors.length === 0 && this.syncAsset) {
this.syncAsset(node);
}
return errors.length === 0;
};
`;