homebridge-flume 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Change Log
2
+
3
+ All notable changes to homebridge-flume will be documented in this file.
4
+
5
+ ## 0.2.0 (2021-11-22)
6
+
7
+ Converted from accessory plugin to platform plugin
8
+
9
+ ## 0.1.0 (2021-11-22)
10
+
11
+ Initial release
package/README.md CHANGED
@@ -5,24 +5,20 @@
5
5
  Configuration sample:
6
6
 
7
7
  ```
8
- "accessories": [
9
- {
10
- "accessory": "Flume",
11
- "name": "Flume Water Monitor",
12
- "username": "user@domain.com",
13
- "password": "password",
14
- "client_id": "12345678901234567890",
15
- "client_secret": "1234567890",
16
- "polling_minutes": 1,
17
- "handicap": 0.1,
18
- "valveType": 3
19
- }
20
- ]
8
+ {
9
+ "name": "Flume Water Monitor",
10
+ "username": "user@domain.com",
11
+ "password": "password",
12
+ "client_id": "12345678901234567890",
13
+ "client_secret": "1234567890",
14
+ "polling_minutes": 1,
15
+ "handicap": 0.1,
16
+ "platform": "Flume"
17
+ }
21
18
  ```
22
19
 
23
20
  Fields:
24
21
 
25
- - "accessory": Must always be "Flume" (required)
26
22
  - "username": Your username for accessing the Flume site at https://portal.flumetech.com/ (required)
27
23
  - "password": Your password for accessing the Flume site (required)
28
24
  - "client_id": Your Client ID for API access, found in Settings on the Flume site (required)
@@ -1,57 +1,55 @@
1
1
  {
2
2
  "pluginAlias": "Flume",
3
- "pluginType": "accessory",
3
+ "pluginType": "platform",
4
4
  "singular": true,
5
- "headerDisplay": "[Flume Water Monitor](https://flumetech.com) plugin for Homebridge.",
5
+ "customUi": true,
6
+ "customUiPath": "./lib/homebridge-ui",
7
+ "headerDisplay": "<p align=\"center\">For help and support please visit our <a href=\"https://github.com/bwp91/homebridge-flume/wiki\">GitHub Wiki</a>. We hope you find this plugin useful!</p>",
6
8
  "schema": {
7
9
  "type": "object",
8
10
  "properties": {
9
11
  "name": {
10
- "title": "Accessory Name",
12
+ "title": "Plugin Name",
11
13
  "type": "string",
12
- "required": true,
13
- "default": "Flume Water Monitor",
14
- "description": "The name used for the Flume water monitor in HomeKit"
14
+ "default": "Flume"
15
15
  },
16
16
  "username": {
17
17
  "title": "Username",
18
18
  "type": "string",
19
19
  "required": true,
20
- "description": "Your username for acessing the Flume site at https://portal.flumetech.com/ (required)"
20
+ "description": "Your username for accessing the Flume site at https://portal.flumetech.com"
21
21
  },
22
22
  "password": {
23
23
  "title": "Password",
24
24
  "type": "string",
25
25
  "required": true,
26
- "description": "Your password for acessing the Flume site (required)"
26
+ "description": "Your password for accessing the Flume site"
27
27
  },
28
28
  "client_id": {
29
29
  "title": "Client ID",
30
30
  "type": "string",
31
31
  "placeholder": "12345678901234567890",
32
32
  "required": true,
33
- "description": "Your Client ID for API access, found in Settings on the Flume site (required)"
33
+ "description": "Your Client ID for API access, found in Settings on the Flume site"
34
34
  },
35
35
  "client_secret": {
36
36
  "title": "Client Secret",
37
37
  "type": "string",
38
38
  "placeholder": "1234567890",
39
39
  "required": true,
40
- "description": "Your Client Secret for API access, found in Settings on the Flume site (required)"
40
+ "description": "Your Client Secret for API access, found in Settings on the Flume site"
41
41
  },
42
42
  "polling_minutes": {
43
43
  "title": "Update Interval",
44
44
  "type": "integer",
45
- "default": "1",
46
- "required": false,
47
- "description": "Number of minutes between updates. Defaults to 1 minute. API has a cap of 120 calls per hour. (optional)"
45
+ "placeholder": 1,
46
+ "description": "Number of minutes between updates. API has a cap of 120 calls per hour."
48
47
  },
49
48
  "handicap": {
50
49
  "title": "Adjustment for False Positives",
51
- "type": "float",
52
- "default": "0.0",
53
- "required": false,
54
- "description": "Set to ignore a steady water draw below this value. Defaults to zero (0.0) gallons. (optional)"
50
+ "type": "number",
51
+ "placeholder": 0,
52
+ "description": "Set to ignore a steady water draw below this value (gallons)."
55
53
  }
56
54
  }
57
55
  }
@@ -0,0 +1,218 @@
1
+ /* jshint node: true, esversion: 10, -W014, -W033 */
2
+ /* eslint-disable new-cap */
3
+ 'use strict'
4
+
5
+ const axios = require('axios')
6
+ const jwtDecode = require('jwt-decode')
7
+
8
+ module.exports = class connectionHTTP {
9
+ constructor (platform) {
10
+ // Create variables usable by the class
11
+ this.consts = platform.consts
12
+ this.debug = platform.config.debug
13
+ this.funcs = platform.funcs
14
+ this.lang = platform.lang
15
+ this.log = platform.log
16
+ this.username = platform.config.username
17
+ this.password = platform.config.password
18
+ this.client_id = platform.config.client_id
19
+ this.client_secret = platform.config.client_secret
20
+ }
21
+
22
+ async obtainToken () {
23
+ try {
24
+ // Generate the JSON data to send
25
+ const body = {
26
+ grant_type: 'password',
27
+ client_id: this.client_id,
28
+ client_secret: this.client_secret,
29
+ username: this.username,
30
+ password: this.password
31
+ }
32
+ const now = Date.now()
33
+
34
+ // Perform the HTTP request
35
+ const res = await axios.post('https://api.flumetech.com/oauth/token', body, {
36
+ timeout: 10000
37
+ })
38
+
39
+ // Check to see we got a response
40
+ if (!res.data) {
41
+ throw new Error(this.lang.noToken)
42
+ }
43
+
44
+ /*
45
+ {
46
+ success: true,
47
+ code: 602,
48
+ message: 'Request OK',
49
+ http_code: 200,
50
+ http_message: 'OK',
51
+ detailed: null,
52
+ data: [
53
+ {
54
+ token_type: 'bearer',
55
+ access_token: '',
56
+ expires_in: 604800,
57
+ refresh_token: ''
58
+ }
59
+ ],
60
+ count: 1,
61
+ pagination: null
62
+ }
63
+ */
64
+
65
+ // Make the token available in other functions
66
+ this.accessToken = res.data.data[0].access_token
67
+ this.refreshToken = res.data.data[0].refresh_token
68
+ this.expiresIn = now + res.data.data[0].expires_in
69
+
70
+ // Obtain the user ID
71
+ this.userId = jwtDecode(this.accessToken).user_id
72
+
73
+ /*
74
+ {
75
+ user_id: 0000,
76
+ type: 'USER',
77
+ scope: [ 'read:personal', 'update:personal', 'query:personal' ],
78
+ iat: 0000000000,
79
+ exp: 0000000000,
80
+ iss: 'flume_oauth',
81
+ sub: ''
82
+ }
83
+ */
84
+ } catch (err) {
85
+ if (err.code && this.consts.httpRetryCodes.includes(err.code)) {
86
+ // Retry if another attempt could be successful
87
+ this.log.warn('[HTTP login()] %s [%s].', this.lang.httpRetry, err.code)
88
+ await this.funcs.sleep(30000)
89
+ return await this.login()
90
+ } else {
91
+ throw new Error('[HTTP obtainToken()] ' + err.message)
92
+ }
93
+ }
94
+ }
95
+
96
+ async renewToken () {
97
+ try {
98
+ // Generate the JSON data to send
99
+ const body = {
100
+ grant_type: 'refresh_token',
101
+ client_id: this.client_id,
102
+ client_secret: this.client_secret,
103
+ refresh_token: this.refreshToken
104
+ }
105
+ const now = Date.now()
106
+
107
+ // Perform the HTTP request
108
+ const res = await axios.post('https://api.flumetech.com/oauth/token', body, {
109
+ timeout: 10000
110
+ })
111
+
112
+ // Check to see we got a response
113
+ if (!res.data) {
114
+ throw new Error(this.lang.noToken)
115
+ }
116
+
117
+ /*
118
+ {
119
+ success: true,
120
+ code: 602,
121
+ message: 'Request OK',
122
+ http_code: 200,
123
+ http_message: 'OK',
124
+ detailed: null,
125
+ data: [
126
+ {
127
+ token_type: 'bearer',
128
+ access_token: '',
129
+ expires_in: 604800,
130
+ refresh_token: ''
131
+ }
132
+ ],
133
+ count: 1,
134
+ pagination: null
135
+ }
136
+ */
137
+
138
+ // Make the token available in other functions
139
+ this.accessToken = res.data.data[0].access_token
140
+ this.refreshToken = res.data.data[0].refresh_token
141
+ this.expiresIn = now + res.data.data[0].expires_in
142
+ } catch (err) {
143
+ if (err.code && this.consts.httpRetryCodes.includes(err.code)) {
144
+ // Retry if another attempt could be successful
145
+ this.log.warn('[HTTP login()] %s [%s].', this.lang.httpRetry, err.code)
146
+ await this.funcs.sleep(30000)
147
+ return await this.login()
148
+ } else {
149
+ throw new Error('[HTTP refreshToken()] ' + err.message)
150
+ }
151
+ }
152
+ }
153
+
154
+ async getDevices () {
155
+ try {
156
+ // Check we have a user id
157
+ if (!this.userId) {
158
+ throw new Error('No user id has been retrieved')
159
+ }
160
+
161
+ // Perform the HTTP request
162
+ const res = await axios.get('https://api.flumetech.com/users/' + this.userId + '/devices', {
163
+ headers: { Authorization: 'Bearer ' + this.accessToken },
164
+ timeout: 10000
165
+ })
166
+
167
+ // Check to see we got a response
168
+ if (!res.data) {
169
+ throw new Error('No data received')
170
+ }
171
+
172
+ return res.data.data
173
+ } catch (err) {
174
+ if (err.code && this.consts.httpRetryCodes.includes(err.code)) {
175
+ // Retry if another attempt could be successful
176
+ this.log.warn('[HTTP login()] %s [%s].', this.lang.httpRetry, err.code)
177
+ await this.funcs.sleep(30000)
178
+ return await this.login()
179
+ } else {
180
+ throw new Error('[HTTP getDevices()] ' + err.message)
181
+ }
182
+ }
183
+ }
184
+
185
+ async getDeviceInfo (deviceId, fromWhen) {
186
+ // Refresh the access token if it has expired already
187
+ if (Date.now() > this.expiresIn) {
188
+ await this.renewToken()
189
+ }
190
+
191
+ // Generate the JSON data to send
192
+ const body = {
193
+ queries: [
194
+ {
195
+ request_id: 'currentusage',
196
+ bucket: 'MIN',
197
+ since_datetime: fromWhen,
198
+ operation: 'SUM',
199
+ units: 'GALLONS'
200
+ }
201
+ ]
202
+ }
203
+
204
+ // Send the request
205
+ const res = await axios.post(
206
+ 'https://api.flumetech.com/users/' + this.userId + '/devices/' + deviceId + '/query',
207
+ body,
208
+ {
209
+ headers: {
210
+ Authorization: 'Bearer ' + this.accessToken
211
+ }
212
+ }
213
+ )
214
+
215
+ // Parse the response
216
+ return res.data.data[0]
217
+ }
218
+ }
@@ -0,0 +1,38 @@
1
+ /* jshint node: true, esversion: 10, -W014, -W033 */
2
+ /* eslint-disable new-cap */
3
+ 'use strict'
4
+
5
+ module.exports = class deviceValve {
6
+ constructor (platform, accessory) {
7
+ // Set up variables from the platform
8
+ this.accessory = accessory
9
+ this.disableDeviceLogging = platform.config.disableDeviceLogging
10
+ this.funcs = platform.funcs
11
+ this.handicap = platform.config.handicap
12
+ this.hapChar = platform.api.hap.Characteristic
13
+ this.hapErr = platform.api.hap.HapStatusError
14
+ this.hapServ = platform.api.hap.Service
15
+ this.lang = platform.lang
16
+ this.log = platform.log
17
+ this.name = accessory.displayName
18
+ this.platform = platform
19
+ this.polling_minutes = platform.config.polling_minutes
20
+ }
21
+
22
+ externalUpdate (params) {
23
+ // Here we deal with the incoming data
24
+ const usage =
25
+ params.currentusage && params.currentusage[0] && params.currentusage[0].value
26
+ ? params.currentusage[0].value
27
+ : 0
28
+ if (usage > this.handicap) {
29
+ this.log(
30
+ '[%s] %s - [%s] gallons within the last [%s] minutes.',
31
+ this.name,
32
+ 'usage detected',
33
+ usage,
34
+ this.polling_minutes
35
+ )
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,159 @@
1
+ <div id="pageIntro" class="text-center" style="display: none;">
2
+ <p class="lead">Thank you for installing <strong>homebridge-flume</strong></p>
3
+ <p>
4
+ You will need your Flume username, password, client ID and secret to continue
5
+ </p>
6
+ <button type="button" class="btn btn-primary" id="introContinue">Continue &rarr;</button>
7
+ </div>
8
+ <div
9
+ id="menuWrapper"
10
+ class="btn-group w-100 mb-0"
11
+ role="group"
12
+ aria-label="UI Menu"
13
+ style="display: none;"
14
+ >
15
+ <button type="button" class="btn btn-primary ml-0" id="menuSettings">Settings</button>
16
+ <button type="button" class="btn btn-primary mr-0" id="menuHome">Support</button>
17
+ </div>
18
+ <div
19
+ id="disabledBanner"
20
+ class="alert alert-secondary mb-0 mt-3"
21
+ role="alert"
22
+ style="display: none;"
23
+ >
24
+ Plugin is currently disabled
25
+ <button id="disabledEnable" type="button" class="btn btn-link p-0 m-0 float-right">Enable</button>
26
+ </div>
27
+ <div id="pageSupport" class="mt-4" style="display: none;">
28
+ <p class="text-center lead">Thank you for using <strong>homebridge-flume</strong></p>
29
+ <p class="text-center">The links below will take you to our GitHub wiki</p>
30
+ <h4>Setup</h4>
31
+ <ul>
32
+ <li>
33
+ <a href="https://github.com/bwp91/homebridge-flume/wiki/Installation" target="_blank"
34
+ >Installation</a
35
+ >
36
+ </li>
37
+ <li>
38
+ <a href="https://github.com/bwp91/homebridge-flume/wiki/Configuration" target="_blank"
39
+ >Configuration</a
40
+ >
41
+ </li>
42
+ <li>
43
+ <a href="https://github.com/bwp91/homebridge-flume/wiki/Beta-Version" target="_blank"
44
+ >Beta Version</a
45
+ >
46
+ </li>
47
+ <li>
48
+ <a href="https://github.com/bwp91/homebridge-flume/wiki/Node-Version" target="_blank"
49
+ >Node Version</a
50
+ >
51
+ </li>
52
+ <li>
53
+ <a href="https://github.com/bwp91/homebridge-flume/wiki/Uninstallation" target="_blank"
54
+ >Uninstallation</a
55
+ >
56
+ </li>
57
+ </ul>
58
+ <h4>Help/About</h4>
59
+ <ul>
60
+ <li>
61
+ <a href="https://github.com/bwp91/homebridge-flume/wiki/Common-Errors" target="_blank"
62
+ >Common Errors</a
63
+ >
64
+ </li>
65
+ <li>
66
+ <a href="https://github.com/bwp91/homebridge-flume/issues/new/choose" target="_blank"
67
+ >Support Request</a
68
+ >
69
+ </li>
70
+ <li>
71
+ <a href="https://github.com/bwp91/homebridge-flume/blob/latest/CHANGELOG.md" target="_blank"
72
+ >Changelog</a
73
+ >
74
+ </li>
75
+ <li><a href="https://github.com/sponsors/bwp91" target="_blank">About Me</a></li>
76
+ </ul>
77
+ <h4>Credits</h4>
78
+ <ul>
79
+ <li>
80
+ To the creators/contributors of
81
+ <a href="https://homebridge.io" target="_blank">Homebridge</a> who make this plugin possible.
82
+ </li>
83
+ </ul>
84
+ <h4>Disclaimer</h4>
85
+ <ul>
86
+ <li>
87
+ I am in no way affiliated with Flume and this plugin is a personal project that I maintain in
88
+ my free time.
89
+ </li>
90
+ <li>Use this plugin entirely at your own risk - please see licence for more information.</li>
91
+ </ul>
92
+ </div>
93
+ <script>
94
+ ;(async () => {
95
+ try {
96
+ const currentConfig = await homebridge.getPluginConfig()
97
+ showIntro = () => {
98
+ const introContinue = document.getElementById('introContinue')
99
+ introContinue.addEventListener('click', () => {
100
+ homebridge.showSpinner()
101
+ document.getElementById('pageIntro').style.display = 'none'
102
+ document.getElementById('menuWrapper').style.display = 'inline-flex'
103
+ showSettings()
104
+ homebridge.hideSpinner()
105
+ })
106
+ document.getElementById('pageIntro').style.display = 'block'
107
+ }
108
+ showSupport = () => {
109
+ homebridge.showSpinner()
110
+ homebridge.hideSchemaForm()
111
+ document.getElementById('menuHome').classList.add('btn-elegant')
112
+ document.getElementById('menuHome').classList.remove('btn-primary')
113
+ document.getElementById('menuSettings').classList.remove('btn-elegant')
114
+ document.getElementById('menuSettings').classList.add('btn-primary')
115
+ document.getElementById('pageSupport').style.display = 'block'
116
+ homebridge.hideSpinner()
117
+ }
118
+ showSettings = () => {
119
+ homebridge.showSpinner()
120
+ document.getElementById('menuHome').classList.remove('btn-elegant')
121
+ document.getElementById('menuHome').classList.add('btn-primary')
122
+ document.getElementById('menuSettings').classList.add('btn-elegant')
123
+ document.getElementById('menuSettings').classList.remove('btn-primary')
124
+ document.getElementById('pageSupport').style.display = 'none'
125
+ homebridge.showSchemaForm()
126
+ homebridge.hideSpinner()
127
+ }
128
+ showDisabledBanner = () => {
129
+ document.getElementById('disabledBanner').style.display = 'block'
130
+ }
131
+ enablePlugin = async () => {
132
+ homebridge.showSpinner()
133
+ document.getElementById('disabledBanner').style.display = 'none'
134
+ currentConfig[0].disablePlugin = false
135
+ await homebridge.updatePluginConfig(currentConfig)
136
+ await homebridge.savePluginConfig()
137
+ homebridge.hideSpinner()
138
+ }
139
+ menuHome.addEventListener('click', () => showSupport())
140
+ menuSettings.addEventListener('click', () => showSettings())
141
+ disabledEnable.addEventListener('click', () => enablePlugin())
142
+ if (currentConfig.length) {
143
+ document.getElementById('menuWrapper').style.display = 'inline-flex'
144
+ showSettings()
145
+ if (currentConfig[0].disablePlugin) {
146
+ showDisabledBanner()
147
+ }
148
+ } else {
149
+ currentConfig.push({ name: 'Flume' })
150
+ await homebridge.updatePluginConfig(currentConfig)
151
+ showIntro()
152
+ }
153
+ } catch (err) {
154
+ homebridge.toast.error(err.message, 'Error')
155
+ } finally {
156
+ homebridge.hideSpinner()
157
+ }
158
+ })()
159
+ </script>
@@ -0,0 +1,14 @@
1
+ /* jshint node: true, esversion: 10, -W014, -W033 */
2
+ /* eslint-disable new-cap */
3
+ 'use strict'
4
+
5
+ const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils')
6
+
7
+ class PluginUiServer extends HomebridgePluginUiServer {
8
+ constructor () {
9
+ super()
10
+ this.ready()
11
+ }
12
+ }
13
+
14
+ ;(() => new PluginUiServer())()
package/lib/index.js CHANGED
@@ -1,219 +1,335 @@
1
- const fetch = require('node-fetch');
2
- const packageJson = require('./package.json');
3
- var jwtDecode = require('jwt-decode');
4
- var Service, Characteristic;
5
-
6
- module.exports = function (homebridge) {
7
- Service = homebridge.hap.Service;
8
- Characteristic = homebridge.hap.Characteristic;
9
- homebridge.registerAccessory('homebridge-flume', 'Flume', Flume);
10
- }
1
+ /* jshint node: true, esversion: 10, -W014, -W033 */
2
+ /* eslint-disable new-cap */
3
+ 'use strict'
4
+
5
+ // Packages and constant variables for this class
6
+ const plugin = require('./../package.json')
7
+
8
+ // Create the platform class
9
+ class FlumePlatform {
10
+ constructor (log, config, api) {
11
+ // Don't load the plugin if these aren't accessible for any reason
12
+ if (!log || !api) {
13
+ return
14
+ }
15
+
16
+ // Begin plugin initialisation
17
+ try {
18
+ this.api = api
19
+ this.consts = require('./utils/constants')
20
+ this.funcs = require('./utils/functions')
21
+ this.log = log
11
22
 
12
- function Flume (log, config, api) {
13
- this.log = log;
14
- this.config = config;
23
+ // Configuration objects for accessories
24
+ this.devicesInHB = new Map()
15
25
 
16
- log("Starting homebridge-flume.");
26
+ // Retrieve the user's chosen language file
27
+ this.lang = require('./utils/lang-en')
17
28
 
18
- // extract name from config
19
- this.name = config.name;
29
+ // Make sure user is running Homebridge v1.3 or above
30
+ if (!api.versionGreaterOrEqual || !api.versionGreaterOrEqual('1.3.0')) {
31
+ throw new Error(this.lang.hbVersionFail)
32
+ }
20
33
 
21
- // pull in initial variables
22
- this.username = config["username"];
23
- this.password = config["password"];
24
- this.client_id = config["client_id"];
25
- this.client_secret = config["client_secret"];
26
- if (config["handicap"] != null) {
27
- this.handicap = config["handicap"];
28
- } else {
29
- this.handicap = 0;
34
+ // Check the user has configured the plugin
35
+ if (!config) {
36
+ throw new Error(this.lang.pluginNotConf)
37
+ }
38
+
39
+ // Log some environment info for debugging
40
+ this.log(
41
+ '%s v%s | Node %s | HB v%s%s...',
42
+ this.lang.initialising,
43
+ plugin.version,
44
+ process.version,
45
+ api.serverVersion,
46
+ config.plugin_map
47
+ ? ' | HOOBS v3'
48
+ : require('os')
49
+ .hostname()
50
+ .includes('hoobs')
51
+ ? ' | HOOBS v4'
52
+ : ''
53
+ )
54
+
55
+ // Apply the user's configuration
56
+ this.config = this.consts.defaultConfig
57
+ this.applyUserConfig(config)
58
+
59
+ // Set up the Homebridge events
60
+ this.api.on('didFinishLaunching', () => this.pluginSetup())
61
+ this.api.on('shutdown', () => this.pluginShutdown())
62
+ } catch (err) {
63
+ // Catch any errors during initialisation
64
+ const eText = this.funcs.parseError(err, [this.lang.hbVersionFail, this.lang.pluginNotConf])
65
+ log.warn('***** %s. *****', this.lang.disabling)
66
+ log.warn('***** %s. *****', eText)
30
67
  }
31
- if (config["polling_minutes"] != null) {
32
- this.interval = parseInt(config["polling_minutes"]) * 60 * 1000;
33
- } else {
34
- this.interval = 1 * 60 * 1000;
68
+ }
69
+
70
+ applyUserConfig (config) {
71
+ // These shorthand functions save line space during config parsing
72
+ const logDefault = (k, def) => {
73
+ this.log.warn('%s [%s] %s %s.', this.lang.cfgItem, k, this.lang.cfgDef, def)
74
+ }
75
+ const logIgnore = k => {
76
+ this.log.warn('%s [%s] %s.', this.lang.cfgItem, k, this.lang.cfgIgn)
77
+ }
78
+ const logIncrease = (k, min) => {
79
+ this.log.warn('%s [%s] %s %s.', this.lang.cfgItem, k, this.lang.cfgLow, min)
80
+ }
81
+ const logQuotes = k => {
82
+ this.log.warn('%s [%s] %s.', this.lang.cfgItem, k, this.lang.cfgQts)
83
+ }
84
+ const logRemove = k => {
85
+ this.log.warn('%s [%s] %s.', this.lang.cfgItem, k, this.lang.cfgRmv)
35
86
  }
36
- this.debug = config["debug"] || true;
37
87
 
38
- if (!this.username) throw new Error("You must provide a value for username.");
39
- if (!this.password) throw new Error("You must provide a value for password.");
40
- if (!this.client_id) throw new Error("You must provide a value for client_id.");
41
- if (!this.client_secret) throw new Error("You must provide a value for client_secret.");
88
+ // Begin applying the user's config
89
+ for (const [key, val] of Object.entries(config)) {
90
+ switch (key) {
91
+ case 'client_id':
92
+ case 'client_secret':
93
+ case 'password':
94
+ case 'username':
95
+ if (typeof val !== 'string' || val === '') {
96
+ logIgnore(key)
97
+ } else {
98
+ this.config[key] = val
99
+ }
100
+ break
101
+ case 'debug':
102
+ case 'disableDeviceLogging':
103
+ case 'disablePlugin':
104
+ if (typeof val === 'string') {
105
+ logQuotes(key)
106
+ }
107
+ this.config[key] = val === 'false' ? false : !!val
108
+ break
109
+ case 'handicap': {
110
+ if (typeof v === 'string') {
111
+ logQuotes(key)
112
+ }
113
+ const numVal = Number(val)
114
+ if (isNaN(numVal)) {
115
+ logIgnore(key)
116
+ } else {
117
+ this.config[key] = numVal
118
+ }
119
+ break
120
+ }
121
+ case 'name':
122
+ case 'platform':
123
+ case 'plugin_map':
124
+ break
125
+ case 'polling_minutes': {
126
+ if (typeof val === 'string') {
127
+ logQuotes(key)
128
+ }
129
+ const intVal = parseInt(val)
130
+ if (isNaN(intVal)) {
131
+ logDefault(key, this.consts.defaultValues[key])
132
+ this.config[key] = this.consts.defaultValues[key]
133
+ } else if (intVal < this.consts.minValues[key]) {
134
+ logIncrease(key, this.consts.minValues[key])
135
+ this.config[key] = this.consts.minValues[key]
136
+ } else {
137
+ this.config[key] = intVal
138
+ }
139
+ break
140
+ }
141
+ default:
142
+ logRemove(key)
143
+ break
144
+ }
145
+ }
146
+ }
42
147
 
43
- this.expires_in = Date.now();
44
- this.device_id = "";
45
- this.device_usage = 0.00;
148
+ async pluginSetup () {
149
+ // Plugin has finished initialising so now onto setup
150
+ try {
151
+ // If the user has disabled the plugin then remove all accessories
152
+ if (this.config.disablePlugin) {
153
+ this.devicesInHB.forEach(accessory => this.removeAccessory(accessory))
154
+ throw new Error(this.lang.disabled)
155
+ }
46
156
 
47
- log("User variables loaded.");
157
+ // Log that the plugin initialisation has been successful
158
+ this.log('%s.', this.lang.initialised)
48
159
 
49
- if (api) {
50
- this.api = api;
51
- this.api.on('didFinishLaunching', this.fetchDevices.bind(this));
52
- this.timer = setInterval(this.fetchDevices.bind(this), this.interval);
160
+ // Ensure username and password have been provided
161
+ if (
162
+ !this.config.username ||
163
+ !this.config.password ||
164
+ !this.config.client_id ||
165
+ !this.config.client_secret
166
+ ) {
167
+ throw new Error(this.lang.noCreds)
168
+ }
53
169
 
54
- log("Update interval set.");
55
- }
170
+ // Setup the HTTP client if Thermobit username and password have been provided
171
+ this.httpClient = new (require('./connection/http'))(this)
172
+ await this.httpClient.obtainToken()
173
+ const deviceList = await this.httpClient.getDevices()
56
174
 
57
- log("Initial load of the accessory started.");
175
+ // Check we have devices we can work with
176
+ if (!Array.isArray(deviceList) || deviceList.length === 0) {
177
+ throw new Error(this.lang.noDevices)
178
+ }
58
179
 
59
- // get the Valve service if it exists, otherwise create a new Valve service
60
- this.informationService = this.accessory.getService(Service.Valve) ||
61
- this.accessory.addService(Service.Valve);
180
+ deviceList.forEach(device => {
181
+ if (!device.bridge_id) {
182
+ return
183
+ }
184
+ this.initialiseDevice(device)
185
+ })
62
186
 
63
- this.informationService
64
- .setCharacteristic(Characteristic.Manufacturer, "Flume Water Monitor")
65
- .setCharacteristic(Characteristic.Model, "001")
66
- .setCharacteristic(Characteristic.SerialNumber, this.device_id)
67
- .setCharacteristic(Characteristic.Name, accessory.context.device.name)
68
- .setCharacteristic(Characteristic.Active, false)
69
- .setCharacteristic(Characteristic.InUse, false)
70
- .setCharacteristic(Characteristic.ValveType, "3")
71
- .setCharacteristic(Characteristic.StatusFault, false);
187
+ // Set up an initial last sync time
188
+ this.lastSync = new Date(Date.now() - this.config.polling_minutes * 60000)
72
189
 
73
- log("Starting position is water is Off");
74
- log("Load of the accessory complete.");
75
- }
190
+ // Perform a first sync and setup the refresh interval
191
+ this.flumeSync()
192
+ this.refreshInterval = setInterval(
193
+ () => this.flumeSync(),
194
+ this.config.polling_minutes * 60000
195
+ )
76
196
 
77
- Flume.prototype.getAccessToken = function() {
78
- var now = Date.now();
79
-
80
- log("Getting the access token.");
81
-
82
- if (!this.access_token) {
83
- return fetch('https://api.flumetech.com/oauth/token', {
84
- method: 'POST',
85
- headers: {
86
- 'content-type': 'application/json'
87
- },
88
- body: '{"grant_type":"password","client_id":' + this.client_id + ',"client_secret":' + this.client_secret + ',"username":' + this.username + ',"password":' + this.password + '}'
89
- })
90
- .then(res => {
91
- if (res.ok) {
92
- return res.json();
93
- log("res was okay.");
94
- } else {
95
- log("ERROR! Unable to retrieve new token: " + res.statusText);
96
- }
97
- })
98
- .then(json => {
99
- this.access_token = json.access_token;
100
- this.refresh_token = json.refresh_token;
101
- this.expires_in = now + json.expires_in;
102
- this.log("Token received: " + this.access_token);
103
- this.log("Refresh token received: " + this.refresh_token);
104
- this.log("Token expires at: " + new Date(this.expires_in));
105
- return this.access_token;
106
- });
107
- } else if (now > this.expires_in) {
108
- return fetch('https://api.flumetech.com/oauth/token', {
109
- method: 'POST',
110
- headers: {
111
- 'content-type': 'application/json'
112
- },
113
- body: '{"grant_type":"refresh_token","client_id":' + this.client_id + ',"client_secret":' + this.client_secret + ',"refresh_token":' + this.refresh_token + '}'
114
- })
115
- .then(res => {
116
- if (res.ok) {
117
- return res.json();
118
- } else {
119
- log("ERROR! Unable to retrieve new token: " + res.statusText);
120
- }
121
- })
122
- .then(json => {
123
- this.access_token = json.access_token;
124
- this.expires_in = now + json.expires_in;
125
- this.log("Token received: " + this.access_token);
126
- this.log("Refresh token received: " + this.refresh_token);
127
- this.log("Token expires at: " + new Date(this.expires_in));
128
- return this.access_token;
129
- });
197
+ // Log that the plugin setup has been successful with a welcome message
198
+ const randIndex = Math.floor(Math.random() * this.lang.zWelcome.length)
199
+ this.log('%s. %s', this.lang.complete, this.lang.zWelcome[randIndex])
200
+ } catch (err) {
201
+ // Catch any errors during setup
202
+ const eText = this.funcs.parseError(err, [
203
+ this.lang.noCreds,
204
+ this.lang.noDevices,
205
+ this.lang.disabled
206
+ ])
207
+ this.log.warn('***** %s. *****', this.lang.disabling)
208
+ this.log.warn('***** %s. *****', eText)
209
+ this.pluginShutdown()
130
210
  }
211
+ }
131
212
 
132
- this.log("Getting User ID from JWT.")
133
-
134
- var token = this.access_token;
135
- var decoded = jwt_decode(token);
136
- // or perhaps: decoded = jwt.decode(config["access_token"], verify=False)
137
-
138
- this.log(decoded);
139
- this.user_id = decoded.user_id; // or perhaps: decoded["user_id"]
140
- this.log("User ID: " + this.user_id);
141
- this.log("Device ID: " + this.device_id);
142
-
143
- return fetch('https://api.flumetech.com/users/' + this.user_id + '/devices', {
144
- method: 'GET',
145
- headers: {
146
- 'authorization': '' + this.access_token + ''
147
- },
148
- })
149
- .then(res => {
150
- if (res.ok) {
151
- return res.json();
152
- } else {
153
- log("ERROR! Unable to retrieve devices: " + res.statusText);
154
- }
155
- })
156
- .then(json => {
157
- this.device_id = json.id;
158
- this.log("User ID: " + this.user_id);
159
- this.log("Device ID: " + this.device_id);
160
- return this.device_id;
161
- });
213
+ pluginShutdown () {
214
+ // A function that is called when the plugin fails to load or Homebridge restarts
215
+ try {
216
+ // Stop the refresh interval
217
+ if (this.refreshInterval) {
218
+ clearInterval(this.refreshInterval)
219
+ }
220
+ } catch (err) {
221
+ // No need to show errors at this point
222
+ }
162
223
  }
163
224
 
225
+ async flumeSync () {
226
+ try {
227
+ const fromWhen = this.lastSync
228
+ .toISOString()
229
+ .substring(0, 19)
230
+ .replace('T', ' ')
231
+ this.devicesInHB.forEach(async accessory => {
232
+ try {
233
+ const res = await this.httpClient.getDeviceInfo(accessory.context.deviceId, fromWhen)
234
+ res.fromWhen = fromWhen
235
+ accessory.control.externalUpdate(res)
236
+ } catch (err) {
237
+ const eText = this.funcs.parseError(err)
238
+ this.log.warn(eText)
239
+ }
240
+ })
164
241
 
165
- Flume.prototype.fetchDevices = function() {
166
- this.log("Fetching current devices and statuses.");
167
-
168
- // alternative approach: var previousminute = (datetime.datetime.now() - datetime.timedelta(minutes=1)).strftime('%Y-%m-%d %H:%M:%S');
169
- var currentminute = (datetime.datetime.now() - datetime.timedelta(minutes=1)).strftime('%Y-%m-%d %H:%M:%S');
170
-
171
- this.log("Token: " + this.access_token);
172
- this.log("User ID: " + this.user_id);
173
- this.log("Device ID: " + this.device_id);
174
-
175
- this.getAccessToken()
176
- .then(token => fetch('https://api.flumetech.com/users/' + this.user_id + '/devices/' + this.device_id + '/query', {
177
- method: 'POST',
178
- headers: {
179
- 'content-type': 'application/json',
180
- 'authorization': 'Bearer ' + this.access_token
181
- },
182
- body: '{"queries":[{"request_id":"currentusage","bucket":"MIN","since_datetime": currentminute,"operation":"SUM","units":"GALLONS"}]}'
183
- // alternative approach might be payload = '{"queries":[{"request_id":"perminute","bucket":"MIN","since_datetime":"' + previousminute() + '","until_datetime":"' + currentminute() + '","group_multiplier":"1","operation":"SUM","sort_direction":"ASC","units":"GALLONS"}]}'
184
- // as seen at https://github.com/ScriptBlock/flumecli/blob/master/flumecli.py
185
- })
186
- .then(res => {
187
- if (res.ok) {
188
- return res.json();
189
- this.device_usage = value;
190
- } else {
191
- log("ERROR! Unable to retrieve devices: " + res.statusText);
192
- }
193
- }));
194
-
195
- log("Updating the status");
196
- this.updateState(accessory);
197
- log("Updated the status");
242
+ this.lastSync = new Date()
243
+ } catch (err) {
244
+ // Catch any errors performing the sync
245
+ const eText = this.funcs.parseError(err, [])
246
+ this.log.warn('%s %s.', this.lang.syncFailed, eText)
198
247
  }
248
+ }
249
+
250
+ initialiseDevice (device) {
251
+ try {
252
+ /*
253
+ {
254
+ id: '',
255
+ type: 2,
256
+ location_id: 0000,
257
+ user_id: 0000,
258
+ bridge_id: '',
259
+ oriented: true,
260
+ last_seen: '',
261
+ connected: true,
262
+ battery_level: 'high',
263
+ product: 'flume1'
264
+ }
265
+ */
266
+
267
+ const uuid = this.api.hap.uuid.generate(device.id)
268
+
269
+ // Get the cached accessory or add to Homebridge if doesn't exist
270
+ const accessory = this.devicesInHB.get(uuid) || this.addAccessory(device)
271
+
272
+ // Final check the accessory now exists in Homebridge
273
+ if (!accessory) {
274
+ throw new Error(this.lang.accNotFound)
275
+ }
199
276
 
277
+ // Create the instance for this device type
278
+ accessory.control = new (require('./device/valve'))(this, accessory)
200
279
 
201
- Flume.prototype.updateState = function(accessory) {
202
- var fresh = Date.now() - Date.parse(accessory.context.time + ".000Z") < 60 * 60 * 1000;
280
+ // Log the device initialisation
281
+ this.log('[%s] %s [%s].', accessory.displayName, this.lang.devInit, device.id)
282
+ } catch (err) {
283
+ // Catch any errors during device initialisation
284
+ const eText = this.funcs.parseError(err, [this.lang.accNotFound])
285
+ this.log.warn('[%s] %s %s.', device.id, this.lang.devNotInit, eText)
286
+ }
287
+ }
288
+
289
+ addAccessory (device) {
290
+ // Add an accessory to Homebridge
291
+ try {
292
+ const uuid = this.api.hap.uuid.generate(device.id)
293
+ const accessory = new this.api.platformAccessory(this.lang.brand, uuid)
294
+ accessory
295
+ .getService(this.api.hap.Service.AccessoryInformation)
296
+ .setCharacteristic(this.api.hap.Characteristic.Name, this.lang.brand)
297
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, this.lang.brand)
298
+ .setCharacteristic(this.api.hap.Characteristic.Manufacturer, this.lang.brand)
299
+ .setCharacteristic(this.api.hap.Characteristic.SerialNumber, device.id)
300
+ .setCharacteristic(this.api.hap.Characteristic.Model, device.product)
301
+ .setCharacteristic(this.api.hap.Characteristic.Identify, true)
302
+ accessory.context.deviceId = device.id
303
+ this.api.registerPlatformAccessories(plugin.name, plugin.alias, [accessory])
304
+ this.devicesInHB.set(accessory.UUID, accessory)
305
+ this.log('[%s] %s.', accessory.displayName, this.lang.devAdd)
306
+ return accessory
307
+ } catch (err) {
308
+ // Catch any errors during add
309
+ const eText = this.funcs.parseError(err)
310
+ this.log.warn('[%s] %s %s.', this.lang.brand, this.lang.devNotAdd, eText)
311
+ }
312
+ }
203
313
 
204
- log("Device Usage & Handicap: ");
205
- log(this.device_usage);
206
- log(this.handicap);
314
+ configureAccessory (accessory) {
315
+ // Add the configured accessory to our global map
316
+ this.devicesInHB.set(accessory.UUID, accessory)
317
+ }
207
318
 
208
- if (this.device_usage > this.handicap) {
209
- log("Water is On");
210
- accessory.getService(Service.Valve)
211
- .setCharacteristic(Characteristic.Active, true)
212
- .setCharacteristic(Characteristic.InUse, true);
213
- } else {
214
- log("Water is Off");
215
- accessory.getService(Service.Valve)
216
- .setCharacteristic(Characteristic.Active, false)
217
- .setCharacteristic(Characteristic.InUse, false);
319
+ removeAccessory (accessory) {
320
+ // Remove an accessory from Homebridge
321
+ try {
322
+ this.api.unregisterPlatformAccessories(plugin.name, plugin.alias, [accessory])
323
+ this.devicesInHB.delete(accessory.UUID)
324
+ this.log('[%s] %s.', accessory.displayName, this.lang.devRemove)
325
+ } catch (err) {
326
+ // Catch any errors during remove
327
+ const eText = this.funcs.parseError(err)
328
+ const name = accessory.displayName
329
+ this.log.warn('[%s] %s %s.', name, this.lang.devNotRemove, eText)
330
+ }
218
331
  }
219
332
  }
333
+
334
+ // Export the plugin to Homebridge
335
+ module.exports = hb => hb.registerPlatform(plugin.alias, FlumePlatform)
@@ -0,0 +1,31 @@
1
+ /* jshint node: true, esversion: 10, -W014, -W033 */
2
+ /* eslint-disable new-cap */
3
+ 'use strict'
4
+
5
+ module.exports = {
6
+ defaultConfig: {
7
+ name: 'Flume',
8
+ username: '',
9
+ password: '',
10
+ client_id: '',
11
+ client_secret: '',
12
+ polling_minutes: 1,
13
+ handicap: 0,
14
+ disableDeviceLogging: false,
15
+ debug: false,
16
+ disablePlugin: false,
17
+ platform: 'Flume'
18
+ },
19
+
20
+ defaultValues: {
21
+ polling_minutes: 1,
22
+ handicap: 0
23
+ },
24
+
25
+ minValues: {
26
+ polling_minutes: 1,
27
+ handicap: 0
28
+ },
29
+
30
+ httpRetryCodes: ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'ECONNABORTED']
31
+ }
@@ -0,0 +1,33 @@
1
+ /* jshint node: true, esversion: 10, -W014, -W033 */
2
+ /* eslint-disable new-cap */
3
+ 'use strict'
4
+
5
+ module.exports = {
6
+ hasProperty: (obj, prop) => {
7
+ return Object.prototype.hasOwnProperty.call(obj, prop)
8
+ },
9
+
10
+ sleep: ms => {
11
+ return new Promise(resolve => setTimeout(resolve, ms))
12
+ },
13
+
14
+ parseError: (err, hideStack = []) => {
15
+ let toReturn = err.message
16
+ if (err.stack && err.stack.length > 0 && !hideStack.includes(err.message)) {
17
+ const stack = err.stack.split('\n')
18
+ if (stack[1]) {
19
+ toReturn += stack[1].replace(' ', '')
20
+ }
21
+ }
22
+ return toReturn
23
+ },
24
+
25
+ generateRandomString: length => {
26
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
27
+ let nonce = ''
28
+ while (nonce.length < length) {
29
+ nonce += chars.charAt(Math.floor(Math.random() * chars.length))
30
+ }
31
+ return nonce
32
+ }
33
+ }
@@ -0,0 +1,64 @@
1
+ /* jshint node: true, esversion: 10, -W014, -W033 */
2
+ /* eslint-disable new-cap */
3
+ 'use strict'
4
+
5
+ module.exports = {
6
+ accNotFound: 'accessory not found',
7
+ accNotReady: 'cannot currently be controlled, see plugin startup logs for any error',
8
+ brand: 'Flume',
9
+ cfgDef: 'is not a valid number so using default of',
10
+ cfgDup: 'will be ignored since another entry with this ID already exists',
11
+ cfgIgn: 'is not configured correctly so ignoring',
12
+ cfgIgnItem: 'has an invalid entry which will be ignored',
13
+ cfgItem: 'Config entry',
14
+ cfgLow: 'is set too low so increasing to',
15
+ cfgRmv: 'is unused and can be removed',
16
+ cfgQts: 'should not have quotes around its entry',
17
+ complete: '✓ Setup complete',
18
+ curHeat: 'current heating',
19
+ curState: 'current state',
20
+ curTarg: 'current target',
21
+ curTemp: 'current temperature',
22
+ devAdd: 'has been added to Homebridge',
23
+ devInit: 'initialised with id',
24
+ devNotAdd: 'could not be added to Homebridge as',
25
+ devNotConf: 'could not be configured as',
26
+ devNotInit: 'could not be initialised as',
27
+ devNotRef: 'could not be refreshed as',
28
+ devNotRemove: 'could not be removed from Homebridge as',
29
+ devNotUpdated: 'could not be updated as',
30
+ devRemove: 'has been removed from Homebridge',
31
+ disabled: 'To change this, set disablePlugin to false',
32
+ disableHTTP: 'Disabling HTTP client as',
33
+ disabling: 'Disabling plugin',
34
+ hbVersionFail: 'Your version of Homebridge is too low - please update to v1.3',
35
+ httpRetry: 'Unable to reach Thermobit, retrying in 30 seconds',
36
+ initialised: 'Plugin initialised. Setting up accessories...',
37
+ initialising: 'Initialising plugin',
38
+ labelHeating: 'heating',
39
+ labelIdle: 'idle',
40
+ labelMan: 'manual',
41
+ labelSch: 'schedule',
42
+ noCreds: 'Thermobit username and/or password not configured',
43
+ noDevices: 'no data received from Thermobit server whilst obtaining device list',
44
+ noTokenExists: 'no Thermobit account token exists',
45
+ pluginNotConf: 'Plugin has not been configured',
46
+ receivingUpdate: 'receiving update',
47
+ scheduleFailed: 'Program schedule could not be updated as',
48
+ scheduleInvalid: 'Not updating program schedule as configuration is invalid or incomplete',
49
+ scheduleUpdated: 'Program schedule successfully updated',
50
+ syncFailed: 'Sync process failed as',
51
+ updateFail: 'could not be updated as',
52
+ zWelcome: [
53
+ "Don't forget to ☆ this plugin on GitHub if you're finding it useful!",
54
+ 'Have a feature request? Visit https://bit.ly/hb-thermobit-issues to ask!',
55
+ 'Interested in sponsoring this plugin? https://github.com/sponsors/bwp91',
56
+ "Join the plugin's Discord community! https://discord.gg/cMGhNtZ3tW",
57
+ 'Thanks for using this plugin, I hope you find it helpful!',
58
+ 'This plugin has been made with ♥ by bwp91 from the UK!',
59
+ 'Check out my other Homebridge plugins! https://github.com/bwp91',
60
+ 'Have time to give this plugin a review? https://bit.ly/hb-thermobit-review',
61
+ "This plugin needs it's first ☆ rating on HOOBS! https://bit.ly/hb-thermobit-review",
62
+ 'Want to see this plugin in your own language? Let me know!'
63
+ ]
64
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "homebridge-flume",
3
3
  "alias": "Flume",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "author": {
6
- "name": "Darrin Thomas",
6
+ "name": "Ben Potter",
7
7
  "email": "bwp91@icloud.com"
8
8
  },
9
9
  "description": "Homebridge plugin to integrate Flume devices into HomeKit.",
@@ -23,7 +23,7 @@
23
23
  "water leak detector"
24
24
  ],
25
25
  "engines": {
26
- "homebridge": "^1.3.6",
26
+ "homebridge": "^1.3.8",
27
27
  "node": "^14.18.1 || ^16.13.0"
28
28
  },
29
29
  "repository": {
@@ -34,16 +34,11 @@
34
34
  "url": "https://github.com/bwp91/homebridge-flume/issues"
35
35
  },
36
36
  "dependencies": {
37
- "homebridge-http-base": "^2.1.12",
38
- "jwt-decode": "^3.1.2",
39
- "node-fetch": "^2.6.0"
37
+ "@homebridge/plugin-ui-utils": "^0.0.19",
38
+ "axios": "^0.24.0",
39
+ "jwt-decode": "^3.1.2"
40
40
  },
41
41
  "prettier": {
42
42
  "printWidth": 100
43
- },
44
- "standard": {
45
- "ignore": [
46
- "/lib/index.js"
47
- ]
48
43
  }
49
44
  }