@homebridge-plugins/homebridge-wemo 7.6.0 → 7.7.1-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,6 +2,32 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-wemo` will be documented in this file.
4
4
 
5
+ ## v7.7.1 (Pending Release)
6
+
7
+ ### Changed
8
+
9
+ - chore: exclude test files and the test config from the published package
10
+ - fix: restore debug logging when the plugin runs in a child bridge
11
+ - fix: only turn adaptive lighting off when a colour is genuinely chosen elsewhere
12
+ - fix: send a colour change when only the saturation moves, and mend the al cleanup
13
+ - fix: explain a port clash on the notification listener instead of crashing the bridge
14
+ - fix: stop gating a few log lines on a debug setting that was never readable
15
+
16
+ ## v7.7.0 (2026-08-02)
17
+
18
+ ### Changed
19
+
20
+ - chore: add a test setup with tests for the shared helper functions
21
+ - chore(github): run the build and tests in ci, on node 22, 24 and 26
22
+ - chore: use the same lint setup across every plugin
23
+ - chore: add a changelog:sync script to populate the pending section from the commits
24
+ - chore: count a repeated commit subject once when syncing the changelog
25
+ - chore(github): check the changelog against the commits in ci
26
+ - chore(deps): dependency updates
27
+ - docs: add node 26 to the supported node versions
28
+ - feat: only accept a device status notification from the address the device was discovered on, with a disableNotifySourceCheck option for networks that rewrite addresses (#277)
29
+ - fix: replace the ip dependency with a local subnet check, clearing its unfixable npm advisory
30
+
5
31
  ## v7.6.0 (2026-07-27)
6
32
 
7
33
  ### Changed
package/README.md CHANGED
@@ -31,7 +31,7 @@ Homebridge plugin to integrate Wemo devices into HomeKit
31
31
  ### Prerequisites
32
32
 
33
33
  - To use this plugin, you will need to already have:
34
- - [Node](https://nodejs.org): latest version of `v22` or `v24` - any other major version is not supported.
34
+ - [Node](https://nodejs.org): latest version of `v22`, `v24` or `v26` - any other major version is not supported.
35
35
  - [Homebridge](https://homebridge.io): `v1.6` or above - refer to link for more information and installation instructions.
36
36
  - For the UPnP connection, make sure your Homebridge instance has an allocated IP from the same IP network or VLAN as your Wemo devices. Otherwise, you should disable the UPnP connection to avoid connection errors.
37
37
 
@@ -106,6 +106,11 @@
106
106
  "title": "Disable Device Logging",
107
107
  "description": "Global logging setting for accessory status changes. If true then accessory status changes will not be logged. This can also be set per accessory later in the config."
108
108
  },
109
+ "disableNotifySourceCheck": {
110
+ "type": "boolean",
111
+ "title": "Disable Notify Source Check",
112
+ "description": "By default, a device status notification is only accepted when it comes from the address the device was discovered on. Enable this if your network rewrites addresses (for example Docker bridge networking or NAT) and the log shows notifications being ignored."
113
+ },
109
114
  "removeByName": {
110
115
  "title": "Remove By Name",
111
116
  "type": "string"
@@ -646,6 +651,7 @@
646
651
  "expandable": true,
647
652
  "items": [
648
653
  "disableDeviceLogging",
654
+ "disableNotifySourceCheck",
649
655
  "hideConnectionErrors",
650
656
  "discoveryInterval",
651
657
  "wemoClient.discover_opts.interfaces",
@@ -57,10 +57,22 @@ export default class {
57
57
  this.service = this.accessory.getService(this.hapServ.Lightbulb)
58
58
  || this.accessory.addService(this.hapServ.Lightbulb)
59
59
 
60
- // If adaptive lighting has just been disabled then remove and re-add service to hide AL icon
60
+ // Adaptive lighting has just been switched off, so take away the three
61
+ // characteristics that make Home offer it. Only these need to go: the whole
62
+ // Lightbulb service used to be thrown away and rebuilt here, which does hide
63
+ // the icon but presents Home with what looks like a different light, and an
64
+ // owner can lose that light's room and its automations as a result.
61
65
  if (this.alShift === -1 && this.accessory.context.adaptiveLighting) {
62
- this.accessory.removeService(this.service)
63
- this.service = this.accessory.addService(this.hapServ.Lightbulb)
66
+ [
67
+ 'SupportedCharacteristicValueTransitionConfiguration',
68
+ 'CharacteristicValueTransitionControl',
69
+ 'CharacteristicValueActiveTransitionCount',
70
+ ].forEach((charName) => {
71
+ const char = this.hapChar[charName]
72
+ if (char && this.service.testCharacteristic(char)) {
73
+ this.service.removeCharacteristic(this.service.getCharacteristic(char))
74
+ }
75
+ })
64
76
  this.accessory.context.adaptiveLighting = false
65
77
  }
66
78
 
@@ -85,6 +97,14 @@ export default class {
85
97
  this.service.getCharacteristic(this.hapChar.Hue).onSet(async (value) => {
86
98
  await this.internalColourUpdate(value)
87
99
  })
100
+ // Saturation needs a handler of its own. HomeKit writes hue and saturation
101
+ // separately, so with only hue listened for, dragging saturation alone -
102
+ // the same colour made paler or richer - reached nothing and the bulb
103
+ // never changed. Both handlers share one update, and the debounce inside
104
+ // it collapses a hue-and-saturation pair into a single command.
105
+ this.service.getCharacteristic(this.hapChar.Saturation).onSet(async () => {
106
+ await this.internalColourUpdate(this.service.getCharacteristic(this.hapChar.Hue).value)
107
+ })
88
108
  this.cacheHue = this.service.getCharacteristic(this.hapChar.Hue).value
89
109
  this.cacheSat = this.service.getCharacteristic(this.hapChar.Saturation).value
90
110
  } else {
@@ -109,6 +129,10 @@ export default class {
109
129
  customTemperatureAdjustment: this.alShift,
110
130
  })
111
131
  this.accessory.configureController(this.alController)
132
+ // Without this the cleanup that removes the adaptive lighting
133
+ // characteristics can never run, because it is guarded on this flag -
134
+ // so switching adaptive lighting off left Home still offering it.
135
+ this.accessory.context.adaptiveLighting = true
112
136
  }
113
137
  }
114
138
 
@@ -289,13 +313,15 @@ export default class {
289
313
  return
290
314
  }
291
315
 
292
- // Don't continue if this value is same as before
293
- if (this.cacheHue === value) {
316
+ // Don't continue if this colour is the same as before. Saturation is part
317
+ // of that question: comparing hue alone meant a change of saturation at
318
+ // the same hue looked like no change at all, and nothing was sent.
319
+ const currentSat = this.service.getCharacteristic(this.hapChar.Saturation).value
320
+ if (this.cacheHue === value && this.cacheSat === currentSat) {
294
321
  return
295
322
  }
296
323
 
297
324
  // First convert to RGB
298
- const currentSat = this.service.getCharacteristic(this.hapChar.Saturation).value
299
325
  const [r, g, b] = hs2rgb(value, currentSat)
300
326
 
301
327
  // Then convert the RGB to the values needed for Wemo
@@ -424,7 +450,10 @@ export default class {
424
450
  // Log the change if appropriate
425
451
  this.accessory.log(`${platformLang.curColour} [X:${valueX} Y:${valueY}]`)
426
452
 
427
- // Colour chosen externally so disable adaptive lighting
453
+ // Somebody chose this colour outside HomeKit - in the Wemo app, on a
454
+ // remote, or at the wall. Homebridge catches changes made in the Home
455
+ // app itself, but nothing else sees these, so adaptive lighting would
456
+ // carry on and move the bulb off that colour within minutes.
428
457
  if (this.alController?.isAdaptiveLightingActive()) {
429
458
  this.alController.disableAdaptiveLighting()
430
459
  this.accessory.log(platformLang.alDisabled)
@@ -449,7 +478,12 @@ export default class {
449
478
  const mToK = Math.round(1000000 / value)
450
479
  this.accessory.log(`${platformLang.curCCT} [${mToK}K / ${value}M]`)
451
480
 
452
- // If the difference is significant (>20) then disable adaptive lighting
481
+ // A bulb repeating back the temperature adaptive lighting just set can
482
+ // land a few mireds off through the conversion, and treating that as the
483
+ // owner's doing would switch adaptive lighting off moments after it was
484
+ // switched on. Further apart than that and somebody set this temperature
485
+ // themselves, outside HomeKit - which nothing else would notice, so
486
+ // adaptive lighting has to be stopped here.
453
487
  if (!Number.isNaN(this.cacheMired)) {
454
488
  const diff = Math.abs(value - this.cacheMired) > 20
455
489
  if (this.alController?.isAdaptiveLightingActive() && diff) {
@@ -192,7 +192,7 @@
192
192
  <ul>
193
193
  <li>To use this plugin, you will need to already have:</li>
194
194
  <ul>
195
- <li><a href="https://nodejs.org" target="_blank">Node</a>: latest version of <code>v22</code> or <code>v24</code> - any other major version is not supported.</li>
195
+ <li><a href="https://nodejs.org" target="_blank">Node</a>: latest version of <code>v22</code>, <code>v24</code> or <code>v26</code> - any other major version is not supported.</li>
196
196
  <li><a href="https://homebridge.io" target="_blank">Homebridge</a>: <code>v1.6</code> or above - refer to link for more information and installation instructions.</li>
197
197
  <li>For the UPnP connection, make sure your Homebridge instance has an allocated IP from the same IP network or VLAN as your Wemo devices. Otherwise, you should disable the UPnP connection to avoid connection errors.</li>
198
198
  </ul>
package/lib/platform.js CHANGED
@@ -5,7 +5,6 @@ import process from 'node:process'
5
5
  import { URL as url } from 'node:url'
6
6
 
7
7
  import axios from 'axios'
8
- import ip from 'ip'
9
8
  import nodeSSDP from 'node-ssdp'
10
9
  import { parseStringPromise } from 'xml2js'
11
10
  import { create as xmlCreate } from 'xmlbuilder'
@@ -16,7 +15,7 @@ import deviceTypes from './device/index.js'
16
15
  import eveService from './fakegato/fakegato-history.js'
17
16
  import platformConsts from './utils/constants.js'
18
17
  import eveChars from './utils/eve-chars.js'
19
- import { parseError, parseSerialNumber } from './utils/functions.js'
18
+ import { parseError, parseSerialNumber, sameIpv4Subnet } from './utils/functions.js'
20
19
  import platformLang from './utils/lang-en.js'
21
20
 
22
21
  const { Client: ssdp } = nodeSSDP
@@ -24,6 +23,7 @@ const require = createRequire(import.meta.url)
24
23
  const plugin = require('../package.json')
25
24
 
26
25
  const devicesInHB = new Map()
26
+ const notifySourceWarned = new Set()
27
27
  const trailingSlashRegex = /\/\s*$/
28
28
 
29
29
  let listenerServer
@@ -41,7 +41,6 @@ export default class {
41
41
  try {
42
42
  this.api = api
43
43
  this.log = log
44
- this.isBeta = process.argv.includes('-D')
45
44
 
46
45
  // Configuration objects for accessories
47
46
  this.ignoredDevices = []
@@ -113,6 +112,7 @@ export default class {
113
112
  const [key, val] = entry
114
113
  switch (key) {
115
114
  case 'disableDeviceLogging':
115
+ case 'disableNotifySourceCheck':
116
116
  case 'disableUPNP':
117
117
  case 'hideConnectionErrors':
118
118
  if (typeof val === 'string') {
@@ -343,13 +343,16 @@ export default class {
343
343
  this.log('%s.', platformLang.initialised)
344
344
 
345
345
  // Sort out some logging functions
346
- if (this.isBeta) {
347
- this.log.debug = this.log
348
- this.log.debugWarn = this.log.warn
349
- } else {
350
- this.log.debug = () => {}
351
- this.log.debugWarn = () => {}
352
- }
346
+ // Homebridge already decides whether debug lines are shown, and it gets it
347
+ // right in a child bridge too. This used to work it out for itself by
348
+ // looking for `-D` in the plugin's own process arguments, which a child
349
+ // bridge does not receive unless that bridge has its own debug setting
350
+ // turned on - so debug logging silently disappeared for anyone running the
351
+ // plugin in a child bridge with debug enabled globally.
352
+ //
353
+ // `debugWarn` has no equivalent on Homebridge's logger, so it goes out as
354
+ // a debug line too rather than being invented here.
355
+ this.log.debugWarn = (...args) => this.log.debug(...args)
353
356
 
354
357
  // Set up the discovery run counter
355
358
  this.discoveryRun = -1
@@ -423,44 +426,68 @@ export default class {
423
426
  let body = ''
424
427
  const accessory = devicesInHB.get(req.url.substring(1))
425
428
  if (req.method === 'NOTIFY' && accessory) {
426
- // A notification from a known device
427
- req.on('data', (chunk) => {
428
- body += chunk.toString()
429
- })
430
- req.on('end', () => this.httpClient.receiveDeviceUpdate(accessory, body))
429
+ // Only accept a notification from the address the device was discovered on.
430
+ // Anything on the network can reach this listener, so without this check a
431
+ // notification for a known device could be forged from elsewhere. Networks
432
+ // which rewrite source addresses (docker bridge, some NAT) can turn this
433
+ // off with the disableNotifySourceCheck setting.
434
+ const remoteIp = (req.socket.remoteAddress || '').replace('::ffff:', '')
435
+ if (this.config.disableNotifySourceCheck || remoteIp === accessory.context.ipAddress) {
436
+ // A notification from a known device
437
+ req.on('data', (chunk) => {
438
+ body += chunk.toString()
439
+ })
440
+ req.on('end', () => this.httpClient.receiveDeviceUpdate(accessory, body))
441
+ } else if (!notifySourceWarned.has(accessory.UUID)) {
442
+ // Warn once per accessory, so a misconfigured network does not flood the log
443
+ notifySourceWarned.add(accessory.UUID)
444
+ this.log.warn(
445
+ '[%s] %s [%s] %s [%s] - %s.',
446
+ accessory.displayName,
447
+ platformLang.notifyIgnored,
448
+ remoteIp,
449
+ platformLang.notifyIgnoredAs,
450
+ accessory.context.ipAddress,
451
+ platformLang.notifyIgnoredOpt,
452
+ )
453
+ }
431
454
  }
432
455
  res.writeHead(200)
433
456
  res.end()
434
457
  })
435
458
 
459
+ // A failure to listen - the configured port already taken, or a privileged
460
+ // port without the rights to use it - arrives as an 'error' event, not
461
+ // through the listen callback. Node throws an 'error' event that nobody is
462
+ // listening for, so without this the bridge dies on a port clash and
463
+ // restarts into the same clash. The callback below only ever runs on
464
+ // success, and node calls it with no arguments.
465
+ listenerServer.on('error', (err) => {
466
+ this.log.warn('%s: %s.', platformLang.listenerError, parseError(err))
467
+ })
468
+
469
+ const logListenerPort = () => {
470
+ this.log.debug('%s [%s].', platformLang.listenerPort, listenerServer.address().port)
471
+ }
472
+
436
473
  // Start listening on the above created server
437
474
  if (this.config.wemoClient.listen_interface) {
438
475
  // User has defined a specific network interface to listen on
439
- listenerServer.listen(this.config.wemoClient.port, this.getLocalInterfaceAddress(), (err) => {
440
- if (err) {
441
- this.log.warn('%s: %s.', platformLang.listenerError, err)
442
- } else if (this.config.debug) {
443
- // Log the port of the listener server in debug mode
444
- this.log('%s [%s].', platformLang.listenerPort, listenerServer.address().port)
445
- }
446
- })
476
+ listenerServer.listen(this.config.wemoClient.port, this.getLocalInterfaceAddress(), logListenerPort)
447
477
  } else {
448
478
  // User has not defined a specific network interface to listen on
449
- listenerServer.listen(this.config.wemoClient.port, (err) => {
450
- if (err) {
451
- this.log.warn('%s: %s', platformLang.listenerError, err)
452
- } else if (this.config.debug) {
453
- // Log the port of the listener server in debug mode
454
- this.log('%s [%s].', platformLang.listenerPort, listenerServer.address().port)
455
- }
456
- })
479
+ listenerServer.listen(this.config.wemoClient.port, logListenerPort)
457
480
  }
458
481
 
459
482
  // Set up the SSDP client if the user has not specified manual devices only
460
483
  if (this.config.mode !== 'manual') {
461
- if (this.isBeta) {
462
- this.config.wemoClient.discover_opts.customLogger = this.log
463
- }
484
+ // The discovery client's own chatter goes to the debug log, where
485
+ // Homebridge decides whether it is shown. It used to be handed the
486
+ // normal logger only when the plugin thought debug was on, which it
487
+ // could not tell in a child bridge. The wrapper matters: Homebridge's
488
+ // `debug` is not bound to its logger, so passing it bare would throw
489
+ // the first time the discovery client tried to log anything.
490
+ this.config.wemoClient.discover_opts.customLogger = (...args) => this.log.debug(...args)
464
491
  ssdpClient = new ssdp(this.config.wemoClient.discover_opts)
465
492
  }
466
493
 
@@ -490,19 +517,13 @@ export default class {
490
517
 
491
518
  // Shutdown the listener server if it's running
492
519
  if (listenerServer) {
493
- listenerServer.close(() => {
494
- if (this.config.debug) {
495
- this.log('%s.', platformLang.listenerClosed)
496
- }
497
- })
520
+ listenerServer.close(() => this.log.debug('%s.', platformLang.listenerClosed))
498
521
  }
499
522
 
500
523
  // Stop the SSDP client if it's running
501
524
  if (ssdpClient) {
502
525
  ssdpClient.stop()
503
- if (this.config.debug) {
504
- this.log('%s.', platformLang.ssdpStopped)
505
- }
526
+ this.log.debug('%s.', platformLang.ssdpStopped)
506
527
  }
507
528
 
508
529
  // Close accessory subscriptions
@@ -757,22 +778,21 @@ export default class {
757
778
  }
758
779
 
759
780
  applyAccessoryLogging(accessory) {
760
- if (this.isBeta) {
761
- accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
762
- accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
763
- accessory.logDebug = msg => this.log('[%s] %s.', accessory.displayName, msg)
764
- accessory.logDebugWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
765
- } else {
766
- if (this.config.disableDeviceLogging) {
767
- accessory.log = () => {}
768
- accessory.logWarn = () => {}
769
- } else {
770
- accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
771
- accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
772
- }
773
- accessory.logDebug = () => {}
774
- accessory.logDebugWarn = () => {}
781
+ // Debug lines always go to Homebridge's debug logger, which prints them only
782
+ // when debug is on - so there is nothing to decide here. Working it out from
783
+ // the process arguments instead is what lost debug logging entirely for
784
+ // anyone running the plugin in a child bridge.
785
+ accessory.logDebug = msg => this.log.debug('[%s] %s.', accessory.displayName, msg)
786
+ accessory.logDebugWarn = accessory.logDebug
787
+
788
+ if (this.config.disableDeviceLogging) {
789
+ accessory.log = () => {}
790
+ accessory.logWarn = () => {}
791
+ return
775
792
  }
793
+
794
+ accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
795
+ accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
776
796
  }
777
797
 
778
798
  async initialiseDevice(device) {
@@ -1419,7 +1439,7 @@ export default class {
1419
1439
  const [k2] = subEntry
1420
1440
  const address = interfaces[k][k2]
1421
1441
  if ((address.family === 'IPv4' || address.family === 4) && !address.internal) {
1422
- if (targetNetwork && ip.subnet(address.address, address.netmask).contains(targetNetwork)) {
1442
+ if (targetNetwork && sameIpv4Subnet(address.address, targetNetwork, address.netmask)) {
1423
1443
  // Try to find IP address on the same IP network as the device's location
1424
1444
  addresses.unshift(address.address)
1425
1445
  } else {
@@ -29,6 +29,15 @@ function parseError(err, hideStack = []) {
29
29
  return toReturn
30
30
  }
31
31
 
32
+ // Whether two IPv4 addresses sit in the same subnet for the given netmask.
33
+ // Replaces the single ip.subnet().contains() call this plugin used from the
34
+ // `ip` package, which carries an unfixed npm advisory (GHSA in its isPublic).
35
+ function sameIpv4Subnet(addressA, addressB, netmask) {
36
+ const toInt = addr => addr.split('.').reduce((acc, octet) => (acc * 256) + Number.parseInt(octet, 10), 0)
37
+ const mask = toInt(netmask)
38
+ return ((toInt(addressA) & mask) >>> 0) === ((toInt(addressB) & mask) >>> 0)
39
+ }
40
+
32
41
  function parseSerialNumber(input) {
33
42
  return input
34
43
  .toString()
@@ -48,5 +57,6 @@ export {
48
57
  hasProperty,
49
58
  parseError,
50
59
  parseSerialNumber,
60
+ sameIpv4Subnet,
51
61
  sleep,
52
62
  }
@@ -1,7 +1,7 @@
1
1
  export default {
2
2
  accNotFound: 'accessory not found',
3
3
  accNotReady: 'has not been discovered yet so commands will fail',
4
- alDisabled: 'adaptive lighting disabled due to significant colour change',
4
+ alDisabled: 'adaptive lighting turned off, as the colour was changed outside HomeKit',
5
5
  awaiting: 'The following devices have still not been initially found',
6
6
  brand: 'Belkin Wemo',
7
7
  brightnessFail: 'could not obtain updated brightness as',
@@ -90,6 +90,9 @@ export default {
90
90
  noInterface: 'Unable to find interface',
91
91
  noPort: 'could not find correct port for device',
92
92
  noService: 'device does not have this service',
93
+ notifyIgnored: 'ignoring a status notification from',
94
+ notifyIgnoredAs: 'as it does not match the device address',
95
+ notifyIgnoredOpt: 'if your network rewrites addresses (docker/nat), enable the Disable Notify Source Check setting',
93
96
  noServices: 'device does not have any upnp services',
94
97
  noSockets: 'NodeSSDP error [no sockets], if this continues to happen try restarting Homebridge',
95
98
  notConfigured: 'Plugin has not been configured',
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@homebridge-plugins/homebridge-wemo",
3
3
  "alias": "Wemo",
4
4
  "type": "module",
5
- "version": "7.6.0",
5
+ "version": "7.7.1-beta.0",
6
6
  "description": "Homebridge plugin to integrate Wemo devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",
@@ -49,22 +49,27 @@
49
49
  "main": "lib/index.js",
50
50
  "engines": {
51
51
  "homebridge": "^1.6.0 || ^2.0.0",
52
- "node": "^22.12.0 || ^24.0.0"
52
+ "node": "^22.12.0 || ^24.0.0 || ^26.0.0"
53
53
  },
54
54
  "scripts": {
55
55
  "lint": "eslint . --max-warnings=0",
56
- "lint:fix": "npm run lint -- --fix"
56
+ "lint:fix": "npm run lint -- --fix",
57
+ "test": "vitest run",
58
+ "test:watch": "vitest watch",
59
+ "test-coverage": "npm run test -- --coverage",
60
+ "changelog:sync": "node .github/scripts/sync-changelog.mjs"
57
61
  },
58
62
  "dependencies": {
59
63
  "@homebridge/plugin-ui-utils": "^2.2.5",
60
64
  "axios": "^1.18.1",
61
- "ip": "^2.0.1",
62
65
  "node-ssdp": "^4.0.1",
63
66
  "p-queue": "^9.3.3",
64
67
  "xml2js": "^0.6.2",
65
68
  "xmlbuilder": "^15.1.1"
66
69
  },
67
70
  "devDependencies": {
68
- "@antfu/eslint-config": "^9.2.0"
71
+ "@antfu/eslint-config": "^9.2.0",
72
+ "@vitest/coverage-v8": "^4.1.10",
73
+ "vitest": "^4.1.10"
69
74
  }
70
75
  }