Skip to content

Commit

Permalink
feat(discovery): adds the ability to do device discovery
Browse files Browse the repository at this point in the history
  • Loading branch information
joekrill committed Jul 30, 2019
1 parent 1acce25 commit 2665ee1
Show file tree
Hide file tree
Showing 16 changed files with 706 additions and 3 deletions.
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
10.16.0
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
```

6 changes: 5 additions & 1 deletion rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,22 @@ 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(),
// Compile TypeScript files
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
Expand Down
1 change: 0 additions & 1 deletion src/ElkClientCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ import {
ArmingStatusReport,
} from 'elk-message';
import TimeoutError from './errors/TimeoutError';
import { cd } from 'shelljs';

class ElkClientCommandsImpl extends ElkClientCommands {
constructor(
Expand Down
27 changes: 27 additions & 0 deletions src/__mocks__/dgram.js
Original file line number Diff line number Diff line change
@@ -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;
42 changes: 42 additions & 0 deletions src/discovery/ElkDevice.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions src/discovery/ElkDeviceType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
enum ElkDeviceType {
/**
* M1XEP devices
*/
M1XEP = 1,

/**
* C1M1 communicators
*/
C1M1 = 2,
}

export default ElkDeviceType;
199 changes: 199 additions & 0 deletions src/discovery/ElkDiscoveryClient.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
});
});
Loading

0 comments on commit 2665ee1

Please sign in to comment.