From 2665ee1781d8e79c72ad5b839fd156868a424875 Mon Sep 17 00:00:00 2001 From: Joe Krill Date: Tue, 30 Jul 2019 17:11:32 -0400 Subject: [PATCH] feat(discovery): adds the ability to do device discovery --- .nvmrc | 1 + README.md | 49 ++++++ rollup.config.ts | 6 +- src/ElkClientCommands.test.ts | 1 - src/__mocks__/dgram.js | 27 +++ src/discovery/ElkDevice.ts | 42 +++++ src/discovery/ElkDeviceType.ts | 13 ++ src/discovery/ElkDiscoveryClient.test.ts | 199 +++++++++++++++++++++++ src/discovery/ElkDiscoveryClient.ts | 90 ++++++++++ src/discovery/ElkDiscoveryOptions.ts | 42 +++++ src/discovery/decode.test.ts | 140 ++++++++++++++++ src/discovery/decode.ts | 80 +++++++++ src/discovery/index.test.ts | 8 + src/discovery/index.ts | 4 + src/index.test.ts | 6 +- src/index.ts | 1 + 16 files changed, 706 insertions(+), 3 deletions(-) create mode 100644 .nvmrc create mode 100644 src/__mocks__/dgram.js create mode 100644 src/discovery/ElkDevice.ts create mode 100644 src/discovery/ElkDeviceType.ts create mode 100644 src/discovery/ElkDiscoveryClient.test.ts create mode 100644 src/discovery/ElkDiscoveryClient.ts create mode 100644 src/discovery/ElkDiscoveryOptions.ts create mode 100644 src/discovery/decode.test.ts create mode 100644 src/discovery/decode.ts create mode 100644 src/discovery/index.test.ts create mode 100644 src/discovery/index.ts diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..5007551b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +10.16.0 diff --git a/README.md b/README.md index f39b6f8d..7ed5e4de 100644 --- a/README.md +++ b/README.md @@ -47,3 +47,52 @@ client console.error(err); }); ``` + +## Device discovery + +Devices can be discovered using UDP broadcast messages. Only M1XEP and C1M1 devices can be discovered. This is not documented by Elk Products, but this is how the ElkRP2 software does it's discovery. + +``` +import { ElkDiscoveryClient } from 'elk-client'; + +const discoveryClient = new ElkDiscoveryClient(); +discoveryClient + .start() + .then((devices) => { + console.log(`Found `${devices.length}` devices!); + }) + .catch((err) => { + console.error(err); + }); +``` + +You can optionally limit the device types requested and adjust the timeout, broadcast address, and port used: + +``` +import { ElkDiscoveryClient, ElkDeviceType } from 'elk-client'; + +const discoveryClient = new ElkDiscoveryClient({ + // Only look for M1XEP devices + deviceTypes: [ElkDeviceType.M1XEP], + + // Use port 9000 instead + // NOTE: This probably won't ever work if you change the port! + port: 9000, + + // Wait 10 seconds instead of the 5 second default + timeout: 10000, + + // Use a different broadcast address (default is 255.255.255.255) + broadcastAddress: "192.168.1.255", +}); + +discoveryClient + .start() + .then((devices) => { + console.log(`Found `${devices.length}` devices!); + }) + .catch((err) => { + console.error(err); + }); +``` + diff --git a/rollup.config.ts b/rollup.config.ts index c6f746a5..48ed94b2 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -15,11 +15,14 @@ export default { { file: pkg.main, name: camelCase(libraryName), format: 'umd', sourcemap: true }, { file: pkg.module, format: 'es', sourcemap: true }, ], + // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') - external: ['net', 'tls', 'events'], + external: ['dgram', 'net', 'tls', 'events'], + watch: { include: 'src/**', }, + plugins: [ // Allow json resolution json(), @@ -27,6 +30,7 @@ export default { typescript({ useTsconfigDeclarationDir: true }), // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) commonjs(), + // Allow node_modules resolution, so you can use 'external' to control // which external modules to include in the bundle // https://github.com/rollup/rollup-plugin-node-resolve#usage diff --git a/src/ElkClientCommands.test.ts b/src/ElkClientCommands.test.ts index 0008f63d..10251635 100644 --- a/src/ElkClientCommands.test.ts +++ b/src/ElkClientCommands.test.ts @@ -76,7 +76,6 @@ import { ArmingStatusReport, } from 'elk-message'; import TimeoutError from './errors/TimeoutError'; -import { cd } from 'shelljs'; class ElkClientCommandsImpl extends ElkClientCommands { constructor( diff --git a/src/__mocks__/dgram.js b/src/__mocks__/dgram.js new file mode 100644 index 00000000..b69a4ad9 --- /dev/null +++ b/src/__mocks__/dgram.js @@ -0,0 +1,27 @@ +'use strict'; +const EventEmitter = require('events'); +const dgram = jest.genMockFromModule('dgram'); + +class DatagramSocketMock extends EventEmitter { + bind(port, cb) { + cb(); + } + setBroadcast() { + + } + send() { + + } + close() { + this.emit('close'); + } +} + +dgram.__resetMockInstance = () => { + dgram.__mockInstance = jest.fn().mockImplementation(() => new DatagramSocketMock())(); +} +dgram.__resetMockInstance(); + +dgram.createSocket = () => dgram.__mockInstance; + +module.exports = dgram; diff --git a/src/discovery/ElkDevice.ts b/src/discovery/ElkDevice.ts new file mode 100644 index 00000000..479cad9c --- /dev/null +++ b/src/discovery/ElkDevice.ts @@ -0,0 +1,42 @@ +import ElkDeviceType from './ElkDeviceType'; + +/** + * Represents the configuration for a discovered Elk M1 + * network devices (a C1M1 or M1XEP). + */ +interface ElkDevice { + /** + * The type of device - a C1M1 communicator, or an M1XEP network device. + */ + deviceType: ElkDeviceType; + + /** + * The MAC address of the network device. + */ + macAddress: string; + + /** + * The device's local IP address + */ + ipAddress: string; + + /** + * An optional name (only for an M1XEP) + */ + name?: string; + + /** + * The (non-secure) port the device uses to communicate on. + */ + port: number; + + /** + * The secure port the device uses to communicate on. + * For some reason it seems only the C1M1 sends this along, even though it is + * configurable by the M1XEP (though I haven't been able to test with an actual + * M1XEP devices, so this may be in the response somewhere). + */ + securePort?: number; +} + +export default ElkDevice; diff --git a/src/discovery/ElkDeviceType.ts b/src/discovery/ElkDeviceType.ts new file mode 100644 index 00000000..9812d7de --- /dev/null +++ b/src/discovery/ElkDeviceType.ts @@ -0,0 +1,13 @@ +enum ElkDeviceType { + /** + * M1XEP devices + */ + M1XEP = 1, + + /** + * C1M1 communicators + */ + C1M1 = 2, +} + +export default ElkDeviceType; diff --git a/src/discovery/ElkDiscoveryClient.test.ts b/src/discovery/ElkDiscoveryClient.test.ts new file mode 100644 index 00000000..0201944f --- /dev/null +++ b/src/discovery/ElkDiscoveryClient.test.ts @@ -0,0 +1,199 @@ +import ElkDiscoveryClient, { C1M1_DISCOVERY_ID, M1XEP_DISCOVERY_ID } from './ElkDiscoveryClient'; +import ElkDeviceType from './ElkDeviceType'; +import { DEFAULT_DISCOVERY_OPTIONS } from './ElkDiscoveryOptions'; +jest.mock('dgram'); + +// prettier-ignore +const C1M1_MSG = Buffer.from([ + 67, 49, 77, 49, 32, // ID + 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, // MAC + 192, 168, 1, 100, // IP + 8, 53, // port + 22, 33, // secure port + 67, 49, 77, 49, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, +]); + +// prettier-ignore +const M1XEP_MSG = Buffer.from([ + 77, 49, 88 , 69, 80, // ID + 0x12, 0x34, 0x56, 0xab, 0xcd, 0xef, // MAC + 10, 10, 1, 202, // IP + 3, 9, // port + 73, 32, 97, 109, 32, 97, 110, 32, 77, 49, 88, 69, 80, 33, 32, 32, // name + 0, 0, 0, 0, 0, 0, 0, 0, 0, // unused +]); + +describe('ElkDiscoveryClient', () => { + let client: ElkDiscoveryClient; + let dgramMockInstance: any; + + beforeEach(() => { + jest.useFakeTimers(); + dgramMockInstance = require('dgram').__mockInstance; + }); + + afterEach(() => { + require('dgram').__resetMockInstance(); + }); + + describe('with defaults', () => { + beforeEach(() => { + client = new ElkDiscoveryClient(); + }); + + it('resolves after default timeout', async () => { + expect.assertions(1); + const result = client.start(); + jest.advanceTimersByTime(5000); + const devices = await result; + expect(devices.length).toBe(0); + }); + + it('requests all device types', async () => { + expect.assertions(3); + const sendMock = jest.fn(); + dgramMockInstance.send = sendMock; + const result = client.start(); + jest.advanceTimersByTime(5000); + const devices = await result; + expect(sendMock.mock.calls.length).toBe(2); + expect(sendMock).toBeCalledWith( + C1M1_DISCOVERY_ID, + 0, + C1M1_DISCOVERY_ID.length, + DEFAULT_DISCOVERY_OPTIONS.port, + DEFAULT_DISCOVERY_OPTIONS.broadcastAddress + ); + expect(sendMock).toBeCalledWith( + M1XEP_DISCOVERY_ID, + 0, + M1XEP_DISCOVERY_ID.length, + DEFAULT_DISCOVERY_OPTIONS.port, + DEFAULT_DISCOVERY_OPTIONS.broadcastAddress + ); + }); + + it('reports devices', async () => { + expect.assertions(2); + const result = client.start(); + dgramMockInstance.emit('message', C1M1_MSG); + dgramMockInstance.emit('message', M1XEP_MSG); + jest.advanceTimersByTime(5000); + const devices = await result; + // expect(devices.length).toBe(2); + expect(devices).toContainEqual({ + deviceType: ElkDeviceType.M1XEP, + macAddress: '12:34:56:ab:cd:ef', + ipAddress: '10.10.1.202', + name: 'I am an M1XEP!', + port: 777, + }); + expect(devices).toContainEqual({ + deviceType: ElkDeviceType.C1M1, + macAddress: '77:88:99:aa:bb:cc', + ipAddress: '192.168.1.100', + port: 2101, + securePort: 5665, + }); + }); + + it('does not report duplicate devices', async () => { + expect.assertions(1); + const result = client.start(); + dgramMockInstance.emit('message', M1XEP_MSG); + dgramMockInstance.emit('message', C1M1_MSG); + dgramMockInstance.emit('message', M1XEP_MSG); + jest.advanceTimersByTime(5000); + const devices = await result; + expect(devices.length).toBe(2); + }); + + it('ignores invalid messages', async () => { + expect.assertions(1); + const result = client.start(); + dgramMockInstance.emit('message', M1XEP_MSG); + dgramMockInstance.emit('message', C1M1_MSG); + dgramMockInstance.emit('message', Buffer.from([0, 0, 0, 0, 0, 0, 0])); + jest.advanceTimersByTime(5000); + const devices = await result; + expect(devices.length).toBe(2); + }); + + it("ignores it's own discovery messages", async () => { + expect.assertions(1); + const result = client.start(); + dgramMockInstance.emit('message', C1M1_MSG); + dgramMockInstance.emit('message', Buffer.from('C1M1ID', 'ascii')); + jest.advanceTimersByTime(5000); + const devices = await result; + expect(devices.length).toBe(1); + }); + + it('rejects on an error', async () => { + expect.assertions(1); + const result = client.start(); + const fakeError = new Error('oops'); + dgramMockInstance.emit('error', fakeError); + try { + await result; + } catch (err) { + expect(err).toBe(fakeError); + } + }); + + it('does not reject if already resolved', async () => { + expect.assertions(1); + const result = client.start(); + dgramMockInstance.emit('close'); + dgramMockInstance.emit('error', new Error('oops')); + const devices = await result; + expect(devices.length).toBe(0); + }); + }); + + describe('with only M1XEP discovery', () => { + beforeEach(() => { + client = new ElkDiscoveryClient({ deviceTypes: [ElkDeviceType.M1XEP] }); + }); + + it('requests only M1XEP device types', async () => { + expect.assertions(2); + const sendMock = jest.fn(); + dgramMockInstance.send = sendMock; + const result = client.start(); + jest.advanceTimersByTime(5000); + const devices = await result; + expect(sendMock.mock.calls.length).toBe(1); + expect(sendMock).toBeCalledWith( + M1XEP_DISCOVERY_ID, + 0, + M1XEP_DISCOVERY_ID.length, + DEFAULT_DISCOVERY_OPTIONS.port, + DEFAULT_DISCOVERY_OPTIONS.broadcastAddress + ); + }); + }); + + describe('with only C1M1 discovery', () => { + beforeEach(() => { + client = new ElkDiscoveryClient({ deviceTypes: [ElkDeviceType.C1M1] }); + }); + + it('requests only M1XEP device types', async () => { + expect.assertions(2); + const sendMock = jest.fn(); + dgramMockInstance.send = sendMock; + const result = client.start(); + jest.advanceTimersByTime(5000); + const devices = await result; + expect(sendMock.mock.calls.length).toBe(1); + expect(sendMock).toBeCalledWith( + C1M1_DISCOVERY_ID, + 0, + C1M1_DISCOVERY_ID.length, + DEFAULT_DISCOVERY_OPTIONS.port, + DEFAULT_DISCOVERY_OPTIONS.broadcastAddress + ); + }); + }); +}); diff --git a/src/discovery/ElkDiscoveryClient.ts b/src/discovery/ElkDiscoveryClient.ts new file mode 100644 index 00000000..a732f6c8 --- /dev/null +++ b/src/discovery/ElkDiscoveryClient.ts @@ -0,0 +1,90 @@ +import { createSocket } from 'dgram'; +import { EventEmitter } from 'events'; +import decode from './decode'; +import ElkDevice from './ElkDevice'; +import ElkDeviceType from './ElkDeviceType'; +import ElkDiscoveryOptions, { DEFAULT_DISCOVERY_OPTIONS } from './ElkDiscoveryOptions'; + +export const C1M1_DISCOVERY_ID = Buffer.from('C1M1ID', 'ascii'); +export const M1XEP_DISCOVERY_ID = Buffer.from('XEPID', 'ascii'); + +/** + * A client that can be used to discover Elk M1 devices on the + * local network using UDP broadcasts. + */ +export default class ElkDiscoveryClient extends EventEmitter { + private options: ElkDiscoveryOptions; + + constructor(initialOptions: Partial = {}) { + super(); + this.options = { ...DEFAULT_DISCOVERY_OPTIONS, ...initialOptions }; + } + + /** + * Starts the discovery process, resolving when complete (after the timeout), + * or rejecting if an error occured. + */ + start = async (): Promise => { + return new Promise((resolve, reject) => { + const { broadcastAddress, deviceTypes, port, timeout } = this.options; + const socket = createSocket({ type: 'udp4', reuseAddr: true }); + let complete = false; + const devices: { [x: string]: ElkDevice } = {}; + + socket.on('message', (msg, rinfo) => { + // Since the discovery requests are broadcast, we actually receive them as well, + // so ignore those. + if (msg.equals(C1M1_DISCOVERY_ID) || msg.equals(M1XEP_DISCOVERY_ID)) { + return; + } + + try { + const device = decode(msg); + devices[device.macAddress] = device; + // devices.push(device); + this.emit('found', device); + } catch (err) { + // Ignore unknown messages + this.emit('unknownMessage', msg); + } + }); + + socket.on('close', () => { + if (!complete) { + complete = true; + this.emit('complete', devices); + resolve(Object.values(devices)); + } + }); + + socket.on('error', error => { + if (!complete) { + complete = true; + reject(error); + } + + try { + socket.close(); + } catch (err) { + // Ignore this, socket was already closed. + } + }); + + socket.bind(port, () => { + socket.setBroadcast(true); + + if (!deviceTypes || deviceTypes.includes(ElkDeviceType.C1M1)) { + socket.send(C1M1_DISCOVERY_ID, 0, C1M1_DISCOVERY_ID.length, port, broadcastAddress); + } + + if (!deviceTypes || deviceTypes.includes(ElkDeviceType.M1XEP)) { + socket.send(M1XEP_DISCOVERY_ID, 0, M1XEP_DISCOVERY_ID.length, port, broadcastAddress); + } + + setTimeout(() => { + socket.close(); + }, timeout); + }); + }); + }; +} diff --git a/src/discovery/ElkDiscoveryOptions.ts b/src/discovery/ElkDiscoveryOptions.ts new file mode 100644 index 00000000..4aa1bb49 --- /dev/null +++ b/src/discovery/ElkDiscoveryOptions.ts @@ -0,0 +1,42 @@ +import ElkDeviceType from './ElkDeviceType'; + +export default interface ElkDiscoveryOptions { + /** + * The address to use to broadcast discovery requests + */ + broadcastAddress: string; + + /** + * The type of devices to discover. When not specified, a + * discovery request should attempt to find any device + * type. + */ + deviceTypes?: ElkDeviceType[]; + + /** + * How long, in milliseconds, to wait for a response. + */ + timeout: number; + + /** + * The UDP port to use for discovery requests/responses. + * Although this is configurable, my experience shows + * only port 2362 works. + */ + port: number; +} + +export const DEFAULT_DISCOVERY_OPTIONS: ElkDiscoveryOptions = { + // This is what ElkRP2 uses, but I've found local broadcast + // addresses work as well (i.e.: 192.168.1.255) + broadcastAddress: '255.255.255.255', + + // Attempt to discover all devices by default. + deviceTypes: undefined, + + // Wait 5 seconds for responses. + timeout: 5000, + + // ElkRP2 uses 2362 exclusively. Not sure if other ports will work. + port: 2362, +}; diff --git a/src/discovery/decode.test.ts b/src/discovery/decode.test.ts new file mode 100644 index 00000000..324fe308 --- /dev/null +++ b/src/discovery/decode.test.ts @@ -0,0 +1,140 @@ +import decode, { extractMacAddress, extractIpAddress, extractPort } from './decode'; +import ElkDeviceType from './ElkDeviceType'; + +describe('extractMacAddress', () => { + const macTest = Buffer.from([0x01, 0x23, 0x45, 0x67, 0x89, 0xab]); + const allZeros = Buffer.from([0, 0, 0, 0, 0, 0]); + const allFfs = Buffer.from([255, 255, 255, 255, 255, 255]); + + it('extracts the expected values', () => { + expect(extractMacAddress(macTest, 0)).toBe('01:23:45:67:89:ab'); + expect(extractMacAddress(allZeros, 0)).toBe('00:00:00:00:00:00'); + expect(extractMacAddress(allFfs, 0)).toBe('ff:ff:ff:ff:ff:ff'); + }); + + it('uses the specified separator', () => { + expect(extractMacAddress(macTest, 0, '-')).toBe('01-23-45-67-89-ab'); + }); + + it('works without a separator', () => { + expect(extractMacAddress(macTest, 0, '')).toBe('0123456789ab'); + }); + + it('extracts from the middle of a buffer', () => { + const start = Buffer.from([1, 2, 3, 4, 5]); + const end = Buffer.from([6, 7, 8, 9]); + const data = Buffer.concat([start, macTest, end]); + expect(extractMacAddress(data, start.length)).toBe('01:23:45:67:89:ab'); + }); +}); + +describe('extractIpAddress', () => { + const privateIp1 = Buffer.from([192, 168, 1, 30]); + const privateIp2 = Buffer.from([10, 10, 10, 2]); + const linkLocalIp = Buffer.from([169, 254, 0, 0]); + const broadcastIp = Buffer.from([255, 255, 255, 255]); + + it('extracts the expected values', () => { + expect(extractIpAddress(privateIp1, 0)).toBe('192.168.1.30'); + expect(extractIpAddress(privateIp2, 0)).toBe('10.10.10.2'); + expect(extractIpAddress(linkLocalIp, 0)).toBe('169.254.0.0'); + expect(extractIpAddress(broadcastIp, 0)).toBe('255.255.255.255'); + }); + + it('extracts from the middle of a buffer', () => { + const start = Buffer.from([1, 2, 3, 4, 5]); + const end = Buffer.from([6, 7, 8, 9]); + expect(extractIpAddress(Buffer.concat([start, privateIp1, end]), start.length)).toBe( + '192.168.1.30' + ); + expect(extractIpAddress(Buffer.concat([start, privateIp2, end]), start.length)).toBe( + '10.10.10.2' + ); + expect(extractIpAddress(Buffer.concat([start, linkLocalIp, end]), start.length)).toBe( + '169.254.0.0' + ); + expect(extractIpAddress(Buffer.concat([start, broadcastIp, end]), start.length)).toBe( + '255.255.255.255' + ); + }); +}); + +describe('extractPort', () => { + const p0 = Buffer.from([0, 0]); + const p1 = Buffer.from([0, 1]); + const p2101 = Buffer.from([8, 53]); + const p2601 = Buffer.from([10, 41]); + const p65535 = Buffer.from([255, 255]); + + it('extracts the expected values', () => { + expect(extractPort(p0, 0)).toBe(0); + expect(extractPort(p1, 0)).toBe(1); + expect(extractPort(p2101, 0)).toBe(2101); + expect(extractPort(p2601, 0)).toBe(2601); + expect(extractPort(p65535, 0)).toBe(65535); + }); + + it('extracts from the middle of a buffer', () => { + const start = Buffer.from([1, 2, 3, 4, 5]); + const end = Buffer.from([6, 7, 8, 9]); + expect(extractPort(Buffer.concat([start, p0, end]), start.length)).toBe(0); + expect(extractPort(Buffer.concat([start, p1, end]), start.length)).toBe(1); + expect(extractPort(Buffer.concat([start, p2101, end]), start.length)).toBe(2101); + expect(extractPort(Buffer.concat([start, p2601, end]), start.length)).toBe(2601); + expect(extractPort(Buffer.concat([start, p65535, end]), start.length)).toBe(65535); + }); +}); + +describe('decode', () => { + describe('C1M1', () => { + // prettier-ignore + const c1m1 = Buffer.from([ + 67, 49, 77, 49, 32, // ID + 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, // MAC + 192, 168, 1, 100, // IP + 8, 53, // port + 22, 33, // secure port + 67, 49, 77, 49, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, // ignored + ]); + + it('returns the expected device', () => { + expect(decode(c1m1)).toMatchObject({ + deviceType: ElkDeviceType.C1M1, + macAddress: '77:88:99:aa:bb:cc', + ipAddress: '192.168.1.100', + port: 2101, + securePort: 5665, + }); + }); + }); + + describe('M1XEP', () => { + // prettier-ignore + const m1xep = Buffer.from([ + 77, 49, 88 , 69, 80, // ID + 0x12, 0x34, 0x56, 0xab, 0xcd, 0xef, // MAC + 10, 10, 1, 202, // IP + 3, 9, // port + 73, 32, 97, 109, 32, 97, 110, 32, 77, 49, 88, 69, 80, 33, 32, 32, // name + 0, 0, 0, 0, 0, 0, 0, 0, 0, // unused + ]); + + it('returns the expected device', () => { + expect(decode(m1xep)).toMatchObject({ + deviceType: ElkDeviceType.M1XEP, + macAddress: '12:34:56:ab:cd:ef', + ipAddress: '10.10.1.202', + name: 'I am an M1XEP!', + port: 777, + }); + }); + }); + + describe('Unknown', () => { + const invalid = Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + + it('throws an error', () => { + expect(() => decode(invalid)).toThrowError('Unknown'); + }); + }); +}); diff --git a/src/discovery/decode.ts b/src/discovery/decode.ts new file mode 100644 index 00000000..459ad922 --- /dev/null +++ b/src/discovery/decode.ts @@ -0,0 +1,80 @@ +import ElkDeviceType from './ElkDeviceType'; +import ElkDevice from './ElkDevice'; + +/** + * Extracts a MAC address from 6 bytes of a data buffer + * @param buffer The data buffer + * @param startIndex The index within the buffer where the MAC address is specified. + * @param [separator=":"] The separator character to use between octets + */ +export function extractMacAddress(buffer: Buffer, startIndex: number, separator: string = ':') { + return Array.from(buffer.slice(startIndex, startIndex + 6)) + .map(value => value.toString(16).padStart(2, '0')) + .join(separator); +} + +/** + * Extracts an IP address from 4 bytes of a data buffer + * @param buffer The data buffer + * @param startIndex The index within the buffer where the IP address is specified. + */ +export function extractIpAddress(buffer: Buffer, startIndex: number) { + return Array.from(buffer.slice(startIndex, startIndex + 4)) + .map(value => value.toString(10)) + .join('.'); +} + +/** + * Extracts a port number from 2 bytes of a data buffer + * @param buffer The data buffer + * @param startIndex The index within the buffer where the port is specified. + */ +export function extractPort(buffer: Buffer, startIndex: number) { + return buffer[startIndex] * 256 + buffer[startIndex + 1]; +} + +/** + * Decodes an `ElkDevice` configuration from a UDP discovery response. + * @param data The UDP response data. + */ +export default function decode(data: Buffer): ElkDevice { + // In both cases, the format of the response starts with: + // + // `DDDDDMMMMMMIIIIPP` + // + // where: + // * `DDDDD` - A device type identifier (either "C1M1 " or "M1XEP" + // * `MMMMMM` - The MAC address + // * `IIII` - The IP address, where each byte is one octet + // * `PP` - The port to use to connect to the device + // + // The remaining bytes vary depending on the device type + + const identifier = data.slice(0, 5).toString(); + + if (identifier === 'C1M1 ') { + return { + deviceType: ElkDeviceType.C1M1, + macAddress: extractMacAddress(data, 5), + ipAddress: extractIpAddress(data, 11), + port: extractPort(data, 15), + + // For C1M1, the next 2 bytes represent the port to use + // for secure connections. + securePort: extractPort(data, 17), + }; + } else if (identifier === 'M1XEP') { + return { + deviceType: ElkDeviceType.M1XEP, + macAddress: extractMacAddress(data, 5), + ipAddress: extractIpAddress(data, 11), + port: extractPort(data, 15), + + // For M1XEP, there is a customizable 16-character + // "name" that can be used to identify the device. + name: data.toString('ascii', 17, 17 + 16).trim(), + }; + } + + throw new Error('Unknown response recieved with ID: ' + identifier); +} diff --git a/src/discovery/index.test.ts b/src/discovery/index.test.ts new file mode 100644 index 00000000..4570329b --- /dev/null +++ b/src/discovery/index.test.ts @@ -0,0 +1,8 @@ +import { ElkDeviceType, ElkDiscoveryClient } from './'; + +describe('exports', () => { + it('includes expected exports', () => { + expect(typeof ElkDeviceType).toBe('object'); + expect(typeof ElkDiscoveryClient).toBe('function'); + }); +}); diff --git a/src/discovery/index.ts b/src/discovery/index.ts new file mode 100644 index 00000000..387c0aec --- /dev/null +++ b/src/discovery/index.ts @@ -0,0 +1,4 @@ +export { default as ElkDevice } from './ElkDevice'; +export { default as ElkDeviceType } from './ElkDeviceType'; +export { default as ElkDiscoveryClient } from './ElkDiscoveryClient'; +export { default as ElkDiscoveryOptions } from './ElkDiscoveryOptions'; diff --git a/src/index.test.ts b/src/index.test.ts index 3f4ad0db..63c3163d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,11 +2,13 @@ import { ElkClient, ElkClientState, ElkConnectionState, + ElkDeviceType, + ElkDiscoveryClient, ElkSocketConnection, AuthenticationFailedError, ConnectCancelledError, NotConnectableError, - WriteError + WriteError, } from './'; describe('exports', () => { @@ -14,6 +16,8 @@ describe('exports', () => { expect(typeof ElkClient).toBe('function'); expect(typeof ElkClientState).toBe('object'); expect(typeof ElkConnectionState).toBe('object'); + expect(typeof ElkDeviceType).toBe('object'); + expect(typeof ElkDiscoveryClient).toBe('function'); expect(typeof ElkSocketConnection).toBe('function'); expect(typeof AuthenticationFailedError).toBe('function'); expect(typeof ConnectCancelledError).toBe('function'); diff --git a/src/index.ts b/src/index.ts index 24fea4a1..a981aa4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,6 @@ export { default as ElkClient } from './ElkClient'; export { default as ElkClientOptions } from './ElkClientOptions'; export { default as ElkClientState } from './ElkClientState'; +export * from './discovery'; export * from './errors'; export * from './connection';