homebridge-bedjet 0.2.4 → 0.2.5
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/homebridge-ui/public/index.html +74 -107
- package/homebridge-ui/server.js +76 -19
- package/package.json +1 -1
|
@@ -55,11 +55,10 @@
|
|
|
55
55
|
.spinner {
|
|
56
56
|
width: 13px;
|
|
57
57
|
height: 13px;
|
|
58
|
-
border: 2px solid rgba(
|
|
58
|
+
border: 2px solid rgba(128,128,128,0.3);
|
|
59
59
|
border-top-color: #e25c00;
|
|
60
60
|
border-radius: 50%;
|
|
61
61
|
animation: spin 0.7s linear infinite;
|
|
62
|
-
flex-shrink: 0;
|
|
63
62
|
}
|
|
64
63
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
65
64
|
|
|
@@ -89,8 +88,8 @@
|
|
|
89
88
|
gap: 10px;
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
.device-
|
|
93
|
-
.device-
|
|
91
|
+
.device-name { font-weight: 500; }
|
|
92
|
+
.device-addr {
|
|
94
93
|
font-size: 11px;
|
|
95
94
|
opacity: 0.55;
|
|
96
95
|
font-family: 'SF Mono', 'Consolas', monospace;
|
|
@@ -101,12 +100,14 @@
|
|
|
101
100
|
display: flex;
|
|
102
101
|
flex-direction: column;
|
|
103
102
|
align-items: flex-end;
|
|
104
|
-
gap:
|
|
103
|
+
gap: 5px;
|
|
105
104
|
flex-shrink: 0;
|
|
105
|
+
min-width: 120px;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
.add-btn {
|
|
109
|
-
|
|
109
|
+
width: 100%;
|
|
110
|
+
padding: 6px 12px;
|
|
110
111
|
border: none;
|
|
111
112
|
border-radius: 5px;
|
|
112
113
|
font-size: 13px;
|
|
@@ -115,19 +116,18 @@
|
|
|
115
116
|
background: #1a7a40;
|
|
116
117
|
color: #fff;
|
|
117
118
|
transition: opacity 0.15s;
|
|
118
|
-
white-space: nowrap;
|
|
119
119
|
}
|
|
120
120
|
.add-btn:hover:not(:disabled) { opacity: 0.85; }
|
|
121
121
|
.add-btn:disabled { opacity: 0.55; cursor: default; }
|
|
122
|
-
.add-btn.done { background: #145c30; }
|
|
123
122
|
|
|
124
|
-
.
|
|
123
|
+
.card-msg {
|
|
125
124
|
font-size: 11px;
|
|
126
|
-
max-width: 140px;
|
|
127
125
|
text-align: right;
|
|
126
|
+
min-height: 14px;
|
|
127
|
+
width: 100%;
|
|
128
128
|
}
|
|
129
|
-
.
|
|
130
|
-
.
|
|
129
|
+
.card-msg.ok { color: #4caf50; }
|
|
130
|
+
.card-msg.err { color: #f28b82; }
|
|
131
131
|
|
|
132
132
|
.notice {
|
|
133
133
|
padding: 10px 13px;
|
|
@@ -157,41 +157,24 @@
|
|
|
157
157
|
<script>
|
|
158
158
|
const SCAN_MS = 12000;
|
|
159
159
|
let scanning = false;
|
|
160
|
-
const addedSet = new Set();
|
|
161
|
-
|
|
162
|
-
// Safely get existing configured addresses
|
|
163
|
-
async function getExistingAddresses() {
|
|
164
|
-
try {
|
|
165
|
-
const configs = await homebridge.getPluginConfig();
|
|
166
|
-
const cfg = configs && configs[0];
|
|
167
|
-
if (cfg && Array.isArray(cfg.devices)) {
|
|
168
|
-
cfg.devices.forEach(d => { if (d.address) addedSet.add(d.address.toUpperCase()); });
|
|
169
|
-
}
|
|
170
|
-
} catch (e) { /* ignore */ }
|
|
171
|
-
}
|
|
172
160
|
|
|
173
|
-
//
|
|
174
|
-
if (typeof homebridge !== 'undefined') {
|
|
175
|
-
getExistingAddresses();
|
|
176
|
-
} else {
|
|
177
|
-
document.addEventListener('DOMContentLoaded', getExistingAddresses);
|
|
178
|
-
}
|
|
161
|
+
// ── Scan ─────────────────────────────────────────────────────────────────
|
|
179
162
|
|
|
180
163
|
async function startScan() {
|
|
181
164
|
if (scanning) return;
|
|
182
165
|
scanning = true;
|
|
183
166
|
|
|
184
|
-
const btn
|
|
185
|
-
const status
|
|
186
|
-
const pWrap
|
|
187
|
-
const pFill
|
|
188
|
-
const results
|
|
167
|
+
const btn = document.getElementById('scanBtn');
|
|
168
|
+
const status = document.getElementById('scanStatus');
|
|
169
|
+
const pWrap = document.getElementById('progressWrap');
|
|
170
|
+
const pFill = document.getElementById('progressFill');
|
|
171
|
+
const results = document.getElementById('results');
|
|
189
172
|
|
|
190
173
|
btn.disabled = true;
|
|
191
174
|
results.innerHTML = '';
|
|
192
175
|
pFill.style.width = '0%';
|
|
193
176
|
pWrap.style.display = 'block';
|
|
194
|
-
status.innerHTML = '<div class="spinner"></div
|
|
177
|
+
status.innerHTML = '<div class="spinner"></div> Scanning…';
|
|
195
178
|
|
|
196
179
|
let elapsed = 0;
|
|
197
180
|
const tickMs = 200;
|
|
@@ -207,19 +190,16 @@
|
|
|
207
190
|
pFill.style.width = '100%';
|
|
208
191
|
status.innerHTML = '';
|
|
209
192
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const devices = (res && res.devices) ? res.devices : [];
|
|
193
|
+
const devices = (res && Array.isArray(res.devices)) ? res.devices : [];
|
|
213
194
|
if (devices.length === 0) {
|
|
214
|
-
results.innerHTML = '<div class="notice">No BedJet devices found. Make sure your BedJet is powered on and not connected to another app.</div>';
|
|
195
|
+
results.innerHTML = '<div class="notice">No BedJet devices found. Make sure your BedJet is powered on and not already connected to another app, then try again.</div>';
|
|
215
196
|
} else {
|
|
216
|
-
results.innerHTML = devices.map(
|
|
197
|
+
results.innerHTML = devices.map(renderCard).join('');
|
|
217
198
|
}
|
|
218
199
|
} catch (err) {
|
|
219
200
|
clearInterval(timer);
|
|
220
201
|
status.innerHTML = '';
|
|
221
|
-
|
|
222
|
-
results.innerHTML = `<div class="notice error">Scan failed: ${esc(msg)}</div>`;
|
|
202
|
+
results.innerHTML = '<div class="notice error">Scan failed: ' + esc(err && err.message ? err.message : String(err)) + '</div>';
|
|
223
203
|
} finally {
|
|
224
204
|
setTimeout(() => { pWrap.style.display = 'none'; pFill.style.width = '0%'; }, 500);
|
|
225
205
|
btn.disabled = false;
|
|
@@ -227,86 +207,73 @@
|
|
|
227
207
|
}
|
|
228
208
|
}
|
|
229
209
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
</div
|
|
210
|
+
// ── Card rendering ────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function renderCard(d) {
|
|
213
|
+
// Use address as a safe DOM-id suffix
|
|
214
|
+
const safeid = d.address.replace(/[^a-zA-Z0-9]/g, '_');
|
|
215
|
+
return [
|
|
216
|
+
'<div class="device-card">',
|
|
217
|
+
' <div>',
|
|
218
|
+
' <div class="device-name">' + esc(d.name) + '</div>',
|
|
219
|
+
' <div class="device-addr">' + esc(d.address) + '</div>',
|
|
220
|
+
' </div>',
|
|
221
|
+
' <div class="device-right">',
|
|
222
|
+
' <button class="add-btn" id="btn_' + safeid + '"',
|
|
223
|
+
' onclick="addDevice(' + JSON.stringify(d.name) + ',' + JSON.stringify(d.address) + ',' + JSON.stringify(safeid) + ')">',
|
|
224
|
+
' Add to Config',
|
|
225
|
+
' </button>',
|
|
226
|
+
' <div class="card-msg" id="msg_' + safeid + '"></div>',
|
|
227
|
+
' </div>',
|
|
228
|
+
'</div>',
|
|
229
|
+
].join('\n');
|
|
249
230
|
}
|
|
250
231
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
232
|
+
// ── Add device via server endpoint ────────────────────────────────────────
|
|
233
|
+
// Uses homebridge.request() — same path as the scan, which we know works.
|
|
234
|
+
// The server reads/writes config.json directly; no client-side config APIs needed.
|
|
235
|
+
|
|
236
|
+
async function addDevice(name, address, safeid) {
|
|
237
|
+
const btn = document.getElementById('btn_' + safeid);
|
|
238
|
+
const msg = document.getElementById('msg_' + safeid);
|
|
254
239
|
|
|
240
|
+
// Immediate visual feedback before any async work
|
|
255
241
|
btn.disabled = true;
|
|
256
242
|
btn.textContent = 'Saving…';
|
|
257
243
|
msg.textContent = '';
|
|
258
|
-
msg.className = '
|
|
244
|
+
msg.className = 'card-msg';
|
|
259
245
|
|
|
246
|
+
let result;
|
|
260
247
|
try {
|
|
261
|
-
|
|
262
|
-
let configs = [];
|
|
263
|
-
try { configs = await homebridge.getPluginConfig(); } catch (e) { configs = []; }
|
|
264
|
-
|
|
265
|
-
let cfg = (configs && configs.length > 0) ? Object.assign({}, configs[0]) : {};
|
|
266
|
-
if (!cfg.platform) cfg.platform = 'BedJetPlatform';
|
|
267
|
-
if (!Array.isArray(cfg.devices)) cfg.devices = [];
|
|
268
|
-
|
|
269
|
-
// Deduplicate
|
|
270
|
-
const already = cfg.devices.some(
|
|
271
|
-
d => d.address && d.address.toUpperCase() === address.toUpperCase()
|
|
272
|
-
);
|
|
273
|
-
if (already) {
|
|
274
|
-
btn.textContent = '✓ Already added';
|
|
275
|
-
btn.classList.add('done');
|
|
276
|
-
msg.textContent = 'Already in config';
|
|
277
|
-
msg.className = 'inline-msg ok';
|
|
278
|
-
addedSet.add(address.toUpperCase());
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
cfg.devices.push({ name: name, address: address, scanTimeout: 30 });
|
|
283
|
-
|
|
284
|
-
await homebridge.updatePluginConfig([cfg]);
|
|
285
|
-
await homebridge.savePluginConfig();
|
|
286
|
-
|
|
287
|
-
addedSet.add(address.toUpperCase());
|
|
288
|
-
btn.textContent = '✓ Added';
|
|
289
|
-
btn.classList.add('done');
|
|
290
|
-
msg.textContent = 'Saved! Restart Homebridge.';
|
|
291
|
-
msg.className = 'inline-msg ok';
|
|
292
|
-
|
|
293
|
-
// Try toast as a bonus (might not be available in all versions)
|
|
294
|
-
try { homebridge.toast.success(name + ' added. Restart Homebridge to activate.', 'Saved'); } catch (e) { /* ignore */ }
|
|
295
|
-
|
|
248
|
+
result = await homebridge.request('/add-device', { name: name, address: address });
|
|
296
249
|
} catch (err) {
|
|
250
|
+
// Request itself failed — show error and re-enable
|
|
297
251
|
btn.disabled = false;
|
|
298
252
|
btn.textContent = 'Add to Config';
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
253
|
+
msg.textContent = 'Error: ' + esc(err && err.message ? err.message : String(err));
|
|
254
|
+
msg.className = 'card-msg err';
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Request succeeded
|
|
259
|
+
if (result && result.status === 'already_exists') {
|
|
260
|
+
btn.textContent = '✓ Already in config';
|
|
261
|
+
msg.textContent = 'Restart Homebridge to see it.';
|
|
262
|
+
msg.className = 'card-msg ok';
|
|
263
|
+
} else {
|
|
264
|
+
btn.textContent = '✓ Added!';
|
|
265
|
+
btn.style.background = '#145c30';
|
|
266
|
+
msg.textContent = 'Saved. Restart Homebridge.';
|
|
267
|
+
msg.className = 'card-msg ok';
|
|
303
268
|
}
|
|
304
269
|
}
|
|
305
270
|
|
|
306
271
|
function esc(s) {
|
|
307
272
|
return String(s)
|
|
308
|
-
.replace(/&/g, '&')
|
|
309
|
-
.replace(
|
|
273
|
+
.replace(/&/g, '&')
|
|
274
|
+
.replace(/</g, '<')
|
|
275
|
+
.replace(/>/g, '>')
|
|
276
|
+
.replace(/"/g, '"');
|
|
310
277
|
}
|
|
311
278
|
</script>
|
|
312
279
|
</body>
|
package/homebridge-ui/server.js
CHANGED
|
@@ -2,20 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
5
7
|
|
|
6
|
-
//
|
|
8
|
+
// node-ble lives one level up in the plugin's own node_modules
|
|
7
9
|
const NodeBle = require(path.join(__dirname, '..', 'node_modules', 'node-ble'));
|
|
8
10
|
const { createBluetooth } = NodeBle;
|
|
9
11
|
|
|
10
12
|
const SCAN_DURATION_MS = 12000;
|
|
11
13
|
|
|
14
|
+
// homebridge-config-ui-x sets UIX_CONFIG_PATH when spawning this child process
|
|
15
|
+
function getConfigPath() {
|
|
16
|
+
if (process.env.UIX_CONFIG_PATH) return process.env.UIX_CONFIG_PATH;
|
|
17
|
+
// Common fallback locations
|
|
18
|
+
const candidates = [
|
|
19
|
+
path.join(os.homedir(), '.homebridge', 'config.json'),
|
|
20
|
+
'/var/lib/homebridge/config.json',
|
|
21
|
+
];
|
|
22
|
+
for (const c of candidates) {
|
|
23
|
+
if (fs.existsSync(c)) return c;
|
|
24
|
+
}
|
|
25
|
+
return path.join(os.homedir(), '.homebridge', 'config.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
12
28
|
class BedJetUiServer extends HomebridgePluginUiServer {
|
|
13
29
|
constructor() {
|
|
14
30
|
super();
|
|
15
|
-
this.onRequest('/scan',
|
|
31
|
+
this.onRequest('/scan', this.handleScan.bind(this));
|
|
32
|
+
this.onRequest('/add-device', this.handleAddDevice.bind(this));
|
|
16
33
|
this.ready();
|
|
17
34
|
}
|
|
18
35
|
|
|
36
|
+
// ── BLE scan ────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
19
38
|
async handleScan() {
|
|
20
39
|
let destroy = null;
|
|
21
40
|
const found = [];
|
|
@@ -25,43 +44,81 @@ class BedJetUiServer extends HomebridgePluginUiServer {
|
|
|
25
44
|
destroy = ble.destroy;
|
|
26
45
|
|
|
27
46
|
const adapter = await ble.bluetooth.defaultAdapter();
|
|
28
|
-
|
|
29
47
|
const wasDiscovering = await adapter.isDiscovering();
|
|
30
|
-
if (!wasDiscovering)
|
|
31
|
-
await adapter.startDiscovery();
|
|
32
|
-
}
|
|
48
|
+
if (!wasDiscovering) await adapter.startDiscovery();
|
|
33
49
|
|
|
34
|
-
// Scan for SCAN_DURATION_MS
|
|
35
50
|
await new Promise(resolve => setTimeout(resolve, SCAN_DURATION_MS));
|
|
36
51
|
|
|
37
|
-
if (!wasDiscovering) {
|
|
38
|
-
await adapter.stopDiscovery().catch(() => {});
|
|
39
|
-
}
|
|
52
|
+
if (!wasDiscovering) await adapter.stopDiscovery().catch(() => {});
|
|
40
53
|
|
|
41
|
-
// Query all devices BlueZ discovered
|
|
42
54
|
const addresses = await adapter.devices();
|
|
43
|
-
|
|
44
55
|
for (const address of addresses) {
|
|
45
56
|
try {
|
|
46
57
|
const device = await adapter.getDevice(address);
|
|
47
|
-
const name
|
|
58
|
+
const name = await device.getName().catch(() => null);
|
|
48
59
|
if (name && name.toUpperCase().includes('BEDJET')) {
|
|
49
60
|
found.push({ name, address });
|
|
50
61
|
}
|
|
51
|
-
} catch {
|
|
52
|
-
// ignore individual device errors
|
|
53
|
-
}
|
|
62
|
+
} catch { /* skip device */ }
|
|
54
63
|
}
|
|
55
64
|
} catch (err) {
|
|
56
65
|
throw new Error(`Bluetooth scan failed: ${err.message || err}`);
|
|
57
66
|
} finally {
|
|
58
|
-
if (destroy) {
|
|
59
|
-
try { destroy(); } catch { /* ignore */ }
|
|
60
|
-
}
|
|
67
|
+
if (destroy) { try { destroy(); } catch { /* ignore */ } }
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
return { devices: found };
|
|
64
71
|
}
|
|
72
|
+
|
|
73
|
+
// ── Config write ─────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async handleAddDevice(body) {
|
|
76
|
+
const { name, address } = body || {};
|
|
77
|
+
|
|
78
|
+
if (!name || !address) {
|
|
79
|
+
throw new Error('Missing name or address');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const configPath = getConfigPath();
|
|
83
|
+
|
|
84
|
+
let raw;
|
|
85
|
+
try {
|
|
86
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new Error(`Cannot read config file (${configPath}): ${err.message}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let config;
|
|
92
|
+
try {
|
|
93
|
+
config = JSON.parse(raw);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new Error(`Config file is not valid JSON: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!Array.isArray(config.platforms)) config.platforms = [];
|
|
99
|
+
|
|
100
|
+
let platform = config.platforms.find(p => p.platform === 'BedJetPlatform');
|
|
101
|
+
if (!platform) {
|
|
102
|
+
platform = { platform: 'BedJetPlatform', name: 'BedJetPlatform', devices: [] };
|
|
103
|
+
config.platforms.push(platform);
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(platform.devices)) platform.devices = [];
|
|
106
|
+
|
|
107
|
+
const addrUp = address.toUpperCase();
|
|
108
|
+
if (platform.devices.some(d => d.address && d.address.toUpperCase() === addrUp)) {
|
|
109
|
+
return { status: 'already_exists' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
platform.devices.push({ name, address, scanTimeout: 30 });
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 4), 'utf8');
|
|
116
|
+
} catch (err) {
|
|
117
|
+
throw new Error(`Cannot write config file: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { status: 'ok' };
|
|
121
|
+
}
|
|
65
122
|
}
|
|
66
123
|
|
|
67
124
|
new BedJetUiServer();
|