@homebridge-plugins/homebridge-meross 10.19.0 → 10.19.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,21 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-meross` will be documented in this file.
4
4
 
5
+ ## v10.19.1 (Pending Release)
6
+
7
+ ### Changed
8
+
9
+ - docs: add node 26 to the supported node versions
10
+ - chore: exclude test files and the test config from the published package
11
+ - fix: restore debug logging when the plugin runs in a child bridge
12
+ - fix: only turn adaptive lighting off when a colour is genuinely chosen elsewhere
13
+ - fix: send a colour change when only the saturation moves, and mend the al cleanup
14
+ - fix: keep the cloud accessories when the device list could not be fetched
15
+ - fix: stop the hub's follow-up request growing on every poll until it fails
16
+ - fix: stop the polling timers on shutdown for a local-only setup, not just a cloud one
17
+ - fix: stop the garage's quick polling when the door is operated again mid-close
18
+ - fix: apply the smoke sensor's battery reading, instead of failing on every update
19
+
5
20
  ## v10.19.0 (2026-08-01)
6
21
 
7
22
  ### Changed
package/README.md CHANGED
@@ -28,7 +28,7 @@ Homebridge plugin to integrate Meross devices into HomeKit
28
28
  ### Prerequisites
29
29
 
30
30
  - To use this plugin, you will need to already have:
31
- - [Node](https://nodejs.org): latest version of `v22` or `v24` - any other major version is not supported.
31
+ - [Node](https://nodejs.org): latest version of `v22`, `v24` or `v26` - any other major version is not supported.
32
32
  - [Homebridge](https://homebridge.io): `v1.6` or above - refer to link for more information and installation instructions.
33
33
 
34
34
  ### Setup
@@ -105,6 +105,18 @@ export default class {
105
105
  platform.log('[%s] %s %s.', this.name, platformLang.devInitOpts, opts)
106
106
  }
107
107
 
108
+ /**
109
+ * Cancel the quick polling started when the door was told to close. Kept in one
110
+ * place so every path that ends a close - a new operation, the door settling,
111
+ * or Homebridge shutting down - stops it rather than leaving it running.
112
+ */
113
+ stopExtremePolling() {
114
+ if (this.extremePolling) {
115
+ clearInterval(this.extremePolling)
116
+ this.extremePolling = false
117
+ }
118
+ }
119
+
108
120
  async internalTargetUpdate(value) {
109
121
  // Add the request to the queue so updates are sent apart
110
122
  try {
@@ -226,6 +238,13 @@ export default class {
226
238
  const updateKey = generateRandomString(5)
227
239
  this.updateKey = updateKey
228
240
 
241
+ // A new operation supersedes any close still in progress, so its quick
242
+ // polling has to stop here. Without this it was orphaned: the close
243
+ // timeout returned early on the changed key and never reached its own
244
+ // clearInterval, so the device was polled every 3 seconds for ever, and
245
+ // each open-during-close cycle leaked another one.
246
+ this.stopExtremePolling()
247
+
229
248
  // Update the Eve times opened characteristic
230
249
  this.accessory.eveService.addEntry({ status: 0 })
231
250
  this.service.updateCharacteristic(
@@ -262,6 +281,7 @@ export default class {
262
281
  */
263
282
  if (action === 'close') {
264
283
  const updateKey = generateRandomString(5)
284
+ this.stopExtremePolling()
265
285
  if (this.accessory.context.connection === 'local') {
266
286
  this.extremePolling = setInterval(() => this.requestUpdate(), 3000)
267
287
  }
@@ -281,10 +301,7 @@ export default class {
281
301
  this.accessory.log(`${platformLang.curState} [${this.states[this.cacheCurrent]}]`)
282
302
 
283
303
  // Cancel any 'extreme' polling intervals from setting the garage to close
284
- if (this.extremePolling) {
285
- clearInterval(this.extremePolling)
286
- this.extremePolling = false
287
- }
304
+ this.stopExtremePolling()
288
305
  }, (this.operationTime + 15) * 1000)
289
306
  }
290
307
  })
@@ -482,10 +499,7 @@ export default class {
482
499
  }
483
500
 
484
501
  // Cancel any 'extreme' polling intervals from setting the garage to close
485
- if (this.extremePolling) {
486
- clearInterval(this.extremePolling)
487
- this.extremePolling = false
488
- }
502
+ this.stopExtremePolling()
489
503
  break
490
504
  }
491
505
  default:
@@ -84,6 +84,18 @@ export default class {
84
84
  platform.log('[%s] %s %s.', this.name, platformLang.devInitOpts, opts)
85
85
  }
86
86
 
87
+ /**
88
+ * Cancel the quick polling started when the door was told to close. Kept in one
89
+ * place so every path that ends a close - a new operation, the door settling,
90
+ * or Homebridge shutting down - stops it rather than leaving it running.
91
+ */
92
+ stopExtremePolling() {
93
+ if (this.extremePolling) {
94
+ clearInterval(this.extremePolling)
95
+ this.extremePolling = false
96
+ }
97
+ }
98
+
87
99
  async internalTargetUpdate(value) {
88
100
  // Add the request to the queue so updates are sent apart
89
101
  try {
@@ -205,6 +217,13 @@ export default class {
205
217
  const updateKey = generateRandomString(5)
206
218
  this.updateKey = updateKey
207
219
 
220
+ // A new operation supersedes any close still in progress, so its quick
221
+ // polling has to stop here. Without this it was orphaned: the close
222
+ // timeout returned early on the changed key and never reached its own
223
+ // clearInterval, so the device was polled every 3 seconds for ever, and
224
+ // each open-during-close cycle leaked another one.
225
+ this.stopExtremePolling()
226
+
208
227
  // Update the Eve times opened characteristic
209
228
  this.accessory.eveService.addEntry({ status: 0 })
210
229
  this.service.updateCharacteristic(
@@ -241,6 +260,7 @@ export default class {
241
260
  */
242
261
  if (action === 'close') {
243
262
  const updateKey = generateRandomString(5)
263
+ this.stopExtremePolling()
244
264
  if (this.accessory.context.connection === 'local') {
245
265
  this.extremePolling = setInterval(() => this.priAcc.control.requestUpdate(), 3000)
246
266
  }
@@ -260,10 +280,7 @@ export default class {
260
280
  this.accessory.log(`${platformLang.curState} [${this.states[this.cacheCurrent]}]`)
261
281
 
262
282
  // Cancel any 'extreme' polling intervals from setting the garage to close
263
- if (this.extremePolling) {
264
- clearInterval(this.extremePolling)
265
- this.extremePolling = false
266
- }
283
+ this.stopExtremePolling()
267
284
  }, (this.operationTime + 15) * 1000)
268
285
  }
269
286
  })
@@ -363,10 +380,7 @@ export default class {
363
380
  }
364
381
 
365
382
  // Cancel any 'extreme' polling intervals from setting the garage to close
366
- if (this.extremePolling) {
367
- clearInterval(this.extremePolling)
368
- this.extremePolling = false
369
- }
383
+ this.stopExtremePolling()
370
384
  break
371
385
  }
372
386
  default:
@@ -92,6 +92,13 @@ export default class {
92
92
  && data.all.digest.hub.subdevice
93
93
  && Array.isArray(data.all.digest.hub.subdevice)
94
94
  ) {
95
+ // Rebuild these each poll. They used to be appended to and never
96
+ // cleared, so with one valve on the hub and the default 300s refresh
97
+ // the follow-up request carried 288 copies of the same id after a day,
98
+ // growing until the hub stopped answering and state stopped updating.
99
+ this.mtsList = []
100
+ this.waterList = []
101
+
95
102
  data.all.digest.hub.subdevice.forEach((subdevice) => {
96
103
  // Check whether the homebridge accessory this relates to exists
97
104
  const subAcc = this.devicesInHB.get(
@@ -1,5 +1,5 @@
1
1
  import platformConsts from '../utils/constants.js'
2
- import { parseError } from '../utils/functions.js'
2
+ import { hasProperty, parseError } from '../utils/functions.js'
3
3
  import platformLang from '../utils/lang-en.js'
4
4
 
5
5
  export default class {
@@ -32,7 +32,30 @@ export default class {
32
32
 
33
33
  applyUpdate(data) {
34
34
  try {
35
- this.log.warn('[%s]\n%s.', this.name, JSON.stringify(data))
35
+ // This warned every incoming frame through `this.log`, which the class never
36
+ // defines - so it threw on every update, the catch below turned that into a
37
+ // warning the owner could do nothing about, and the battery reading was
38
+ // never applied at all. Routine frames belong in the debug log.
39
+ this.accessory.logDebug(JSON.stringify(data))
40
+
41
+ // Battery % from reported voltage, the same way the other hub sensors do it
42
+ if (hasProperty(data, 'voltage')) {
43
+ // 1. Reduce/enlarge value so in [2000, 3000]
44
+ let newVoltage = Math.min(Math.max(data.voltage, 2000), 3000)
45
+
46
+ // 2. Scale this from [2000, 3000] to [0, 100] and round to nearest whole number
47
+ newVoltage = Math.round((newVoltage - 2000) / 10)
48
+
49
+ // This should be a rough estimate of the battery %
50
+ if (newVoltage !== this.cacheBatt) {
51
+ this.cacheBatt = newVoltage
52
+ this.battService.updateCharacteristic(this.hapChar.BatteryLevel, this.cacheBatt)
53
+ this.battService.updateCharacteristic(
54
+ this.hapChar.StatusLowBattery,
55
+ this.cacheBatt < this.lowBattThreshold ? 1 : 0,
56
+ )
57
+ }
58
+ }
36
59
  } catch (err) {
37
60
  this.accessory.logWarn(`${platformLang.refFailed} ${parseError(err)}`)
38
61
  }
@@ -43,10 +43,22 @@ export default class {
43
43
  this.service = this.accessory.getService(this.hapServ.Lightbulb)
44
44
  || this.accessory.addService(this.hapServ.Lightbulb)
45
45
 
46
- // If adaptive lighting has just been disabled then remove and re-add service to hide AL icon
46
+ // Adaptive lighting has just been switched off, so take away the three
47
+ // characteristics that make Home offer it. Only these need to go: the whole
48
+ // Lightbulb service used to be thrown away and rebuilt here, which does hide
49
+ // the icon but presents Home with what looks like a different light, and an
50
+ // owner can lose that light's room and its automations as a result.
47
51
  if (this.alShift === -1 && this.accessory.context.adaptiveLighting) {
48
- this.accessory.removeService(this.service)
49
- this.service = this.accessory.addService(this.hapServ.Lightbulb)
52
+ [
53
+ 'SupportedCharacteristicValueTransitionConfiguration',
54
+ 'CharacteristicValueTransitionControl',
55
+ 'CharacteristicValueActiveTransitionCount',
56
+ ].forEach((charName) => {
57
+ const char = this.hapChar[charName]
58
+ if (char && this.service.testCharacteristic(char)) {
59
+ this.service.removeCharacteristic(this.service.getCharacteristic(char))
60
+ }
61
+ })
50
62
  this.accessory.context.adaptiveLighting = false
51
63
  }
52
64
 
@@ -463,7 +475,13 @@ export default class {
463
475
  this.cacheMired = hkTemp
464
476
  const kelvin = Math.round(1000000 / this.cacheMired)
465
477
  this.accessory.log(`${platformLang.curLightTemp} [${this.cacheMired} / ${kelvin}]`)
466
- if (dif > 10 && this.alController && this.alController.isAdaptiveLightingActive()) {
478
+ // A light repeating back the temperature adaptive lighting just set
479
+ // can land a few mireds off through the conversion, and treating that
480
+ // as the owner's doing would switch adaptive lighting off moments
481
+ // after it was switched on. Further apart than that and somebody set
482
+ // this temperature themselves, outside HomeKit - which nothing else
483
+ // would notice, so adaptive lighting has to be stopped here.
484
+ if (dif > 10 && this.alController?.isAdaptiveLightingActive?.()) {
467
485
  this.alController.disableAdaptiveLighting()
468
486
  this.accessory.log(platformLang.alDisabled)
469
487
  }
@@ -49,10 +49,22 @@ export default class {
49
49
  this.service = this.accessory.getService(this.hapServ.Lightbulb)
50
50
  || this.accessory.addService(this.hapServ.Lightbulb)
51
51
 
52
- // If adaptive lighting has just been disabled then remove and re-add service to hide AL icon
52
+ // Adaptive lighting has just been switched off, so take away the three
53
+ // characteristics that make Home offer it. Only these need to go: the whole
54
+ // Lightbulb service used to be thrown away and rebuilt here, which does hide
55
+ // the icon but presents Home with what looks like a different light, and an
56
+ // owner can lose that light's room and its automations as a result.
53
57
  if (this.alShift === -1 && this.accessory.context.adaptiveLighting) {
54
- this.accessory.removeService(this.service)
55
- this.service = this.accessory.addService(this.hapServ.Lightbulb)
58
+ [
59
+ 'SupportedCharacteristicValueTransitionConfiguration',
60
+ 'CharacteristicValueTransitionControl',
61
+ 'CharacteristicValueActiveTransitionCount',
62
+ ].forEach((charName) => {
63
+ const char = this.hapChar[charName]
64
+ if (char && this.service.testCharacteristic(char)) {
65
+ this.service.removeCharacteristic(this.service.getCharacteristic(char))
66
+ }
67
+ })
56
68
  this.accessory.context.adaptiveLighting = false
57
69
  }
58
70
 
@@ -73,6 +85,14 @@ export default class {
73
85
  this.service
74
86
  .getCharacteristic(this.hapChar.Hue)
75
87
  .onSet(async value => this.internalColourUpdate(value))
88
+ // Saturation needs a handler of its own. HomeKit writes hue and saturation
89
+ // separately, so with only hue listened for, dragging saturation alone -
90
+ // the same colour made paler or richer - reached nothing at all. Both
91
+ // handlers share one update, and the debounce inside it collapses a
92
+ // hue-and-saturation pair into a single command.
93
+ this.service
94
+ .getCharacteristic(this.hapChar.Saturation)
95
+ .onSet(async () => this.internalColourUpdate(this.service.getCharacteristic(this.hapChar.Hue).value))
76
96
  this.cacheHue = this.service.getCharacteristic(this.hapChar.Hue).value
77
97
  this.cacheSat = this.service.getCharacteristic(this.hapChar.Saturation).value
78
98
 
@@ -495,11 +515,19 @@ export default class {
495
515
  this.cacheHue = newHue
496
516
  this.cacheSat = newSat
497
517
  this.accessory.log(`${platformLang.curLightColour} [${this.cacheHue}, ${this.cacheSat}] / [${r}, ${g}, ${b}]`)
498
- }
499
- // Disable adaptive lighting
500
- if (this.alController && this.alController.isAdaptiveLightingActive()) {
501
- this.alController.disableAdaptiveLighting()
502
- this.accessory.log(platformLang.alDisabled)
518
+
519
+ // Somebody chose this colour outside HomeKit - in the Meross app, on
520
+ // a remote, or at the wall. Homebridge catches changes made in the
521
+ // Home app itself, but nothing else sees these, so adaptive lighting
522
+ // would carry on and move the light off that colour within minutes.
523
+ //
524
+ // This used to sit outside the check above and fire on every report,
525
+ // so a routine refresh that simply repeated the current colour back
526
+ // switched adaptive lighting off seconds after it was switched on.
527
+ if (this.alController?.isAdaptiveLightingActive?.()) {
528
+ this.alController.disableAdaptiveLighting()
529
+ this.accessory.log(platformLang.alDisabled)
530
+ }
503
531
  }
504
532
  }
505
533
  if (
@@ -517,7 +545,13 @@ export default class {
517
545
  this.cacheMired = hkTemp
518
546
  const kelvin = Math.round(1000000 / this.cacheMired)
519
547
  this.accessory.log(`${platformLang.curLightTemp} [${this.cacheMired} / ${kelvin}]`)
520
- if (dif > 10 && this.alController && this.alController.isAdaptiveLightingActive()) {
548
+ // A light repeating back the temperature adaptive lighting just set
549
+ // can land a few mireds off through the conversion, and treating that
550
+ // as the owner's doing would switch adaptive lighting off moments
551
+ // after it was switched on. Further apart than that and somebody set
552
+ // this temperature themselves, outside HomeKit - which nothing else
553
+ // would notice, so adaptive lighting has to be stopped here.
554
+ if (dif > 10 && this.alController?.isAdaptiveLightingActive?.()) {
521
555
  this.alController.disableAdaptiveLighting()
522
556
  this.accessory.log(platformLang.alDisabled)
523
557
  }
@@ -197,7 +197,7 @@
197
197
  <ul>
198
198
  <li>To use this plugin, you will need to already have:</li>
199
199
  <ul>
200
- <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>
200
+ <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>
201
201
  <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>
202
202
  </ul>
203
203
  </ul>
package/lib/platform.js CHANGED
@@ -29,7 +29,6 @@ export default class {
29
29
  try {
30
30
  this.api = api
31
31
  this.log = log
32
- this.isBeta = process.argv.includes('-D')
33
32
  this.cloudClient = false
34
33
  this.deviceConf = {}
35
34
  this.devicesInHB = new Map()
@@ -304,13 +303,16 @@ export default class {
304
303
  this.log('%s.', platformLang.initialised)
305
304
 
306
305
  // Sort out some logging functions
307
- if (this.isBeta) {
308
- this.log.debug = this.log
309
- this.log.debugWarn = this.log.warn
310
- } else {
311
- this.log.debug = () => {}
312
- this.log.debugWarn = () => {}
313
- }
306
+ // Homebridge already decides whether debug lines are shown, and it gets it
307
+ // right in a child bridge too. This used to work it out for itself by
308
+ // looking for `-D` in the plugin's own process arguments, which a child
309
+ // bridge does not receive unless that bridge has its own debug setting
310
+ // turned on - so debug logging silently disappeared for anyone running the
311
+ // plugin in a child bridge with debug enabled globally.
312
+ //
313
+ // `debugWarn` has no equivalent on Homebridge's logger, so it goes out as
314
+ // a debug line too rather than being invented here.
315
+ this.log.debugWarn = (...args) => this.log.debug(...args)
314
316
 
315
317
  // Require any libraries that the accessory instances use
316
318
  this.cusChar = new platformChars(this.api)
@@ -339,6 +341,7 @@ export default class {
339
341
  // If the user has configured cloud username and password then get a device list
340
342
  this.accountDetails = {}
341
343
  let cloudDevices = []
344
+ let cloudListFetched = false
342
345
  try {
343
346
  if (!this.config.username || !this.config.password) {
344
347
  throw new Error(platformLang.missingCreds)
@@ -381,6 +384,7 @@ export default class {
381
384
  }
382
385
 
383
386
  // Initialise the cloud configured devices into Homebridge
387
+ cloudListFetched = true
384
388
  cloudDevices.forEach(device => this.initialiseDevice(device))
385
389
  } catch (err) {
386
390
  const eText = parseError(err, [platformLang.mfaFail, platformLang.missingCreds, platformLang.accTokenInvalid])
@@ -434,7 +438,12 @@ export default class {
434
438
  switch (accessory.context.connection) {
435
439
  case 'cloud':
436
440
  case 'hybrid':
437
- if (!cloudDevices.some(el => el.uuid === accessory.context.serialNumber)) {
441
+ // Only a list that actually arrived says anything about what is on the
442
+ // account. `cloudDevices` is also empty when the login or the device
443
+ // request failed, and an owner with a user key set reaches this code
444
+ // in that case - so one bad morning at Meross used to wipe their whole
445
+ // setup out of HomeKit, rooms, scenes and automations included.
446
+ if (cloudListFetched && !cloudDevices.some(el => el.uuid === accessory.context.serialNumber)) {
438
447
  this.removeAccessory(accessory)
439
448
  }
440
449
  break
@@ -464,42 +473,43 @@ export default class {
464
473
  pluginShutdown() {
465
474
  // A function that is called when the plugin fails to load or Homebridge restarts
466
475
  try {
467
- // Close the mqtt connection for the accessories with an open connection
468
- if (this.cloudClient) {
469
- this.devicesInHB.forEach((accessory) => {
470
- if (accessory.mqtt) {
471
- accessory.mqtt.disconnect()
472
- }
473
- if (accessory.refreshInterval) {
474
- clearInterval(accessory.refreshInterval)
475
- }
476
- if (accessory.powerInterval) {
477
- clearInterval(accessory.powerInterval)
478
- }
479
- })
480
- }
476
+ // Stop every accessory's timers. Only the mqtt disconnect needs a cloud
477
+ // client: this whole block used to sit behind that check, so a local-only
478
+ // setup - no username or password, just a user key - kept polling every
479
+ // device while Homebridge was tearing down, and kept the process alive
480
+ this.devicesInHB.forEach((accessory) => {
481
+ if (this.cloudClient && accessory.mqtt) {
482
+ accessory.mqtt.disconnect()
483
+ }
484
+ if (accessory.refreshInterval) {
485
+ clearInterval(accessory.refreshInterval)
486
+ }
487
+ if (accessory.powerInterval) {
488
+ clearInterval(accessory.powerInterval)
489
+ }
490
+ accessory.control?.stopExtremePolling?.()
491
+ })
481
492
  } catch (err) {
482
493
  // No need to show errors at this point
483
494
  }
484
495
  }
485
496
 
486
497
  applyAccessoryLogging(accessory) {
487
- if (this.isBeta) {
488
- accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
489
- accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
490
- accessory.logDebug = msg => this.log('[%s] %s.', accessory.displayName, msg)
491
- accessory.logDebugWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
492
- } else {
493
- if (this.config.disableDeviceLogging) {
494
- accessory.log = () => {}
495
- accessory.logWarn = () => {}
496
- } else {
497
- accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
498
- accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
499
- }
500
- accessory.logDebug = () => {}
501
- accessory.logDebugWarn = () => {}
498
+ // Debug lines always go to Homebridge's debug logger, which prints them only
499
+ // when debug is on - so there is nothing to decide here. Working it out from
500
+ // the process arguments instead is what lost debug logging entirely for
501
+ // anyone running the plugin in a child bridge.
502
+ accessory.logDebug = msg => this.log.debug('[%s] %s.', accessory.displayName, msg)
503
+ accessory.logDebugWarn = accessory.logDebug
504
+
505
+ if (this.config.disableDeviceLogging) {
506
+ accessory.log = () => {}
507
+ accessory.logWarn = () => {}
508
+ return
502
509
  }
510
+
511
+ accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
512
+ accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
503
513
  }
504
514
 
505
515
  async initialiseDevice(device) {
@@ -6,7 +6,7 @@ export default {
6
6
  accTokenNoExist: 'does not exist, this is normally not an issue',
7
7
  accTokenStoreErr: 'could not store access token as',
8
8
  accTokenUserChange: 'username has changed',
9
- alDisabled: 'adaptive lighting disabled due to change of colour detected',
9
+ alDisabled: 'adaptive lighting turned off, as the colour was changed outside HomeKit',
10
10
  brand: 'Meross Technology',
11
11
  cfgDef: 'is not a valid number so using default of',
12
12
  cfgDup: 'will be ignored since another entry with this ID already exists',
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@homebridge-plugins/homebridge-meross",
3
3
  "alias": "Meross",
4
4
  "type": "module",
5
- "version": "10.19.0",
5
+ "version": "10.19.1-beta.0",
6
6
  "description": "Homebridge plugin to integrate Meross devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",
@@ -1,78 +0,0 @@
1
- import { Buffer } from 'node:buffer'
2
-
3
- import { describe, expect, it } from 'vitest'
4
-
5
- import { encodeParams, generateRandomString, hasProperty, parseError } from './functions.js'
6
-
7
- describe('encodeParams', () => {
8
- it('base64 encodes the json body the meross cloud expects', () => {
9
- const encoded = encodeParams({ email: 'a@b.com', password: 'x' })
10
- expect(JSON.parse(Buffer.from(encoded, 'base64').toString())).toEqual({
11
- email: 'a@b.com',
12
- password: 'x',
13
- })
14
- })
15
-
16
- it('produces valid base64 with no line breaks', () => {
17
- // The value goes straight into a request body, so a wrapped or padded
18
- // encoding would be rejected by the cloud.
19
- const encoded = encodeParams({ a: 'x'.repeat(200) })
20
- expect(encoded).toMatch(/^[A-Z0-9+/]+=*$/i)
21
- })
22
-
23
- it('handles an empty object', () => {
24
- expect(Buffer.from(encodeParams({}), 'base64').toString()).toBe('{}')
25
- })
26
- })
27
-
28
- describe('generateRandomString', () => {
29
- it('returns the requested length', () => {
30
- expect(generateRandomString(16)).toHaveLength(16)
31
- expect(generateRandomString(1)).toHaveLength(1)
32
- })
33
-
34
- it('uses only lowercase letters and digits', () => {
35
- // The nonce is signed alongside the request, so any character outside the
36
- // documented set risks a signature mismatch.
37
- expect(generateRandomString(200)).toMatch(/^[a-z0-9]+$/)
38
- })
39
-
40
- it('does not return the same value twice in a row', () => {
41
- expect(generateRandomString(32)).not.toBe(generateRandomString(32))
42
- })
43
- })
44
-
45
- describe('hasProperty', () => {
46
- it('detects own properties only', () => {
47
- expect(hasProperty({ a: 1 }, 'a')).toBe(true)
48
- expect(hasProperty({ a: undefined }, 'a')).toBe(true)
49
- expect(hasProperty({}, 'a')).toBe(false)
50
- })
51
-
52
- it('ignores inherited properties', () => {
53
- // A device payload with no "toString" key must not read as having one.
54
- expect(hasProperty({}, 'toString')).toBe(false)
55
- })
56
- })
57
-
58
- describe('parseError', () => {
59
- it('appends the first stack frame to the message', () => {
60
- const err = new Error('boom')
61
- err.stack = 'Error: boom\n at thing (/a.js:1:1)'
62
- expect(parseError(err)).toBe('boom at thing (/a.js:1:1)')
63
- })
64
-
65
- it('returns just the message when the error is listed in hideStack', () => {
66
- // Routine failures - a device being unreachable - are logged without a
67
- // stack so the log stays readable.
68
- const err = new Error('offline')
69
- err.stack = 'Error: offline\n at thing (/a.js:1:1)'
70
- expect(parseError(err, ['offline'])).toBe('offline')
71
- })
72
-
73
- it('returns the message when there is no stack at all', () => {
74
- const err = new Error('nostack')
75
- err.stack = ''
76
- expect(parseError(err)).toBe('nostack')
77
- })
78
- })