diff --git a/examples/discover-devices-via-bbmd.ts b/examples/discover-devices-via-bbmd.ts new file mode 100644 index 0000000..8dd6292 --- /dev/null +++ b/examples/discover-devices-via-bbmd.ts @@ -0,0 +1,52 @@ +/** + * Discover devices through BBMD: + * 1) Register as foreign device + * 2) Send Who-Is using BVLC Distribute-Broadcast-To-Network (0x09) + * + * Usage: + * npx ts-node examples/discover-devices-via-bbmd.ts [ttl-seconds] + */ + +import Bacnet from '../src' + +const bbmdAddress = process.argv[2] +const ttlSeconds = Number(process.argv[3] || 60) +const localPort = Number(process.env.BACNET_PORT || 47809) + +if (!bbmdAddress) { + console.error('Missing BBMD address. Usage: [ttl-seconds]') + process.exit(1) +} + +const client = new Bacnet({ + apduTimeout: 5000, + interface: '0.0.0.0', + port: localPort, +}) + +client.on('error', (err: Error) => { + console.error(`BACnet error: ${err.message}`) +}) + +client.on('iAm', (device: any) => { + console.log( + `iAm from ${device?.payload?.deviceId} via ${device?.header?.sender?.address} (forwardedFrom=${device?.header?.sender?.forwardedFrom ?? 'n/a'})`, + ) +}) + +client.on('listening', async () => { + try { + console.log(`Listening on UDP ${localPort}`) + await client.registerForeignDevice({ address: bbmdAddress }, ttlSeconds) + console.log(`FDR success on ${bbmdAddress} (ttl=${ttlSeconds}s)`) + client.whoIsThroughBBMD({ address: bbmdAddress }) + console.log('Who-Is sent through BBMD') + } catch (err) { + console.error(`Failed: ${String((err as Error)?.message || err)}`) + } +}) + +setTimeout(() => { + client.close() + console.log('Done') +}, 20000) diff --git a/examples/register-foreign-device.ts b/examples/register-foreign-device.ts new file mode 100644 index 0000000..c2bf7aa --- /dev/null +++ b/examples/register-foreign-device.ts @@ -0,0 +1,111 @@ +/** + * Register this BACnet client as a Foreign Device in a BBMD and periodically renew it. + * + * Usage: + * npx ts-node examples/register-foreign-device.ts [ttl-seconds] + * + * Example: + * npx ts-node examples/register-foreign-device.ts 192.168.40.10:47808 900 + */ + +import Bacnet from '../src' + +const bbmdAddress = process.argv[2] || process.env.BBMD_ADDRESS +const ttlSeconds = Number(process.argv[3] || process.env.FDR_TTL || 900) +const localPort = Number(process.env.BACNET_PORT || 47809) +const renewRatio = Number(process.env.FDR_RENEW_RATIO || 0.8) + +if (!bbmdAddress) { + console.error( + 'Missing BBMD address. Pass or set BBMD_ADDRESS.', + ) + process.exit(1) +} + +if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0 || ttlSeconds > 0xffff) { + console.error('Invalid TTL. Expected integer in range 1..65535.') + process.exit(1) +} + +const renewDelayMs = Math.max( + 1000, + Math.floor( + ttlSeconds * + (renewRatio > 0 && renewRatio < 1 ? renewRatio : 0.8) * + 1000, + ), +) + +const bacnetClient = new Bacnet({ + apduTimeout: 5000, + interface: '0.0.0.0', + port: localPort, +}) + +let renewTimer: NodeJS.Timeout | null = null +let registerInFlight = false + +const clearRenewTimer = () => { + if (renewTimer) clearTimeout(renewTimer) + renewTimer = null +} + +const closeClient = () => { + clearRenewTimer() + bacnetClient.close() +} + +const register = async () => { + if (registerInFlight) return + registerInFlight = true + try { + await bacnetClient.registerForeignDevice( + { address: bbmdAddress }, + ttlSeconds, + ) + console.log( + `FDR success: bbmd=${bbmdAddress}, ttl=${ttlSeconds}s, next_renew_in=${Math.floor(renewDelayMs / 1000)}s`, + ) + clearRenewTimer() + renewTimer = setTimeout(() => { + register().catch((err) => + console.error( + `FDR renew failed: ${String((err as Error)?.message || err)}`, + ), + ) + }, renewDelayMs) + } catch (err) { + console.error(`FDR failed: ${String((err as Error)?.message || err)}`) + } finally { + registerInFlight = false + } +} + +bacnetClient.on('listening', () => { + console.log(`BACnet transport listening on UDP ${localPort}`) + console.log(`Registering to BBMD ${bbmdAddress} ...`) + register().catch((err) => + console.error(`FDR failed: ${String((err as Error)?.message || err)}`), + ) +}) + +bacnetClient.on('bvlcResult', (content) => { + console.log( + `BVLC result from ${content?.header?.sender?.address}: ${content?.payload?.resultCode}`, + ) +}) + +bacnetClient.on('error', (err: Error) => { + console.error(`BACnet error: ${err.message}`) +}) + +process.on('SIGINT', () => { + console.log('Stopping...') + closeClient() + process.exit(0) +}) + +process.on('SIGTERM', () => { + closeClient() + process.exit(0) +}) diff --git a/src/lib/EventTypes.ts b/src/lib/EventTypes.ts index 4073316..2a9c6e5 100644 --- a/src/lib/EventTypes.ts +++ b/src/lib/EventTypes.ts @@ -21,6 +21,7 @@ import { ListElementOperationPayload, PrivateTransferPayload, RegisterForeignDevicePayload, + BvlcResultPayload, WhoHasPayload, TimeSyncPayload, IHavePayload, @@ -180,6 +181,9 @@ export interface BACnetClientEvents { privateTransfer: ( content: BaseEventContent & { payload: PrivateTransferPayload }, ) => void + bvlcResult: ( + content: BaseEventContent & { payload: BvlcResultPayload }, + ) => void registerForeignDevice: ( content: BaseEventContent & { payload: RegisterForeignDevicePayload }, ) => void diff --git a/src/lib/bvlc.ts b/src/lib/bvlc.ts index 63c6fd4..8633d72 100644 --- a/src/lib/bvlc.ts +++ b/src/lib/bvlc.ts @@ -2,11 +2,10 @@ import { BVLL_TYPE_BACNET_IP, BvlcResultPurpose, BVLC_HEADER_LENGTH, + DEFAULT_BACNET_PORT, } from './enum' import { BvlcPacket } from './types' -const DEFAULT_BACNET_PORT = 47808 - export const encode = ( buffer: Buffer, func: number, @@ -50,9 +49,12 @@ export const decode = ( buffer: Buffer, _offset: number, ): BvlcPacket | undefined => { + if (buffer.length < BVLC_HEADER_LENGTH) return undefined + let len: number const func = buffer[1] const msgLength = (buffer[2] << 8) | (buffer[3] << 0) + if (msgLength < BVLC_HEADER_LENGTH) return undefined if (buffer[0] !== BVLL_TYPE_BACNET_IP || buffer.length !== msgLength) return undefined let originatingIP = null @@ -71,6 +73,7 @@ export const decode = ( len = 4 break case BvlcResultPurpose.FORWARDED_NPDU: + if (msgLength < 10) return undefined // Work out where the packet originally came from before the BBMD // forwarded it to us, so we can tell the BBMD where to send any reply to. const port = (buffer[8] << 8) | buffer[9] diff --git a/src/lib/client.ts b/src/lib/client.ts index 6e6fd2a..f3a8f97 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -106,6 +106,7 @@ import { PDU_TYPE_MASK, ErrorClass, ErrorCode, + BvlcResultFormat, NpduControlBit, MaxSegmentsAccepted, MaxApduLengthAccepted, @@ -113,6 +114,7 @@ import { ASN1_NO_PRIORITY, PropertyIdentifier, ReadRangeType, + DEFAULT_BACNET_PORT, } from './enum' import { RequestManager } from './request-manager' @@ -185,6 +187,15 @@ export default class BACnetClient extends TypedEventEmitter private _transport: Transport + private _pendingForeignDeviceRegistrations?: Map< + string, + { + ttl: number + promise: Promise + reject: (err: Error) => void + } + > + private _invokeCounter = 1 private _requestManager: RequestManager @@ -193,13 +204,15 @@ export default class BACnetClient extends TypedEventEmitter private _segmentStore: Buffer[] = [] + private _isClosed = false + constructor(options?: ClientOptions) { super() options = options || {} this._settings = { - port: options.port || 47808, + port: options.port || DEFAULT_BACNET_PORT, interface: options.interface || ALL_INTERFACES, // Usa la costante transport: options.transport, broadcastAddress: options.broadcastAddress || BROADCAST_ADDRESS, // Usa la costante @@ -245,6 +258,58 @@ export default class BACnetClient extends TypedEventEmitter } } + private _normalizeAddress( + address?: string, + strictPort = false, + ): string | null { + const value = String(address ?? '').trim() + if (!value) return null + + const parts = value.split(':') + if (parts.length > 2) { + if (strictPort) + throw new Error(`Invalid receiver.address "${value}"`) + return null + } + + const host = parts[0]?.trim() + if (!host) { + if (strictPort) + throw new Error(`Invalid receiver.address "${value}"`) + return null + } + + if (parts.length === 1) { + if (strictPort) + throw new Error(`Invalid receiver.address "${value}"`) + return `${host}:${DEFAULT_BACNET_PORT}` + } + + const portRaw = parts[1]?.trim() + if (!portRaw) { + if (strictPort) + throw new Error(`Invalid receiver.address "${value}"`) + return `${host}:${DEFAULT_BACNET_PORT}` + } + + const port = Number(portRaw) + const isValidPort = Number.isInteger(port) && port >= 1 && port <= 65535 + if (!isValidPort) { + if (strictPort) + throw new Error(`Invalid receiver.address "${value}"`) + return null + } + + return `${host}:${port}` + } + + private _getPendingForeignDeviceRegistrations() { + if (!this._pendingForeignDeviceRegistrations) { + this._pendingForeignDeviceRegistrations = new Map() + } + return this._pendingForeignDeviceRegistrations + } + private _processError( invokeId: number, buffer: Buffer, @@ -742,6 +807,18 @@ export default class BACnetClient extends TypedEventEmitter } // Check BVLC function switch (result.func) { + case BvlcResultPurpose.BVLC_RESULT: { + if (result.msgLength - result.len < 2) { + return trace('Received invalid BVLC result message') + } + const bvlcResult = baApdu.decodeResult(buffer, result.len) + this.emit('bvlcResult', { + header, + payload: bvlcResult, + }) + break + } + case BvlcResultPurpose.ORIGINAL_UNICAST_NPDU: case BvlcResultPurpose.ORIGINAL_BROADCAST_NPDU: this._handleNpdu( @@ -764,7 +841,7 @@ export default class BACnetClient extends TypedEventEmitter ) break - case BvlcResultPurpose.REGISTER_FOREIGN_DEVICE: + case BvlcResultPurpose.REGISTER_FOREIGN_DEVICE: { const decodeResult = RegisterForeignDevice.decode( buffer, result.len, @@ -780,6 +857,7 @@ export default class BACnetClient extends TypedEventEmitter payload: decodeResult, }) break + } case BvlcResultPurpose.DISTRIBUTE_BROADCAST_TO_NETWORK: this._handleNpdu( @@ -837,16 +915,21 @@ export default class BACnetClient extends TypedEventEmitter } else { receiver = receiverOrOptions as BACNetAddress } + } else { + receiver = receiverOrOptions as BACNetAddress } options = options || {} const buffer = this._getApduBuffer(receiver) + const npduDestination = receiver?.distributeBroadcastToNetwork + ? undefined + : receiver baNpdu.encode( buffer, NpduControlPriority.NORMAL_MESSAGE, - receiver, + npduDestination, null, DEFAULT_HOP_COUNT, NetworkLayerMessageType.WHO_IS_ROUTER_TO_NETWORK, @@ -863,6 +946,25 @@ export default class BACnetClient extends TypedEventEmitter this.sendBvlc(receiver, buffer) } + /** + * Sends Who-Is through a BBMD using BVLC Distribute-Broadcast-To-Network (0x09). + * Requires prior foreign-device registration in the same BBMD. + */ + public whoIsThroughBBMD(bbmd: BACNetAddress, options?: WhoIsOptions): void { + if (!bbmd?.address) { + throw new Error( + 'whoIsThroughBBMD requires bbmd.address (bbmd_ip:port)', + ) + } + this.whoIs( + { + ...bbmd, + distributeBroadcastToNetwork: true, + }, + options, + ) + } + /** * The timeSync command sets the time of a target device. */ @@ -893,6 +995,128 @@ export default class BACnetClient extends TypedEventEmitter this.sendBvlc(receiver, buffer) } + /** + * Registers this client as a foreign device in a BBMD. + */ + async registerForeignDevice( + receiver: BACNetAddress, + ttl: number, + ): Promise { + if (this._isClosed) { + throw new Error('ERR_CLOSED') + } + if (!receiver?.address) { + throw new Error( + 'registerForeignDevice requires receiver.address (bbmd_ip:port)', + ) + } + if (!Number.isInteger(ttl) || ttl <= 0 || ttl > 0xffff) { + throw new Error( + 'registerForeignDevice ttl must be 1..65535 seconds', + ) + } + + const expectedAddress = this._normalizeAddress(receiver.address, true) + if (!expectedAddress) { + throw new Error( + `Invalid receiver.address "${String(receiver.address)}"`, + ) + } + const pendingRegistrations = + this._getPendingForeignDeviceRegistrations() + // BVLC-Result has no invoke-id, so registrations to the same BBMD + // must be serialized to avoid correlating one response to multiple requests. + while (true) { + const pending = pendingRegistrations.get(expectedAddress) + if (!pending) break + if (pending.ttl === ttl) return pending.promise + try { + await pending.promise + } catch (err) { + if ((err as Error)?.message === 'ERR_CLOSED') { + throw err + } + // If the earlier registration failed, still allow a new attempt + // with the requested TTL instead of propagating stale failure. + } + if (this._isClosed) { + throw new Error('ERR_CLOSED') + } + } + + const buffer = this._getApduBuffer(receiver) + RegisterForeignDevice.encode(buffer, ttl) + baBvlc.encode( + buffer.buffer, + BvlcResultPurpose.REGISTER_FOREIGN_DEVICE, + buffer.offset, + ) + + let rejectRegistration = (_err: Error) => {} + const registrationPromise = new Promise((resolve, reject) => { + let settled = false + const timeout = setTimeout(() => { + cleanup() + reject(new Error('ERR_TIMEOUT')) + }, this._settings.apduTimeout || 3000) + if (typeof (timeout as NodeJS.Timeout).unref === 'function') { + ;(timeout as NodeJS.Timeout).unref() + } + + const cleanup = () => { + if (settled) return + settled = true + clearTimeout(timeout) + this.off('bvlcResult', onResult) + } + rejectRegistration = (err: Error) => { + cleanup() + reject(err) + } + + const onResult = (content: { + header?: { sender?: { address?: string } } + payload?: { resultCode?: number } + }) => { + if ( + this._normalizeAddress(content?.header?.sender?.address) !== + expectedAddress + ) + return + const resultCode = Number(content?.payload?.resultCode) + // ASHRAE 135 Annex J encodes successful completion as 0x0000 for all + // BVLC operations. For now we can only correlate by sender address. + if (resultCode === BvlcResultFormat.SUCCESSFUL_COMPLETION) { + cleanup() + resolve() + return + } + cleanup() + reject( + new Error( + `BacnetError - Class:${ErrorClass.COMMUNICATION} - Code:${ErrorCode.REGISTER_FOREIGN_DEVICE_FAILED} - Result:${resultCode}`, + ), + ) + } + + this.on('bvlcResult', onResult) + this._send(buffer, receiver) + }) + pendingRegistrations.set(expectedAddress, { + ttl, + promise: registrationPromise, + reject: rejectRegistration, + }) + try { + await registrationPromise + } finally { + const current = pendingRegistrations.get(expectedAddress) + if (current?.promise === registrationPromise) { + pendingRegistrations.delete(expectedAddress) + } + } + } + /** * The readProperty command reads a single property of an object from a device. */ @@ -2199,6 +2423,13 @@ export default class BACnetClient extends TypedEventEmitter buffer.offset, receiver.forwardedFrom, ) + } else if (receiver && receiver.distributeBroadcastToNetwork) { + // Foreign device broadcast distribution through BBMD (BVLC 0x09) + baBvlc.encode( + buffer.buffer, + BvlcResultPurpose.DISTRIBUTE_BROADCAST_TO_NETWORK, + buffer.offset, + ) } else if (receiver && receiver.address) { // Specific address, unicast baBvlc.encode( @@ -2237,7 +2468,15 @@ export default class BACnetClient extends TypedEventEmitter * Unloads the current bacnet instance and closes the underlying UDP socket. */ close(): void { + this._isClosed = true this._requestManager.clear(true) + if (this._pendingForeignDeviceRegistrations?.size) { + const err = new Error('ERR_CLOSED') + for (const pending of this._pendingForeignDeviceRegistrations.values()) { + pending.reject(err) + } + this._pendingForeignDeviceRegistrations.clear() + } this._transport.close() } diff --git a/src/lib/enum.ts b/src/lib/enum.ts index 5065d2f..f567f35 100644 --- a/src/lib/enum.ts +++ b/src/lib/enum.ts @@ -68,6 +68,7 @@ export const ASN1_MAX_OBJECT_TYPE = 1024 export const ASN1_MAX_PROPERTY_ID = 4194303 export const BVLL_TYPE_BACNET_IP = 0x81 export const BVLC_HEADER_LENGTH = 4 +export const DEFAULT_BACNET_PORT = 47808 // ASHRE 135-2016 - 21 FORMAL DESCRIPTION OF APPLICATION PROTOCOL DATA UNITS - Enumerators export enum ConfirmedServiceChoice { diff --git a/src/lib/services/IAm.ts b/src/lib/services/IAm.ts index f5cf6f0..89fe24f 100644 --- a/src/lib/services/IAm.ts +++ b/src/lib/services/IAm.ts @@ -42,7 +42,6 @@ export default class IAm extends BacnetService { public static decode(buffer: Buffer, offset: number) { let result: any let apduLen = 0 - const orgOffset = offset result = baAsn1.decodeTagNumberAndValue(buffer, offset + apduLen) apduLen += result.len if (result.tagNumber !== ApplicationTag.OBJECTIDENTIFIER) @@ -74,7 +73,7 @@ export default class IAm extends BacnetService { if (result.value > 0xffff) return undefined const vendorId = result.value return { - len: offset - orgOffset, + len: apduLen, deviceId, maxApdu, segmentation, diff --git a/src/lib/transport.ts b/src/lib/transport.ts index f3078b7..068343b 100644 --- a/src/lib/transport.ts +++ b/src/lib/transport.ts @@ -1,6 +1,7 @@ import { createSocket, Socket } from 'dgram' import { EventEmitter } from 'events' import { TypedEventEmitter, TransportEvents } from './EventTypes' +import { DEFAULT_BACNET_PORT } from './enum' import { TransportSettings } from './types' import debugLib from 'debug' @@ -8,8 +9,6 @@ import debugLib from 'debug' const debug = debugLib('bacnet:transport:debug') const trace = debugLib('bacnet:transport:trace') -const DEFAULT_BACNET_PORT = 47808 - export default class Transport extends TypedEventEmitter { private _settings: TransportSettings diff --git a/src/lib/types.ts b/src/lib/types.ts index 827a653..67042c9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -44,6 +44,11 @@ export interface BACNetAddress { * The BACnet address `:`. */ address?: string + /** + * When true, wraps outgoing NPDU in BVLC Distribute-Broadcast-To-Network (0x09) + * and unicasts it to `address` (typically a BBMD). + */ + distributeBroadcastToNetwork?: boolean } /** @@ -874,6 +879,10 @@ export interface RegisterForeignDevicePayload extends BasicServicePayload { ttl: number } +export interface BvlcResultPayload extends BasicServicePayload { + resultCode: number +} + export interface WhoHasPayload extends BasicServicePayload { lowLimit?: number highLimit?: number diff --git a/test/integration/register-foreign-device.spec.ts b/test/integration/register-foreign-device.spec.ts index a31b5be..4d8e1bb 100644 --- a/test/integration/register-foreign-device.spec.ts +++ b/test/integration/register-foreign-device.spec.ts @@ -4,20 +4,18 @@ import assert from 'node:assert' import { RegisterForeignDevice } from '../../src/lib/services' test.describe('bacnet - register foreign device integration', () => { - // TODO: this is just documentation what it does for now - needs a review - test('should encode', () => { - const buffer = { buffer: Buffer.alloc(16, 12), offset: 0 } - const testBuffer = { buffer: Buffer.alloc(16, 12), offset: 2 } - const testBufferChange = Buffer.from([0, 0, 12, 12]) - testBuffer.buffer.fill(testBufferChange, 0, 4) - RegisterForeignDevice.encode(buffer, 0) - assert.deepStrictEqual(buffer, testBuffer) + test('should encode ttl as 2-byte unsigned integer', () => { + const buffer = { buffer: Buffer.alloc(16), offset: 0 } + RegisterForeignDevice.encode(buffer, 60) + assert.strictEqual(buffer.offset, 2) + assert.strictEqual(buffer.buffer[0], 0x00) + assert.strictEqual(buffer.buffer[1], 0x3c) }) - test('should decode', () => { - const buffer = Buffer.alloc(16, 23) - const bufferCompare = Buffer.alloc(16, 23) - RegisterForeignDevice.decode(buffer, 0) - assert.deepStrictEqual(buffer, bufferCompare) + test('should decode ttl from payload', () => { + const buffer = Buffer.from([0x00, 0x3c]) + const decoded = RegisterForeignDevice.decode(buffer, 0) + assert.strictEqual(decoded.len, 2) + assert.strictEqual(decoded.ttl, 60) }) }) diff --git a/test/unit/bvlc.spec.ts b/test/unit/bvlc.spec.ts index e4642b6..7588226 100644 --- a/test/unit/bvlc.spec.ts +++ b/test/unit/bvlc.spec.ts @@ -71,6 +71,18 @@ test.describe('bacnet - BVLC layer', () => { assert.strictEqual(result, undefined) }) + test('should fail when UDP payload contains trailing bytes', () => { + const packet = Buffer.alloc(6) + packet[0] = 0x81 + packet[1] = 0x0a + packet[2] = 0x00 + packet[3] = 0x04 + packet[4] = 0xaa + packet[5] = 0xbb + const result = baBvlc.decode(packet, 0) + assert.strictEqual(result, undefined) + }) + test('should fail if unsuported function', () => { const buffer = utils.getBuffer() baBvlc.encode(buffer.buffer, 99, 1482) diff --git a/test/unit/client.spec.ts b/test/unit/client.spec.ts index b013d68..eb59ee2 100644 --- a/test/unit/client.spec.ts +++ b/test/unit/client.spec.ts @@ -4,8 +4,20 @@ import assert from 'node:assert' import BACnetClient from '../../src/lib/client' import * as baNpdu from '../../src/lib/npdu' import * as baApdu from '../../src/lib/apdu' -import { GetEventInformation } from '../../src/lib/services' -import { EventState, NotifyType, ServicesSupported, TimeStamp } from '../../src' +import * as baBvlc from '../../src/lib/bvlc' +import { + GetEventInformation, + RegisterForeignDevice, + WhoIs, +} from '../../src/lib/services' +import { + BvlcResultFormat, + BvlcResultPurpose, + EventState, + NotifyType, + ServicesSupported, + TimeStamp, +} from '../../src' test.describe('bacnet - client', () => { test('should successfuly encode a bitstring > 32 bits', () => { @@ -116,4 +128,662 @@ test.describe('bacnet - client', () => { assert.strictEqual(payloadOffset, sentRequest.length) assert.deepStrictEqual(events, expected.events) }) + + test('registerForeignDevice should send BVLC register and resolve on success result', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + let sentData: Buffer | undefined + client._settings = { apduTimeout: 100 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = (buffer, receiver) => { + sentData = Buffer.from(buffer.buffer.subarray(0, buffer.offset)) + setImmediate(() => { + client.emit('bvlcResult', { + header: { sender: { address: receiver?.address } }, + payload: { + resultCode: BvlcResultFormat.SUCCESSFUL_COMPLETION, + }, + }) + }) + } + + await client.registerForeignDevice({ address: '127.0.0.1:47808' }, 60) + + assert.ok(sentData) + const bvlc = baBvlc.decode(sentData, 0) + assert.ok(bvlc) + assert.strictEqual(bvlc.func, BvlcResultPurpose.REGISTER_FOREIGN_DEVICE) + const payload = RegisterForeignDevice.decode( + sentData, + bvlc.len, + sentData.length - bvlc.len, + ) + assert.strictEqual(payload.ttl, 60) + }) + + test('registerForeignDevice should accept BVLC result sender without default port', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + client._settings = { apduTimeout: 100 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = (_buffer, _receiver) => { + setImmediate(() => { + client.emit('bvlcResult', { + header: { sender: { address: '127.0.0.1' } }, + payload: { + resultCode: BvlcResultFormat.SUCCESSFUL_COMPLETION, + }, + }) + }) + } + + await assert.doesNotReject(async () => { + await client.registerForeignDevice( + { address: '127.0.0.1:47808' }, + 60, + ) + }) + }) + + test('registerForeignDevice should reject on BVLC result NAK', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + client._settings = { apduTimeout: 100 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = (_buffer, receiver) => { + setImmediate(() => { + client.emit('bvlcResult', { + header: { sender: { address: receiver?.address } }, + payload: { + resultCode: + BvlcResultFormat.REGISTER_FOREIGN_DEVICE_NAK, + }, + }) + }) + } + + await assert.rejects( + client.registerForeignDevice({ address: '127.0.0.1:47808' }, 60), + /Code:118.*Result:48/, + ) + }) + + test('registerForeignDevice should reject on timeout', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + client._settings = { apduTimeout: 25 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = () => {} + + // registerForeignDevice timeout is unref'ed; keep one ref'ed handle + // so this test can still observe the rejection deterministically. + const keepAlive = setInterval(() => {}, 1000) + try { + await assert.rejects( + client.registerForeignDevice( + { address: '127.0.0.1:47808' }, + 60, + ), + /ERR_TIMEOUT/, + ) + } finally { + clearInterval(keepAlive) + } + }) + + test('registerForeignDevice should reject pending registration when client closes', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + _requestManager: { clear: (withError?: boolean) => void } + _transport: { close: () => void } + } + + let transportClosed = false + client._settings = { apduTimeout: 1000 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = () => {} + client._requestManager = { clear: () => {} } + client._transport = { + close: () => { + transportClosed = true + }, + } + + const pending = client.registerForeignDevice( + { address: '127.0.0.1:47808' }, + 60, + ) + client.close() + + await assert.rejects(pending, /ERR_CLOSED/) + assert.strictEqual(transportClosed, true) + }) + + test('registerForeignDevice should reject queued different-TTL requests when client closes', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + _requestManager: { clear: (withError?: boolean) => void } + _transport: { close: () => void; getMaxPayload: () => number } + } + + let transportClosed = false + client._settings = { apduTimeout: 1000 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = () => {} + client._requestManager = { clear: () => {} } + client._transport = { + close: () => { + transportClosed = true + }, + getMaxPayload: () => 1482, + } + + const first = client.registerForeignDevice( + { address: '127.0.0.1:47808' }, + 60, + ) + const second = client.registerForeignDevice( + { address: '127.0.0.1:47808' }, + 120, + ) + client.close() + + await assert.rejects(first, /ERR_CLOSED/) + await assert.rejects(second, /ERR_CLOSED/) + assert.strictEqual(transportClosed, true) + }) + + test('registerForeignDevice should reject invalid receiver address port', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + client._settings = { apduTimeout: 25 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = () => {} + + await assert.rejects( + client.registerForeignDevice({ address: '127.0.0.1:abc' }, 60), + /Invalid receiver\.address/, + ) + }) + + test('normalizeAddress should treat trailing colon as default port in non-strict mode', () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _normalizeAddress: ( + address?: string, + strictPort?: boolean, + ) => string | null + } + + const nonStrict = client._normalizeAddress('127.0.0.1:', false) + assert.strictEqual(nonStrict, '127.0.0.1:47808') + assert.throws( + () => client._normalizeAddress('127.0.0.1:', true), + /Invalid receiver\.address/, + ) + }) + + test('registerForeignDevice should reject receiver address without port', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + client._settings = { apduTimeout: 25 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = () => {} + + await assert.rejects( + client.registerForeignDevice({ address: '127.0.0.1' }, 60), + /Invalid receiver\.address/, + ) + }) + + test('registerForeignDevice should dedupe parallel calls for the same target', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + let sends = 0 + client._settings = { apduTimeout: 100 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = (_buffer, receiver) => { + sends += 1 + setImmediate(() => { + client.emit('bvlcResult', { + header: { sender: { address: receiver?.address } }, + payload: { + resultCode: BvlcResultFormat.SUCCESSFUL_COMPLETION, + }, + }) + }) + } + + await Promise.all([ + client.registerForeignDevice({ address: '127.0.0.1:47808' }, 60), + client.registerForeignDevice({ address: '127.0.0.1:47808' }, 60), + ]) + assert.strictEqual(sends, 1) + }) + + test('registerForeignDevice should avoid extra buffer allocation for deduplicated same-TTL calls', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + let getApduBufferCalls = 0 + let sends = 0 + client._settings = { apduTimeout: 100 } + client._getApduBuffer = () => { + getApduBufferCalls += 1 + return { + buffer: Buffer.alloc(32), + offset: 4, + } + } + client._send = (_buffer, receiver) => { + sends += 1 + setImmediate(() => { + client.emit('bvlcResult', { + header: { sender: { address: receiver?.address } }, + payload: { + resultCode: BvlcResultFormat.SUCCESSFUL_COMPLETION, + }, + }) + }) + } + + await Promise.all([ + client.registerForeignDevice({ address: '127.0.0.1:47808' }, 60), + client.registerForeignDevice({ address: '127.0.0.1:47808' }, 60), + ]) + + assert.strictEqual(sends, 1) + assert.strictEqual(getApduBufferCalls, 1) + }) + + test('registerForeignDevice should not dedupe parallel calls with different TTL', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + let sends = 0 + client._settings = { apduTimeout: 100 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = (_buffer, receiver) => { + sends += 1 + setImmediate(() => { + client.emit('bvlcResult', { + header: { sender: { address: receiver?.address } }, + payload: { + resultCode: BvlcResultFormat.SUCCESSFUL_COMPLETION, + }, + }) + }) + } + + await Promise.all([ + client.registerForeignDevice({ address: '127.0.0.1:47808' }, 60), + client.registerForeignDevice({ address: '127.0.0.1:47808' }, 120), + ]) + assert.strictEqual(sends, 2) + }) + + test('registerForeignDevice should not resolve two different TTL requests from a single BVLC result', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + let sends = 0 + client._settings = { apduTimeout: 30 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = (_buffer, receiver) => { + sends += 1 + if (sends === 1) { + setImmediate(() => { + client.emit('bvlcResult', { + header: { sender: { address: receiver?.address } }, + payload: { + resultCode: BvlcResultFormat.SUCCESSFUL_COMPLETION, + }, + }) + }) + } + } + + const first = client.registerForeignDevice( + { address: '127.0.0.1:47808' }, + 60, + ) + const second = client.registerForeignDevice( + { address: '127.0.0.1:47808' }, + 120, + ) + + const keepAlive = setInterval(() => {}, 1000) + try { + await assert.doesNotReject(first) + await assert.rejects(second, /ERR_TIMEOUT/) + } finally { + clearInterval(keepAlive) + } + assert.strictEqual(sends, 2) + }) + + test('registerForeignDevice should retry queued TTL request if prior attempt fails', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + let sends = 0 + client._settings = { apduTimeout: 30 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = (_buffer, receiver) => { + sends += 1 + if (sends === 2) { + setImmediate(() => { + client.emit('bvlcResult', { + header: { sender: { address: receiver?.address } }, + payload: { + resultCode: BvlcResultFormat.SUCCESSFUL_COMPLETION, + }, + }) + }) + } + } + + const first = client.registerForeignDevice( + { address: '127.0.0.1:47808' }, + 60, + ) + const second = client.registerForeignDevice( + { address: '127.0.0.1:47808' }, + 120, + ) + + const keepAlive = setInterval(() => {}, 1000) + try { + await assert.rejects(first, /ERR_TIMEOUT/) + await assert.doesNotReject(second) + } finally { + clearInterval(keepAlive) + } + assert.strictEqual(sends, 2) + }) + + test('registerForeignDevice should ignore unrelated error events', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _settings: { apduTimeout: number } + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + } + + client._settings = { apduTimeout: 100 } + client.on('error', () => {}) + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(32), + offset: 4, + }) + client._send = (_buffer, receiver) => { + setImmediate(() => { + client.emit('error', new Error('unrelated socket error')) + client.emit('bvlcResult', { + header: { sender: { address: receiver?.address } }, + payload: { + resultCode: BvlcResultFormat.SUCCESSFUL_COMPLETION, + }, + }) + }) + } + + await assert.doesNotReject(async () => { + await client.registerForeignDevice( + { address: '127.0.0.1:47808' }, + 60, + ) + }) + }) + + test('whoIs should keep explicit options when receiver also contains limit keys', () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + _transport: { getMaxPayload: () => number } + } + + let sentData: Buffer | undefined + let sentReceiver: { address?: string } | undefined + client._transport = { getMaxPayload: () => 1482 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(64), + offset: 4, + }) + client._send = (buffer, receiver) => { + sentData = Buffer.from(buffer.buffer.subarray(0, buffer.offset)) + sentReceiver = receiver + } + + client.whoIs( + { + address: '127.0.0.1:47808', + lowLimit: 10, + highLimit: 20, + } as Parameters[0], + { lowLimit: 1, highLimit: 2 }, + ) + + assert.ok(sentData) + assert.strictEqual(sentReceiver?.address, '127.0.0.1:47808') + + const bvlc = baBvlc.decode(sentData, 0) + const npdu = baNpdu.decode(sentData, bvlc.len) + const apdu = baApdu.decodeUnconfirmedServiceRequest( + sentData, + bvlc.len + npdu.len, + ) + const payloadOffset = bvlc.len + npdu.len + apdu.len + const payload = WhoIs.decode( + sentData, + payloadOffset, + sentData.length - payloadOffset, + ) + delete payload.len + assert.deepStrictEqual(payload, { + lowLimit: 1, + highLimit: 2, + }) + }) + + test('whoIsThroughBBMD should send BVLC distribute-broadcast-to-network with no NPDU destination', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + _transport: { getMaxPayload: () => number } + } + + let sentData: Buffer | undefined + let sentReceiver: { address?: string } | undefined + client._transport = { getMaxPayload: () => 1482 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(64), + offset: 4, + }) + client._send = (buffer, receiver) => { + sentData = Buffer.from(buffer.buffer.subarray(0, buffer.offset)) + sentReceiver = receiver + } + + client.whoIsThroughBBMD({ address: '127.0.0.1:47808', net: 1 }) + + assert.ok(sentData) + const bvlc = baBvlc.decode(sentData, 0) + assert.ok(bvlc) + assert.strictEqual( + bvlc.func, + BvlcResultPurpose.DISTRIBUTE_BROADCAST_TO_NETWORK, + ) + assert.strictEqual(sentReceiver?.address, '127.0.0.1:47808') + const npdu = baNpdu.decode(sentData, bvlc.len) + assert.strictEqual(npdu?.destination, undefined) + }) + + test('whoIsThroughBBMD should keep BBMD receiver when limits are provided', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient & { + _getApduBuffer: () => { buffer: Buffer; offset: number } + _send: ( + buffer: { buffer: Buffer; offset: number }, + receiver?: { address?: string }, + ) => void + _transport: { getMaxPayload: () => number } + } + + let sentData: Buffer | undefined + client._transport = { getMaxPayload: () => 1482 } + client._getApduBuffer = () => ({ + buffer: Buffer.alloc(64), + offset: 4, + }) + client._send = (buffer) => { + sentData = Buffer.from(buffer.buffer.subarray(0, buffer.offset)) + } + + client.whoIsThroughBBMD( + { address: '127.0.0.1:47808' }, + { lowLimit: 0, highLimit: 100 }, + ) + + assert.ok(sentData) + const bvlc = baBvlc.decode(sentData, 0) + assert.ok(bvlc) + assert.strictEqual( + bvlc.func, + BvlcResultPurpose.DISTRIBUTE_BROADCAST_TO_NETWORK, + ) + }) + + test('whoIsThroughBBMD should reject missing bbmd address', async () => { + const client = Object.create(BACnetClient.prototype) as BACnetClient + assert.throws( + () => client.whoIsThroughBBMD({}), + /whoIsThroughBBMD requires bbmd\.address/, + ) + }) }) diff --git a/test/unit/service-i-am.spec.ts b/test/unit/service-i-am.spec.ts index 078255b..b3b135a 100644 --- a/test/unit/service-i-am.spec.ts +++ b/test/unit/service-i-am.spec.ts @@ -9,6 +9,7 @@ test.describe('bacnet - Services layer Iam unit', () => { const buffer = utils.getBuffer() IAm.encode(buffer, 47, 1, 1, 7) const result = IAm.decode(buffer.buffer, 0) + assert.strictEqual(result.len, buffer.offset) delete result.len assert.deepStrictEqual(result, { deviceId: 47,