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.

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