@halsystems/red-bacnet
Version:
NodeRED BACnet IP client
336 lines (297 loc) • 13.2 kB
JavaScript
'use strict';
require('./_alias.js');
const EventEmitter = require('events');
const bacnet = require('@root/ext/node-bacstack/dist/index.js')
const baEnum = bacnet.enum;
const { BaseJob } = require('@root/common/job/core.js')
const { bacnetDeviceSchema, bacnetObjectListSchema, bacnetPointSchema } = require('@root/common/schema.js')
const { EVENT_OUTPUT, EVENT_UPDATE_STATUS, EVENT_ERROR } = require('@root/common/core/constant.js')
const { readObjectList, smartReadProperty } = require('@root/common/bacnet.js')
const { bacnetUnitMap } = require('@root/common/bacnet_unit.js')
const { EG_BACNET_DEVICES } = require('@root/common/example')
const { errMsg, getErrMsg } = require('@root/common/func.js')
const { concurrentTasks } = require('@root/common/core/concurrent.js')
const {
ERR_GENERIC, ERR_SCHEMA_VALIDATION, ERR_INVALID_DATA_TYPE, ERR_IGNORE_DUPLICATED_DEVICE_NAME,
ERR_READING_POINTS,
} = require('@root/common/core/constant.js')
// ---------------------------------- constants ----------------------------------
const analogObjectTypes = [
baEnum.ObjectType.ANALOG_INPUT,
baEnum.ObjectType.ANALOG_OUTPUT,
baEnum.ObjectType.ANALOG_VALUE,
]
const binaryObjectTypes = [
baEnum.ObjectType.BINARY_INPUT,
baEnum.ObjectType.BINARY_OUTPUT,
baEnum.ObjectType.BINARY_VALUE,
]
const multiStateObjectTypes = [
baEnum.ObjectType.MULTI_STATE_INPUT,
baEnum.ObjectType.MULTI_STATE_OUTPUT,
baEnum.ObjectType.MULTI_STATE_VALUE
]
const supportedObjectTypes = [
...analogObjectTypes,
...binaryObjectTypes,
...multiStateObjectTypes
]
// ---------------------------------- export ----------------------------------
module.exports = {
DiscoverPointJob: class DiscoverPointJob extends BaseJob {
devices = [];
pointsList = [];
constructor(
client, eventEmitter, inputDevices, discoverMode, readMethod, groupExportDeviceCount,
maxConcurrentDeviceRead, maxConcurrentSinglePointRead, name = 'discover point'
) {
super();
this.client = client
this.eventEmitter = eventEmitter
this.inputDevices = inputDevices
this.discoverMode = discoverMode
this.readMethod = readMethod
this.groupExportDeviceCount = groupExportDeviceCount
this.maxConcurrentDeviceRead = maxConcurrentDeviceRead
this.maxConcurrentSinglePointRead = maxConcurrentSinglePointRead
this.name = name
}
async execute() {
this.#updateProgress(0)
return new Promise((resolve) => {
try {
// validate devices
if (!Array.isArray(this.inputDevices)) {
this.eventEmitter.emit(EVENT_ERROR, errMsg(
this.name, ERR_INVALID_DATA_TYPE,
{
devices: this?.inputDevices,
expected: 'array',
example: EG_BACNET_DEVICES
})
)
this.#updateProgress(100)
resolve();
return
}
let deviceNameSet = new Set();
for (let i = 0; i < this.inputDevices.length; i++) {
const device = this.inputDevices[i];
const { error, value: result } = bacnetDeviceSchema.validate(
device,
{ stripUnknown: true }
);
if (error) {
this.eventEmitter.emit(EVENT_ERROR, errMsg(
this.name, ERR_SCHEMA_VALIDATION, error
))
continue
}
// ensure no duplicate device name
if (deviceNameSet.has(result.deviceName)) {
this.eventEmitter.emit(EVENT_ERROR, errMsg(
this.name, ERR_IGNORE_DUPLICATED_DEVICE_NAME, result
))
continue
}
deviceNameSet.add(result.deviceName);
// add device to list
this.devices.push(result);
}
this.#updateProgress(10)
// read all device points
this.#readAllDevicePoints().then(() => {
if (this.pointsList.length > 0)
this.#exportPoints();
this.#updateProgress(100)
resolve();
});
} catch (err) {
this.eventEmitter.emit(EVENT_ERROR, errMsg(this.name, ERR_GENERIC, getErrMsg(err)))
resolve();
}
})
}
async #readAllDevicePoints() {
const discoverPointEvent = new EventEmitter();
const size = this.devices.length;
let count = 0;
let discoveredDevices = 0
discoverPointEvent.on(EVENT_OUTPUT, (data) => {
this.#updateProgress(
Math.round((85 / size) * (count + 1) + 10)
)
if (Array.isArray(data.result))
this.pointsList.push(...data.result);
count++;
discoveredDevices++;
if (discoveredDevices >= this.groupExportDeviceCount) {
this.#exportPoints();
discoveredDevices = 0
}
});
discoverPointEvent.on(EVENT_ERROR, (data) => {
this.eventEmitter.emit(EVENT_ERROR, data)
});
const tasks = this.devices.map((d, i) => ({
id: d.deviceName,
task: async () => {
void i
try {
const objectList = await readObjectList(this.client, d);
// istanbul ignore next
if (objectList == null) return;
let objectListFinal = objectList
.map(obj => {
const { error, value } = bacnetObjectListSchema.validate(obj);
if (error) {
/* istanbul ignore next */
{
discoverPointEvent.emit(EVENT_ERROR, errMsg(
this.name, ERR_SCHEMA_VALIDATION, {
bacnetObject: obj,
error: error,
}));
return null;
}
}
return value;
})
.filter(obj => {
if (this.discoverMode == 0) // basic
return obj && supportedObjectTypes.includes(obj.value.type);
// all
return obj !== null;
});
return await readPoints(
d, objectListFinal, discoverPointEvent, this.name, this.client, this.readMethod,
this.maxConcurrentSinglePointRead
);
} catch (error) {
this.eventEmitter.emit(EVENT_ERROR, errMsg(this.name, `Error reading ${d.deviceName} points`, error));
}
}
}));
await concurrentTasks(discoverPointEvent, tasks, this.maxConcurrentDeviceRead);
}
#updateProgress(progress) {
this.eventEmitter.emit(EVENT_UPDATE_STATUS, progress);
}
#exportPoints() {
let exportPointsList = [];
for (let i = 0; i < this.pointsList.length; i++) {
const { error, value: result } = bacnetPointSchema.validate(
this.pointsList[i], { stripUnknown: true }
);
// istanbul ignore next
{
if (error) {
this.eventEmitter.emit(EVENT_ERROR, errMsg(
this.name, ERR_SCHEMA_VALIDATION, {
bacnetPoint: this.pointsList[i],
error: error,
}));
continue
}
}
exportPointsList.push(result);
}
this.eventEmitter.emit(EVENT_OUTPUT, exportPointsList);
this.pointsList = [];
}
}
}
// ---------------------------------- functions ----------------------------------
const readPoints = async (
device, objects, eventEmitter, name, client, readMethod, maxConcurrentSinglePointRead
) => {
const points = [];
const reqArr = objects.map(obj => ({
objectId: { type: obj.value.type, instance: obj.value.instance },
properties: [
{ id: baEnum.PropertyIdentifier.PRESENT_VALUE },
{ id: baEnum.PropertyIdentifier.OBJECT_NAME },
...(analogObjectTypes.includes(obj.value.type) ? [{ id: baEnum.PropertyIdentifier.UNITS }] : []),
...(binaryObjectTypes.includes(obj.value.type) ? [
{ id: baEnum.PropertyIdentifier.INACTIVE_TEXT },
{ id: baEnum.PropertyIdentifier.ACTIVE_TEXT }
] : []),
...(multiStateObjectTypes.includes(obj.value.type) ? [{ id: baEnum.PropertyIdentifier.STATE_TEXT }] : [])
]
}));
try {
const result = await smartReadProperty(
client, device, reqArr, readMethod, maxConcurrentSinglePointRead, 50
);
result.forEach(i => {
/** i example
{
objectId: { type: 1, instance: 0 },
values: [
{ id: 85, index: 4294967295, value: [Array] },
{ id: 77, index: 4294967295, value: [Array] }
]
}
*/
const point = processPoint(i, device.deviceName);
points.push(point);
});
return points
} catch (error) {
eventEmitter.emit(EVENT_ERROR, errMsg(name, ERR_READING_POINTS, error));
}
};
const processPoint = (i, deviceName) => {
const point = {
deviceName,
pointName: `UnknownObjectName:[${i.objectId.type}:${i.objectId.instance}]`,
bacType: i.objectId.type,
bacInstance: i.objectId.instance,
bacProp: null,
value: null,
valueType: null,
facets: '',
priority: 0
};
setPointFacets(point, i);
setPointValues(point, i);
return point;
};
const setPointFacets = (point, i) => {
if (analogObjectTypes.includes(point.bacType)) {
const unitRaw = i.values.find(prop => prop.id === baEnum.PropertyIdentifier.UNITS)?.value?.[0]?.value;
const unit = bacnetUnitMap[unitRaw]
point.facets = unit ? `unit:${unit};precision:1` : 'precision:1';
} else if (binaryObjectTypes.includes(point.bacType)) {
const inactive = i.values.find(prop => prop.id === baEnum.PropertyIdentifier.INACTIVE_TEXT)?.value?.[0]?.value;
const active = i.values.find(prop => prop.id === baEnum.PropertyIdentifier.ACTIVE_TEXT)?.value?.[0]?.value;
point.facets = `falseText:${inactive ? inactive : 'false'};trueText:${active ? active : 'true'}`;
} else if (multiStateObjectTypes.includes(point.bacType)) {
const states = i.values.find(prop => prop.id === baEnum.PropertyIdentifier.STATE_TEXT)?.value;
if (Array.isArray(states)) {
point.facets = `range:{${states.map((item, index) => `${index + 1}:${item.value}`).join(';')}}`;
}
}
};
const setPointValues = (point, i) => {
i.values.forEach(prop => {
const value = prop?.value?.[0]?.value;
const valueType = prop?.value?.[0]?.type;
if (prop.id === baEnum.PropertyIdentifier.PRESENT_VALUE) {
point.bacProp = prop.id;
point.value = processValue(value, point.facets);
point.valueType = valueType
} else if (prop.id === baEnum.PropertyIdentifier.OBJECT_NAME && value?.errorClass == null && value?.errorCode == null) {
point.pointName = value;
}
});
};
const processValue = (value, facets) => {
if (value?.errorClass != null && value?.errorCode != null) return null;
const match = facets.match(/precision:(\d+)/);
const precision = match ? +match[1] : null;
if (typeof value === 'number')
return precision != null ? +value.toFixed(precision) : +value.toFixed(1);
else
return value
};