@homebridge-plugins/homebridge-firstalert 0.0.1-beta.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.
Files changed (121) hide show
  1. package/.gitattributes +2 -0
  2. package/.github/FUNDING.yml +4 -0
  3. package/.github/ISSUE_TEMPLATE/bug-report.yml +97 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  5. package/.github/ISSUE_TEMPLATE/feature-request.yml +38 -0
  6. package/.github/ISSUE_TEMPLATE/support-request.yml +85 -0
  7. package/.github/ISSUE_TEMPLATE.md +52 -0
  8. package/.github/PULL_REQUEST_TEMPLATE/pull_request.md +27 -0
  9. package/.github/dependabot.yml +17 -0
  10. package/.github/labeler.yml +38 -0
  11. package/.github/release-drafter.yml +33 -0
  12. package/.github/workflows/beta-release.yml +55 -0
  13. package/.github/workflows/build.yml +18 -0
  14. package/.github/workflows/changerelease.yml +11 -0
  15. package/.github/workflows/labeler.yml +9 -0
  16. package/.github/workflows/release-drafter.yml +14 -0
  17. package/.github/workflows/release.yml +35 -0
  18. package/.github/workflows/stale.yml +12 -0
  19. package/CHANGELOG.md +10 -0
  20. package/LICENSE +14 -0
  21. package/README.md +67 -0
  22. package/SECURITY.md +19 -0
  23. package/branding/Homebridge_x_FirstAlert.svg +48 -0
  24. package/branding/icon.png +0 -0
  25. package/config.schema.json +58 -0
  26. package/dist/homebridge-ui/public/index.html +548 -0
  27. package/dist/src/api/resideoClient.d.ts +32 -0
  28. package/dist/src/api/resideoClient.d.ts.map +1 -0
  29. package/dist/src/api/resideoClient.js +76 -0
  30. package/dist/src/api/resideoClient.js.map +1 -0
  31. package/dist/src/devices/device.d.ts +40 -0
  32. package/dist/src/devices/device.d.ts.map +1 -0
  33. package/dist/src/devices/device.js +207 -0
  34. package/dist/src/devices/device.js.map +1 -0
  35. package/dist/src/devices/leaksensors.d.ts +34 -0
  36. package/dist/src/devices/leaksensors.d.ts.map +1 -0
  37. package/dist/src/devices/leaksensors.js +163 -0
  38. package/dist/src/devices/leaksensors.js.map +1 -0
  39. package/dist/src/devices/smoke.d.ts +35 -0
  40. package/dist/src/devices/smoke.d.ts.map +1 -0
  41. package/dist/src/devices/smoke.js +150 -0
  42. package/dist/src/devices/smoke.js.map +1 -0
  43. package/dist/src/devices/thermostats.d.ts +42 -0
  44. package/dist/src/devices/thermostats.d.ts.map +1 -0
  45. package/dist/src/devices/thermostats.js +192 -0
  46. package/dist/src/devices/thermostats.js.map +1 -0
  47. package/dist/src/devices/valve.d.ts +21 -0
  48. package/dist/src/devices/valve.d.ts.map +1 -0
  49. package/dist/src/devices/valve.js +131 -0
  50. package/dist/src/devices/valve.js.map +1 -0
  51. package/dist/src/homebridge-ui/server.d.ts +5 -0
  52. package/dist/src/homebridge-ui/server.d.ts.map +1 -0
  53. package/dist/src/homebridge-ui/server.js +95 -0
  54. package/dist/src/homebridge-ui/server.js.map +1 -0
  55. package/dist/src/index.d.ts +4 -0
  56. package/dist/src/index.d.ts.map +1 -0
  57. package/dist/src/index.js +7 -0
  58. package/dist/src/index.js.map +1 -0
  59. package/dist/src/platform.d.ts +18 -0
  60. package/dist/src/platform.d.ts.map +1 -0
  61. package/dist/src/platform.js +108 -0
  62. package/dist/src/platform.js.map +1 -0
  63. package/dist/src/settings.d.ts +341 -0
  64. package/dist/src/settings.d.ts.map +1 -0
  65. package/dist/src/settings.js +25 -0
  66. package/dist/src/settings.js.map +1 -0
  67. package/dist/src/utils.d.ts +21 -0
  68. package/dist/src/utils.d.ts.map +1 -0
  69. package/dist/src/utils.js +58 -0
  70. package/dist/src/utils.js.map +1 -0
  71. package/dist/test/index.test.d.ts +2 -0
  72. package/dist/test/index.test.d.ts.map +1 -0
  73. package/dist/test/index.test.js +14 -0
  74. package/dist/test/index.test.js.map +1 -0
  75. package/dist/test/platform.test.d.ts +2 -0
  76. package/dist/test/platform.test.d.ts.map +1 -0
  77. package/dist/test/platform.test.js +56 -0
  78. package/dist/test/platform.test.js.map +1 -0
  79. package/dist/test/settings.test.d.ts +2 -0
  80. package/dist/test/settings.test.d.ts.map +1 -0
  81. package/dist/test/settings.test.js +48 -0
  82. package/dist/test/settings.test.js.map +1 -0
  83. package/dist/test/utils.test.d.ts +2 -0
  84. package/dist/test/utils.test.d.ts.map +1 -0
  85. package/dist/test/utils.test.js +17 -0
  86. package/dist/test/utils.test.js.map +1 -0
  87. package/docs/.nojekyll +1 -0
  88. package/docs/assets/hierarchy.js +1 -0
  89. package/docs/assets/highlight.css +22 -0
  90. package/docs/assets/icons.js +18 -0
  91. package/docs/assets/icons.svg +1 -0
  92. package/docs/assets/main.js +60 -0
  93. package/docs/assets/navigation.js +1 -0
  94. package/docs/assets/search.js +1 -0
  95. package/docs/assets/style.css +1633 -0
  96. package/docs/hierarchy.html +1 -0
  97. package/docs/index.html +77 -0
  98. package/docs/modules.html +1 -0
  99. package/docs/variables/default.html +1 -0
  100. package/eslint.config.js +44 -0
  101. package/nodemon.json +10 -0
  102. package/package.json +106 -0
  103. package/scripts/free-dev-ports.mjs +105 -0
  104. package/src/api/resideoClient.ts +106 -0
  105. package/src/devices/device.ts +226 -0
  106. package/src/devices/leaksensors.ts +206 -0
  107. package/src/devices/smoke.ts +173 -0
  108. package/src/devices/thermostats.ts +243 -0
  109. package/src/devices/valve.ts +162 -0
  110. package/src/homebridge-ui/public/index.html +548 -0
  111. package/src/homebridge-ui/server.ts +102 -0
  112. package/src/index.ts +13 -0
  113. package/src/platform.ts +112 -0
  114. package/src/settings.ts +402 -0
  115. package/src/utils.ts +61 -0
  116. package/test/index.test.ts +18 -0
  117. package/test/platform.test.ts +65 -0
  118. package/test/settings.test.ts +56 -0
  119. package/test/utils.test.ts +20 -0
  120. package/tsconfig.json +27 -0
  121. package/typedoc.json +22 -0
@@ -0,0 +1,548 @@
1
+ <p class="text-center">
2
+ <img
3
+ src="https://raw.githubusercontent.com/homebridge-plugins/homebridge-firstalert/latest/branding/Homebridge_x_Resideo.svg"
4
+ alt="homebridge-firstalert logo" style="width: 40%;" />
5
+ </p>
6
+ <div id="pageIntro" style="display: none;">
7
+ <p class="lead text-center">Thank you for installing <strong>homebridge-firstalert</strong></p>
8
+ <p class="lead text-center">Before continuing:</p>
9
+ <ol>
10
+ <li class="mb-3">Login / create an account at <a href="https://developer.honeywellhome.com/user" target="_blank"
11
+ rel="noreferrer noopener">https://developer.honeywellhome.com/user</a>.</li>
12
+ <li class="mb-3">Click <strong>Create New App</strong>.</li>
13
+ <li class="mb-3">
14
+ Give your application a name, and enter the 'Callback URL' exactly as it is displayed below.
15
+ <br>
16
+ <div class="input-group mt-3">
17
+ <input type="text" class="form-control" disabled id="copyMe">
18
+ <div class="input-group-append">
19
+ <button class="btn btn-primary m-0 py-0 px-3" onclick="copyMyText()" type="button">
20
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor"
21
+ class="mb-1 bi bi-clipboard" viewBox="0 0 16 16">
22
+ <path
23
+ d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z" />
24
+ <path
25
+ d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z" />
26
+ </svg>
27
+ </button>
28
+ </div>
29
+ </div>
30
+ </li>
31
+ <li>Enter the generated 'Consumer Key' and 'Consumer Secret' on the next page.</li>
32
+ </ol>
33
+ <div class="text-center">
34
+ <button type="button" class="btn btn-primary" id="introLink">Continue &rarr;</button>
35
+ </div>
36
+ </div>
37
+ <div id="menuWrapper" class="btn-group w-100 mb-0" role="group" aria-label="UI Menu" style="display: none;">
38
+ <button type="button" class="btn btn-primary ml-0" id="menuAccount">
39
+ Account
40
+ </button>
41
+ <button type="button" class="btn btn-primary" id="menuSettings">
42
+ Settings
43
+ </button>
44
+ <button type="button" class="btn btn-primary" id="menuDevices">
45
+ Devices
46
+ </button>
47
+ <button type="button" class="btn btn-primary mr-0" id="menuHome">
48
+ Support
49
+ </button>
50
+ </div>
51
+ <div id="pageAccount" class="mt-4" style="display: none;">
52
+ <div class="alert alert-warning" style="display: none;" id="validateForm">
53
+ Please enter both values
54
+ </div>
55
+ <div class="alert alert-success" style="display: none;" id="linkSuccess">
56
+ Config saved, please close this window and restart Homebridge
57
+ </div>
58
+ <div class="form-group">
59
+ <label for="inputConsumerKey">Consumer Key <span class="text-danger">*</span></label>
60
+ <input type="text" class="form-control" id="inputConsumerKey">
61
+ </div>
62
+ <div class="form-group">
63
+ <label for="inputConsumerSecret">Consumer Secret <span class="text-danger">*</span></label>
64
+ <input type="text" class="form-control" id="inputConsumerSecret">
65
+ </div>
66
+ <div class="form-group text-center">
67
+ <button type="button" class="btn btn-primary" id="backToIntro">&larr;</button>
68
+ <button type="button" class="btn btn-primary" id="linkButton">Continue &rarr;</button>
69
+ <button type="button" class="btn btn-danger" id="unLinkButton">Unlink Account &rarr;</button>
70
+ </div>
71
+ </div>
72
+ <div id="pageDevices" class="mt-4" style="display: none;">
73
+ <div id="deviceInfo">
74
+ <form>
75
+ <div class="form-group">
76
+ <select class="form-control" id="deviceSelect"></select>
77
+ </div>
78
+ </form>
79
+ <table class="table w-100" id="deviceTable" style="display: none;">
80
+ <thead>
81
+ <tr class="table-active">
82
+ <th scope="col" style="width: 40%;">Device Name</th>
83
+ <th scope="col" style="width: 60%;" id="displayName"></th>
84
+ </tr>
85
+ </thead>
86
+ <tbody>
87
+ <tr>
88
+ <th scope="row">Device ID</th>
89
+ <td id="deviceID"></td>
90
+ </tr>
91
+ <tr>
92
+ <th scope="row">Model</th>
93
+ <td id="model"></td>
94
+ </tr>
95
+ <tr>
96
+ <th scope="row">Firmware Version</th>
97
+ <td id="firmwareRevision"></td>
98
+ </tr>
99
+ </tbody>
100
+ </table>
101
+ </div>
102
+ </div>
103
+ <div id="pageSupport" class="mt-4" style="display: none;">
104
+ <p class="text-center lead">Thank you for using <strong>homebridge-firstalert</strong></p>
105
+ <p class="text-center">The links below will take you to our GitHub wiki</p>
106
+ <h5>Setup</h5>
107
+ <ul>
108
+ <li>
109
+ <a href="https://github.com/homebridge-plugins/homebridge-firstalert/wiki" target="_blank">Wiki Home</a>
110
+ </li>
111
+ <li>
112
+ <a href="https://github.com/homebridge-plugins/homebridge-firstalert/wiki/Configuration"
113
+ target="_blank">Configuration</a>
114
+ </li>
115
+ <li>
116
+ <a href="https://github.com/homebridge-plugins/homebridge-firstalert/wiki/Beta-Version" target="_blank">Beta
117
+ Version</a>
118
+ </li>
119
+ <li>
120
+ <a href="https://github.com/homebridge-plugins/homebridge-firstalert/wiki/Node-Version" target="_blank">Node
121
+ Version</a>
122
+ </li>
123
+ <li>
124
+ <a href="https://github.com/homebridge-plugins/homebridge-firstalert/wiki/Uninstallation"
125
+ target="_blank">Uninstallation</a>
126
+ </li>
127
+ </ul>
128
+ <h5>Features</h5>
129
+ <ul>
130
+ <li>
131
+ <a href="https://github.com/homebridge-plugins/homebridge-firstalert/wiki/Supported-Devices"
132
+ target="_blank">Supported
133
+ Devices</a>
134
+ </li>
135
+ <li>
136
+ <a href="https://github.com/homebridge-plugins/homebridge-firstalert/wiki/Fan-Modes" target="_blank">Fan
137
+ Modes</a>
138
+ </li>
139
+ </ul>
140
+ <h5>Help/About</h5>
141
+ <ul>
142
+ <li>
143
+ <a href="https://github.com/homebridge-plugins/homebridge-firstalert/issues/new/choose" target="_blank">Support
144
+ Request</a>
145
+ </li>
146
+ <li>
147
+ <a href="https://github.com/homebridge-plugins/homebridge-firstalert/blob/latest/CHANGELOG.md"
148
+ target="_blank">Changelog</a>
149
+ </li>
150
+ <li>
151
+ <a href="https://github.com/sponsors/donavanbecker" target="_blank">About Me</a>
152
+ </li>
153
+ </ul>
154
+ <h5>Disclaimer</h5>
155
+ <ul>
156
+ <li>
157
+ I am in no way affiliated with FirstAlert and this plugin is a personal project that I maintain in
158
+ my free time.
159
+ </li>
160
+ <li>
161
+ Use this plugin entirely at your own risk - please see licence for more information.
162
+ </li>
163
+ </ul>
164
+ </div>
165
+ <script>
166
+ ; (async () => {
167
+ try {
168
+ const currentConfig = await homebridge.getPluginConfig()
169
+ const hostname = window.location.hostname
170
+ try {
171
+ await homebridge.request('Start FirstAlert Login Server')
172
+ } catch (err) {
173
+ const msg = err.message
174
+ homebridge.toast.error(msg, 'HTTP Server Not Started')
175
+ }
176
+ homebridge.addEventListener('creds-received', async event => {
177
+ try {
178
+ if (event.data.access && event.data.refresh) {
179
+ this.popup.close()
180
+ currentConfig[0].credentials = {
181
+ consumerKey: event.data.key,
182
+ consumerSecret: event.data.secret,
183
+ accessToken: event.data.access,
184
+ refreshToken: event.data.refresh
185
+ }
186
+ await homebridge.updatePluginConfig(currentConfig)
187
+ await homebridge.savePluginConfig()
188
+ homebridge.toast.success("Successfully Linked FirstAlert Account", "homebridge-firstalert")
189
+ document.getElementById('backToIntro').style.display = 'none'
190
+ document.getElementById('linkButton').style.display = 'none'
191
+ document.getElementById('unLinkButton').style.display = 'none'
192
+ document.getElementById('linkSuccess').style.display = 'block'
193
+ // Show discover devices button after successful login
194
+ if (!document.getElementById('discoverDevicesBtn')) {
195
+ const discoverBtn = document.createElement('button')
196
+ discoverBtn.id = 'discoverDevicesBtn'
197
+ discoverBtn.className = 'btn btn-success mt-3'
198
+ discoverBtn.innerText = 'Discover Devices'
199
+ discoverBtn.onclick = async () => {
200
+ homebridge.showSpinner()
201
+ try {
202
+ const result = await homebridge.request('discoverDevices', { accessToken: result.data.accessToken })
203
+ if (result.status === 'ok' && result.data) {
204
+ // Show device list and allow adding to config
205
+ let deviceList = result.data.devices || result.data;
206
+ if (!Array.isArray(deviceList)) deviceList = Object.values(deviceList).flat();
207
+ const deviceDiv = document.createElement('div')
208
+ deviceDiv.id = 'discoveredDevices'
209
+ deviceDiv.innerHTML = '<h5>Discovered Devices</h5>'
210
+ deviceList.forEach(device => {
211
+ const d = document.createElement('div')
212
+ d.className = 'alert alert-info mb-2'
213
+ d.innerHTML = `<b>${device.name || device.deviceID}</b> (${device.type || device.deviceType || ''}) <button class='btn btn-sm btn-primary ml-2 addDeviceBtn'>Add</button>`
214
+ d.querySelector('.addDeviceBtn').onclick = async () => {
215
+ // Add device to config
216
+ if (!currentConfig[0].devices) currentConfig[0].devices = []
217
+ currentConfig[0].devices.push({ id: device.deviceID, name: device.name, type: device.type || device.deviceType })
218
+ await homebridge.updatePluginConfig(currentConfig)
219
+ await homebridge.savePluginConfig()
220
+ homebridge.toast.success('Device added to config', 'homebridge-firstalert')
221
+ }
222
+ deviceDiv.appendChild(d)
223
+ })
224
+ document.getElementById('pageAccount').appendChild(deviceDiv)
225
+ } else {
226
+ homebridge.toast.error('Device discovery failed: ' + (result.data || 'Unknown error'), 'Error')
227
+ }
228
+ } catch (err) {
229
+ homebridge.toast.error('Device discovery failed: ' + (err?.message || err), 'Error')
230
+ } finally {
231
+ homebridge.hideSpinner()
232
+ }
233
+ }
234
+ document.getElementById('pageAccount').appendChild(discoverBtn)
235
+ }
236
+ } else {
237
+ throw new Error('no access/token received from server')
238
+ }
239
+ } catch (err) {
240
+ console.log(err)
241
+ homebridge.toast.error('Try again, or see the console message for info.', 'Error')
242
+ }
243
+ })
244
+ copyMyText = () => {
245
+ var textToCopy = document.getElementById("copyMe");
246
+ textToCopy.select();
247
+ document.execCommand("copy");
248
+ }
249
+ linkAccount = async () => {
250
+ document.getElementById('validateForm').style.display = 'none'
251
+ const consumerKey = document.getElementById('inputConsumerKey').value
252
+ const consumerSecret = document.getElementById('inputConsumerSecret').value
253
+ if (!consumerKey || !consumerSecret) {
254
+ document.getElementById('validateForm').style.display = 'block'
255
+ return
256
+ }
257
+ homebridge.showSpinner()
258
+ try {
259
+ // Step 1: Start OAuth flow, get authUrl and codeVerifier
260
+ const startResult = await homebridge.request('startOAuthFlow', { consumerKey, consumerSecret })
261
+ if (startResult.status === 'need_code' && startResult.data && startResult.data.authUrl && startResult.data.codeVerifier) {
262
+ // Show modal for code entry
263
+ let modal = document.getElementById('oauthModal')
264
+ if (!modal) {
265
+ modal = document.createElement('div')
266
+ modal.id = 'oauthModal'
267
+ modal.style.position = 'fixed'
268
+ modal.style.top = '0'
269
+ modal.style.left = '0'
270
+ modal.style.width = '100vw'
271
+ modal.style.height = '100vh'
272
+ modal.style.background = 'rgba(0,0,0,0.5)'
273
+ modal.style.display = 'flex'
274
+ modal.style.alignItems = 'center'
275
+ modal.style.justifyContent = 'center'
276
+ modal.innerHTML = `<div style="background:#fff;padding:2em;border-radius:8px;max-width:400px;width:100%;text-align:center;">
277
+ <h5>FirstAlert Login</h5>
278
+ <p>Click <a href='${startResult.data.authUrl}' target='_blank'>here</a> to log in and authorize, then paste the code below:</p>
279
+ <input id='oauthCodeInput' class='form-control mb-2' placeholder='Paste code here'>
280
+ <div class='text-danger mb-2' id='oauthCodeError' style='display:none'></div>
281
+ <button class='btn btn-primary' id='oauthCodeSubmit'>Submit</button>
282
+ <button class='btn btn-link' id='oauthCodeCancel'>Cancel</button>
283
+ </div>`
284
+ document.body.appendChild(modal)
285
+ } else {
286
+ modal.style.display = 'flex'
287
+ }
288
+ document.getElementById('oauthCodeInput').value = ''
289
+ document.getElementById('oauthCodeError').style.display = 'none'
290
+ document.getElementById('oauthCodeSubmit').onclick = async () => {
291
+ const code = document.getElementById('oauthCodeInput').value
292
+ if (!code) {
293
+ document.getElementById('oauthCodeError').innerText = 'Please enter the code.'
294
+ document.getElementById('oauthCodeError').style.display = 'block'
295
+ return
296
+ }
297
+ modal.style.display = 'none'
298
+ homebridge.showSpinner()
299
+ try {
300
+ const finishResult = await homebridge.request('startOAuthFlow', { consumerKey, consumerSecret, code, codeVerifier: startResult.data.codeVerifier })
301
+ if (finishResult.status === 'ok' && finishResult.data && finishResult.data.refreshToken) {
302
+ currentConfig[0].credentials = {
303
+ consumerKey,
304
+ consumerSecret,
305
+ accessToken: finishResult.data.accessToken,
306
+ refreshToken: finishResult.data.refreshToken
307
+ }
308
+ await homebridge.updatePluginConfig(currentConfig)
309
+ await homebridge.savePluginConfig()
310
+ homebridge.toast.success('Successfully Linked FirstAlert Account', 'homebridge-firstalert')
311
+ document.getElementById('backToIntro').style.display = 'none'
312
+ document.getElementById('linkButton').style.display = 'none'
313
+ document.getElementById('unLinkButton').style.display = 'none'
314
+ document.getElementById('linkSuccess').style.display = 'block'
315
+ } else {
316
+ homebridge.toast.error('Login failed: ' + (finishResult.data?.error_description || finishResult.data || 'Unknown error'), 'Error')
317
+ }
318
+ } catch (err) {
319
+ homebridge.toast.error('Login failed: ' + (err?.message || err), 'Error')
320
+ } finally {
321
+ homebridge.hideSpinner()
322
+ }
323
+ }
324
+ document.getElementById('oauthCodeCancel').onclick = () => {
325
+ modal.style.display = 'none'
326
+ }
327
+ return
328
+ }
329
+ // If we get here, try the legacy direct token flow
330
+ if (startResult.status === 'ok' && startResult.data && startResult.data.refreshToken) {
331
+ currentConfig[0].credentials = {
332
+ consumerKey,
333
+ consumerSecret,
334
+ accessToken: startResult.data.accessToken,
335
+ refreshToken: startResult.data.refreshToken
336
+ }
337
+ await homebridge.updatePluginConfig(currentConfig)
338
+ await homebridge.savePluginConfig()
339
+ homebridge.toast.success('Successfully Linked FirstAlert Account', 'homebridge-firstalert')
340
+ document.getElementById('backToIntro').style.display = 'none'
341
+ document.getElementById('linkButton').style.display = 'none'
342
+ document.getElementById('unLinkButton').style.display = 'none'
343
+ document.getElementById('linkSuccess').style.display = 'block'
344
+ } else {
345
+ homebridge.toast.error('Login failed: ' + (startResult.data?.error_description || startResult.data || 'Unknown error'), 'Error')
346
+ }
347
+ } catch (err) {
348
+ homebridge.toast.error('Login failed: ' + (err?.message || err), 'Error')
349
+ } finally {
350
+ homebridge.hideSpinner()
351
+ }
352
+ }
353
+ unLinkAccount = async () => {
354
+ try {
355
+ document.getElementById('validateForm').style.display = 'none'
356
+ if (
357
+ !document.getElementById('inputConsumerKey').value ||
358
+ !document.getElementById('inputConsumerSecret').value
359
+ ) {
360
+ document.getElementById('validateForm').style.display = 'block'
361
+ return
362
+ }
363
+ delete currentConfig[0].credentials
364
+ await homebridge.updatePluginConfig(currentConfig)
365
+ await homebridge.savePluginConfig()
366
+ document.getElementById('inputConsumerKey').value = ''
367
+ document.getElementById('inputConsumerSecret').value = ''
368
+ document.getElementById('unLinkButton').style.display = 'none'
369
+ } catch (err) {
370
+ console.error(err)
371
+ homebridge.toast.error('Try again, or see the console message for info.', 'Error')
372
+ }
373
+ }
374
+ showIntro = () => {
375
+ const introLink = document.getElementById('introLink')
376
+ document.getElementById('copyMe').value = 'http://' + hostname + ':8585/auth'
377
+ introLink.addEventListener('click', () => {
378
+ homebridge.showSpinner()
379
+ document.getElementById('pageIntro').style.display = 'none'
380
+ document.getElementById('menuWrapper').style.display = 'inline-flex'
381
+ showAccount()
382
+ homebridge.hideSpinner()
383
+ })
384
+ document.getElementById('pageAccount').style.display = 'none'
385
+ document.getElementById('menuWrapper').style.display = 'none'
386
+ document.getElementById('pageIntro').style.display = 'block'
387
+ }
388
+ showDevices = async () => {
389
+ homebridge.showSpinner()
390
+ homebridge.hideSchemaForm()
391
+ document.getElementById('menuHome').classList.remove('btn-elegant')
392
+ document.getElementById('menuHome').classList.add('btn-primary')
393
+ document.getElementById('menuDevices').classList.add('btn-elegant')
394
+ document.getElementById('menuDevices').classList.remove('btn-primary')
395
+ document.getElementById('menuAccount').classList.remove('btn-elegant')
396
+ document.getElementById('menuAccount').classList.add('btn-primary')
397
+ document.getElementById('menuSettings').classList.remove('btn-elegant')
398
+ document.getElementById('menuSettings').classList.add('btn-primary')
399
+ document.getElementById('pageSupport').style.display = 'none'
400
+ document.getElementById('pageAccount').style.display = 'none'
401
+ document.getElementById('pageDevices').style.display = 'block'
402
+ const cachedAccessories =
403
+ typeof homebridge.getCachedAccessories === 'function'
404
+ ? await homebridge.getCachedAccessories()
405
+ : await homebridge.request('/getCachedAccessories')
406
+ if (cachedAccessories.length > 0) {
407
+ cachedAccessories.sort((a, b) => {
408
+ return a.displayName.toLowerCase() > b.displayName.toLowerCase()
409
+ ? 1
410
+ : b.displayName.toLowerCase() > a.displayName.toLowerCase()
411
+ ? -1
412
+ : 0
413
+ })
414
+ }
415
+ const deviceSelect = document.getElementById('deviceSelect')
416
+ deviceSelect.innerHTML = ''
417
+ cachedAccessories.forEach(a => {
418
+ const option = document.createElement('option')
419
+ option.text = a.displayName
420
+ option.value = a.UUID
421
+ deviceSelect.add(option)
422
+ })
423
+ showDeviceInfo = async UUID => {
424
+ homebridge.showSpinner()
425
+ const thisAcc = cachedAccessories.find(x => x.UUID === UUID)
426
+ const context = thisAcc.context
427
+ document.getElementById('displayName').innerHTML = thisAcc.displayName
428
+ document.getElementById('deviceID').innerHTML = context.deviceID
429
+ document.getElementById('model').innerHTML = context.model
430
+ document.getElementById('firmwareRevision').innerHTML = context.firmwareRevision || 'N/A'
431
+ document.getElementById('deviceTable').style.display = 'inline-table'
432
+ homebridge.hideSpinner()
433
+ }
434
+ deviceSelect.addEventListener('change', event => showDeviceInfo(event.target.value))
435
+ if (cachedAccessories.length > 0) {
436
+ showDeviceInfo(cachedAccessories[0].UUID)
437
+ } else {
438
+ const option = document.createElement('option')
439
+ option.text = 'No Devices'
440
+ deviceSelect.add(option)
441
+ deviceSelect.disabled = true
442
+ }
443
+ homebridge.hideSpinner()
444
+ }
445
+ showSupport = () => {
446
+ homebridge.showSpinner()
447
+ homebridge.hideSchemaForm()
448
+ document.getElementById('menuHome').classList.add('btn-elegant')
449
+ document.getElementById('menuHome').classList.remove('btn-primary')
450
+ document.getElementById('menuDevices').classList.remove('btn-elegant')
451
+ document.getElementById('menuDevices').classList.add('btn-primary')
452
+ document.getElementById('menuSettings').classList.remove('btn-elegant')
453
+ document.getElementById('menuSettings').classList.add('btn-primary')
454
+ document.getElementById('menuAccount').classList.remove('btn-elegant')
455
+ document.getElementById('menuAccount').classList.add('btn-primary')
456
+ document.getElementById('pageSupport').style.display = 'block'
457
+ document.getElementById('pageDevices').style.display = 'none'
458
+ document.getElementById('pageAccount').style.display = 'none'
459
+ homebridge.hideSpinner()
460
+ }
461
+ showSettings = () => {
462
+ homebridge.showSpinner()
463
+ document.getElementById('menuHome').classList.remove('btn-elegant')
464
+ document.getElementById('menuHome').classList.add('btn-primary')
465
+ document.getElementById('menuDevices').classList.remove('btn-elegant')
466
+ document.getElementById('menuDevices').classList.add('btn-primary')
467
+ document.getElementById('menuSettings').classList.add('btn-elegant')
468
+ document.getElementById('menuSettings').classList.remove('btn-primary')
469
+ document.getElementById('menuAccount').classList.remove('btn-elegant')
470
+ document.getElementById('menuAccount').classList.add('btn-primary')
471
+ document.getElementById('pageSupport').style.display = 'none'
472
+ document.getElementById('pageDevices').style.display = 'none'
473
+ document.getElementById('pageAccount').style.display = 'none'
474
+ homebridge.showSchemaForm()
475
+ homebridge.hideSpinner()
476
+ }
477
+ showAccount = () => {
478
+ homebridge.showSpinner()
479
+ const backToIntro = document.getElementById('backToIntro')
480
+ backToIntro.addEventListener('click', () => {
481
+ showIntro()
482
+ })
483
+ backToIntro.style.display = 'none'
484
+ const linkButton = document.getElementById('linkButton')
485
+ linkButton.addEventListener('click', () => {
486
+ linkAccount()
487
+ })
488
+ const unLinkButton = document.getElementById('unLinkButton')
489
+ unLinkButton.style.display = 'none'
490
+ unLinkButton.addEventListener('click', () => {
491
+ unLinkAccount()
492
+ })
493
+ document.getElementById('linkSuccess').style.display = 'none'
494
+ homebridge.hideSchemaForm()
495
+ document.getElementById('menuHome').classList.remove('btn-elegant')
496
+ document.getElementById('menuHome').classList.add('btn-primary')
497
+ document.getElementById('menuDevices').classList.remove('btn-elegant')
498
+ document.getElementById('menuDevices').classList.add('btn-primary')
499
+ document.getElementById('menuAccount').classList.add('btn-elegant')
500
+ document.getElementById('menuAccount').classList.remove('btn-primary')
501
+ document.getElementById('menuSettings').classList.remove('btn-elegant')
502
+ document.getElementById('menuSettings').classList.add('btn-primary')
503
+ document.getElementById('pageSupport').style.display = 'none'
504
+ document.getElementById('pageDevices').style.display = 'none'
505
+ document.getElementById('pageAccount').style.display = 'block'
506
+ document.getElementById('linkButton').style.display = 'inline-block'
507
+ let isRelink = false
508
+ if (currentConfig[0] && currentConfig[0].credentials) {
509
+ const key = currentConfig[0].credentials.consumerKey || ''
510
+ const secret = currentConfig[0].credentials.consumerSecret || ''
511
+ document.getElementById('inputConsumerKey').value = key
512
+ document.getElementById('inputConsumerSecret').value = secret
513
+ if (key && secret) {
514
+ isRelink = true
515
+ document.getElementById('unLinkButton').style.display = 'inline-block'
516
+ } else {
517
+ document.getElementById('unLinkButton').style.display = 'none'
518
+ }
519
+ }
520
+ document.getElementById('linkButton').innerHTML = isRelink
521
+ ? 'Relink Account &rarr;'
522
+ : 'Link Account &rarr;'
523
+ if (!isRelink) {
524
+ document.getElementById('backToIntro').style.display = 'inline-block'
525
+ } else {
526
+ document.getElementById('backToIntro').style.display = 'none'
527
+ }
528
+ homebridge.hideSpinner()
529
+ }
530
+ menuHome.addEventListener('click', () => showSupport())
531
+ menuAccount.addEventListener('click', () => showAccount())
532
+ menuDevices.addEventListener('click', () => showDevices())
533
+ menuSettings.addEventListener('click', () => showSettings())
534
+ if (currentConfig.length) {
535
+ document.getElementById('menuWrapper').style.display = 'inline-flex'
536
+ showAccount()
537
+ } else {
538
+ currentConfig.push({ name: 'FirstAlert' })
539
+ await homebridge.updatePluginConfig(currentConfig)
540
+ showIntro()
541
+ }
542
+ } catch (err) {
543
+ homebridge.toast.error(err.message, 'Error')
544
+ } finally {
545
+ homebridge.hideSpinner()
546
+ }
547
+ })()
548
+ </script>
@@ -0,0 +1,32 @@
1
+ export interface ResideoDevice {
2
+ id: string;
3
+ name: string;
4
+ deviceId: string;
5
+ globalDeviceType: string;
6
+ }
7
+ export interface ResideoAccount {
8
+ id: string;
9
+ firstName: string;
10
+ lastName: string;
11
+ contactEmail: string;
12
+ countryCode: string;
13
+ locale: string;
14
+ devices: ResideoDevice[];
15
+ }
16
+ export interface ResideoDeviceState {
17
+ name: string;
18
+ deviceType: string;
19
+ isOnline: boolean;
20
+ deviceState: any;
21
+ }
22
+ export declare class ResideoClient {
23
+ private clientId;
24
+ private refreshToken;
25
+ private accessToken;
26
+ constructor(refreshToken: string);
27
+ refreshAccessToken(): Promise<string>;
28
+ getAccessToken(): Promise<string>;
29
+ getAccount(): Promise<ResideoAccount>;
30
+ getDeviceState(deviceId: string): Promise<ResideoDeviceState>;
31
+ }
32
+ //# sourceMappingURL=resideoClient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resideoClient.d.ts","sourceRoot":"","sources":["../../../src/api/resideoClient.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,gBAAgB,EAAE,MAAM,CAAA;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,aAAa,EAAE,CAAA;CACzB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,OAAO,CAAA;IACjB,WAAW,EAAE,GAAG,CAAA;CACjB;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAqC;IACrD,OAAO,CAAC,YAAY,CAAQ;IAC5B,OAAO,CAAC,WAAW,CAAsB;gBAE7B,YAAY,EAAE,MAAM;IAI1B,kBAAkB,IAAI,OAAO,CAAC,MAAM,CAAC;IAmBrC,cAAc,IAAI,OAAO,CAAC,MAAM,CAAC;IAOjC,UAAU,IAAI,OAAO,CAAC,cAAc,CAAC;IAiCrC,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC;CAQpE"}
@@ -0,0 +1,76 @@
1
+ // FirstAlert API client for OAuth and device polling
2
+ // Implements token refresh, account/device fetch, and strong typing
3
+ import { request } from 'undici';
4
+ export class ResideoClient {
5
+ clientId = 'SRmiA7CaYi1JgivDZdzzoZu4X5VBogGt';
6
+ refreshToken;
7
+ accessToken = null;
8
+ constructor(refreshToken) {
9
+ this.refreshToken = refreshToken;
10
+ }
11
+ async refreshAccessToken() {
12
+ const body = JSON.stringify({
13
+ grant_type: 'refresh_token',
14
+ refresh_token: this.refreshToken,
15
+ client_id: this.clientId,
16
+ });
17
+ const { body: resBody } = await request('https://login.firstalert.com/oauth/token', {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body,
21
+ });
22
+ const data = await resBody.json();
23
+ this.accessToken = data.access_token ?? '';
24
+ if (!this.accessToken) {
25
+ throw new Error('Failed to obtain access token from FirstAlert API');
26
+ }
27
+ return this.accessToken;
28
+ }
29
+ async getAccessToken() {
30
+ if (!this.accessToken) {
31
+ await this.refreshAccessToken();
32
+ }
33
+ return this.accessToken;
34
+ }
35
+ async getAccount() {
36
+ const token = await this.getAccessToken();
37
+ const { body: resBody } = await request('https://api.firstalert.com/ris-public-api/api/v1/accounts', {
38
+ method: 'GET',
39
+ headers: { Authorization: `Bearer ${token}` },
40
+ });
41
+ const resp = await resBody.json();
42
+ // Parse and flatten devices
43
+ const data = resp.data;
44
+ const devices = [];
45
+ for (const user of data.consumerUsers) {
46
+ for (const loc of user.consumerAccount.locations) {
47
+ for (const dev of loc.consumerDevices) {
48
+ devices.push({
49
+ id: dev.id,
50
+ name: dev.name,
51
+ deviceId: dev.device.deviceId,
52
+ globalDeviceType: dev.device.globalDeviceType,
53
+ });
54
+ }
55
+ }
56
+ }
57
+ return {
58
+ id: data.id,
59
+ firstName: data.firstName,
60
+ lastName: data.lastName,
61
+ contactEmail: data.contactEmail,
62
+ countryCode: data.countryCode,
63
+ locale: data.locale,
64
+ devices,
65
+ };
66
+ }
67
+ async getDeviceState(deviceId) {
68
+ const token = await this.getAccessToken();
69
+ const { body: resBody } = await request(`https://api.firstalert.com/ris-public-api/api/v2/devices/smokeDetectors/${deviceId}/state`, {
70
+ method: 'GET',
71
+ headers: { Authorization: `Bearer ${token}` },
72
+ });
73
+ return await resBody.json();
74
+ }
75
+ }
76
+ //# sourceMappingURL=resideoClient.js.map