@homebridge-plugins/homebridge-ewelink 12.16.3-beta.0 → 12.17.0-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/CHANGELOG.md CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-ewelink` will be documented in this file.
4
4
 
5
- ## v12.16.3 (Pending Release)
5
+ ## v12.17.0 (Pending Release)
6
6
 
7
7
  ### Changed
8
8
 
9
9
  - fix: fire the button press for R5 and SwitchMan scene controllers when the action time arrives as a number, so HomeKit sees the press (#762)
10
+ - feat(ui): add, remove and hide devices from the config via the my devices tab
10
11
 
11
12
  ## v12.16.2 (2026-07-18)
12
13
 
@@ -17,6 +17,22 @@
17
17
  color: #eeeeee !important;
18
18
  font-weight: 500 !important;
19
19
  }
20
+
21
+ .list-group-item {
22
+ background-color: #333333;
23
+ border-color: #444444;
24
+ color: #eeeeee;
25
+ }
26
+
27
+ .text-muted {
28
+ color: #aaaaaa !important;
29
+ }
30
+ }
31
+ .min-w-50 {
32
+ min-width: 50px;
33
+ }
34
+ .action-desc {
35
+ font-size: 0.85rem;
20
36
  }
21
37
  select {
22
38
  background-image:
@@ -81,11 +97,46 @@
81
97
  <div id="pageDevices" class="mt-4" style="display: none;">
82
98
  <div id="deviceInfo">
83
99
  <form>
84
- <div class="form-group">
100
+ <div class="form-group mb-0">
85
101
  <select class="form-control" id="deviceSelect"></select>
86
102
  </div>
87
103
  </form>
88
- <table class="table w-100 mt-3" id="deviceTable" style="display: none;">
104
+ <ul class="list-group mt-4 mb-4 mx-0" id="configActionList" style="display: none;">
105
+ <li class="list-group-item">
106
+ <div class="d-flex justify-content-between align-items-center">
107
+ <div class="pr-2">
108
+ <div>Add All Devices</div>
109
+ <div class="text-muted action-desc">Add all devices to the config that are not already in the config.</div>
110
+ </div>
111
+ <button type="button" class="btn btn-primary m-0 ml-3 py-1 min-w-50" id="addAllBtn" aria-label="Add all devices to the config">
112
+ <i aria-hidden="true" class="fas fa-arrow-right"></i>
113
+ </button>
114
+ </div>
115
+ </li>
116
+ <li class="list-group-item">
117
+ <div class="d-flex justify-content-between align-items-center">
118
+ <div class="pr-2">
119
+ <div id="deviceConfigTitle">Add This Device</div>
120
+ <div class="text-muted action-desc" id="deviceConfigText">Add this device to the plugin config.</div>
121
+ </div>
122
+ <button type="button" class="btn btn-primary m-0 ml-3 py-1 min-w-50" id="deviceConfigBtn" aria-label="Add this device to the config">
123
+ <i aria-hidden="true" class="fas fa-arrow-right"></i>
124
+ </button>
125
+ </div>
126
+ </li>
127
+ <li class="list-group-item">
128
+ <div class="d-flex justify-content-between align-items-center">
129
+ <div class="pr-2">
130
+ <div id="hideDeviceTitle">Hide This Device</div>
131
+ <div class="text-muted action-desc" id="hideDeviceText">Hide this device from HomeKit. This adds the device to the config if needed.</div>
132
+ </div>
133
+ <button type="button" class="btn btn-primary m-0 ml-3 py-1 min-w-50" id="hideDeviceBtn" aria-label="Hide this device from HomeKit">
134
+ <i aria-hidden="true" class="fas fa-arrow-right"></i>
135
+ </button>
136
+ </div>
137
+ </li>
138
+ </ul>
139
+ <table class="table w-100 mt-0" id="deviceTable" style="display: none;">
89
140
  <thead>
90
141
  <tr class="table-active">
91
142
  <th scope="col" style="width: 40%;">Device Name</th>
@@ -258,6 +309,232 @@
258
309
  ;(async () => {
259
310
  try {
260
311
  const currentConfig = await homebridge.getPluginConfig()
312
+
313
+ // ===== Device picker (add/remove/hide config entries from the My Devices tab) =====
314
+ // The control state is always computed from a fresh getPluginConfig() call,
315
+ // so staged-but-unsaved edits made in the Settings tab are respected.
316
+ // Config entries are matched by the base eWeLink device id, so the
317
+ // channels of a multi-channel device all share one config entry.
318
+ const deviceArrays = [
319
+ 'singleDevices',
320
+ 'multiDevices',
321
+ 'lightDevices',
322
+ 'thDevices',
323
+ 'fanDevices',
324
+ 'sensorDevices',
325
+ 'rfDevices',
326
+ ]
327
+ let pickerAccessories = []
328
+ let pickerUiidMap = {}
329
+ let removeConfirmTimer
330
+
331
+ // The action buttons always show the settings-page arrow; the row text
332
+ // (and the button's aria-label) says what the arrow will do
333
+ const actionArrow = '<i aria-hidden="true" class="fas fa-arrow-right"></i>'
334
+
335
+ // Compare device ids ignoring case and separators, so an entry typed by
336
+ // hand in a different format still counts as the same device
337
+ const looseId = id => (id || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, '')
338
+
339
+ const getConfigBlocks = async () => {
340
+ const blocks = await homebridge.getPluginConfig()
341
+ if (!blocks.length) {
342
+ blocks.push({ name: 'eWeLink' })
343
+ }
344
+ return blocks
345
+ }
346
+
347
+ const findInConfig = (config, deviceId) => {
348
+ const target = looseId(deviceId)
349
+ for (const key of deviceArrays) {
350
+ if (Array.isArray(config[key])) {
351
+ const index = config[key].findIndex(entry => entry && looseId(entry.deviceId) === target)
352
+ if (index !== -1) {
353
+ return { key, index }
354
+ }
355
+ }
356
+ }
357
+ return null
358
+ }
359
+
360
+ const selectedAccessory = () => {
361
+ const hbId = document.getElementById('deviceSelect').value
362
+ return pickerAccessories.find(x => x.context.hbDeviceId === hbId)
363
+ }
364
+
365
+ const setHideControl = hidden => {
366
+ const btn = document.getElementById('hideDeviceBtn')
367
+ btn.dataset.hidden = hidden ? 'yes' : 'no'
368
+ btn.innerHTML = actionArrow
369
+ btn.setAttribute('aria-label', hidden ? 'Show this device in HomeKit' : 'Hide this device from HomeKit')
370
+ document.getElementById('hideDeviceTitle').innerHTML = hidden ? 'Show This Device' : 'Hide This Device'
371
+ document.getElementById('hideDeviceText').innerHTML = hidden
372
+ ? 'Show this device in HomeKit again. The config entry is kept.'
373
+ : 'Hide this device from HomeKit. This adds the device to the config if needed.'
374
+ }
375
+
376
+ const setConfigButton = inConfig => {
377
+ const btn = document.getElementById('deviceConfigBtn')
378
+ clearTimeout(removeConfirmTimer)
379
+ delete btn.dataset.confirm
380
+ btn.dataset.state = inConfig ? 'remove' : 'add'
381
+ btn.classList.remove('btn-danger')
382
+ btn.classList.add('btn-primary')
383
+ btn.innerHTML = actionArrow
384
+ btn.setAttribute('aria-label', inConfig ? 'Remove this device from the config' : 'Add this device to the config')
385
+ document.getElementById('deviceConfigTitle').innerHTML = inConfig ? 'Remove This Device' : 'Add This Device'
386
+ document.getElementById('deviceConfigText').innerHTML = inConfig
387
+ ? 'Remove this device from the plugin config.'
388
+ : 'Add this device to the plugin config.'
389
+ }
390
+
391
+ const addDeviceToConfig = (config, acc) => {
392
+ // Which array a device belongs in comes from its uiid via the plugin's
393
+ // own lists; anything unknown is safe in singleDevices because the
394
+ // platform merges every array into one lookup by device id
395
+ const arrayKey = pickerUiidMap[acc.context.eweUIID] || 'singleDevices'
396
+ if (!Array.isArray(config[arrayKey])) {
397
+ config[arrayKey] = []
398
+ }
399
+ // label is included so the entry is identifiable in the config editor
400
+ config[arrayKey].push({ label: acc.displayName, deviceId: acc.context.eweDeviceId })
401
+ }
402
+
403
+ document.getElementById('deviceConfigBtn').addEventListener('click', async () => {
404
+ try {
405
+ const btn = document.getElementById('deviceConfigBtn')
406
+ const acc = selectedAccessory()
407
+ if (!acc) {
408
+ return
409
+ }
410
+ const blocks = await getConfigBlocks()
411
+ const config = blocks[0]
412
+ const found = findInConfig(config, acc.context.eweDeviceId)
413
+ if (btn.dataset.state === 'add') {
414
+ // The config may have changed since the button was drawn, so
415
+ // re-check and never add a duplicate - just correct the button
416
+ if (found) {
417
+ setConfigButton(true)
418
+ setHideControl(!!config[found.key][found.index].ignoreDevice)
419
+ homebridge.toast.info(acc.displayName + ' is already in the config.', 'Not Added')
420
+ return
421
+ }
422
+ addDeviceToConfig(config, acc)
423
+ await homebridge.updatePluginConfig(blocks)
424
+ setConfigButton(true)
425
+ setHideControl(false)
426
+ homebridge.toast.success(acc.displayName + ' added - press Save to apply.', 'Added To Config')
427
+ } else {
428
+ if (!found) {
429
+ setConfigButton(false)
430
+ setHideControl(false)
431
+ homebridge.toast.info(acc.displayName + ' is not in the config.', 'Not Removed')
432
+ return
433
+ }
434
+ const entry = config[found.key][found.index]
435
+ const extraKeys = Object.keys(entry).filter(k => !['deviceId', 'label'].includes(k))
436
+ if (extraKeys.length > 0 && btn.dataset.confirm !== 'yes') {
437
+ // The entry has settings beyond id and label which would be
438
+ // lost, so ask for a second click within five seconds
439
+ btn.dataset.confirm = 'yes'
440
+ btn.classList.remove('btn-primary')
441
+ btn.classList.add('btn-danger')
442
+ btn.innerHTML = actionArrow
443
+ btn.setAttribute('aria-label', 'Confirm removing this device from the config')
444
+ document.getElementById('deviceConfigText').innerHTML = 'This entry has extra settings which will be lost. Click again to remove it.'
445
+ removeConfirmTimer = setTimeout(() => setConfigButton(true), 5000)
446
+ return
447
+ }
448
+ config[found.key].splice(found.index, 1)
449
+ if (config[found.key].length === 0) {
450
+ delete config[found.key]
451
+ }
452
+ await homebridge.updatePluginConfig(blocks)
453
+ setConfigButton(false)
454
+ setHideControl(false)
455
+ homebridge.toast.success(acc.displayName + ' removed - press Save to apply.', 'Removed From Config')
456
+ }
457
+ } catch (err) {
458
+ homebridge.toast.error(err.message, 'Error')
459
+ }
460
+ })
461
+
462
+ document.getElementById('hideDeviceBtn').addEventListener('click', async () => {
463
+ try {
464
+ const acc = selectedAccessory()
465
+ if (!acc) {
466
+ return
467
+ }
468
+ const blocks = await getConfigBlocks()
469
+ const config = blocks[0]
470
+ let found = findInConfig(config, acc.context.eweDeviceId)
471
+ const currentlyHidden = document.getElementById('hideDeviceBtn').dataset.hidden === 'yes'
472
+ if (!currentlyHidden) {
473
+ // Hiding needs a config entry to carry the flag, so create one if
474
+ // the device is not in the config yet
475
+ if (!found) {
476
+ addDeviceToConfig(config, acc)
477
+ found = findInConfig(config, acc.context.eweDeviceId)
478
+ }
479
+ config[found.key][found.index].ignoreDevice = true
480
+ await homebridge.updatePluginConfig(blocks)
481
+ setConfigButton(true)
482
+ setHideControl(true)
483
+ homebridge.toast.success(acc.displayName + ' will be hidden from HomeKit - press Save to apply.', 'Device Hidden')
484
+ } else {
485
+ if (found) {
486
+ // Drop the flag rather than storing ignoreDevice false, to keep
487
+ // the entry clean
488
+ delete config[found.key][found.index].ignoreDevice
489
+ await homebridge.updatePluginConfig(blocks)
490
+ }
491
+ setConfigButton(!!found)
492
+ setHideControl(false)
493
+ homebridge.toast.success(acc.displayName + ' will be shown in HomeKit - press Save to apply.', 'Device Unhidden')
494
+ }
495
+ } catch (err) {
496
+ homebridge.toast.error(err.message, 'Error')
497
+ }
498
+ })
499
+
500
+ document.getElementById('addAllBtn').addEventListener('click', async () => {
501
+ try {
502
+ const blocks = await getConfigBlocks()
503
+ const config = blocks[0]
504
+ let added = 0
505
+ let skipped = 0
506
+ pickerAccessories.forEach(acc => {
507
+ if (!acc.context?.eweDeviceId) {
508
+ return
509
+ }
510
+ // Channels of a multi-channel device share one entry, so once the
511
+ // first channel is added the rest count as already in the config
512
+ if (findInConfig(config, acc.context.eweDeviceId)) {
513
+ skipped += 1
514
+ return
515
+ }
516
+ addDeviceToConfig(config, acc)
517
+ added += 1
518
+ })
519
+ if (added > 0) {
520
+ await homebridge.updatePluginConfig(blocks)
521
+ homebridge.toast.success(added + ' device(s) added, ' + skipped + ' already in the config - press Save to apply.', 'Added To Config')
522
+ } else {
523
+ homebridge.toast.info('All devices are already in the config.', 'Nothing To Add')
524
+ }
525
+ const acc = selectedAccessory()
526
+ if (acc) {
527
+ const nowFound = findInConfig(config, acc.context.eweDeviceId)
528
+ setConfigButton(!!nowFound)
529
+ if (nowFound) {
530
+ setHideControl(!!config[nowFound.key][nowFound.index].ignoreDevice)
531
+ }
532
+ }
533
+ } catch (err) {
534
+ homebridge.toast.error(err.message, 'Error')
535
+ }
536
+ })
537
+ // ===== End device picker =====
261
538
  showIntro = () => {
262
539
  homebridge.disableSaveButton?.()
263
540
  const introContinue = document.getElementById('introContinue')
@@ -272,7 +549,9 @@
272
549
  }
273
550
  showDevices = async () => {
274
551
  homebridge.showSpinner()
275
- homebridge.disableSaveButton?.()
552
+ // The save button stays enabled here so entries staged by the picker
553
+ // buttons can be saved without switching back to the Settings tab
554
+ homebridge.enableSaveButton?.()
276
555
  homebridge.hideSchemaForm()
277
556
  document.getElementById('menuHome').classList.remove('btn-elegant')
278
557
  document.getElementById('menuHome').classList.add('btn-primary')
@@ -295,6 +574,16 @@
295
574
  : 0
296
575
  })
297
576
  }
577
+ pickerAccessories = cachedAccessories
578
+ if (Object.keys(pickerUiidMap).length === 0) {
579
+ try {
580
+ pickerUiidMap = await homebridge.request('/getUiidMap')
581
+ } catch (err) {
582
+ // Without the map every added device falls back to singleDevices,
583
+ // which still works because the platform merges all the arrays
584
+ pickerUiidMap = {}
585
+ }
586
+ }
298
587
  const deviceSelect = document.getElementById('deviceSelect')
299
588
  deviceSelect.innerHTML = ''
300
589
  cachedAccessories.forEach(a => {
@@ -329,6 +618,11 @@
329
618
  ? '<img src="' + context.eweBrandLogo + '" style="width: 150px;">'
330
619
  : ''
331
620
  document.getElementById('deviceTable').style.display = 'inline-table'
621
+ const configBlocks = await getConfigBlocks()
622
+ const configEntry = findInConfig(configBlocks[0], context.eweDeviceId)
623
+ setConfigButton(!!configEntry)
624
+ setHideControl(!!(configEntry && configBlocks[0][configEntry.key][configEntry.index].ignoreDevice))
625
+ document.getElementById('configActionList').style.display = 'block'
332
626
  homebridge.hideSpinner()
333
627
  }
334
628
  deviceSelect.addEventListener('change', event => showDeviceInfo(event.target.value))
@@ -339,6 +633,7 @@
339
633
  option.text = 'No Devices'
340
634
  deviceSelect.add(option)
341
635
  deviceSelect.disabled = true
636
+ document.getElementById('configActionList').style.display = 'none'
342
637
  }
343
638
  homebridge.hideSpinner()
344
639
  }
@@ -2,10 +2,55 @@ import fs from 'node:fs'
2
2
 
3
3
  import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils'
4
4
 
5
+ import platformConsts from '../utils/constants.js'
6
+
7
+ // Which config array each device category belongs in. The platform merges
8
+ // every array into one lookup by device id, so this only decides which set of
9
+ // options the schema editor shows next to the entry; anything not listed sits
10
+ // safely in singleDevices.
11
+ const categoryToArray = {
12
+ switchMulti: 'multiDevices',
13
+ switchMultiPower: 'multiDevices',
14
+ panel: 'multiDevices',
15
+ lightDimmer: 'lightDevices',
16
+ lightRGB: 'lightDevices',
17
+ lightCCT: 'lightDevices',
18
+ lightRGBCCT: 'lightDevices',
19
+ zbLightDimmer: 'lightDevices',
20
+ zbLightCCT: 'lightDevices',
21
+ zbLightRGBCCT: 'lightDevices',
22
+ sensorAmbient: 'thDevices',
23
+ thermostat: 'thDevices',
24
+ fan: 'fanDevices',
25
+ fanGear: 'fanDevices',
26
+ rfBridge: 'rfDevices',
27
+ sensorContact: 'sensorDevices',
28
+ sensorTempHumi: 'sensorDevices',
29
+ sensorAirQuality: 'sensorDevices',
30
+ zbSensorAmbient: 'sensorDevices',
31
+ zbSensorMotion: 'sensorDevices',
32
+ zbSensorOccupancy: 'sensorDevices',
33
+ zbSensorContact: 'sensorDevices',
34
+ zbSensorWater: 'sensorDevices',
35
+ zbSensorSmoke: 'sensorDevices',
36
+ }
37
+
5
38
  class PluginUiServer extends HomebridgePluginUiServer {
6
39
  constructor() {
7
40
  super()
8
41
 
42
+ // Used by the My Devices tab to know which config array a device belongs
43
+ // in, built from the same uiid lists the plugin itself uses
44
+ this.onRequest('/getUiidMap', () => {
45
+ const uiidToArray = {}
46
+ Object.entries(categoryToArray).forEach(([category, arrayKey]) => {
47
+ (platformConsts.devices[category] || []).forEach((uiid) => {
48
+ uiidToArray[uiid] = arrayKey
49
+ })
50
+ })
51
+ return uiidToArray
52
+ })
53
+
9
54
  /*
10
55
  A native method getCachedAccessories() was introduced in config-ui-x v4.37.0
11
56
  The following is for users who have a lower version of config-ui-x
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "displayName": "Homebridge eWeLink",
4
4
  "alias": "eWeLink",
5
5
  "type": "module",
6
- "version": "12.16.3-beta.0",
6
+ "version": "12.17.0-beta.0",
7
7
  "description": "Homebridge plugin to integrate eWeLink devices into HomeKit.",
8
8
  "author": {
9
9
  "name": "bwp91",