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.
@@ -55,11 +55,10 @@
55
55
  .spinner {
56
56
  width: 13px;
57
57
  height: 13px;
58
- border: 2px solid rgba(255,255,255,0.15);
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-info .device-name { font-weight: 500; }
93
- .device-info .device-addr {
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: 4px;
103
+ gap: 5px;
105
104
  flex-shrink: 0;
105
+ min-width: 120px;
106
106
  }
107
107
 
108
108
  .add-btn {
109
- padding: 6px 15px;
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
- .inline-msg {
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
- .inline-msg.ok { color: #4caf50; }
130
- .inline-msg.err { color: #f28b82; }
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
- // Run after homebridge is ready
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 = document.getElementById('scanBtn');
185
- const status = document.getElementById('scanStatus');
186
- const pWrap = document.getElementById('progressWrap');
187
- const pFill = document.getElementById('progressFill');
188
- const results = document.getElementById('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> Scanning…';
177
+ status.innerHTML = '<div class="spinner"></div>&nbsp;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
- await getExistingAddresses();
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(d => deviceCard(d)).join('');
197
+ results.innerHTML = devices.map(renderCard).join('');
217
198
  }
218
199
  } catch (err) {
219
200
  clearInterval(timer);
220
201
  status.innerHTML = '';
221
- const msg = (err && err.message) ? err.message : String(err);
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
- function deviceCard(d) {
231
- const already = addedSet.has(d.address.toUpperCase());
232
- const id = 'btn_' + d.address.replace(/:/g, '');
233
- const mid = 'msg_' + d.address.replace(/:/g, '');
234
- return `
235
- <div class="device-card">
236
- <div class="device-info">
237
- <div class="device-name">${esc(d.name)}</div>
238
- <div class="device-addr">${esc(d.address)}</div>
239
- </div>
240
- <div class="device-right">
241
- <button id="${id}" class="add-btn${already ? ' done' : ''}"
242
- ${already ? 'disabled' : ''}
243
- onclick="addDevice(${JSON.stringify(d.name)}, ${JSON.stringify(d.address)}, '${id}', '${mid}')">
244
- ${already ? '✓ Already added' : 'Add to Config'}
245
- </button>
246
- <div id="${mid}" class="inline-msg"></div>
247
- </div>
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
- async function addDevice(name, address, btnId, msgId) {
252
- const btn = document.getElementById(btnId);
253
- const msg = document.getElementById(msgId);
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 = 'inline-msg';
244
+ msg.className = 'card-msg';
259
245
 
246
+ let result;
260
247
  try {
261
- // Get current config (returns array of platform configs)
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
- btn.classList.remove('done');
300
- const errMsg = (err && err.message) ? err.message : String(err);
301
- msg.textContent = 'Error: ' + errMsg;
302
- msg.className = 'inline-msg err';
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, '&amp;').replace(/</g, '&lt;')
309
- .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
273
+ .replace(/&/g, '&amp;')
274
+ .replace(/</g, '&lt;')
275
+ .replace(/>/g, '&gt;')
276
+ .replace(/"/g, '&quot;');
310
277
  }
311
278
  </script>
312
279
  </body>
@@ -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
- // Resolve node-ble from the plugin root (one level up from homebridge-ui/)
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', this.handleScan.bind(this));
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 = await device.getName().catch(() => null);
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();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-bedjet",
3
3
  "displayName": "BedJet",
4
- "version": "0.2.4",
4
+ "version": "0.2.5",
5
5
  "description": "Homebridge plugin for BedJet V3 via Bluetooth LE",
6
6
  "license": "MIT",
7
7
  "main": "dist/index.js",