@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 +24 -1
- package/README.md +1 -1
- package/config.schema.json +6 -0
- package/lib/homebridge-ui/public/index.html +1 -1
- package/lib/platform.js +29 -8
- package/lib/utils/functions.js +10 -0
- package/lib/utils/functions.test.js +98 -0
- package/lib/utils/lang-en.js +3 -0
- package/package.json +11 -6
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.
|
|
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 `
|
|
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
|
|
package/config.schema.json
CHANGED
|
@@ -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>
|
|
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
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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 &&
|
|
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 {
|
package/lib/utils/functions.js
CHANGED
|
@@ -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('<tag>')).toBe('<tag>')
|
|
8
|
+
expect(decodeXML('Tom & Jerry')).toBe('Tom & Jerry')
|
|
9
|
+
expect(decodeXML('"quoted"')).toBe('"quoted"')
|
|
10
|
+
expect(decodeXML('it'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('<BinaryState>1</BinaryState>'))
|
|
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 & is replaced first', () => {
|
|
26
|
+
// ⚠️ Documenting real behaviour, not endorsing it. The replacements run in
|
|
27
|
+
// order with & first, so "&lt;" becomes "<" and is then decoded
|
|
28
|
+
// again to "<". Anything relying on a literal "<" surviving would be
|
|
29
|
+
// disappointed - worth knowing if this is ever reused elsewhere.
|
|
30
|
+
expect(decodeXML('&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
|
+
})
|
package/lib/utils/lang-en.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
71
|
+
"@antfu/eslint-config": "^9.2.0",
|
|
72
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
73
|
+
"vitest": "^4.1.10"
|
|
69
74
|
}
|
|
70
75
|
}
|