@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.
- package/.gitattributes +2 -0
- package/.github/FUNDING.yml +4 -0
- package/.github/ISSUE_TEMPLATE/bug-report.yml +97 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature-request.yml +38 -0
- package/.github/ISSUE_TEMPLATE/support-request.yml +85 -0
- package/.github/ISSUE_TEMPLATE.md +52 -0
- package/.github/PULL_REQUEST_TEMPLATE/pull_request.md +27 -0
- package/.github/dependabot.yml +17 -0
- package/.github/labeler.yml +38 -0
- package/.github/release-drafter.yml +33 -0
- package/.github/workflows/beta-release.yml +55 -0
- package/.github/workflows/build.yml +18 -0
- package/.github/workflows/changerelease.yml +11 -0
- package/.github/workflows/labeler.yml +9 -0
- package/.github/workflows/release-drafter.yml +14 -0
- package/.github/workflows/release.yml +35 -0
- package/.github/workflows/stale.yml +12 -0
- package/CHANGELOG.md +10 -0
- package/LICENSE +14 -0
- package/README.md +67 -0
- package/SECURITY.md +19 -0
- package/branding/Homebridge_x_FirstAlert.svg +48 -0
- package/branding/icon.png +0 -0
- package/config.schema.json +58 -0
- package/dist/homebridge-ui/public/index.html +548 -0
- package/dist/src/api/resideoClient.d.ts +32 -0
- package/dist/src/api/resideoClient.d.ts.map +1 -0
- package/dist/src/api/resideoClient.js +76 -0
- package/dist/src/api/resideoClient.js.map +1 -0
- package/dist/src/devices/device.d.ts +40 -0
- package/dist/src/devices/device.d.ts.map +1 -0
- package/dist/src/devices/device.js +207 -0
- package/dist/src/devices/device.js.map +1 -0
- package/dist/src/devices/leaksensors.d.ts +34 -0
- package/dist/src/devices/leaksensors.d.ts.map +1 -0
- package/dist/src/devices/leaksensors.js +163 -0
- package/dist/src/devices/leaksensors.js.map +1 -0
- package/dist/src/devices/smoke.d.ts +35 -0
- package/dist/src/devices/smoke.d.ts.map +1 -0
- package/dist/src/devices/smoke.js +150 -0
- package/dist/src/devices/smoke.js.map +1 -0
- package/dist/src/devices/thermostats.d.ts +42 -0
- package/dist/src/devices/thermostats.d.ts.map +1 -0
- package/dist/src/devices/thermostats.js +192 -0
- package/dist/src/devices/thermostats.js.map +1 -0
- package/dist/src/devices/valve.d.ts +21 -0
- package/dist/src/devices/valve.d.ts.map +1 -0
- package/dist/src/devices/valve.js +131 -0
- package/dist/src/devices/valve.js.map +1 -0
- package/dist/src/homebridge-ui/server.d.ts +5 -0
- package/dist/src/homebridge-ui/server.d.ts.map +1 -0
- package/dist/src/homebridge-ui/server.js +95 -0
- package/dist/src/homebridge-ui/server.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/platform.d.ts +18 -0
- package/dist/src/platform.d.ts.map +1 -0
- package/dist/src/platform.js +108 -0
- package/dist/src/platform.js.map +1 -0
- package/dist/src/settings.d.ts +341 -0
- package/dist/src/settings.d.ts.map +1 -0
- package/dist/src/settings.js +25 -0
- package/dist/src/settings.js.map +1 -0
- package/dist/src/utils.d.ts +21 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +58 -0
- package/dist/src/utils.js.map +1 -0
- package/dist/test/index.test.d.ts +2 -0
- package/dist/test/index.test.d.ts.map +1 -0
- package/dist/test/index.test.js +14 -0
- package/dist/test/index.test.js.map +1 -0
- package/dist/test/platform.test.d.ts +2 -0
- package/dist/test/platform.test.d.ts.map +1 -0
- package/dist/test/platform.test.js +56 -0
- package/dist/test/platform.test.js.map +1 -0
- package/dist/test/settings.test.d.ts +2 -0
- package/dist/test/settings.test.d.ts.map +1 -0
- package/dist/test/settings.test.js +48 -0
- package/dist/test/settings.test.js.map +1 -0
- package/dist/test/utils.test.d.ts +2 -0
- package/dist/test/utils.test.d.ts.map +1 -0
- package/dist/test/utils.test.js +17 -0
- package/dist/test/utils.test.js.map +1 -0
- package/docs/.nojekyll +1 -0
- package/docs/assets/hierarchy.js +1 -0
- package/docs/assets/highlight.css +22 -0
- package/docs/assets/icons.js +18 -0
- package/docs/assets/icons.svg +1 -0
- package/docs/assets/main.js +60 -0
- package/docs/assets/navigation.js +1 -0
- package/docs/assets/search.js +1 -0
- package/docs/assets/style.css +1633 -0
- package/docs/hierarchy.html +1 -0
- package/docs/index.html +77 -0
- package/docs/modules.html +1 -0
- package/docs/variables/default.html +1 -0
- package/eslint.config.js +44 -0
- package/nodemon.json +10 -0
- package/package.json +106 -0
- package/scripts/free-dev-ports.mjs +105 -0
- package/src/api/resideoClient.ts +106 -0
- package/src/devices/device.ts +226 -0
- package/src/devices/leaksensors.ts +206 -0
- package/src/devices/smoke.ts +173 -0
- package/src/devices/thermostats.ts +243 -0
- package/src/devices/valve.ts +162 -0
- package/src/homebridge-ui/public/index.html +548 -0
- package/src/homebridge-ui/server.ts +102 -0
- package/src/index.ts +13 -0
- package/src/platform.ts +112 -0
- package/src/settings.ts +402 -0
- package/src/utils.ts +61 -0
- package/test/index.test.ts +18 -0
- package/test/platform.test.ts +65 -0
- package/test/settings.test.ts +56 -0
- package/test/utils.test.ts +20 -0
- package/tsconfig.json +27 -0
- 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 →</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">←</button>
|
|
68
|
+
<button type="button" class="btn btn-primary" id="linkButton">Continue →</button>
|
|
69
|
+
<button type="button" class="btn btn-danger" id="unLinkButton">Unlink Account →</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 →'
|
|
522
|
+
: 'Link Account →'
|
|
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,102 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
|
|
5
|
+
import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils'
|
|
6
|
+
|
|
7
|
+
interface CustomRequestResponse {
|
|
8
|
+
status: string
|
|
9
|
+
data?: any
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class PluginUiServer extends HomebridgePluginUiServer {
|
|
13
|
+
constructor() {
|
|
14
|
+
super()
|
|
15
|
+
|
|
16
|
+
// OAuth PKCE flow handler
|
|
17
|
+
this.onRequest('startOAuthFlow', async (payload): Promise<CustomRequestResponse> => {
|
|
18
|
+
try {
|
|
19
|
+
const { consumerKey, consumerSecret, code, codeVerifier } = payload || {}
|
|
20
|
+
if (!consumerKey || !consumerSecret) {
|
|
21
|
+
return { status: 'error', data: 'Missing consumerKey or consumerSecret' }
|
|
22
|
+
}
|
|
23
|
+
// If no code, generate PKCE and return authUrl for UI
|
|
24
|
+
if (!code) {
|
|
25
|
+
const generatedVerifier = crypto.randomBytes(32).toString('base64url')
|
|
26
|
+
const challenge = crypto.createHash('sha256').update(generatedVerifier).digest('base64url')
|
|
27
|
+
const redirectUri = 'urn:ietf:wg:oauth:2.0:oob'
|
|
28
|
+
const authUrl = `https://api.honeywell.com/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(consumerKey)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${encodeURIComponent(challenge)}&code_challenge_method=S256&appSelect=1`
|
|
29
|
+
return { status: 'need_code', data: { authUrl, codeVerifier: generatedVerifier } }
|
|
30
|
+
}
|
|
31
|
+
// Exchange code for tokens
|
|
32
|
+
if (!codeVerifier) {
|
|
33
|
+
return { status: 'error', data: 'Missing PKCE code_verifier' }
|
|
34
|
+
}
|
|
35
|
+
const redirectUri = 'urn:ietf:wg:oauth:2.0:oob'
|
|
36
|
+
const tokenRes = await fetch('https://api.honeywell.com/oauth2/token', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'Authorization': `Basic ${Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64')}`,
|
|
40
|
+
'Accept': 'application/json',
|
|
41
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
42
|
+
},
|
|
43
|
+
body: `grant_type=authorization_code&code=${encodeURIComponent(code)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_verifier=${encodeURIComponent(codeVerifier)}`,
|
|
44
|
+
})
|
|
45
|
+
const tokenData = await tokenRes.json()
|
|
46
|
+
if (tokenData.refresh_token) {
|
|
47
|
+
return { status: 'ok', data: { refreshToken: tokenData.refresh_token, accessToken: tokenData.access_token } }
|
|
48
|
+
} else {
|
|
49
|
+
return { status: 'error', data: tokenData }
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { status: 'error', data: (err && typeof err === 'object' && 'message' in err) ? (err as any).message : String(err) }
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Device discovery handler
|
|
57
|
+
this.onRequest('discoverDevices', async (payload): Promise<CustomRequestResponse> => {
|
|
58
|
+
try {
|
|
59
|
+
const { accessToken } = payload || {}
|
|
60
|
+
if (!accessToken) {
|
|
61
|
+
return { status: 'error', data: 'No access token provided' }
|
|
62
|
+
}
|
|
63
|
+
const res = await fetch('https://api.honeywell.com/v2/devices', {
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `Bearer ${accessToken}`,
|
|
66
|
+
Accept: 'application/json',
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
return { status: 'error', data: await res.text() }
|
|
71
|
+
}
|
|
72
|
+
const data = await res.json()
|
|
73
|
+
return { status: 'ok', data }
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { status: 'error', data: (err && typeof err === 'object' && 'message' in err) ? (err as any).message : String(err) }
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Legacy: getCachedAccessories for older config-ui-x
|
|
80
|
+
this.onRequest('/getCachedAccessories', async (): Promise<CustomRequestResponse> => {
|
|
81
|
+
try {
|
|
82
|
+
const plugin = '@homebridge-plugins/homebridge-firstalert'
|
|
83
|
+
const devicesToReturn: any[] = []
|
|
84
|
+
const accFile = `${this.homebridgeStoragePath}/accessories/cachedAccessories`
|
|
85
|
+
if (fs.existsSync(accFile)) {
|
|
86
|
+
const cachedAccessoriesData = await fs.promises.readFile(accFile, 'utf8')
|
|
87
|
+
const cachedAccessories: any[] = JSON.parse(cachedAccessoriesData)
|
|
88
|
+
cachedAccessories
|
|
89
|
+
.filter((accessory: any) => accessory.plugin === plugin)
|
|
90
|
+
.forEach((accessory: any) => devicesToReturn.push(accessory))
|
|
91
|
+
}
|
|
92
|
+
return { status: 'ok', data: devicesToReturn }
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return { status: 'error', data: [] }
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
this.ready()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
(() => new PluginUiServer())()
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* Copyright(C) 2022-2024, donavanbecker (https://github.com/donavanbecker). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* index.ts: homebridge-firstalert.
|
|
4
|
+
*/
|
|
5
|
+
import type { API } from 'homebridge'
|
|
6
|
+
|
|
7
|
+
import { ResideoPlatform } from './platform.js'
|
|
8
|
+
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
|
|
9
|
+
|
|
10
|
+
// Register our platform with homebridge.
|
|
11
|
+
export default (api: API): void => {
|
|
12
|
+
api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, ResideoPlatform)
|
|
13
|
+
}
|