@homebridge-plugins/homebridge-wemo 7.7.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,17 @@
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
+
5
16
  ## v7.7.0 (2026-08-02)
6
17
 
7
18
  ### Changed
@@ -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) {
package/lib/platform.js CHANGED
@@ -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 = []
@@ -344,13 +343,16 @@ export default class {
344
343
  this.log('%s.', platformLang.initialised)
345
344
 
346
345
  // Sort out some logging functions
347
- if (this.isBeta) {
348
- this.log.debug = this.log
349
- this.log.debugWarn = this.log.warn
350
- } else {
351
- this.log.debug = () => {}
352
- this.log.debugWarn = () => {}
353
- }
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)
354
356
 
355
357
  // Set up the discovery run counter
356
358
  this.discoveryRun = -1
@@ -454,34 +456,38 @@ export default class {
454
456
  res.end()
455
457
  })
456
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
+
457
473
  // Start listening on the above created server
458
474
  if (this.config.wemoClient.listen_interface) {
459
475
  // User has defined a specific network interface to listen on
460
- listenerServer.listen(this.config.wemoClient.port, this.getLocalInterfaceAddress(), (err) => {
461
- if (err) {
462
- this.log.warn('%s: %s.', platformLang.listenerError, err)
463
- } else if (this.config.debug) {
464
- // Log the port of the listener server in debug mode
465
- this.log('%s [%s].', platformLang.listenerPort, listenerServer.address().port)
466
- }
467
- })
476
+ listenerServer.listen(this.config.wemoClient.port, this.getLocalInterfaceAddress(), logListenerPort)
468
477
  } else {
469
478
  // User has not defined a specific network interface to listen on
470
- listenerServer.listen(this.config.wemoClient.port, (err) => {
471
- if (err) {
472
- this.log.warn('%s: %s', platformLang.listenerError, err)
473
- } else if (this.config.debug) {
474
- // Log the port of the listener server in debug mode
475
- this.log('%s [%s].', platformLang.listenerPort, listenerServer.address().port)
476
- }
477
- })
479
+ listenerServer.listen(this.config.wemoClient.port, logListenerPort)
478
480
  }
479
481
 
480
482
  // Set up the SSDP client if the user has not specified manual devices only
481
483
  if (this.config.mode !== 'manual') {
482
- if (this.isBeta) {
483
- this.config.wemoClient.discover_opts.customLogger = this.log
484
- }
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)
485
491
  ssdpClient = new ssdp(this.config.wemoClient.discover_opts)
486
492
  }
487
493
 
@@ -511,19 +517,13 @@ export default class {
511
517
 
512
518
  // Shutdown the listener server if it's running
513
519
  if (listenerServer) {
514
- listenerServer.close(() => {
515
- if (this.config.debug) {
516
- this.log('%s.', platformLang.listenerClosed)
517
- }
518
- })
520
+ listenerServer.close(() => this.log.debug('%s.', platformLang.listenerClosed))
519
521
  }
520
522
 
521
523
  // Stop the SSDP client if it's running
522
524
  if (ssdpClient) {
523
525
  ssdpClient.stop()
524
- if (this.config.debug) {
525
- this.log('%s.', platformLang.ssdpStopped)
526
- }
526
+ this.log.debug('%s.', platformLang.ssdpStopped)
527
527
  }
528
528
 
529
529
  // Close accessory subscriptions
@@ -778,22 +778,21 @@ export default class {
778
778
  }
779
779
 
780
780
  applyAccessoryLogging(accessory) {
781
- if (this.isBeta) {
782
- accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
783
- accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
784
- accessory.logDebug = msg => this.log('[%s] %s.', accessory.displayName, msg)
785
- accessory.logDebugWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
786
- } else {
787
- if (this.config.disableDeviceLogging) {
788
- accessory.log = () => {}
789
- accessory.logWarn = () => {}
790
- } else {
791
- accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
792
- accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
793
- }
794
- accessory.logDebug = () => {}
795
- 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
796
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)
797
796
  }
798
797
 
799
798
  async initialiseDevice(device) {
@@ -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',
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.7.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",
@@ -1,98 +0,0 @@
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
- })