node-red-contrib-lorawan-bacnet-server 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of node-red-contrib-lorawan-bacnet-server might be problematic. Click here for more details.
- package/.gitattributes +2 -0
- package/CONTRIBUTING.md +64 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/examples/BACnet-Server.json +401 -0
- package/examples/LoRaBAC.json +1152 -0
- package/images/BACnetObjectListExample.png +0 -0
- package/images/controllerSetpointExample3.png +0 -0
- package/images/deviceListExample.png +0 -0
- package/images/deviceListExample3.png +0 -0
- package/images/lorabac.png +0 -0
- package/images/objectConfigurationExample2.png +0 -0
- package/images/objectListExample2.png +0 -0
- package/images/objectListExample3.png +0 -0
- package/images/valveSetpointExample3.png +0 -0
- package/images/valveTemperatureExample3.png +0 -0
- package/nodes/bacnet-point/bacnet-point.html +403 -0
- package/nodes/bacnet-point/bacnet-point.js +293 -0
- package/nodes/bacnet-server/bacnet-server.html +138 -0
- package/nodes/bacnet-server/bacnet-server.js +817 -0
- package/nodes/lorabac/lorabac.html +1588 -0
- package/nodes/lorabac/lorabac.js +652 -0
- package/package.json +39 -0
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BACnet Server Configuration Node
|
|
3
|
+
*
|
|
4
|
+
* This node manages the BACnet server that exposes Node-RED as a BACnet device
|
|
5
|
+
* on the network. It handles:
|
|
6
|
+
* - UDP listener initialization
|
|
7
|
+
* - Who-Is / I-Am discovery protocol
|
|
8
|
+
* - Object registry management
|
|
9
|
+
* - Read/Write Property requests from external BACnet clients
|
|
10
|
+
* - COV (Change of Value) subscriptions
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
module.exports = function (RED) {
|
|
14
|
+
const bacnet = require('node-bacnet');
|
|
15
|
+
|
|
16
|
+
// BACnet object type constants
|
|
17
|
+
const ObjectType = {
|
|
18
|
+
ANALOG_INPUT: 0,
|
|
19
|
+
ANALOG_OUTPUT: 1,
|
|
20
|
+
ANALOG_VALUE: 2,
|
|
21
|
+
BINARY_INPUT: 3,
|
|
22
|
+
BINARY_OUTPUT: 4,
|
|
23
|
+
BINARY_VALUE: 5,
|
|
24
|
+
DEVICE: 8,
|
|
25
|
+
MULTI_STATE_VALUE: 19
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// BACnet property identifiers
|
|
29
|
+
const PropertyId = {
|
|
30
|
+
OBJECT_IDENTIFIER: 75,
|
|
31
|
+
OBJECT_NAME: 77,
|
|
32
|
+
OBJECT_TYPE: 79,
|
|
33
|
+
PRESENT_VALUE: 85,
|
|
34
|
+
STATUS_FLAGS: 111,
|
|
35
|
+
EVENT_STATE: 36,
|
|
36
|
+
OUT_OF_SERVICE: 81,
|
|
37
|
+
UNITS: 117,
|
|
38
|
+
DESCRIPTION: 28,
|
|
39
|
+
PRIORITY_ARRAY: 87,
|
|
40
|
+
RELINQUISH_DEFAULT: 104,
|
|
41
|
+
COV_INCREMENT: 22,
|
|
42
|
+
NUMBER_OF_STATES: 74,
|
|
43
|
+
STATE_TEXT: 110,
|
|
44
|
+
VENDOR_NAME: 121,
|
|
45
|
+
VENDOR_IDENTIFIER: 120,
|
|
46
|
+
MODEL_NAME: 70,
|
|
47
|
+
FIRMWARE_REVISION: 44,
|
|
48
|
+
APPLICATION_SOFTWARE_VERSION: 12,
|
|
49
|
+
PROTOCOL_VERSION: 98,
|
|
50
|
+
PROTOCOL_REVISION: 139,
|
|
51
|
+
PROTOCOL_SERVICES_SUPPORTED: 97,
|
|
52
|
+
PROTOCOL_OBJECT_TYPES_SUPPORTED: 96,
|
|
53
|
+
MAX_APDU_LENGTH_ACCEPTED: 62,
|
|
54
|
+
SEGMENTATION_SUPPORTED: 107,
|
|
55
|
+
APDU_TIMEOUT: 11,
|
|
56
|
+
NUMBER_OF_APDU_RETRIES: 73,
|
|
57
|
+
DEVICE_ADDRESS_BINDING: 30,
|
|
58
|
+
DATABASE_REVISION: 155,
|
|
59
|
+
OBJECT_LIST: 76,
|
|
60
|
+
SYSTEM_STATUS: 112
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// BACnet application tags
|
|
64
|
+
const ApplicationTag = {
|
|
65
|
+
NULL: 0,
|
|
66
|
+
BOOLEAN: 1,
|
|
67
|
+
UNSIGNED_INTEGER: 2,
|
|
68
|
+
SIGNED_INTEGER: 3,
|
|
69
|
+
REAL: 4,
|
|
70
|
+
DOUBLE: 5,
|
|
71
|
+
OCTET_STRING: 6,
|
|
72
|
+
CHARACTER_STRING: 7,
|
|
73
|
+
BIT_STRING: 8,
|
|
74
|
+
ENUMERATED: 9,
|
|
75
|
+
DATE: 10,
|
|
76
|
+
TIME: 11,
|
|
77
|
+
OBJECT_IDENTIFIER: 12
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function BacnetServerNode(config) {
|
|
81
|
+
RED.nodes.createNode(this, config);
|
|
82
|
+
const node = this;
|
|
83
|
+
|
|
84
|
+
// Configuration
|
|
85
|
+
node.deviceId = parseInt(config.deviceId) || 123456;
|
|
86
|
+
node.deviceName = config.deviceName || 'Node-RED-BACnet';
|
|
87
|
+
node.vendorId = parseInt(config.vendorId) || 999;
|
|
88
|
+
node.port = parseInt(config.port) || 47808;
|
|
89
|
+
node.interface = config.interface || '';
|
|
90
|
+
node.broadcastAddress = config.broadcastAddress || '255.255.255.255';
|
|
91
|
+
|
|
92
|
+
// Object registry: Map of objectKey -> object data
|
|
93
|
+
// objectKey format: "type:instance" e.g., "2:1" for Analog Value instance 1
|
|
94
|
+
node.objects = new Map();
|
|
95
|
+
|
|
96
|
+
// Point nodes registered with this server
|
|
97
|
+
node.pointNodes = new Map();
|
|
98
|
+
|
|
99
|
+
// COV subscriptions: Map of subscriberKey -> subscription data
|
|
100
|
+
node.covSubscriptions = new Map();
|
|
101
|
+
|
|
102
|
+
// BACnet client instance
|
|
103
|
+
node.client = null;
|
|
104
|
+
|
|
105
|
+
// Initialize the BACnet server
|
|
106
|
+
node.init = function () {
|
|
107
|
+
try {
|
|
108
|
+
const clientOptions = {
|
|
109
|
+
port: node.port,
|
|
110
|
+
interface: node.interface || undefined,
|
|
111
|
+
broadcastAddress: node.broadcastAddress
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
node.client = new bacnet(clientOptions);
|
|
115
|
+
|
|
116
|
+
// Handle Who-Is requests
|
|
117
|
+
node.client.on('whoIs', (data) => {
|
|
118
|
+
node.handleWhoIs(data);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Handle Read Property requests
|
|
122
|
+
node.client.on('readProperty', (data) => {
|
|
123
|
+
node.handleReadProperty(data);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Handle Write Property requests
|
|
127
|
+
node.client.on('writeProperty', (data) => {
|
|
128
|
+
node.handleWriteProperty(data);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Handle Read Property Multiple requests
|
|
132
|
+
node.client.on('readPropertyMultiple', (data) => {
|
|
133
|
+
node.handleReadPropertyMultiple(data);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Handle COV subscriptions
|
|
137
|
+
node.client.on('subscribeCov', (data) => {
|
|
138
|
+
node.handleSubscribeCov(data);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Handle COV subscription cancellation
|
|
142
|
+
node.client.on('subscribeProperty', (data) => {
|
|
143
|
+
node.handleSubscribeProperty(data);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
node.log(`BACnet Server started - Device ID: ${node.deviceId}, Port: ${node.port}`);
|
|
147
|
+
node.emit('ready');
|
|
148
|
+
|
|
149
|
+
} catch (err) {
|
|
150
|
+
node.error(`Failed to initialize BACnet server: ${err.message}`);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Handle Who-Is discovery requests
|
|
155
|
+
node.handleWhoIs = function (data) {
|
|
156
|
+
const lowLimit = data.lowLimit;
|
|
157
|
+
const highLimit = data.highLimit;
|
|
158
|
+
|
|
159
|
+
// Check if our device ID is within the requested range
|
|
160
|
+
if ((lowLimit === undefined || node.deviceId >= lowLimit) &&
|
|
161
|
+
(highLimit === undefined || node.deviceId <= highLimit)) {
|
|
162
|
+
|
|
163
|
+
// Respond with I-Am
|
|
164
|
+
node.client.iAmResponse(
|
|
165
|
+
node.deviceId,
|
|
166
|
+
bacnet.enum.Segmentation.NO_SEGMENTATION,
|
|
167
|
+
node.vendorId
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
node.debug(`Responded to Who-Is from ${data.address}`);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Handle Read Property requests
|
|
175
|
+
node.handleReadProperty = function (data) {
|
|
176
|
+
const objectId = data.request.objectId;
|
|
177
|
+
const propertyId = data.request.property.id;
|
|
178
|
+
const arrayIndex = data.request.property.index;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const value = node.getPropertyValue(objectId, propertyId, arrayIndex);
|
|
182
|
+
|
|
183
|
+
if (value !== null && value !== undefined) {
|
|
184
|
+
node.client.readPropertyResponse(
|
|
185
|
+
data.address,
|
|
186
|
+
data.invokeId,
|
|
187
|
+
objectId,
|
|
188
|
+
{ id: propertyId, index: arrayIndex },
|
|
189
|
+
value
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Notify point node if configured
|
|
193
|
+
const pointNode = node.getPointNode(objectId);
|
|
194
|
+
if (pointNode && pointNode.outputOnRead) {
|
|
195
|
+
pointNode.emitReadEvent(value);
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
node.client.errorResponse(
|
|
199
|
+
data.address,
|
|
200
|
+
bacnet.enum.ConfirmedService.READ_PROPERTY,
|
|
201
|
+
data.invokeId,
|
|
202
|
+
bacnet.enum.ErrorClass.PROPERTY,
|
|
203
|
+
bacnet.enum.ErrorCode.UNKNOWN_PROPERTY
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
node.error(`Read Property error: ${err.message}`);
|
|
208
|
+
node.client.errorResponse(
|
|
209
|
+
data.address,
|
|
210
|
+
bacnet.enum.ConfirmedService.READ_PROPERTY,
|
|
211
|
+
data.invokeId,
|
|
212
|
+
bacnet.enum.ErrorClass.OBJECT,
|
|
213
|
+
bacnet.enum.ErrorCode.UNKNOWN_OBJECT
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Handle Write Property requests
|
|
219
|
+
node.handleWriteProperty = function (data) {
|
|
220
|
+
const objectId = data.request.objectId;
|
|
221
|
+
const propertyId = data.request.property.id;
|
|
222
|
+
const priority = data.request.priority || 16;
|
|
223
|
+
const value = data.request.value;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const result = node.setPropertyValue(objectId, propertyId, value, priority);
|
|
227
|
+
|
|
228
|
+
if (result.success) {
|
|
229
|
+
node.client.simpleAckResponse(
|
|
230
|
+
data.address,
|
|
231
|
+
bacnet.enum.ConfirmedService.WRITE_PROPERTY,
|
|
232
|
+
data.invokeId
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Notify point node
|
|
236
|
+
const pointNode = node.getPointNode(objectId);
|
|
237
|
+
if (pointNode) {
|
|
238
|
+
pointNode.emitWriteEvent(result.value, priority, data.address);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Check COV notifications
|
|
242
|
+
node.checkCovNotifications(objectId);
|
|
243
|
+
} else {
|
|
244
|
+
node.client.errorResponse(
|
|
245
|
+
data.address,
|
|
246
|
+
bacnet.enum.ConfirmedService.WRITE_PROPERTY,
|
|
247
|
+
data.invokeId,
|
|
248
|
+
bacnet.enum.ErrorClass.PROPERTY,
|
|
249
|
+
result.errorCode || bacnet.enum.ErrorCode.WRITE_ACCESS_DENIED
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
node.error(`Write Property error: ${err.message}`);
|
|
254
|
+
node.client.errorResponse(
|
|
255
|
+
data.address,
|
|
256
|
+
bacnet.enum.ConfirmedService.WRITE_PROPERTY,
|
|
257
|
+
data.invokeId,
|
|
258
|
+
bacnet.enum.ErrorClass.OBJECT,
|
|
259
|
+
bacnet.enum.ErrorCode.UNKNOWN_OBJECT
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Handle Read Property Multiple requests
|
|
265
|
+
node.handleReadPropertyMultiple = function (data) {
|
|
266
|
+
const properties = data.request.properties;
|
|
267
|
+
const results = [];
|
|
268
|
+
|
|
269
|
+
for (const prop of properties) {
|
|
270
|
+
const objectId = prop.objectId;
|
|
271
|
+
const propResults = [];
|
|
272
|
+
|
|
273
|
+
for (const propRef of prop.properties) {
|
|
274
|
+
const propertyId = propRef.id;
|
|
275
|
+
const arrayIndex = propRef.index;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const value = node.getPropertyValue(objectId, propertyId, arrayIndex);
|
|
279
|
+
|
|
280
|
+
if (value !== null && value !== undefined) {
|
|
281
|
+
propResults.push({
|
|
282
|
+
property: { id: propertyId, index: arrayIndex },
|
|
283
|
+
value: value
|
|
284
|
+
});
|
|
285
|
+
} else {
|
|
286
|
+
propResults.push({
|
|
287
|
+
property: { id: propertyId, index: arrayIndex },
|
|
288
|
+
error: {
|
|
289
|
+
errorClass: bacnet.enum.ErrorClass.PROPERTY,
|
|
290
|
+
errorCode: bacnet.enum.ErrorCode.UNKNOWN_PROPERTY
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
propResults.push({
|
|
296
|
+
property: { id: propertyId, index: arrayIndex },
|
|
297
|
+
error: {
|
|
298
|
+
errorClass: bacnet.enum.ErrorClass.OBJECT,
|
|
299
|
+
errorCode: bacnet.enum.ErrorCode.UNKNOWN_OBJECT
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
results.push({
|
|
306
|
+
objectId: objectId,
|
|
307
|
+
values: propResults
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
node.client.readPropertyMultipleResponse(
|
|
312
|
+
data.address,
|
|
313
|
+
data.invokeId,
|
|
314
|
+
results
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Handle Subscribe COV requests
|
|
319
|
+
node.handleSubscribeCov = function (data) {
|
|
320
|
+
const subscriberProcessId = data.request.subscriberProcessId;
|
|
321
|
+
const objectId = data.request.monitoredObjectId;
|
|
322
|
+
const issueConfirmedNotifications = data.request.issueConfirmedNotifications;
|
|
323
|
+
const lifetime = data.request.lifetime || 0;
|
|
324
|
+
|
|
325
|
+
const objectKey = `${objectId.type}:${objectId.instance}`;
|
|
326
|
+
const obj = node.objects.get(objectKey);
|
|
327
|
+
|
|
328
|
+
if (!obj) {
|
|
329
|
+
node.client.errorResponse(
|
|
330
|
+
data.address,
|
|
331
|
+
bacnet.enum.ConfirmedService.SUBSCRIBE_COV,
|
|
332
|
+
data.invokeId,
|
|
333
|
+
bacnet.enum.ErrorClass.OBJECT,
|
|
334
|
+
bacnet.enum.ErrorCode.UNKNOWN_OBJECT
|
|
335
|
+
);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const subscriptionKey = `${data.address}:${subscriberProcessId}:${objectKey}`;
|
|
340
|
+
|
|
341
|
+
if (lifetime === 0 && data.request.cancellationRequest) {
|
|
342
|
+
// Cancel subscription
|
|
343
|
+
node.covSubscriptions.delete(subscriptionKey);
|
|
344
|
+
node.debug(`COV subscription cancelled: ${subscriptionKey}`);
|
|
345
|
+
} else {
|
|
346
|
+
// Create/update subscription
|
|
347
|
+
node.covSubscriptions.set(subscriptionKey, {
|
|
348
|
+
address: data.address,
|
|
349
|
+
processId: subscriberProcessId,
|
|
350
|
+
objectId: objectId,
|
|
351
|
+
confirmed: issueConfirmedNotifications,
|
|
352
|
+
lifetime: lifetime,
|
|
353
|
+
expiresAt: lifetime > 0 ? Date.now() + (lifetime * 1000) : null,
|
|
354
|
+
lastValue: obj.presentValue
|
|
355
|
+
});
|
|
356
|
+
node.debug(`COV subscription created: ${subscriptionKey}, lifetime: ${lifetime}s`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
node.client.simpleAckResponse(
|
|
360
|
+
data.address,
|
|
361
|
+
bacnet.enum.ConfirmedService.SUBSCRIBE_COV,
|
|
362
|
+
data.invokeId
|
|
363
|
+
);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// Handle Subscribe Property requests (COV-P)
|
|
367
|
+
node.handleSubscribeProperty = function (data) {
|
|
368
|
+
// Similar to COV but for specific properties
|
|
369
|
+
node.handleSubscribeCov(data);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Check and send COV notifications
|
|
373
|
+
node.checkCovNotifications = function (objectId) {
|
|
374
|
+
const objectKey = `${objectId.type}:${objectId.instance}`;
|
|
375
|
+
const obj = node.objects.get(objectKey);
|
|
376
|
+
|
|
377
|
+
if (!obj) return;
|
|
378
|
+
|
|
379
|
+
const now = Date.now();
|
|
380
|
+
|
|
381
|
+
for (const [key, sub] of node.covSubscriptions) {
|
|
382
|
+
// Check if subscription is for this object
|
|
383
|
+
if (`${sub.objectId.type}:${sub.objectId.instance}` !== objectKey) continue;
|
|
384
|
+
|
|
385
|
+
// Check if subscription has expired
|
|
386
|
+
if (sub.expiresAt && now > sub.expiresAt) {
|
|
387
|
+
node.covSubscriptions.delete(key);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Check if value has changed enough to notify
|
|
392
|
+
const covIncrement = obj.covIncrement || 0;
|
|
393
|
+
const valueDiff = Math.abs(obj.presentValue - sub.lastValue);
|
|
394
|
+
|
|
395
|
+
if (valueDiff >= covIncrement || obj.objectType === ObjectType.BINARY_VALUE ||
|
|
396
|
+
obj.objectType === ObjectType.BINARY_INPUT || obj.objectType === ObjectType.BINARY_OUTPUT) {
|
|
397
|
+
|
|
398
|
+
// Send COV notification
|
|
399
|
+
const values = [
|
|
400
|
+
{ property: { id: PropertyId.PRESENT_VALUE }, value: node.encodeValue(obj.presentValue, obj.objectType) },
|
|
401
|
+
{ property: { id: PropertyId.STATUS_FLAGS }, value: [{ type: ApplicationTag.BIT_STRING, value: { bitsUsed: 4, value: [0] } }] }
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
if (sub.confirmed) {
|
|
406
|
+
node.client.confirmedCovNotification(
|
|
407
|
+
sub.address,
|
|
408
|
+
{ type: ObjectType.DEVICE, instance: node.deviceId },
|
|
409
|
+
objectId,
|
|
410
|
+
sub.lifetime,
|
|
411
|
+
values
|
|
412
|
+
);
|
|
413
|
+
} else {
|
|
414
|
+
node.client.unconfirmedCovNotification(
|
|
415
|
+
sub.address,
|
|
416
|
+
sub.processId,
|
|
417
|
+
{ type: ObjectType.DEVICE, instance: node.deviceId },
|
|
418
|
+
objectId,
|
|
419
|
+
sub.lifetime,
|
|
420
|
+
values
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
sub.lastValue = obj.presentValue;
|
|
425
|
+
node.debug(`COV notification sent to ${sub.address} for ${objectKey}`);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
node.error(`Failed to send COV notification: ${err.message}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Get property value for an object
|
|
434
|
+
node.getPropertyValue = function (objectId, propertyId, arrayIndex) {
|
|
435
|
+
// Handle Device object properties
|
|
436
|
+
if (objectId.type === ObjectType.DEVICE && objectId.instance === node.deviceId) {
|
|
437
|
+
return node.getDeviceProperty(propertyId, arrayIndex);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const objectKey = `${objectId.type}:${objectId.instance}`;
|
|
441
|
+
const obj = node.objects.get(objectKey);
|
|
442
|
+
|
|
443
|
+
if (!obj) return null;
|
|
444
|
+
|
|
445
|
+
switch (propertyId) {
|
|
446
|
+
case PropertyId.OBJECT_IDENTIFIER:
|
|
447
|
+
return [{ type: ApplicationTag.OBJECT_IDENTIFIER, value: objectId }];
|
|
448
|
+
|
|
449
|
+
case PropertyId.OBJECT_NAME:
|
|
450
|
+
return [{ type: ApplicationTag.CHARACTER_STRING, value: obj.objectName }];
|
|
451
|
+
|
|
452
|
+
case PropertyId.OBJECT_TYPE:
|
|
453
|
+
return [{ type: ApplicationTag.ENUMERATED, value: objectId.type }];
|
|
454
|
+
|
|
455
|
+
case PropertyId.PRESENT_VALUE:
|
|
456
|
+
return node.encodeValue(obj.presentValue, objectId.type);
|
|
457
|
+
|
|
458
|
+
case PropertyId.STATUS_FLAGS:
|
|
459
|
+
return [{ type: ApplicationTag.BIT_STRING, value: { bitsUsed: 4, value: [0] } }];
|
|
460
|
+
|
|
461
|
+
case PropertyId.EVENT_STATE:
|
|
462
|
+
return [{ type: ApplicationTag.ENUMERATED, value: 0 }]; // NORMAL
|
|
463
|
+
|
|
464
|
+
case PropertyId.OUT_OF_SERVICE:
|
|
465
|
+
return [{ type: ApplicationTag.BOOLEAN, value: obj.outOfService || false }];
|
|
466
|
+
|
|
467
|
+
case PropertyId.UNITS:
|
|
468
|
+
if (obj.units !== undefined) {
|
|
469
|
+
return [{ type: ApplicationTag.ENUMERATED, value: obj.units }];
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
|
|
473
|
+
case PropertyId.DESCRIPTION:
|
|
474
|
+
return [{ type: ApplicationTag.CHARACTER_STRING, value: obj.description || '' }];
|
|
475
|
+
|
|
476
|
+
case PropertyId.PRIORITY_ARRAY:
|
|
477
|
+
if (obj.priorityArray) {
|
|
478
|
+
if (arrayIndex !== undefined && arrayIndex >= 1 && arrayIndex <= 16) {
|
|
479
|
+
const val = obj.priorityArray[arrayIndex - 1];
|
|
480
|
+
if (val === null) {
|
|
481
|
+
return [{ type: ApplicationTag.NULL, value: null }];
|
|
482
|
+
}
|
|
483
|
+
return node.encodeValue(val, objectId.type);
|
|
484
|
+
}
|
|
485
|
+
// Return full array
|
|
486
|
+
return obj.priorityArray.map(val => {
|
|
487
|
+
if (val === null) {
|
|
488
|
+
return { type: ApplicationTag.NULL, value: null };
|
|
489
|
+
}
|
|
490
|
+
return node.encodeValue(val, objectId.type)[0];
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
|
|
495
|
+
case PropertyId.RELINQUISH_DEFAULT:
|
|
496
|
+
if (obj.relinquishDefault !== undefined) {
|
|
497
|
+
return node.encodeValue(obj.relinquishDefault, objectId.type);
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
|
|
501
|
+
case PropertyId.COV_INCREMENT:
|
|
502
|
+
if (obj.covIncrement !== undefined) {
|
|
503
|
+
return [{ type: ApplicationTag.REAL, value: obj.covIncrement }];
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
|
|
507
|
+
case PropertyId.NUMBER_OF_STATES:
|
|
508
|
+
if (obj.numberOfStates !== undefined) {
|
|
509
|
+
return [{ type: ApplicationTag.UNSIGNED_INTEGER, value: obj.numberOfStates }];
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
|
|
513
|
+
case PropertyId.STATE_TEXT:
|
|
514
|
+
if (obj.stateText) {
|
|
515
|
+
if (arrayIndex !== undefined && arrayIndex >= 1 && arrayIndex <= obj.stateText.length) {
|
|
516
|
+
return [{ type: ApplicationTag.CHARACTER_STRING, value: obj.stateText[arrayIndex - 1] }];
|
|
517
|
+
}
|
|
518
|
+
return obj.stateText.map(text => ({ type: ApplicationTag.CHARACTER_STRING, value: text }));
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
|
|
522
|
+
default:
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// Get device object property
|
|
528
|
+
node.getDeviceProperty = function (propertyId, arrayIndex) {
|
|
529
|
+
switch (propertyId) {
|
|
530
|
+
case PropertyId.OBJECT_IDENTIFIER:
|
|
531
|
+
return [{ type: ApplicationTag.OBJECT_IDENTIFIER, value: { type: ObjectType.DEVICE, instance: node.deviceId } }];
|
|
532
|
+
|
|
533
|
+
case PropertyId.OBJECT_NAME:
|
|
534
|
+
return [{ type: ApplicationTag.CHARACTER_STRING, value: node.deviceName }];
|
|
535
|
+
|
|
536
|
+
case PropertyId.OBJECT_TYPE:
|
|
537
|
+
return [{ type: ApplicationTag.ENUMERATED, value: ObjectType.DEVICE }];
|
|
538
|
+
|
|
539
|
+
case PropertyId.SYSTEM_STATUS:
|
|
540
|
+
return [{ type: ApplicationTag.ENUMERATED, value: 0 }]; // OPERATIONAL
|
|
541
|
+
|
|
542
|
+
case PropertyId.VENDOR_NAME:
|
|
543
|
+
return [{ type: ApplicationTag.CHARACTER_STRING, value: 'Node-RED' }];
|
|
544
|
+
|
|
545
|
+
case PropertyId.VENDOR_IDENTIFIER:
|
|
546
|
+
return [{ type: ApplicationTag.UNSIGNED_INTEGER, value: node.vendorId }];
|
|
547
|
+
|
|
548
|
+
case PropertyId.MODEL_NAME:
|
|
549
|
+
return [{ type: ApplicationTag.CHARACTER_STRING, value: 'BACnet Server Node' }];
|
|
550
|
+
|
|
551
|
+
case PropertyId.FIRMWARE_REVISION:
|
|
552
|
+
return [{ type: ApplicationTag.CHARACTER_STRING, value: '1.0.0' }];
|
|
553
|
+
|
|
554
|
+
case PropertyId.APPLICATION_SOFTWARE_VERSION:
|
|
555
|
+
return [{ type: ApplicationTag.CHARACTER_STRING, value: '1.0.0' }];
|
|
556
|
+
|
|
557
|
+
case PropertyId.PROTOCOL_VERSION:
|
|
558
|
+
return [{ type: ApplicationTag.UNSIGNED_INTEGER, value: 1 }];
|
|
559
|
+
|
|
560
|
+
case PropertyId.PROTOCOL_REVISION:
|
|
561
|
+
return [{ type: ApplicationTag.UNSIGNED_INTEGER, value: 14 }];
|
|
562
|
+
|
|
563
|
+
case PropertyId.PROTOCOL_SERVICES_SUPPORTED:
|
|
564
|
+
// Bit string indicating supported services
|
|
565
|
+
return [{ type: ApplicationTag.BIT_STRING, value: { bitsUsed: 40, value: [0x0F, 0x0C, 0x00, 0x00, 0x00] } }];
|
|
566
|
+
|
|
567
|
+
case PropertyId.PROTOCOL_OBJECT_TYPES_SUPPORTED:
|
|
568
|
+
// Bit string indicating supported object types
|
|
569
|
+
return [{ type: ApplicationTag.BIT_STRING, value: { bitsUsed: 25, value: [0xFF, 0x03, 0x00, 0x08] } }];
|
|
570
|
+
|
|
571
|
+
case PropertyId.MAX_APDU_LENGTH_ACCEPTED:
|
|
572
|
+
return [{ type: ApplicationTag.UNSIGNED_INTEGER, value: 1476 }];
|
|
573
|
+
|
|
574
|
+
case PropertyId.SEGMENTATION_SUPPORTED:
|
|
575
|
+
return [{ type: ApplicationTag.ENUMERATED, value: 3 }]; // NO_SEGMENTATION
|
|
576
|
+
|
|
577
|
+
case PropertyId.APDU_TIMEOUT:
|
|
578
|
+
return [{ type: ApplicationTag.UNSIGNED_INTEGER, value: 3000 }];
|
|
579
|
+
|
|
580
|
+
case PropertyId.NUMBER_OF_APDU_RETRIES:
|
|
581
|
+
return [{ type: ApplicationTag.UNSIGNED_INTEGER, value: 3 }];
|
|
582
|
+
|
|
583
|
+
case PropertyId.DEVICE_ADDRESS_BINDING:
|
|
584
|
+
return [];
|
|
585
|
+
|
|
586
|
+
case PropertyId.DATABASE_REVISION:
|
|
587
|
+
return [{ type: ApplicationTag.UNSIGNED_INTEGER, value: 1 }];
|
|
588
|
+
|
|
589
|
+
case PropertyId.OBJECT_LIST:
|
|
590
|
+
const objectList = [{ type: ObjectType.DEVICE, instance: node.deviceId }];
|
|
591
|
+
for (const [key, obj] of node.objects) {
|
|
592
|
+
const [type, instance] = key.split(':').map(Number);
|
|
593
|
+
objectList.push({ type, instance });
|
|
594
|
+
}
|
|
595
|
+
if (arrayIndex !== undefined) {
|
|
596
|
+
if (arrayIndex === 0) {
|
|
597
|
+
return [{ type: ApplicationTag.UNSIGNED_INTEGER, value: objectList.length }];
|
|
598
|
+
}
|
|
599
|
+
if (arrayIndex >= 1 && arrayIndex <= objectList.length) {
|
|
600
|
+
return [{ type: ApplicationTag.OBJECT_IDENTIFIER, value: objectList[arrayIndex - 1] }];
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
return objectList.map(obj => ({ type: ApplicationTag.OBJECT_IDENTIFIER, value: obj }));
|
|
605
|
+
|
|
606
|
+
default:
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// Set property value for an object
|
|
612
|
+
node.setPropertyValue = function (objectId, propertyId, value, priority) {
|
|
613
|
+
const objectKey = `${objectId.type}:${objectId.instance}`;
|
|
614
|
+
const obj = node.objects.get(objectKey);
|
|
615
|
+
|
|
616
|
+
if (!obj) {
|
|
617
|
+
return { success: false, errorCode: bacnet.enum.ErrorCode.UNKNOWN_OBJECT };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (!obj.writable && propertyId === PropertyId.PRESENT_VALUE) {
|
|
621
|
+
return { success: false, errorCode: bacnet.enum.ErrorCode.WRITE_ACCESS_DENIED };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (propertyId === PropertyId.PRESENT_VALUE) {
|
|
625
|
+
const decodedValue = node.decodeValue(value, objectId.type);
|
|
626
|
+
|
|
627
|
+
if (obj.priorityArray) {
|
|
628
|
+
// Handle priority array for commandable objects
|
|
629
|
+
const priorityIndex = (priority || 16) - 1;
|
|
630
|
+
|
|
631
|
+
if (decodedValue === null) {
|
|
632
|
+
// Relinquish command at this priority
|
|
633
|
+
obj.priorityArray[priorityIndex] = null;
|
|
634
|
+
} else {
|
|
635
|
+
obj.priorityArray[priorityIndex] = decodedValue;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Calculate present value from priority array
|
|
639
|
+
obj.presentValue = node.calculatePresentValue(obj);
|
|
640
|
+
} else {
|
|
641
|
+
obj.presentValue = decodedValue;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return { success: true, value: obj.presentValue };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (propertyId === PropertyId.OUT_OF_SERVICE) {
|
|
648
|
+
obj.outOfService = node.decodeValue(value, 'boolean');
|
|
649
|
+
return { success: true, value: obj.outOfService };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return { success: false, errorCode: bacnet.enum.ErrorCode.WRITE_ACCESS_DENIED };
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// Calculate present value from priority array
|
|
656
|
+
node.calculatePresentValue = function (obj) {
|
|
657
|
+
if (!obj.priorityArray) {
|
|
658
|
+
return obj.presentValue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
for (let i = 0; i < 16; i++) {
|
|
662
|
+
if (obj.priorityArray[i] !== null) {
|
|
663
|
+
return obj.priorityArray[i];
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return obj.relinquishDefault !== undefined ? obj.relinquishDefault : 0;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// Encode value based on object type
|
|
671
|
+
node.encodeValue = function (value, objectType) {
|
|
672
|
+
switch (objectType) {
|
|
673
|
+
case ObjectType.BINARY_INPUT:
|
|
674
|
+
case ObjectType.BINARY_OUTPUT:
|
|
675
|
+
case ObjectType.BINARY_VALUE:
|
|
676
|
+
return [{ type: ApplicationTag.ENUMERATED, value: value ? 1 : 0 }];
|
|
677
|
+
|
|
678
|
+
case ObjectType.MULTI_STATE_VALUE:
|
|
679
|
+
return [{ type: ApplicationTag.UNSIGNED_INTEGER, value: value }];
|
|
680
|
+
|
|
681
|
+
case ObjectType.ANALOG_INPUT:
|
|
682
|
+
case ObjectType.ANALOG_OUTPUT:
|
|
683
|
+
case ObjectType.ANALOG_VALUE:
|
|
684
|
+
default:
|
|
685
|
+
return [{ type: ApplicationTag.REAL, value: parseFloat(value) || 0 }];
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// Decode value from BACnet format
|
|
690
|
+
node.decodeValue = function (value, objectType) {
|
|
691
|
+
if (!value || !value.length) return null;
|
|
692
|
+
|
|
693
|
+
const firstValue = value[0];
|
|
694
|
+
|
|
695
|
+
if (firstValue.type === ApplicationTag.NULL) {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
switch (objectType) {
|
|
700
|
+
case ObjectType.BINARY_INPUT:
|
|
701
|
+
case ObjectType.BINARY_OUTPUT:
|
|
702
|
+
case ObjectType.BINARY_VALUE:
|
|
703
|
+
case 'boolean':
|
|
704
|
+
return firstValue.value ? 1 : 0;
|
|
705
|
+
|
|
706
|
+
case ObjectType.MULTI_STATE_VALUE:
|
|
707
|
+
return parseInt(firstValue.value) || 1;
|
|
708
|
+
|
|
709
|
+
case ObjectType.ANALOG_INPUT:
|
|
710
|
+
case ObjectType.ANALOG_OUTPUT:
|
|
711
|
+
case ObjectType.ANALOG_VALUE:
|
|
712
|
+
default:
|
|
713
|
+
return parseFloat(firstValue.value) || 0;
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// Register a point object
|
|
718
|
+
node.registerObject = function (objectData, pointNode) {
|
|
719
|
+
const objectKey = `${objectData.objectType}:${objectData.instanceNumber}`;
|
|
720
|
+
|
|
721
|
+
node.objects.set(objectKey, {
|
|
722
|
+
objectType: objectData.objectType,
|
|
723
|
+
instanceNumber: objectData.instanceNumber,
|
|
724
|
+
objectName: objectData.objectName,
|
|
725
|
+
presentValue: objectData.initialValue,
|
|
726
|
+
units: objectData.units,
|
|
727
|
+
description: objectData.description || '',
|
|
728
|
+
writable: objectData.writable,
|
|
729
|
+
outOfService: false,
|
|
730
|
+
priorityArray: objectData.priorityArray ? new Array(16).fill(null) : null,
|
|
731
|
+
relinquishDefault: objectData.relinquishDefault,
|
|
732
|
+
covIncrement: objectData.covIncrement,
|
|
733
|
+
numberOfStates: objectData.numberOfStates,
|
|
734
|
+
stateText: objectData.stateText
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
node.pointNodes.set(objectKey, pointNode);
|
|
738
|
+
node.debug(`Registered object: ${objectData.objectName} (${objectKey})`);
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
// Unregister a point object
|
|
742
|
+
node.unregisterObject = function (objectType, instanceNumber) {
|
|
743
|
+
const objectKey = `${objectType}:${instanceNumber}`;
|
|
744
|
+
node.objects.delete(objectKey);
|
|
745
|
+
node.pointNodes.delete(objectKey);
|
|
746
|
+
|
|
747
|
+
// Remove COV subscriptions for this object
|
|
748
|
+
for (const [key, sub] of node.covSubscriptions) {
|
|
749
|
+
if (`${sub.objectId.type}:${sub.objectId.instance}` === objectKey) {
|
|
750
|
+
node.covSubscriptions.delete(key);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
node.debug(`Unregistered object: ${objectKey}`);
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// Update object value from Node-RED flow
|
|
758
|
+
node.updateObjectValue = function (objectType, instanceNumber, value) {
|
|
759
|
+
const objectKey = `${objectType}:${instanceNumber}`;
|
|
760
|
+
const obj = node.objects.get(objectKey);
|
|
761
|
+
|
|
762
|
+
if (obj) {
|
|
763
|
+
const oldValue = obj.presentValue;
|
|
764
|
+
obj.presentValue = value;
|
|
765
|
+
|
|
766
|
+
// Check COV notifications
|
|
767
|
+
if (oldValue !== value) {
|
|
768
|
+
node.checkCovNotifications({ type: objectType, instance: instanceNumber });
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
return false;
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// Get point node for an object
|
|
777
|
+
node.getPointNode = function (objectId) {
|
|
778
|
+
const objectKey = `${objectId.type}:${objectId.instance}`;
|
|
779
|
+
return node.pointNodes.get(objectKey);
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// Clean up expired COV subscriptions periodically
|
|
783
|
+
node.covCleanupInterval = setInterval(() => {
|
|
784
|
+
const now = Date.now();
|
|
785
|
+
for (const [key, sub] of node.covSubscriptions) {
|
|
786
|
+
if (sub.expiresAt && now > sub.expiresAt) {
|
|
787
|
+
node.covSubscriptions.delete(key);
|
|
788
|
+
node.debug(`COV subscription expired: ${key}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}, 60000); // Check every minute
|
|
792
|
+
|
|
793
|
+
// Initialize the server
|
|
794
|
+
node.init();
|
|
795
|
+
|
|
796
|
+
// Handle node close
|
|
797
|
+
node.on('close', function (done) {
|
|
798
|
+
if (node.covCleanupInterval) {
|
|
799
|
+
clearInterval(node.covCleanupInterval);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (node.client) {
|
|
803
|
+
node.client.close();
|
|
804
|
+
node.client = null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
node.objects.clear();
|
|
808
|
+
node.pointNodes.clear();
|
|
809
|
+
node.covSubscriptions.clear();
|
|
810
|
+
|
|
811
|
+
node.log('BACnet Server stopped');
|
|
812
|
+
done();
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
RED.nodes.registerType('bacnet-server', BacnetServerNode);
|
|
817
|
+
};
|