hb-nb-tools 1.0.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.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # Homebridge NB Tools
2
+ [![Downloads](https://img.shields.io/npm/dt/hb-nb-tools.svg)](https://www.npmjs.com/package/hb-nb-tools)
3
+ [![Version](https://img.shields.io/npm/v/hb-nb-tools.svg)](https://www.npmjs.com/package/hb-nb-tools)
4
+ [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
5
+
6
+ </span>
7
+
8
+ ## Homebridge NB Tools
9
+ Copyright © 2020-2023 Erik Baauw. All rights reserved.
10
+
11
+ This repository provides a standalone installation of the command-line tools from [Homebridge NB](https://github.com/ebaauw/homebridge-nb):
12
+
13
+ Tool | Description
14
+ --------- | -----------
15
+ `nb ` | Interact with Nuki bridge from command line.
16
+
17
+ Each command-line tool takes a `-h` or `--help` argument to provide a brief overview of its functionality and command-line arguments.
18
+
19
+ ### Prerequisites
20
+ Homebridge NB communicates with the Nuki Bridge using the local
21
+ [Nuki Bridge HTTP API](https://developer.nuki.io/page/nuki-bridge-http-api-1-12/4).
22
+ You need to enable this API through the Nuki app.
23
+
24
+ ### Installation
25
+ ```
26
+ $ sudo npm -g i hb-nb-tools
27
+ ```
28
+ Homebridge NB Tools uses [`sodium-plus`](https://github.com/paragonie/sodium-plus) for [encrypted tokens](https://developer.nuki.io/t/bridge-beta-fw-1-22-1-2-14-0-with-new-encrypted-bridge-http-api-token/15816).
29
+ This optional dependency contains C++ modules, that might need to be compiled on installation.
30
+ In case you want to install Homebridge NB Tools on a system that doesn't include the build tools needed for this, you might need to skip installation of `sodium-plus` by
31
+ ```
32
+ $ sudo npm -g i hb-nb-tools --omit=optional
33
+ ```
34
+ Homebrdige NB Tools reverts to hashed tokens when `sodium-plus` isn't installed.
package/cli/nb.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+
3
+ // nb.js
4
+ //
5
+ // Command line interface to Nuki bridge HTTP API.
6
+ // Copyright © 2018-2023 Erik Baauw. All rights reserved.
7
+
8
+ 'use strict'
9
+
10
+ const { NbTool } = require('../index')
11
+
12
+ new NbTool(require('../package.json')).main()
package/index.js ADDED
@@ -0,0 +1,38 @@
1
+ // hb-nb-tools/index.js
2
+ //
3
+ // Homebridge NB Tools.
4
+ // Copyright © 2017-2023 Erik Baauw. All rights reserved.
5
+
6
+ 'use strict'
7
+
8
+ /** Homebridge NB Tools.
9
+ *
10
+ * @module hbNbTools
11
+ */
12
+ class hbNbTools {
13
+ /** Colour conversions.
14
+ * <br>See {@link NbClient}.
15
+ * @type {Class}
16
+ * @memberof module:hbNbTools
17
+ */
18
+ static get NbClient () { return require('./lib/NbClient') }
19
+
20
+ /** Parser and validator for command-line arguments.
21
+ * <br>See {@link NbDiscovery}.
22
+ * @type {Class}
23
+ * @memberof module:hbNbTools
24
+ */
25
+ static get NbDiscovery () { return require('./lib/NbDiscovery') }
26
+
27
+ /** Command-line tool.
28
+ * <br>See {@link NbListener}.
29
+ * @type {Class}
30
+ * @memberof module:hbNbTools
31
+ */
32
+ static get NbListener () { return require('./lib/NbListener') }
33
+
34
+ // Command-line tools.
35
+ static get NbTool () { return require('./lib/NbTool') }
36
+ }
37
+
38
+ module.exports = hbNbTools
@@ -0,0 +1,224 @@
1
+ // hb-nb-tools/lib/NbClient.js
2
+ // Copyright © 2020-2023 Erik Baauw. All rights reserved.
3
+ //
4
+ // Homebridge NB Tools.
5
+
6
+ 'use strict'
7
+
8
+ const crypto = require('crypto')
9
+ const hbLibTools = require('hb-lib-tools')
10
+ const { HttpClient, JsonFormatter, OptionParser } = hbLibTools
11
+ let sodiumPlus
12
+ try {
13
+ sodiumPlus = require('sodium-plus')
14
+ } catch (error) {}
15
+ const { CryptographyKey, SodiumPlus } = sodiumPlus || {}
16
+
17
+ class NbClient extends HttpClient {
18
+ static get DeviceTypes () {
19
+ return {
20
+ SMARTLOCK: 0,
21
+ OPENER: 2,
22
+ SMARTDOOR: 3,
23
+ SMARTLOCK3: 4
24
+ }
25
+ }
26
+
27
+ static get LockStates () {
28
+ return {
29
+ UNCALIBRATED: 0,
30
+ LOCKED: 1,
31
+ UNLOCKING: 2,
32
+ UNLOCKED: 3,
33
+ LOCKING: 4,
34
+ UNLATCHED: 5,
35
+ UNLOCKED_LOCK_N_GO: 6,
36
+ UNLATCHING: 7,
37
+ MOTOR_BLOCKED: 254,
38
+ UNDEFINED: 255
39
+ }
40
+ }
41
+
42
+ static get DoorSensorStates () {
43
+ return {
44
+ DEACTIVATED: 0,
45
+ CLOSED: 2,
46
+ OPEN: 3,
47
+ UNKNOWN: 4,
48
+ CALIBRATING: 5
49
+ }
50
+ }
51
+
52
+ static get LockActions () {
53
+ return {
54
+ UNLOCK: 1,
55
+ LOCK: 2,
56
+ UNLATCH: 3,
57
+ LOCK_N_GO: 4,
58
+ LOCK_N_GO_WITH_UNLATCH: 5
59
+ }
60
+ }
61
+
62
+ static get OpenerModes () {
63
+ return {
64
+ DOOR_MODE: 2,
65
+ CONTINUOUS_MODE: 3
66
+ }
67
+ }
68
+
69
+ static get OpenerStates () {
70
+ return {
71
+ UNTRAINED: 0,
72
+ ONLINE: 1,
73
+ RTO_ACTIVE: 3,
74
+ OPEN: 5,
75
+ OPENING: 7,
76
+ BOOT_RUN: 253,
77
+ UNDEFINED: 255
78
+ }
79
+ }
80
+
81
+ static get OpenerActions () {
82
+ return {
83
+ ACTIVATE_RTO: 1,
84
+ DEACTIVATE_RTO: 2,
85
+ OPEN: 3,
86
+ ACTIVATE_CM: 4,
87
+ DEACTIVATE_CM: 5
88
+ }
89
+ }
90
+
91
+ static modelName (deviceType, firmware) {
92
+ switch (deviceType) {
93
+ case NbClient.DeviceTypes.SMARTLOCK:
94
+ if (firmware[0] === '1') {
95
+ return 'Smart Lock 1.0'
96
+ }
97
+ return 'Smart Lock 2.0'
98
+ case NbClient.DeviceTypes.OPENER:
99
+ return 'Opener'
100
+ case NbClient.DeviceTypes.SMARTDOOR:
101
+ return 'Smart Door'
102
+ case NbClient.DeviceTypes.SMARTLOCK3:
103
+ return 'Smart Lock 3.0'
104
+ }
105
+ }
106
+
107
+ constructor (params = {}) {
108
+ const _params = {
109
+ encryption: sodiumPlus == null ? 'hashedToken' : 'encryptedToken',
110
+ port: 8080,
111
+ timeout: 5
112
+ }
113
+ const optionParser = new OptionParser(_params)
114
+ optionParser
115
+ .hostKey('host')
116
+ .intKey('timeout', 1, 60)
117
+ .stringKey('token')
118
+ .enumKey('encryption')
119
+ .enumKeyValue('encryption', 'none')
120
+ .enumKeyValue('encryption', 'hashedToken')
121
+ .enumKeyValue('encryption', 'encryptedToken')
122
+ .parse(params)
123
+ super({
124
+ host: _params.hostname + ':' + _params.port,
125
+ json: true,
126
+ keepAlive: true,
127
+ maxSockets: 1,
128
+ timeout: _params.timeout
129
+ })
130
+ this._params = _params
131
+ this._jsonFormatter = new JsonFormatter()
132
+ }
133
+
134
+ get id () { return this._params.id }
135
+ get name () { return 'Nuki_Bridge_' + this._params.id }
136
+ get encryption () { return this._params.encryption }
137
+ get firmware () { return this._params.firmware }
138
+ get token () { return this._params.token }
139
+
140
+ async auth () {
141
+ const response = await this._get('/auth')
142
+ if (response.body.success) {
143
+ this._params.token = response.body.token
144
+ return response.body.token
145
+ }
146
+ throw new Error('Nuki bridge button not pressed')
147
+ }
148
+
149
+ async info () { return this._get('/info') }
150
+ async list () { return this._get('/list') }
151
+ async log () { return this._get('/log') }
152
+ async reboot () { return this._get('/reboot') }
153
+ async fwupdate () { return this._get('/fwupdate') }
154
+
155
+ async lockState (nukiId, deviceType) {
156
+ return this._get('/lockState', { nukiId, deviceType })
157
+ }
158
+
159
+ async lock (nukiId, deviceType) {
160
+ return this._get('/lock', { nukiId, deviceType })
161
+ }
162
+
163
+ async unlock (nukiId, deviceType) {
164
+ return this._get('/unlock', { nukiId, deviceType })
165
+ }
166
+
167
+ async lockAction (nukiId, deviceType, action) {
168
+ return this._get(
169
+ '/lockAction', { nukiId, deviceType, action }
170
+ )
171
+ }
172
+
173
+ async init () {
174
+ const response = await this.info()
175
+ this._params.id = response.body.ids.serverId.toString(16).toUpperCase()
176
+ this._params.firmware = response.body.versions.firmwareVersion
177
+ }
178
+
179
+ async callbackAdd (url) {
180
+ return this._get('/callback/add', { url: encodeURIComponent(url) })
181
+ }
182
+
183
+ async callbackList () { return this._get('/callback/list') }
184
+ async callbackRemove (id) { return this._get('/callback/remove', { id }) }
185
+
186
+ async _get (resource, params = {}) {
187
+ // Append parameters
188
+ let separator = '?'
189
+ for (const param in params) {
190
+ resource += separator + param + '=' + params[param]
191
+ separator = '&'
192
+ }
193
+ let suffix = ''
194
+ if (resource !== '/auth') {
195
+ if (this._params.encryption === 'none') {
196
+ suffix = separator + 'token=' + this._params.token
197
+ } else {
198
+ const date = new Date().toISOString()
199
+ const ts = date.slice(0, 19) + 'Z'
200
+ if (this._params.encryption === 'encryptedToken') {
201
+ if (this._sodium == null) {
202
+ const hash = crypto.createHash('sha256')
203
+ hash.update(this._params.token)
204
+ this._key = new CryptographyKey(hash.digest())
205
+ this._sodium = await SodiumPlus.auto()
206
+ }
207
+ const rnr = await this._sodium.randombytes_uniform(10000)
208
+ const nonce = await this._sodium.randombytes_buf(24)
209
+ const ctoken = await this._sodium.crypto_secretbox([ts, rnr].join(','), nonce, this._key)
210
+ suffix = separator + 'ctoken=' + ctoken.toString('hex') + '&nonce=' + nonce.toString('hex')
211
+ } else if (this._params.encryption === 'hashedToken') { // deprecated
212
+ const hash = crypto.createHash('sha256')
213
+ const rnr = Number(date[18] + date.slice(20, 23))
214
+ hash.update([ts, rnr, this._params.token].join(','))
215
+ suffix = separator + 'ts=' + ts + '&rnr=' + rnr +
216
+ '&hash=' + hash.digest('hex')
217
+ }
218
+ }
219
+ }
220
+ return super.get(resource, undefined, suffix)
221
+ }
222
+ }
223
+
224
+ module.exports = NbClient
@@ -0,0 +1,57 @@
1
+ // hb-nb-tools/lib/NbDiscovery.js
2
+ // Copyright © 2020-2023 Erik Baauw. All rights reserved.
3
+ //
4
+ // Homebridge NB Tools.
5
+
6
+ 'use strict'
7
+
8
+ const hbLibTools = require('hb-lib-tools')
9
+ const { HttpClient, OptionParser } = hbLibTools
10
+
11
+ class NbDiscovery extends HttpClient {
12
+ constructor (params = {}) {
13
+ const config = {
14
+ timeout: 5
15
+ }
16
+ const optionParser = new OptionParser(config)
17
+ optionParser.intKey('timeout', 1, 60)
18
+ optionParser.parse(params)
19
+ super({
20
+ https: true,
21
+ host: 'api.nuki.io',
22
+ json: true,
23
+ keepAlive: false,
24
+ name: 'nuki server',
25
+ path: '/discover',
26
+ timeout: config.timeout
27
+ })
28
+ this.config = config
29
+ }
30
+
31
+ async discover () {
32
+ const bridges = []
33
+ const response = await super.get('/bridges')
34
+ if (response == null) {
35
+ return bridges
36
+ }
37
+ for (const bridge of response.body.bridges) {
38
+ const client = new HttpClient({
39
+ host: bridge.ip + ':' + bridge.port,
40
+ path: '',
41
+ timeout: this.config.timeout,
42
+ validStatusCodes: [200, 401]
43
+ })
44
+ client
45
+ .on('error', (error) => { this.emit('error', error) })
46
+ .on('request', (request) => { this.emit('request', request) })
47
+ .on('response', (response) => { this.emit('response', response) })
48
+ try {
49
+ await client.get('/info')
50
+ bridges.push(bridge)
51
+ } catch (error) {}
52
+ }
53
+ return bridges
54
+ }
55
+ }
56
+
57
+ module.exports = NbDiscovery
@@ -0,0 +1,105 @@
1
+ // homebridge-nb/lib/NbListener.js
2
+ // Copyright © 2020-2023 Erik Baauw. All rights reserved.
3
+ //
4
+ // Homebridge NB Tools.
5
+
6
+ 'use strict'
7
+
8
+ const events = require('events')
9
+ const http = require('http')
10
+
11
+ class NbListener extends events.EventEmitter {
12
+ constructor (port = 0) {
13
+ super()
14
+ this._myPort = port
15
+ this._clients = {}
16
+ this._clientsByName = {}
17
+ this._server = http.createServer((request, response) => {
18
+ let buffer = ''
19
+ request.on('data', (data) => {
20
+ buffer += data
21
+ })
22
+ request.on('end', async () => {
23
+ try {
24
+ request.body = buffer
25
+ if (request.method === 'GET' && request.url === '/notify') {
26
+ // Provide an easy way to check that listener is reachable.
27
+ response.writeHead(200, { 'Content-Type': 'text/html' })
28
+ response.write('<table>')
29
+ response.write(`<caption><h3>Listening to ${Object.keys(this._clients).length} clients</h3></caption>`)
30
+ response.write('<tr><th scope="col">Nuki Bridge</th>')
31
+ response.write('<th scope="col">IP Address</th>')
32
+ response.write('<th scope="col">Local IP Address</th>')
33
+ for (const name of Object.keys(this._clientsByName).sort()) {
34
+ const client = this._clientsByName[name]
35
+ response.write(`<tr><td>${name}</td><td>${client.address}</td>`)
36
+ response.write(`<td>${client.localAddress}</td></tr>`)
37
+ }
38
+ response.write('</table>')
39
+ } else if (request.method === 'POST') {
40
+ const array = request.url.split('/')
41
+ const client = this._clients[array[2]]
42
+ if (array[1] === 'notify' && client !== null) {
43
+ const obj = JSON.parse(request.body)
44
+ client.emit('event', obj)
45
+ }
46
+ }
47
+ response.end()
48
+ } catch (error) {
49
+ this.emit('error', error)
50
+ }
51
+ })
52
+ })
53
+ this._server
54
+ .on('error', (error) => { this.emit('error', error) })
55
+ .on('close', () => {
56
+ this.emit('close', this._callbackUrl)
57
+ delete this._callbackUrl
58
+ })
59
+ }
60
+
61
+ async _listen () {
62
+ if (this._server.listening) {
63
+ return
64
+ }
65
+ return new Promise((resolve, reject) => {
66
+ try {
67
+ this._server.listen(this._myPort, '0.0.0.0', () => {
68
+ const address = this._server.address()
69
+ this._myIp = address.address
70
+ this._myPort = address.port
71
+ this._callbackUrl =
72
+ 'http://' + this._myIp + ':' + this._myPort + '/notify'
73
+ /** Emitted when the web server has started.
74
+ * @event NbListener#listening
75
+ * @param {string} url - The url the web server is listening on.
76
+ */
77
+ this.emit('listening', this._callbackUrl)
78
+ return resolve()
79
+ })
80
+ } catch (error) {
81
+ return reject(error)
82
+ }
83
+ })
84
+ }
85
+
86
+ async addClient (nbClient) {
87
+ this._clients[nbClient.id] = nbClient
88
+ this._clientsByName[nbClient.name] = nbClient
89
+ await this._listen()
90
+ const callbackUrl = 'http://' + nbClient.localAddress + ':' + this._myPort +
91
+ '/notify/' + nbClient.id
92
+ return callbackUrl
93
+ }
94
+
95
+ async removeClient (nbClient) {
96
+ this.removeAllListeners(nbClient.id)
97
+ delete this._clientsByName[nbClient.name]
98
+ delete this._clients[nbClient.id]
99
+ if (Object.keys(this._clients).length === 0) {
100
+ await this._server.close()
101
+ }
102
+ }
103
+ }
104
+
105
+ module.exports = NbListener
package/lib/NbTool.js ADDED
@@ -0,0 +1,645 @@
1
+ // hb-nb-tools/lib/nb.js
2
+ // Copyright © 2020-2023 Erik Baauw. All rights reserved.
3
+ //
4
+ // Command line interface to Nuki bridge HTTP API.
5
+
6
+ 'use strict'
7
+
8
+ const NbClient = require('./NbClient')
9
+ const NbDiscovery = require('./NbDiscovery')
10
+ const NbListener = require('./NbListener')
11
+ const hbLibTools = require('hb-lib-tools')
12
+ const packageJson = require('../package.json')
13
+
14
+ const { CommandLineParser, CommandLineTool, JsonFormatter, OptionParser } = hbLibTools
15
+ const { b, u } = CommandLineTool
16
+ const { UsageError } = CommandLineParser
17
+
18
+ const usage = {
19
+ nb: `${b('nb')} [${b('-hVD')}] [${b('-H')} ${u('hostname')}[${b(':')}${u('port')}]] [${b('-T')} ${u('token')}] [${b('-E')} [${b('none')}|${b('hasedToken')}|${b('encryptedToken')}]] [${b('-t')} ${u('timeout')}] ${u('command')} [${u('argument')} ...]`,
20
+
21
+ discover: `${b('discover')} [${b('-h')}] [${b('-t')} ${u('timeout')}]`,
22
+
23
+ auth: `${b('auth')} [${b('-h')}]`,
24
+ info: `${b('info')} [${b('-h')}]`,
25
+ getlog: `${b('getlog')} [${b('-h')}]`,
26
+ clearlog: `${b('clearlog')} [${b('-h')}]`,
27
+ reboot: `${b('reboot')} [${b('-h')}]`,
28
+ fwupdate: `${b('fwupdate')} [${b('-h')}]`,
29
+ list: `${b('list')} [${b('-h')}]`,
30
+
31
+ lockState: `${b('lockState')} [${b('-h')}] ${u('nukiId')} ${u('deviceType')}`,
32
+ lock: `${b('lock')} [${b('-h')}] ${u('nukiId')} ${u('deviceType')}`,
33
+ unlock: `${b('unlock')} [${b('-h')}] ${u('nukiId')} ${u('deviceType')}`,
34
+ lockAction: `${b('lockAction')} [${b('-h')}] ${u('nukiId')} ${u('deviceType')} ${u('action')}`,
35
+
36
+ eventlog: `${b('eventlog')} [${b('-hns')}]`,
37
+ callbackList: `${b('callbackList')} [${b('-h')}]`,
38
+ callbackRemove: `${b('callbackRemove')} [${b('-h')}] ${u('id')}`
39
+ }
40
+
41
+ const description = {
42
+ nb: 'Command line interface to Nuki bridge HTTP API.',
43
+
44
+ discover: 'Discover Nuki bridges.',
45
+
46
+ auth: 'Obtain Nuki bridge token.',
47
+ info: 'Get Nuki bridge info.',
48
+ getlog: 'Get Nuki bridge log.',
49
+ clearlog: 'Clear Nuki bridge log.',
50
+ reboot: 'Reboot Nuki bridge.',
51
+ fwupdate: 'Trigger a firmware update of the bridge and connected devices.',
52
+ list: 'Get list of paired Nuki devices.',
53
+
54
+ lockState: 'Refresh state from paired Nuki device.',
55
+ lock: 'Lock paired Nuki device.',
56
+ unlock: 'Unlock paired Nuki device.',
57
+ lockAction: 'Send action to paired Nuki device.',
58
+
59
+ eventlog: 'Add Nuki bridge subscription and listen for events.',
60
+ callbackList: 'List Nuki bridge subscriptions.',
61
+ callbackRemove: 'Remove Nuki bridge subscription.'
62
+ }
63
+
64
+ const help = {
65
+ nb: `${description.nb}
66
+
67
+ Usage: ${usage.nb}
68
+
69
+ Parameters:
70
+ ${b('-h')}, ${b('--help')}
71
+ Print this help and exit.
72
+
73
+ ${b('-V')}, ${b('--version')}
74
+ Print version and exit.
75
+
76
+ ${b('-D')}, ${b('--debug')}
77
+ Print debug messages for communication with Nuki bridge.
78
+
79
+ ${b('-H')} ${u('hostname')}[${b(':')}${u('port')}], ${b('--host=')}${u('hostname')}[${b(':')}${u('port')}]
80
+ Connect to Nuki bridge at ${u('hostname')}${b(':8080')} or ${u('hostname')}${b(':')}${u('port')}.
81
+ You can also specify the hostname and port in the ${b('NB_HOST')} environment variable.
82
+
83
+ ${b('-T')} ${u('token')}, ${b('--token=')}${u('token')}
84
+ Use token ${u('token')} to connect to the Nuki bridge.
85
+ You can also specify the token in the ${b('NB_TOKEN')} environment variable.
86
+
87
+ ${b('-E')} [${b('none')}|${b('hasedToken')}|${b('encryptedToken')}], ${b('--encryption=')}[${b('none')}|${b('hasedToken')}|${b('encryptedToken')}]
88
+ Use encryption method for communication with the Nuki bridge.
89
+ The default is ${b('hashedToken')}.
90
+
91
+ ${b('-t')} ${u('timeout')}
92
+ Set timeout to ${u('timeout')} seconds instead of default ${b('5')}.
93
+
94
+ Commands:
95
+ ${usage.discover}
96
+ ${description.discover}
97
+
98
+ ${usage.auth}
99
+ ${description.auth}
100
+
101
+ ${usage.info}
102
+ ${description.info}
103
+
104
+ ${usage.getlog}
105
+ ${description.getlog}
106
+
107
+ ${usage.clearlog}
108
+ ${description.clearlog}
109
+
110
+ ${usage.reboot}
111
+ ${description.reboot}
112
+
113
+ ${usage.fwupdate}
114
+ ${description.fwupdate}
115
+
116
+ ${usage.list}
117
+ ${description.list}
118
+
119
+ ${usage.lockState}
120
+ ${description.lockState}
121
+
122
+ ${usage.lock}
123
+ ${description.lock}
124
+
125
+ ${usage.unlock}
126
+ ${description.unlock}
127
+
128
+ ${usage.lockAction}
129
+ ${description.lockAction}
130
+
131
+ ${usage.eventlog}
132
+ ${description.eventlog}
133
+
134
+ ${usage.callbackList}
135
+ ${description.callbackList}
136
+
137
+ ${usage.callbackRemove}
138
+ ${description.callbackRemove}
139
+
140
+ For more help, issue: ${b('nb')} ${u('command')} ${b('-h')}`,
141
+ discover: `${description.discover}
142
+
143
+ Usage: ${usage.discover}
144
+
145
+ Parameters:
146
+ ${b('-h')}, ${b('--help')}
147
+ Print this help and exit.`,
148
+ auth: `${description.auth}
149
+
150
+ Usage: ${usage.auth}
151
+
152
+ Parameters:
153
+ ${b('-h')}, ${b('--help')}
154
+ Print this help and exit.`,
155
+ info: `${description.info}
156
+
157
+ Usage: ${usage.info}
158
+
159
+ Parameters:
160
+ ${b('-h')}, ${b('--help')}
161
+ Print this help and exit.`,
162
+ getlog: `${description.getlog}
163
+
164
+ Usage: ${usage.getlog}
165
+
166
+ Parameters:
167
+ ${b('-h')}, ${b('--help')}
168
+ Print this help and exit.`,
169
+ clearlog: `${description.clearlog}
170
+
171
+ Usage: ${usage.clearlog}
172
+
173
+ Parameters:
174
+ ${b('-h')}, ${b('--help')}
175
+ Print this help and exit.`,
176
+ reboot: `${description.reboot}
177
+
178
+ Usage: ${usage.reboot}
179
+
180
+ Parameters:
181
+ ${b('-h')}, ${b('--help')}
182
+ Print this help and exit.`,
183
+ fwupdate: `${description.fwupdate}
184
+
185
+ Usage: ${usage.fwupdate}
186
+
187
+ Parameters:
188
+ ${b('-h')}, ${b('--help')}
189
+ Print this help and exit.`,
190
+ list: `${description.list}
191
+
192
+ Usage: ${usage.list}
193
+
194
+ Parameters:
195
+ ${b('-h')}, ${b('--help')}
196
+ Print this help and exit.`,
197
+ lockState: `${description.lockState}
198
+
199
+ Usage: ${usage.lockState}
200
+
201
+ Parameters:
202
+ ${b('-h')}, ${b('--help')}
203
+ Print this help and exit.
204
+
205
+ ${u('nukiId')}
206
+ The ID of the Nuki device (from ${b('nb list')}).
207
+
208
+ ${u('deviceType')}
209
+ The type of the Nuki device (from ${b('nb list')}):
210
+ 0: Smart Lock 1.0 or 2.0
211
+ 2: Opener
212
+ 3: Smart Door
213
+ 4: Smart Lock 3.0`,
214
+ lock: `${description.lock}
215
+
216
+ Usage: ${usage.lock}
217
+
218
+ Parameters:
219
+ ${b('-h')}, ${b('--help')}
220
+ Print this help and exit.
221
+
222
+ ${u('nukiId')}
223
+ The ID of the Nuki device (from ${b('nb list')}).
224
+
225
+ ${u('deviceType')}
226
+ The type of the Nuki device (from ${b('nb list')}):
227
+ 0: Smart Lock 1.0 or 2.0
228
+ 2: Opener
229
+ 3: Smart Door
230
+ 4: Smart Lock 3.0`,
231
+ unlock: `${description.unlock}
232
+
233
+ Usage: ${usage.unlock}
234
+
235
+ Parameters:
236
+ ${b('-h')}, ${b('--help')}
237
+ Print this help and exit.
238
+
239
+ ${u('nukiId')}
240
+ The ID of the Nuki device (from ${b('nb list')}).
241
+
242
+ ${u('deviceType')}
243
+ The type of the Nuki device (from ${b('nb list')}):
244
+ 0: Smart Lock 1.0 or 2.0
245
+ 2: Opener
246
+ 3: Smart Door
247
+ 4: Smart Lock 3.0`,
248
+ lockAction: `${description.lockAction}
249
+
250
+ Usage: ${usage.lockAction}
251
+
252
+ Parameters:
253
+ ${b('-h')}, ${b('--help')}
254
+ Print this help and exit.
255
+
256
+ ${u('nukiId')}
257
+ The ID of the Nuki device (from ${b('nb list')}).
258
+
259
+ ${u('deviceType')}
260
+ The type of the Nuki device (from ${b('nb list')}):
261
+ 0: Smart Lock 1.0 or 2.0
262
+ 2: Opener
263
+ 3: Smart Door
264
+ 4: Smart Lock 3.0
265
+
266
+ ${u('action')}
267
+ The action to send to the Nuki device:
268
+ Smart Lock, Smart Door Opener
269
+ - ------------------------ -------------------------
270
+ 1 unlock activate rto
271
+ 2 lock deactivate rto
272
+ 3 unlatch electric strike actuation
273
+ 4 lock ‘n’ go activate continuous mode
274
+ 5 lock ‘n’ go with unlatch deactivate continuous mode`,
275
+ eventlog: `${description.eventlog}
276
+
277
+ Usage: ${usage.eventlog}
278
+
279
+ Parameters:
280
+ ${b('-h')}, ${b('--help')}
281
+ Print this help and exit.
282
+
283
+ ${b('-n')}, ${b('--noWhiteSpace')}
284
+ Do not include spaces nor newlines in JSON output.
285
+
286
+ ${b('-s')}, ${b('--service')}
287
+ Do not output timestamps (useful when running as service).`,
288
+ callbackList: `${description.callbackList}
289
+
290
+ Usage: ${usage.callbackList}
291
+
292
+ Parameters:
293
+ ${b('-h')}, ${b('--help')}
294
+ Print this help and exit.`,
295
+ callbackRemove: `${description.callbackRemove}
296
+
297
+ Usage: ${usage.callbackRemove}
298
+
299
+ Parameters:
300
+ ${b('-h')}, ${b('--help')}
301
+ Print this help and exit.
302
+
303
+ ${u('id')}
304
+ Remove callback with ID ${u('id')} (from ${b('nb callbackList')}).`
305
+ }
306
+
307
+ class NbTool extends CommandLineTool {
308
+ constructor (pkgJson) {
309
+ super({ mode: 'command', debug: false })
310
+ this.pkgJson = pkgJson
311
+ this.usage = usage.nb
312
+ }
313
+
314
+ parseArguments () {
315
+ const parser = new CommandLineParser(this.pkgJson)
316
+ const clargs = {
317
+ options: {
318
+ host: process.env.NB_HOST,
319
+ token: process.env.NB_TOKEN
320
+ }
321
+ }
322
+ parser
323
+ .help('h', 'help', help.nb)
324
+ .version('V', 'version')
325
+ .flag('D', 'debug', () => {
326
+ if (this.debugEnabled) {
327
+ this.setOptions({ vdebug: true })
328
+ } else {
329
+ this.setOptions({ debug: true, chalk: true })
330
+ }
331
+ })
332
+ .option('H', 'host', (value) => {
333
+ OptionParser.toHost('host', value, false, true)
334
+ clargs.options.host = value
335
+ })
336
+ .option('t', 'timeout', (value) => {
337
+ clargs.options.timeout = OptionParser.toInt('timeout', value, 1, 60, true)
338
+ })
339
+ .option('T', 'token', (value) => {
340
+ clargs.options.token = OptionParser.toString(
341
+ 'token', value, true, true
342
+ )
343
+ })
344
+ .option('E', 'encryption', (value) => {
345
+ clargs.options.encryption = OptionParser.toString(
346
+ 'encryption', value, true, true
347
+ )
348
+ if (!['none', 'hashedToken', 'encryptedToken'].includes(clargs.options.encryption)) {
349
+ throw new UsageError(`${value}: invalid encryption value`)
350
+ }
351
+ })
352
+ .parameter('command', (value) => {
353
+ if (usage[value] == null || typeof this[value] !== 'function') {
354
+ throw new UsageError(`${value}: unknown command`)
355
+ }
356
+ clargs.command = value
357
+ })
358
+ .remaining((list) => { clargs.args = list })
359
+ .parse()
360
+ return clargs
361
+ }
362
+
363
+ async main () {
364
+ try {
365
+ this.usage = usage.nb
366
+ const clargs = this.parseArguments()
367
+ this.jsonFormatter = new JsonFormatter({ sortKeys: true })
368
+ if (clargs.command !== 'discover') {
369
+ if (clargs.options.host == null) {
370
+ await this.fatal(`Missing host. Set ${b('NB_HOST')} or specify ${b('-H')}.`)
371
+ }
372
+ if (clargs.command === 'auth') {
373
+ clargs.options.timeout = 60
374
+ }
375
+ const name = clargs.options.host
376
+ this.client = new NbClient(clargs.options)
377
+ this.client
378
+ .on('error', (error) => {
379
+ this.log(
380
+ '%s: request %d: %s %s', name, error.request.id,
381
+ error.request.method, error.request.resource
382
+ )
383
+ this.warn(
384
+ '%s: request %d: error: %s', name, error.request.id, error
385
+ )
386
+ })
387
+ .on('request', (request) => {
388
+ this.debug(
389
+ '%s: request %d: %s %s', name, request.id,
390
+ request.method, request.resource
391
+ )
392
+ this.vdebug(
393
+ '%s: request %d: %s %s', name, request.id,
394
+ request.method, request.url
395
+ )
396
+ })
397
+ .on('response', (response) => {
398
+ this.vdebug(
399
+ '%s: request %d: response: %j', name, response.request.id,
400
+ response.body
401
+ )
402
+ this.debug(
403
+ '%s: request %d: %d %s', name, response.request.id,
404
+ response.statusCode, response.statusMessage
405
+ )
406
+ })
407
+ if (clargs.options.token == null && clargs.command !== 'auth') {
408
+ let args = ''
409
+ if (clargs.options.host !== process.env.NB_HOST) {
410
+ args += ' -H ' + clargs.options.host
411
+ }
412
+ await this.fatal(
413
+ `Missing token. Set ${b('NB_TOKEN')} or specify ${b('-T')}. Run ${b('nb' + args + ' auth')} to obtain the token.`
414
+ )
415
+ }
416
+ }
417
+ this.name = 'nb ' + clargs.command
418
+ this.usage = `${b('nb')} ${usage[clargs.command]}`
419
+ this.parser = new CommandLineParser(packageJson)
420
+ this.parser.help('h', 'help', help[clargs.command])
421
+ await this[clargs.command](clargs.args)
422
+ } catch (error) {
423
+ await this.fatal(error)
424
+ }
425
+ }
426
+
427
+ async discover (...args) {
428
+ const options = {}
429
+ this.parser
430
+ .option('t', 'timeout', (value, key) => {
431
+ options.timeout = OptionParser.toInt('timeout', value, 1, 60, true)
432
+ })
433
+ .parse(...args)
434
+ const nbDiscovery = new NbDiscovery(options)
435
+ nbDiscovery
436
+ .on('error', (error) => {
437
+ this.log(
438
+ '%s: request %d: %s %s', error.request.name, error.request.id,
439
+ error.request.method, error.request.resource
440
+ )
441
+ this.warn(
442
+ '%s: request %d: error: %s', error.request.name, error.request.id, error
443
+ )
444
+ })
445
+ .on('request', (request) => {
446
+ this.debug(
447
+ '%s: request %d: %s %s', request.name, request.id,
448
+ request.method, request.resource
449
+ )
450
+ this.vdebug(
451
+ '%s: request %d: %s %s', request.name, request.id,
452
+ request.method, request.url
453
+ )
454
+ })
455
+ .on('response', (response) => {
456
+ this.vdebug(
457
+ '%s: request %d: response: %j', response.request.name, response.request.id,
458
+ response.body
459
+ )
460
+ this.debug(
461
+ '%s: request %d: %d %s', response.request.name, response.request.id,
462
+ response.statusCode, response.statusMessage
463
+ )
464
+ })
465
+ const bridges = await nbDiscovery.discover()
466
+ this.print(this.jsonFormatter.stringify(bridges))
467
+ }
468
+
469
+ async auth (...args) {
470
+ this.parser.parse(...args)
471
+ this.log('press button on Nuki bridge to obtain token')
472
+ const token = await this.client.auth()
473
+ this.print(token)
474
+ }
475
+
476
+ async info (...args) {
477
+ this.parser.parse(...args)
478
+ const response = await this.client.info()
479
+ this.print(this.jsonFormatter.stringify(response.body))
480
+ }
481
+
482
+ async getlog (...args) {
483
+ this.parser.parse(...args)
484
+ const response = await this.client.log()
485
+ this.print(this.jsonFormatter.stringify(response.body))
486
+ }
487
+
488
+ async clearlog (...args) {
489
+ this.parser.parse(...args)
490
+ const response = await this.client.clearlog()
491
+ this.print(this.jsonFormatter.stringify(response.body))
492
+ }
493
+
494
+ async reboot (...args) {
495
+ this.parser.parse(...args)
496
+ const response = await this.client.reboot()
497
+ this.print(this.jsonFormatter.stringify(response.body))
498
+ }
499
+
500
+ async fwupdate (...args) {
501
+ this.parser.parse(...args)
502
+ const response = await this.client.fwupdate()
503
+ this.print(this.jsonFormatter.stringify(response.body))
504
+ }
505
+
506
+ async list (...args) {
507
+ this.parser.parse(...args)
508
+ const response = await this.client.list()
509
+ this.print(this.jsonFormatter.stringify(response.body))
510
+ }
511
+
512
+ async lockState (...args) {
513
+ let nukiId
514
+ let deviceType
515
+ this.parser
516
+ .parameter('nukiId', (value) => {
517
+ nukiId = OptionParser.toInt('nukiId', value, 0, Infinity, true)
518
+ })
519
+ .parameter('deviceType', (value) => {
520
+ deviceType = OptionParser.toInt('deviceType', value, 0, 2, true)
521
+ })
522
+ .parse(...args)
523
+ const response = await this.client.lockState(nukiId, deviceType)
524
+ this.print(this.jsonFormatter.stringify(response.body))
525
+ }
526
+
527
+ async lock (...args) {
528
+ let nukiId
529
+ let deviceType
530
+ this.parser
531
+ .parameter('nukiId', (value) => {
532
+ nukiId = OptionParser.toInt('nukiId', value, 0, Infinity, true)
533
+ })
534
+ .parameter('deviceType', (value) => {
535
+ deviceType = OptionParser.toInt('deviceType', value, 0, 2, true)
536
+ })
537
+ .parse(...args)
538
+ const response = await this.client.lock(nukiId, deviceType)
539
+ this.print(this.jsonFormatter.stringify(response.body))
540
+ }
541
+
542
+ async unlock (...args) {
543
+ let nukiId
544
+ let deviceType
545
+ this.parser
546
+ .parameter('nukiId', (value) => {
547
+ nukiId = OptionParser.toInt('nukiId', value, 0, Infinity, true)
548
+ })
549
+ .parameter('deviceType', (value) => {
550
+ deviceType = OptionParser.toInt('deviceType', value, 0, 2, true)
551
+ })
552
+ .parse(...args)
553
+ const response = await this.client.unlock(nukiId, deviceType)
554
+ this.print(this.jsonFormatter.stringify(response.body))
555
+ }
556
+
557
+ async lockAction (...args) {
558
+ let nukiId
559
+ let deviceType
560
+ let action
561
+ this.parser
562
+ .parameter('nukiId', (value) => {
563
+ nukiId = OptionParser.toInt('nukiId', value, 0, Infinity, true)
564
+ })
565
+ .parameter('deviceType', (value) => {
566
+ deviceType = OptionParser.toInt('deviceType', value, 0, 2, true)
567
+ })
568
+ .parameter('action', (value) => {
569
+ action = OptionParser.toInt('action', value, 1, 5, true)
570
+ })
571
+ .parse(...args)
572
+ const response = await this.client.lockAction(nukiId, deviceType, action)
573
+ this.print(this.jsonFormatter.stringify(response.body))
574
+ }
575
+
576
+ async destroy () {
577
+ if (this.listener != null) {
578
+ const response = await this.client.callbackList()
579
+ for (const callback of response.body.callbacks) {
580
+ if (callback.url === this._callbackUrl) {
581
+ this.log(
582
+ 'Removing subscription %s for %s', callback.id, callback.url
583
+ )
584
+ try {
585
+ await this.client.callbackRemove(callback.id)
586
+ } catch (error) {
587
+ this.error(error)
588
+ }
589
+ }
590
+ }
591
+ this.listener.removeClient(this.client)
592
+ }
593
+ }
594
+
595
+ async eventlog (...args) {
596
+ let noWhiteSpace = false
597
+ let mode = 'daemon'
598
+ this.parser
599
+ .flag('n', 'noWhiteSpace', () => { noWhiteSpace = true })
600
+ .flag('s', 'service', () => { mode = 'service' })
601
+ .parse(...args)
602
+ this.setOptions({ mode })
603
+ const jsonFormatter = new JsonFormatter({ sortKeys: true, noWhiteSpace })
604
+
605
+ this.listener = new NbListener()
606
+ this.listener
607
+ .on('error', (error) => { this.error(error) })
608
+ .on('listening', (url) => {
609
+ this.log('listening on %s', url)
610
+ })
611
+ .on('close', (url) => {
612
+ this.log('closed %s', url)
613
+ })
614
+ this.client.on('event', (event) => {
615
+ this.log('%s', jsonFormatter.stringify(event))
616
+ })
617
+
618
+ await this.client.init()
619
+ this._callbackUrl = await this.listener.addClient(this.client)
620
+ const response = await this.client.callbackAdd(this._callbackUrl)
621
+ if (!response.body.success) {
622
+ this.listener.removeClient(this.client)
623
+ this.error(response.body.message)
624
+ }
625
+ }
626
+
627
+ async callbackList (...args) {
628
+ this.parser.parse(...args)
629
+ const response = await this.client.callbackList()
630
+ this.print(this.jsonFormatter.stringify(response.body))
631
+ }
632
+
633
+ async callbackRemove (...args) {
634
+ let id
635
+ this.parser
636
+ .parameter('id', (value) => {
637
+ id = OptionParser.toInt('id', value, 0, Infinity, true)
638
+ })
639
+ .parse(...args)
640
+ const response = await this.client.callbackRemove(id)
641
+ this.print(this.jsonFormatter.stringify(response.body))
642
+ }
643
+ }
644
+
645
+ module.exports = NbTool
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "hb-nb-tools",
3
+ "description": "Homebridge NB Command-Line Tools",
4
+ "author": "Erik Baauw",
5
+ "license": "Apache-2.0",
6
+ "version": "1.0.0",
7
+ "keywords": [
8
+ "nuki",
9
+ "smart-lock",
10
+ "lock",
11
+ "opener"
12
+ ],
13
+ "bin": {
14
+ "nb": "cli/nb.js"
15
+ },
16
+ "engines": {
17
+ "node": "^18.15.0"
18
+ },
19
+ "dependencies": {
20
+ "hb-lib-tools": "~1.0.6"
21
+ },
22
+ "optionalDependencies": {
23
+ "sodium-plus": "~0.9.0"
24
+ },
25
+ "scripts": {
26
+ "prepare": "standard",
27
+ "test": "standard && echo \"Error: no test specified\" && exit 1"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/ebaauw/hb-nb-tools.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/ebaauw/hb-nb-tools/issues"
35
+ },
36
+ "homepage": "https://github.com/ebaauw/hb-nb-tools#readme"
37
+ }