@homebridge-plugins/homebridge-wemo 7.6.0-beta.3 → 7.7.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,14 +2,37 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-wemo` will be documented in this file.
4
4
 
5
- ## v7.6.0 (Pending Release)
5
+ ## v7.7.0 (2026-08-02)
6
6
 
7
7
  ### Changed
8
8
 
9
+ - chore: add a test setup with tests for the shared helper functions
10
+ - chore(github): run the build and tests in ci, on node 22, 24 and 26
11
+ - chore: use the same lint setup across every plugin
12
+ - chore: add a changelog:sync script to populate the pending section from the commits
13
+ - chore: count a repeated commit subject once when syncing the changelog
14
+ - chore(github): check the changelog against the commits in ci
15
+ - chore(deps): dependency updates
16
+ - docs: add node 26 to the supported node versions
17
+ - 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)
18
+ - fix: replace the ip dependency with a local subnet check, clearing its unfixable npm advisory
19
+
20
+ ## v7.6.0 (2026-07-27)
21
+
22
+ ### Changed
23
+
24
+ - docs: point the beta version link at this plugin's wiki
25
+ - chore(branding): store the plugin header in the standard branding folder
26
+ - chore(github): allow the codeql scan to be started manually
27
+ - Bump p-queue from 9.3.1 to 9.3.2 (#273)
28
+ - chore(github): stop concurrent release runs racing for the same version
29
+ - chore(github): use the shared homebridge action to deprecate past pre-releases
9
30
  - feat(ui): add, remove and hide devices from the config via the my devices tab
10
31
  - style(ui): standardise the custom ui layout and sync the support tab with the readme
11
32
  - feat(ui): add a remove all devices action to the my devices tab
12
33
  - chore: declare the supports-hap transport keyword for the homebridge ui
34
+ - chore(deps): dependency updates
35
+ - docs(changelog): list every unreleased commit in the pending section
13
36
 
14
37
  ## v7.5.3 (2026-07-18)
15
38
 
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",
@@ -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
@@ -113,6 +113,7 @@ export default class {
113
113
  const [key, val] = entry
114
114
  switch (key) {
115
115
  case 'disableDeviceLogging':
116
+ case 'disableNotifySourceCheck':
116
117
  case 'disableUPNP':
117
118
  case 'hideConnectionErrors':
118
119
  if (typeof val === 'string') {
@@ -423,11 +424,31 @@ export default class {
423
424
  let body = ''
424
425
  const accessory = devicesInHB.get(req.url.substring(1))
425
426
  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))
427
+ // Only accept a notification from the address the device was discovered on.
428
+ // Anything on the network can reach this listener, so without this check a
429
+ // notification for a known device could be forged from elsewhere. Networks
430
+ // which rewrite source addresses (docker bridge, some NAT) can turn this
431
+ // off with the disableNotifySourceCheck setting.
432
+ const remoteIp = (req.socket.remoteAddress || '').replace('::ffff:', '')
433
+ if (this.config.disableNotifySourceCheck || remoteIp === accessory.context.ipAddress) {
434
+ // A notification from a known device
435
+ req.on('data', (chunk) => {
436
+ body += chunk.toString()
437
+ })
438
+ req.on('end', () => this.httpClient.receiveDeviceUpdate(accessory, body))
439
+ } else if (!notifySourceWarned.has(accessory.UUID)) {
440
+ // Warn once per accessory, so a misconfigured network does not flood the log
441
+ notifySourceWarned.add(accessory.UUID)
442
+ this.log.warn(
443
+ '[%s] %s [%s] %s [%s] - %s.',
444
+ accessory.displayName,
445
+ platformLang.notifyIgnored,
446
+ remoteIp,
447
+ platformLang.notifyIgnoredAs,
448
+ accessory.context.ipAddress,
449
+ platformLang.notifyIgnoredOpt,
450
+ )
451
+ }
431
452
  }
432
453
  res.writeHead(200)
433
454
  res.end()
@@ -1419,7 +1440,7 @@ export default class {
1419
1440
  const [k2] = subEntry
1420
1441
  const address = interfaces[k][k2]
1421
1442
  if ((address.family === 'IPv4' || address.family === 4) && !address.internal) {
1422
- if (targetNetwork && ip.subnet(address.address, address.netmask).contains(targetNetwork)) {
1443
+ if (targetNetwork && sameIpv4Subnet(address.address, targetNetwork, address.netmask)) {
1423
1444
  // Try to find IP address on the same IP network as the device's location
1424
1445
  addresses.unshift(address.address)
1425
1446
  } 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
  }
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { decodeXML, generateRandomString, hasProperty, parseError, parseSerialNumber } from './functions.js'
4
+
5
+ describe('decodeXML', () => {
6
+ it('decodes the entities wemo devices put in their soap responses', () => {
7
+ expect(decodeXML('&lt;tag&gt;')).toBe('<tag>')
8
+ expect(decodeXML('Tom &amp; Jerry')).toBe('Tom & Jerry')
9
+ expect(decodeXML('&quot;quoted&quot;')).toBe('"quoted"')
10
+ expect(decodeXML('it&#039;s')).toBe('it\'s')
11
+ })
12
+
13
+ it('decodes a realistic nested payload', () => {
14
+ // Wemo wraps its state XML inside another XML document, so the inner
15
+ // document arrives entity-encoded and has to be decoded before parsing.
16
+ expect(decodeXML('&lt;BinaryState&gt;1&lt;/BinaryState&gt;'))
17
+ .toBe('<BinaryState>1</BinaryState>')
18
+ })
19
+
20
+ it('leaves text with no entities untouched', () => {
21
+ expect(decodeXML('plain text')).toBe('plain text')
22
+ expect(decodeXML('')).toBe('')
23
+ })
24
+
25
+ it('decodes an escaped ampersand entity twice, because &amp; is replaced first', () => {
26
+ // ⚠️ Documenting real behaviour, not endorsing it. The replacements run in
27
+ // order with &amp; first, so "&amp;lt;" becomes "&lt;" and is then decoded
28
+ // again to "<". Anything relying on a literal "&lt;" surviving would be
29
+ // disappointed - worth knowing if this is ever reused elsewhere.
30
+ expect(decodeXML('&amp;lt;')).toBe('<')
31
+ })
32
+ })
33
+
34
+ describe('parseSerialNumber', () => {
35
+ it('uppercases and strips the spacing users paste in', () => {
36
+ expect(parseSerialNumber('abc 123')).toBe('ABC123')
37
+ expect(parseSerialNumber(' abc123 ')).toBe('ABC123')
38
+ })
39
+
40
+ it('strips quotes, which get added when copying from a log', () => {
41
+ expect(parseSerialNumber('"ABC123"')).toBe('ABC123')
42
+ expect(parseSerialNumber('\'ABC123\'')).toBe('ABC123')
43
+ })
44
+
45
+ it('accepts a non-string value without throwing', () => {
46
+ expect(parseSerialNumber(123456)).toBe('123456')
47
+ })
48
+
49
+ it('leaves an already-clean serial alone', () => {
50
+ expect(parseSerialNumber('ABC123')).toBe('ABC123')
51
+ })
52
+ })
53
+
54
+ describe('generateRandomString', () => {
55
+ it('returns the requested length', () => {
56
+ expect(generateRandomString(16)).toHaveLength(16)
57
+ })
58
+
59
+ it('uses only lowercase letters and digits', () => {
60
+ expect(generateRandomString(200)).toMatch(/^[a-z0-9]+$/)
61
+ })
62
+
63
+ it('does not return the same value twice in a row', () => {
64
+ expect(generateRandomString(32)).not.toBe(generateRandomString(32))
65
+ })
66
+ })
67
+
68
+ describe('hasProperty', () => {
69
+ it('detects own properties only', () => {
70
+ expect(hasProperty({ a: 1 }, 'a')).toBe(true)
71
+ expect(hasProperty({ a: undefined }, 'a')).toBe(true)
72
+ expect(hasProperty({}, 'a')).toBe(false)
73
+ })
74
+
75
+ it('ignores inherited properties', () => {
76
+ expect(hasProperty({}, 'toString')).toBe(false)
77
+ })
78
+ })
79
+
80
+ describe('parseError', () => {
81
+ it('appends the first stack frame to the message', () => {
82
+ const err = new Error('boom')
83
+ err.stack = 'Error: boom\n at thing (/a.js:1:1)'
84
+ expect(parseError(err)).toBe('boom at thing (/a.js:1:1)')
85
+ })
86
+
87
+ it('hides the stack for errors the caller expects', () => {
88
+ const err = new Error('EHOSTUNREACH')
89
+ err.stack = 'Error: EHOSTUNREACH\n at thing (/a.js:1:1)'
90
+ expect(parseError(err, ['EHOSTUNREACH'])).toBe('EHOSTUNREACH')
91
+ })
92
+
93
+ it('returns the message when there is no stack', () => {
94
+ const err = new Error('nostack')
95
+ err.stack = ''
96
+ expect(parseError(err)).toBe('nostack')
97
+ })
98
+ })
@@ -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-beta.3",
5
+ "version": "7.7.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
- "p-queue": "^9.3.1",
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.1.0"
71
+ "@antfu/eslint-config": "^9.2.0",
72
+ "@vitest/coverage-v8": "^4.1.10",
73
+ "vitest": "^4.1.10"
69
74
  }
70
75
  }