homebridge-bedjet 0.2.8 → 0.3.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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # homebridge-bedjet
2
2
 
3
+ DISCLAIMER - This project is entirely vibecoded. I don't have development experience.
4
+
3
5
  Homebridge plugin for the **BedJet V3** climate comfort system via Bluetooth LE.
4
6
 
5
7
  Exposes a **Thermostat** and a **Fan** accessory in HomeKit so you can control temperature and fan speed from the Home app, Siri, or automations — without needing the BedJet cloud or the BedJet mobile app running.
package/dist/accessory.js CHANGED
@@ -10,6 +10,20 @@ const DEFAULT_MODE_MAP = {
10
10
  cool: constants_1.OperatingMode.COOL,
11
11
  dry: constants_1.OperatingMode.DRY,
12
12
  };
13
+ /**
14
+ * HomeKit only allows alphanumeric, space, and apostrophe characters in names,
15
+ * starting and ending with alphanumeric. Underscores and other punctuation
16
+ * cause HAP warnings and may prevent the accessory from pairing.
17
+ */
18
+ function sanitizeName(name) {
19
+ return name
20
+ .replace(/_/g, ' ') // underscores → spaces
21
+ .replace(/[^a-zA-Z0-9 ']/g, ' ') // anything else invalid → space
22
+ .replace(/\s+/g, ' ') // collapse multiple spaces
23
+ .trim()
24
+ .replace(/^[^a-zA-Z0-9]+/, '') // must start with alphanumeric
25
+ .replace(/[^a-zA-Z0-9]+$/, '') || 'BedJet'; // must end with alphanumeric
26
+ }
13
27
  // OperatingMode → CurrentHeatingCoolingState value
14
28
  const CURRENT_STATE_MAP = {
15
29
  [constants_1.OperatingMode.STANDBY]: 0, // OFF
@@ -52,6 +66,7 @@ class BedJetAccessory {
52
66
  this.pendingMode = null;
53
67
  this.pendingModeTimer = null;
54
68
  const { Service, Characteristic } = platform.api.hap;
69
+ const safeName = sanitizeName(config.name);
55
70
  // AccessoryInformation
56
71
  const infoService = this.accessory.getService(Service.AccessoryInformation)
57
72
  ?? this.accessory.addService(Service.AccessoryInformation);
@@ -61,7 +76,7 @@ class BedJetAccessory {
61
76
  .setCharacteristic(Characteristic.SerialNumber, config.address);
62
77
  // Thermostat service
63
78
  this.thermostatService = this.accessory.getService(Service.Thermostat)
64
- ?? this.accessory.addService(Service.Thermostat, config.name, 'thermostat');
79
+ ?? this.accessory.addService(Service.Thermostat, safeName, 'thermostat');
65
80
  this.thermostatService.getCharacteristic(Characteristic.TemperatureDisplayUnits)
66
81
  .onGet(() => Characteristic.TemperatureDisplayUnits.CELSIUS);
67
82
  this.thermostatService.getCharacteristic(Characteristic.CurrentTemperature)
@@ -100,7 +115,7 @@ class BedJetAccessory {
100
115
  });
101
116
  // FanV2 service
102
117
  this.fanService = this.accessory.getService(Service.Fanv2)
103
- ?? this.accessory.addService(Service.Fanv2, `${config.name} Fan`, 'fan');
118
+ ?? this.accessory.addService(Service.Fanv2, `${safeName} Fan`, 'fan');
104
119
  this.fanService.getCharacteristic(Characteristic.Active)
105
120
  .onGet(() => this.bedjet.state.operatingMode !== constants_1.OperatingMode.STANDBY
106
121
  ? Characteristic.Active.ACTIVE
@@ -77,15 +77,20 @@
77
77
  transition: width 0.3s linear;
78
78
  }
79
79
 
80
+ /* ── Device card ─────────────────────────────────────────────────────── */
81
+
80
82
  .device-card {
81
83
  border: 1px solid rgba(128,128,128,0.25);
82
84
  border-radius: 8px;
83
- padding: 11px 13px;
84
- margin-bottom: 8px;
85
+ padding: 12px 13px;
86
+ margin-bottom: 10px;
87
+ }
88
+
89
+ .card-header {
85
90
  display: flex;
86
91
  align-items: center;
87
92
  justify-content: space-between;
88
- gap: 10px;
93
+ margin-bottom: 10px;
89
94
  }
90
95
 
91
96
  .device-name { font-weight: 500; }
@@ -96,18 +101,68 @@
96
101
  margin-top: 2px;
97
102
  }
98
103
 
99
- .device-right {
104
+ /* ── Defaults row ────────────────────────────────────────────────────── */
105
+
106
+ .defaults-row {
100
107
  display: flex;
101
- flex-direction: column;
108
+ gap: 8px;
102
109
  align-items: flex-end;
103
- gap: 5px;
104
- flex-shrink: 0;
105
- min-width: 120px;
110
+ flex-wrap: wrap;
111
+ margin-bottom: 10px;
106
112
  }
107
113
 
108
- .add-btn {
114
+ .field {
115
+ display: flex;
116
+ flex-direction: column;
117
+ gap: 3px;
118
+ flex: 1;
119
+ min-width: 80px;
120
+ }
121
+
122
+ .field label {
123
+ font-size: 11px;
124
+ opacity: 0.55;
125
+ font-weight: 500;
126
+ text-transform: uppercase;
127
+ letter-spacing: 0.04em;
128
+ }
129
+
130
+ .field select,
131
+ .field input[type="number"] {
132
+ padding: 5px 7px;
133
+ border-radius: 5px;
134
+ border: 1px solid rgba(128,128,128,0.35);
135
+ background: rgba(128,128,128,0.08);
136
+ color: inherit;
137
+ font-size: 13px;
109
138
  width: 100%;
110
- padding: 6px 12px;
139
+ box-sizing: border-box;
140
+ }
141
+
142
+ .field select:focus,
143
+ .field input[type="number"]:focus {
144
+ outline: none;
145
+ border-color: #e25c00;
146
+ }
147
+
148
+ /* ── Footer row (button + message) ──────────────────────────────────── */
149
+
150
+ .card-footer {
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: space-between;
154
+ gap: 8px;
155
+ }
156
+
157
+ .card-msg {
158
+ font-size: 12px;
159
+ flex: 1;
160
+ }
161
+ .card-msg.ok { color: #4caf50; }
162
+ .card-msg.err { color: #f28b82; }
163
+
164
+ .add-btn {
165
+ padding: 6px 16px;
111
166
  border: none;
112
167
  border-radius: 5px;
113
168
  font-size: 13px;
@@ -116,18 +171,13 @@
116
171
  background: #1a7a40;
117
172
  color: #fff;
118
173
  transition: opacity 0.15s;
174
+ white-space: nowrap;
175
+ flex-shrink: 0;
119
176
  }
120
177
  .add-btn:hover:not(:disabled) { opacity: 0.85; }
121
178
  .add-btn:disabled { opacity: 0.55; cursor: default; }
122
179
 
123
- .card-msg {
124
- font-size: 11px;
125
- text-align: right;
126
- min-height: 14px;
127
- width: 100%;
128
- }
129
- .card-msg.ok { color: #4caf50; }
130
- .card-msg.err { color: #f28b82; }
180
+ /* ── Notices ─────────────────────────────────────────────────────────── */
131
181
 
132
182
  .notice {
133
183
  padding: 10px 13px;
@@ -158,17 +208,38 @@
158
208
  var SCAN_MS = 12000;
159
209
  var scanning = false;
160
210
 
161
- // ── Wire up buttons via addEventListener, never inline onclick ────────────
211
+ var MODES = [
212
+ { value: 'heat', label: 'Heat' },
213
+ { value: 'turbo', label: 'Turbo' },
214
+ { value: 'extendedHeat', label: 'Extended Heat' },
215
+ { value: 'cool', label: 'Cool (Fan Only)' },
216
+ { value: 'dry', label: 'Dry' },
217
+ ];
218
+
219
+ // ── Scan button ───────────────────────────────────────────────────────────
162
220
 
163
221
  document.getElementById('scanBtn').addEventListener('click', startScan);
164
222
 
165
- // Event delegation for dynamically created Add buttons
223
+ // Event delegation for Add buttons
166
224
  document.getElementById('results').addEventListener('click', function (e) {
167
225
  var btn = e.target;
168
226
  if (!btn.classList.contains('add-btn') || btn.disabled) return;
169
- var name = btn.getAttribute('data-name');
170
- var address = btn.getAttribute('data-address');
171
- addDevice(btn, name, address);
227
+ var card = btn.closest('.device-card');
228
+ if (!card) return;
229
+
230
+ var name = card.getAttribute('data-name');
231
+ var address = card.getAttribute('data-address');
232
+
233
+ // Read defaults from the card's inputs
234
+ var modeEl = card.querySelector('.field-mode');
235
+ var tempEl = card.querySelector('.field-temp');
236
+ var fanEl = card.querySelector('.field-fan');
237
+
238
+ var defaultMode = modeEl ? modeEl.value : 'heat';
239
+ var defaultTemperature = tempEl && tempEl.value !== '' ? parseFloat(tempEl.value) : null;
240
+ var defaultFanSpeed = fanEl && fanEl.value !== '' ? parseInt(fanEl.value, 10) : null;
241
+
242
+ addDevice(btn, card, name, address, defaultMode, defaultTemperature, defaultFanSpeed);
172
243
  });
173
244
 
174
245
  // ── Scan ──────────────────────────────────────────────────────────────────
@@ -177,11 +248,11 @@
177
248
  if (scanning) return;
178
249
  scanning = true;
179
250
 
180
- var scanBtn = document.getElementById('scanBtn');
181
- var status = document.getElementById('scanStatus');
182
- var pWrap = document.getElementById('progressWrap');
183
- var pFill = document.getElementById('progressFill');
184
- var results = document.getElementById('results');
251
+ var scanBtn = document.getElementById('scanBtn');
252
+ var status = document.getElementById('scanStatus');
253
+ var pWrap = document.getElementById('progressWrap');
254
+ var pFill = document.getElementById('progressFill');
255
+ var results = document.getElementById('results');
185
256
 
186
257
  scanBtn.disabled = true;
187
258
  results.innerHTML = '';
@@ -205,9 +276,13 @@
205
276
 
206
277
  var devices = (res && Array.isArray(res.devices)) ? res.devices : [];
207
278
  if (devices.length === 0) {
208
- results.innerHTML = '<div class="notice">No BedJet devices found nearby. Make sure your BedJet is powered on and not connected to another app, then try again.</div>';
279
+ results.innerHTML = '<div class="notice">No BedJet devices found nearby. Make sure your BedJet is powered on and not already connected to another app, then try again.</div>';
209
280
  } else {
210
- results.innerHTML = devices.map(renderCard).join('');
281
+ // Build cards via DOM (no inline JS strings → no quote escaping issues)
282
+ results.innerHTML = '';
283
+ devices.forEach(function (d) {
284
+ results.appendChild(buildCard(d));
285
+ });
211
286
  }
212
287
  } catch (err) {
213
288
  clearInterval(timer);
@@ -220,61 +295,123 @@
220
295
  }
221
296
  }
222
297
 
223
- // ── Card ──────────────────────────────────────────────────────────────────
224
- // Device name/address stored in data-* attributes — no inline JS strings
298
+ // ── Card builder (pure DOM, no innerHTML with user data) ──────────────────
225
299
 
226
- function renderCard(d) {
300
+ function buildCard(d) {
227
301
  var card = document.createElement('div');
228
302
  card.className = 'device-card';
303
+ card.setAttribute('data-name', d.name);
304
+ card.setAttribute('data-address', d.address);
229
305
 
230
- var info = document.createElement('div');
306
+ // Header: name + address —
307
+ var header = document.createElement('div');
308
+ header.className = 'card-header';
231
309
 
310
+ var info = document.createElement('div');
232
311
  var nameEl = document.createElement('div');
233
312
  nameEl.className = 'device-name';
234
313
  nameEl.textContent = d.name;
235
-
236
314
  var addrEl = document.createElement('div');
237
315
  addrEl.className = 'device-addr';
238
316
  addrEl.textContent = d.address;
239
-
240
317
  info.appendChild(nameEl);
241
318
  info.appendChild(addrEl);
242
-
243
- var right = document.createElement('div');
244
- right.className = 'device-right';
319
+ header.appendChild(info);
320
+ card.appendChild(header);
321
+
322
+ // — Defaults row —
323
+ var row = document.createElement('div');
324
+ row.className = 'defaults-row';
325
+
326
+ // Mode select
327
+ var modeField = document.createElement('div');
328
+ modeField.className = 'field';
329
+ modeField.style.flex = '2';
330
+ var modeLabel = document.createElement('label');
331
+ modeLabel.textContent = 'Default Mode';
332
+ var modeSelect = document.createElement('select');
333
+ modeSelect.className = 'field-mode';
334
+ MODES.forEach(function (m) {
335
+ var opt = document.createElement('option');
336
+ opt.value = m.value;
337
+ opt.textContent = m.label;
338
+ if (m.value === 'heat') opt.selected = true;
339
+ modeSelect.appendChild(opt);
340
+ });
341
+ modeField.appendChild(modeLabel);
342
+ modeField.appendChild(modeSelect);
343
+ row.appendChild(modeField);
344
+
345
+ // Temperature input
346
+ var tempField = document.createElement('div');
347
+ tempField.className = 'field';
348
+ var tempLabel = document.createElement('label');
349
+ tempLabel.textContent = 'Temp °C';
350
+ var tempInput = document.createElement('input');
351
+ tempInput.type = 'number';
352
+ tempInput.className = 'field-temp';
353
+ tempInput.min = '19';
354
+ tempInput.max = '43';
355
+ tempInput.step = '0.5';
356
+ tempInput.placeholder = 'Optional';
357
+ tempField.appendChild(tempLabel);
358
+ tempField.appendChild(tempInput);
359
+ row.appendChild(tempField);
360
+
361
+ // Fan speed input
362
+ var fanField = document.createElement('div');
363
+ fanField.className = 'field';
364
+ var fanLabel = document.createElement('label');
365
+ fanLabel.textContent = 'Fan %';
366
+ var fanInput = document.createElement('input');
367
+ fanInput.type = 'number';
368
+ fanInput.className = 'field-fan';
369
+ fanInput.min = '5';
370
+ fanInput.max = '100';
371
+ fanInput.step = '5';
372
+ fanInput.placeholder = 'Optional';
373
+ fanField.appendChild(fanLabel);
374
+ fanField.appendChild(fanInput);
375
+ row.appendChild(fanField);
376
+
377
+ card.appendChild(row);
378
+
379
+ // — Footer: message + button —
380
+ var footer = document.createElement('div');
381
+ footer.className = 'card-footer';
382
+
383
+ var msg = document.createElement('div');
384
+ msg.className = 'card-msg';
245
385
 
246
386
  var btn = document.createElement('button');
247
387
  btn.className = 'add-btn';
248
388
  btn.textContent = 'Add to Config';
249
- btn.setAttribute('data-name', d.name);
250
- btn.setAttribute('data-address', d.address);
251
-
252
- var msgEl = document.createElement('div');
253
- msgEl.className = 'card-msg';
254
389
 
255
- right.appendChild(btn);
256
- right.appendChild(msgEl);
257
- card.appendChild(info);
258
- card.appendChild(right);
390
+ footer.appendChild(msg);
391
+ footer.appendChild(btn);
392
+ card.appendChild(footer);
259
393
 
260
- // Store msgEl reference on btn for easy access in handler
261
- btn._msgEl = msgEl;
262
-
263
- return card.outerHTML;
394
+ return card;
264
395
  }
265
396
 
266
- // ── Add device ─────────────────────────────────────────────────────────────
397
+ // ── Add device ────────────────────────────────────────────────────────────
267
398
 
268
- async function addDevice(btn, name, address) {
269
- // Find the sibling message element
270
- var msg = btn.parentElement ? btn.parentElement.querySelector('.card-msg') : null;
399
+ async function addDevice(btn, card, name, address, defaultMode, defaultTemperature, defaultFanSpeed) {
400
+ var msg = card.querySelector('.card-msg');
271
401
 
272
402
  btn.disabled = true;
273
403
  btn.textContent = 'Saving…';
274
404
  if (msg) { msg.textContent = ''; msg.className = 'card-msg'; }
275
405
 
406
+ // Lock inputs while saving
407
+ card.querySelectorAll('select, input').forEach(function (el) { el.disabled = true; });
408
+
276
409
  try {
277
- var result = await homebridge.request('/add-device', { name: name, address: address });
410
+ var payload = { name: name, address: address, defaultMode: defaultMode };
411
+ if (defaultTemperature !== null) payload.defaultTemperature = defaultTemperature;
412
+ if (defaultFanSpeed !== null) payload.defaultFanSpeed = defaultFanSpeed;
413
+
414
+ var result = await homebridge.request('/add-device', payload);
278
415
 
279
416
  if (result && result.status === 'already_exists') {
280
417
  btn.textContent = '✓ Already added';
@@ -285,8 +422,10 @@
285
422
  if (msg) { msg.textContent = 'Saved — restart Homebridge.'; msg.className = 'card-msg ok'; }
286
423
  }
287
424
  } catch (err) {
425
+ // Re-enable on failure so the user can retry
288
426
  btn.disabled = false;
289
427
  btn.textContent = 'Add to Config';
428
+ card.querySelectorAll('select, input').forEach(function (el) { el.disabled = false; });
290
429
  var errMsg = err && err.message ? err.message : String(err);
291
430
  if (msg) { msg.textContent = 'Error: ' + errMsg; msg.className = 'card-msg err'; }
292
431
  }
@@ -73,7 +73,7 @@ class BedJetUiServer extends HomebridgePluginUiServer {
73
73
  // ── Config write ─────────────────────────────────────────────────────────────
74
74
 
75
75
  async handleAddDevice(body) {
76
- const { name, address } = body || {};
76
+ const { name, address, defaultMode, defaultTemperature, defaultFanSpeed } = body || {};
77
77
 
78
78
  if (!name || !address) {
79
79
  throw new Error('Missing name or address');
@@ -109,7 +109,12 @@ class BedJetUiServer extends HomebridgePluginUiServer {
109
109
  return { status: 'already_exists' };
110
110
  }
111
111
 
112
- platform.devices.push({ name, address, scanTimeout: 30 });
112
+ const entry = { name, address, scanTimeout: 30 };
113
+ if (defaultMode) entry.defaultMode = defaultMode;
114
+ if (defaultTemperature !== undefined && defaultTemperature !== null) entry.defaultTemperature = Number(defaultTemperature);
115
+ if (defaultFanSpeed !== undefined && defaultFanSpeed !== null) entry.defaultFanSpeed = Number(defaultFanSpeed);
116
+
117
+ platform.devices.push(entry);
113
118
 
114
119
  try {
115
120
  fs.writeFileSync(configPath, JSON.stringify(config, null, 4), 'utf8');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-bedjet",
3
3
  "displayName": "BedJet",
4
- "version": "0.2.8",
4
+ "version": "0.3.0",
5
5
  "description": "Homebridge plugin for BedJet V3 via Bluetooth LE",
6
6
  "license": "MIT",
7
7
  "main": "dist/index.js",
package/src/accessory.ts CHANGED
@@ -12,6 +12,21 @@ const DEFAULT_MODE_MAP: Record<DefaultMode, OperatingMode> = {
12
12
  dry: OperatingMode.DRY,
13
13
  };
14
14
 
15
+ /**
16
+ * HomeKit only allows alphanumeric, space, and apostrophe characters in names,
17
+ * starting and ending with alphanumeric. Underscores and other punctuation
18
+ * cause HAP warnings and may prevent the accessory from pairing.
19
+ */
20
+ function sanitizeName(name: string): string {
21
+ return name
22
+ .replace(/_/g, ' ') // underscores → spaces
23
+ .replace(/[^a-zA-Z0-9 ']/g, ' ') // anything else invalid → space
24
+ .replace(/\s+/g, ' ') // collapse multiple spaces
25
+ .trim()
26
+ .replace(/^[^a-zA-Z0-9]+/, '') // must start with alphanumeric
27
+ .replace(/[^a-zA-Z0-9]+$/, '') || 'BedJet'; // must end with alphanumeric
28
+ }
29
+
15
30
  // OperatingMode → CurrentHeatingCoolingState value
16
31
  const CURRENT_STATE_MAP: Record<OperatingMode, number> = {
17
32
  [OperatingMode.STANDBY]: 0, // OFF
@@ -69,6 +84,7 @@ export class BedJetAccessory {
69
84
  private readonly config: BedJetConfig,
70
85
  ) {
71
86
  const { Service, Characteristic } = platform.api.hap;
87
+ const safeName = sanitizeName(config.name);
72
88
 
73
89
  // AccessoryInformation
74
90
  const infoService = this.accessory.getService(Service.AccessoryInformation)
@@ -80,7 +96,7 @@ export class BedJetAccessory {
80
96
 
81
97
  // Thermostat service
82
98
  this.thermostatService = this.accessory.getService(Service.Thermostat)
83
- ?? this.accessory.addService(Service.Thermostat, config.name, 'thermostat');
99
+ ?? this.accessory.addService(Service.Thermostat, safeName, 'thermostat');
84
100
 
85
101
  this.thermostatService.getCharacteristic(Characteristic.TemperatureDisplayUnits)
86
102
  .onGet(() => Characteristic.TemperatureDisplayUnits.CELSIUS);
@@ -127,7 +143,7 @@ export class BedJetAccessory {
127
143
 
128
144
  // FanV2 service
129
145
  this.fanService = this.accessory.getService(Service.Fanv2)
130
- ?? this.accessory.addService(Service.Fanv2, `${config.name} Fan`, 'fan');
146
+ ?? this.accessory.addService(Service.Fanv2, `${safeName} Fan`, 'fan');
131
147
 
132
148
  this.fanService.getCharacteristic(Characteristic.Active)
133
149
  .onGet(() =>