homebridge-bedjet 0.2.1 → 0.2.2
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/config.schema.json +1 -0
- package/homebridge-ui/public/index.html +307 -0
- package/homebridge-ui/server.js +67 -0
- package/package.json +2 -1
package/config.schema.json
CHANGED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>BedJet Discovery</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--card-bg: #1e1e1e;
|
|
10
|
+
--card-border: #3a3a3a;
|
|
11
|
+
--text: #e0e0e0;
|
|
12
|
+
--text-muted: #888;
|
|
13
|
+
--btn-bg: #c25700;
|
|
14
|
+
--btn-hover: #d96200;
|
|
15
|
+
--btn-text: #fff;
|
|
16
|
+
--add-bg: #1a6b3a;
|
|
17
|
+
--add-hover: #1f8047;
|
|
18
|
+
--done-bg: #2a4a2a;
|
|
19
|
+
--done-text: #6fcf97;
|
|
20
|
+
--error-bg: #4a1a1a;
|
|
21
|
+
--error-text: #f28b82;
|
|
22
|
+
--row-border: #2e2e2e;
|
|
23
|
+
--spinner-color: #c25700;
|
|
24
|
+
--address-color: #aaa;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
body {
|
|
28
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
29
|
+
margin: 0;
|
|
30
|
+
padding: 16px;
|
|
31
|
+
background: transparent;
|
|
32
|
+
color: var(--text);
|
|
33
|
+
font-size: 14px;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
h6 {
|
|
37
|
+
font-size: 13px;
|
|
38
|
+
font-weight: 600;
|
|
39
|
+
text-transform: uppercase;
|
|
40
|
+
letter-spacing: 0.05em;
|
|
41
|
+
color: var(--text-muted);
|
|
42
|
+
margin: 0 0 12px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.scan-row {
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: 12px;
|
|
49
|
+
margin-bottom: 16px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
button {
|
|
53
|
+
padding: 8px 18px;
|
|
54
|
+
border: none;
|
|
55
|
+
border-radius: 6px;
|
|
56
|
+
font-size: 14px;
|
|
57
|
+
font-weight: 500;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
transition: background 0.15s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#scanBtn {
|
|
63
|
+
background: var(--btn-bg);
|
|
64
|
+
color: var(--btn-text);
|
|
65
|
+
}
|
|
66
|
+
#scanBtn:hover:not(:disabled) { background: var(--btn-hover); }
|
|
67
|
+
#scanBtn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
68
|
+
|
|
69
|
+
#status {
|
|
70
|
+
font-size: 13px;
|
|
71
|
+
color: var(--text-muted);
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 8px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.spinner {
|
|
78
|
+
width: 14px;
|
|
79
|
+
height: 14px;
|
|
80
|
+
border: 2px solid transparent;
|
|
81
|
+
border-top-color: var(--spinner-color);
|
|
82
|
+
border-radius: 50%;
|
|
83
|
+
animation: spin 0.7s linear infinite;
|
|
84
|
+
flex-shrink: 0;
|
|
85
|
+
}
|
|
86
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
87
|
+
|
|
88
|
+
.progress-bar-wrap {
|
|
89
|
+
height: 3px;
|
|
90
|
+
background: var(--card-border);
|
|
91
|
+
border-radius: 2px;
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
margin-bottom: 14px;
|
|
94
|
+
}
|
|
95
|
+
.progress-bar-fill {
|
|
96
|
+
height: 100%;
|
|
97
|
+
background: var(--btn-bg);
|
|
98
|
+
width: 0%;
|
|
99
|
+
transition: width 0.4s linear;
|
|
100
|
+
border-radius: 2px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#results {
|
|
104
|
+
margin-top: 4px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.device-card {
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
justify-content: space-between;
|
|
111
|
+
padding: 10px 12px;
|
|
112
|
+
border-radius: 6px;
|
|
113
|
+
border: 1px solid var(--card-border);
|
|
114
|
+
background: var(--card-bg);
|
|
115
|
+
margin-bottom: 8px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.device-info {
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
gap: 2px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.device-name {
|
|
125
|
+
font-weight: 500;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.device-address {
|
|
129
|
+
font-size: 12px;
|
|
130
|
+
color: var(--address-color);
|
|
131
|
+
font-family: 'SF Mono', 'Consolas', monospace;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.add-btn {
|
|
135
|
+
background: var(--add-bg);
|
|
136
|
+
color: #fff;
|
|
137
|
+
padding: 6px 14px;
|
|
138
|
+
font-size: 13px;
|
|
139
|
+
border-radius: 5px;
|
|
140
|
+
flex-shrink: 0;
|
|
141
|
+
}
|
|
142
|
+
.add-btn:hover:not(:disabled) { background: var(--add-hover); }
|
|
143
|
+
.add-btn:disabled {
|
|
144
|
+
background: var(--done-bg);
|
|
145
|
+
color: var(--done-text);
|
|
146
|
+
cursor: default;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.empty-state, .error-state {
|
|
150
|
+
padding: 12px 14px;
|
|
151
|
+
border-radius: 6px;
|
|
152
|
+
font-size: 13px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.empty-state {
|
|
156
|
+
background: var(--card-bg);
|
|
157
|
+
border: 1px solid var(--card-border);
|
|
158
|
+
color: var(--text-muted);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.error-state {
|
|
162
|
+
background: var(--error-bg);
|
|
163
|
+
border: 1px solid #6b2020;
|
|
164
|
+
color: var(--error-text);
|
|
165
|
+
}
|
|
166
|
+
</style>
|
|
167
|
+
</head>
|
|
168
|
+
<body>
|
|
169
|
+
|
|
170
|
+
<h6>Bluetooth Discovery</h6>
|
|
171
|
+
|
|
172
|
+
<div class="scan-row">
|
|
173
|
+
<button id="scanBtn" onclick="startScan()">Scan for BedJets</button>
|
|
174
|
+
<div id="status"></div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div class="progress-bar-wrap" id="progressWrap" style="display:none">
|
|
178
|
+
<div class="progress-bar-fill" id="progressFill"></div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div id="results"></div>
|
|
182
|
+
|
|
183
|
+
<script>
|
|
184
|
+
const SCAN_DURATION_MS = 12000;
|
|
185
|
+
let scanning = false;
|
|
186
|
+
let existingAddresses = new Set();
|
|
187
|
+
|
|
188
|
+
// Load existing configured addresses so we can mark them
|
|
189
|
+
async function loadExisting() {
|
|
190
|
+
try {
|
|
191
|
+
const configs = await homebridge.getPluginConfig();
|
|
192
|
+
const cfg = configs[0] || {};
|
|
193
|
+
(cfg.devices || []).forEach(d => {
|
|
194
|
+
if (d.address) existingAddresses.add(d.address.toUpperCase());
|
|
195
|
+
});
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
loadExisting();
|
|
200
|
+
|
|
201
|
+
async function startScan() {
|
|
202
|
+
if (scanning) return;
|
|
203
|
+
scanning = true;
|
|
204
|
+
|
|
205
|
+
const btn = document.getElementById('scanBtn');
|
|
206
|
+
const status = document.getElementById('status');
|
|
207
|
+
const progressWrap = document.getElementById('progressWrap');
|
|
208
|
+
const progressFill = document.getElementById('progressFill');
|
|
209
|
+
const results = document.getElementById('results');
|
|
210
|
+
|
|
211
|
+
btn.disabled = true;
|
|
212
|
+
results.innerHTML = '';
|
|
213
|
+
progressFill.style.width = '0%';
|
|
214
|
+
progressWrap.style.display = 'block';
|
|
215
|
+
status.innerHTML = '<div class="spinner"></div> Scanning…';
|
|
216
|
+
|
|
217
|
+
// Animate progress bar over scan duration
|
|
218
|
+
let elapsed = 0;
|
|
219
|
+
const tick = 200;
|
|
220
|
+
const timer = setInterval(() => {
|
|
221
|
+
elapsed += tick;
|
|
222
|
+
const pct = Math.min(100, (elapsed / SCAN_DURATION_MS) * 100);
|
|
223
|
+
progressFill.style.width = pct + '%';
|
|
224
|
+
if (elapsed >= SCAN_DURATION_MS) clearInterval(timer);
|
|
225
|
+
}, tick);
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const res = await homebridge.request('/scan');
|
|
229
|
+
clearInterval(timer);
|
|
230
|
+
progressFill.style.width = '100%';
|
|
231
|
+
status.textContent = '';
|
|
232
|
+
|
|
233
|
+
if (!res.devices || res.devices.length === 0) {
|
|
234
|
+
results.innerHTML = '<div class="empty-state">No BedJet devices found nearby. Make sure your BedJet is powered on and not already connected to another device.</div>';
|
|
235
|
+
} else {
|
|
236
|
+
// Reload existing in case user added something during scan
|
|
237
|
+
await loadExisting();
|
|
238
|
+
results.innerHTML = res.devices.map(d => buildCard(d)).join('');
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
clearInterval(timer);
|
|
242
|
+
status.textContent = '';
|
|
243
|
+
const msg = (err && err.message) ? err.message : String(err);
|
|
244
|
+
results.innerHTML = `<div class="error-state">⚠ ${msg}</div>`;
|
|
245
|
+
} finally {
|
|
246
|
+
setTimeout(() => { progressWrap.style.display = 'none'; progressFill.style.width = '0%'; }, 600);
|
|
247
|
+
btn.disabled = false;
|
|
248
|
+
scanning = false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function buildCard(device) {
|
|
253
|
+
const alreadyAdded = existingAddresses.has(device.address.toUpperCase());
|
|
254
|
+
return `
|
|
255
|
+
<div class="device-card">
|
|
256
|
+
<div class="device-info">
|
|
257
|
+
<span class="device-name">${esc(device.name)}</span>
|
|
258
|
+
<span class="device-address">${esc(device.address)}</span>
|
|
259
|
+
</div>
|
|
260
|
+
<button
|
|
261
|
+
class="add-btn"
|
|
262
|
+
${alreadyAdded ? 'disabled' : ''}
|
|
263
|
+
onclick="addDevice(${JSON.stringify(device.name)}, ${JSON.stringify(device.address)}, this)"
|
|
264
|
+
>${alreadyAdded ? '✓ Added' : 'Add to Config'}</button>
|
|
265
|
+
</div>`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function addDevice(name, address, btn) {
|
|
269
|
+
btn.disabled = true;
|
|
270
|
+
btn.textContent = 'Adding…';
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const configs = await homebridge.getPluginConfig();
|
|
274
|
+
const cfg = configs[0] || { platform: 'BedJetPlatform' };
|
|
275
|
+
if (!cfg.devices) cfg.devices = [];
|
|
276
|
+
|
|
277
|
+
// Avoid duplicates
|
|
278
|
+
if (cfg.devices.some(d => d.address && d.address.toUpperCase() === address.toUpperCase())) {
|
|
279
|
+
btn.textContent = '✓ Added';
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
cfg.devices.push({ name, address, scanTimeout: 30 });
|
|
284
|
+
await homebridge.updatePluginConfig([cfg]);
|
|
285
|
+
await homebridge.savePluginConfig();
|
|
286
|
+
|
|
287
|
+
existingAddresses.add(address.toUpperCase());
|
|
288
|
+
btn.textContent = '✓ Added';
|
|
289
|
+
homebridge.toast.success(`${name} added to config. Restart Homebridge to activate.`, 'Device Added');
|
|
290
|
+
} catch (err) {
|
|
291
|
+
btn.disabled = false;
|
|
292
|
+
btn.textContent = 'Add to Config';
|
|
293
|
+
homebridge.toast.error('Failed to save config: ' + ((err && err.message) || err), 'Error');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function esc(str) {
|
|
298
|
+
return String(str)
|
|
299
|
+
.replace(/&/g, '&')
|
|
300
|
+
.replace(/</g, '<')
|
|
301
|
+
.replace(/>/g, '>')
|
|
302
|
+
.replace(/"/g, '"');
|
|
303
|
+
}
|
|
304
|
+
</script>
|
|
305
|
+
|
|
306
|
+
</body>
|
|
307
|
+
</html>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// Resolve node-ble from the plugin root (one level up from homebridge-ui/)
|
|
7
|
+
const NodeBle = require(path.join(__dirname, '..', 'node_modules', 'node-ble'));
|
|
8
|
+
const { createBluetooth } = NodeBle;
|
|
9
|
+
|
|
10
|
+
const SCAN_DURATION_MS = 12000;
|
|
11
|
+
|
|
12
|
+
class BedJetUiServer extends HomebridgePluginUiServer {
|
|
13
|
+
constructor() {
|
|
14
|
+
super();
|
|
15
|
+
this.onRequest('/scan', this.handleScan.bind(this));
|
|
16
|
+
this.ready();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async handleScan() {
|
|
20
|
+
let destroy = null;
|
|
21
|
+
const found = [];
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const ble = createBluetooth();
|
|
25
|
+
destroy = ble.destroy;
|
|
26
|
+
|
|
27
|
+
const adapter = await ble.bluetooth.defaultAdapter();
|
|
28
|
+
|
|
29
|
+
const wasDiscovering = await adapter.isDiscovering();
|
|
30
|
+
if (!wasDiscovering) {
|
|
31
|
+
await adapter.startDiscovery();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Scan for SCAN_DURATION_MS
|
|
35
|
+
await new Promise(resolve => setTimeout(resolve, SCAN_DURATION_MS));
|
|
36
|
+
|
|
37
|
+
if (!wasDiscovering) {
|
|
38
|
+
await adapter.stopDiscovery().catch(() => {});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Query all devices BlueZ discovered
|
|
42
|
+
const addresses = await adapter.devices();
|
|
43
|
+
|
|
44
|
+
for (const address of addresses) {
|
|
45
|
+
try {
|
|
46
|
+
const device = await adapter.getDevice(address);
|
|
47
|
+
const name = await device.getName().catch(() => null);
|
|
48
|
+
if (name && name.toUpperCase().includes('BEDJET')) {
|
|
49
|
+
found.push({ name, address });
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// ignore individual device errors
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
throw new Error(`Bluetooth scan failed: ${err.message || err}`);
|
|
57
|
+
} finally {
|
|
58
|
+
if (destroy) {
|
|
59
|
+
try { destroy(); } catch { /* ignore */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { devices: found };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
new BedJetUiServer();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-bedjet",
|
|
3
3
|
"displayName": "BedJet",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.2",
|
|
5
5
|
"description": "Homebridge plugin for BedJet V3 via Bluetooth LE",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"node": ">=18.0.0"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
+
"@homebridge/plugin-ui-utils": "^1.0.0",
|
|
19
20
|
"node-ble": "^1.10.1"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|