@homebridge-plugins/homebridge-meross 10.17.0 → 10.18.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,25 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-meross` will be documented in this file.
4
4
 
5
+ ## v10.18.0 (2026-07-30)
6
+
7
+ ### Changed
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
+ - feat(thermostat): honour SummerMode on MTS215/MTS215B (@marcaicart)
16
+ - chore(deps): dependency updates
17
+
18
+ ## v10.17.1 (2026-07-28)
19
+
20
+ ### Changed
21
+
22
+ - fix(thermostat): use GET for Sensor.Latest on MTS215/MTS215B (#799) (@marcaicart)
23
+
5
24
  ## v10.17.0 (2026-07-27)
6
25
 
7
26
  ### Changed
@@ -43,19 +43,32 @@ export default class {
43
43
  4: this.cusChar.ValveEconomyMode,
44
44
  }
45
45
 
46
+ // Appliance.Control.Thermostat.SummerMode (mode: 1 = heat orientation, 2 = cool
47
+ // orientation) tells us whether the device is wired to heating or cooling
48
+ // equipment -- it flips which direction "state: 1" (actively calling) means,
49
+ // and which HomeKit HeatingCoolingState value should represent it. Comes
50
+ // bundled in the same Appliance.System.All digest as mode/windowOpened, so no
51
+ // extra request is needed; corrected as soon as the first poll comes back.
52
+ //
53
+ // Read-only here on purpose: this reflects how the installation is physically
54
+ // wired (boiler vs AC), not something to flip live from HomeKit like a sensor
55
+ // or a mode setting. If that's ever wanted, it can be set with
56
+ // sendUpdate(accessory, { namespace: 'Appliance.Control.Thermostat.SummerMode',
57
+ // payload: { summerMode: [{ channel: 0, mode: 1|2 }] } })
58
+ // -- note the SETACK payload comes back empty even on success, so the write
59
+ // has to be confirmed with a follow-up GET rather than trusted from the SET
60
+ // response (confirmed empirically, same device).
61
+ this.cacheSummerMode = false
62
+
46
63
  // Add the thermostat service if it doesn't already exist
47
64
  this.service = this.accessory.getService(this.hapServ.Thermostat)
48
65
  || this.accessory.addService(this.hapServ.Thermostat)
49
66
 
50
67
  this.service
51
68
  .getCharacteristic(this.hapChar.TargetHeatingCoolingState)
52
- .setProps({
53
- minValue: 0,
54
- maxValue: 1,
55
- validValues: [0, 1],
56
- })
57
69
  .onSet(async value => this.internalStateUpdate(value))
58
70
  this.cacheState = this.service.getCharacteristic(this.hapChar.TargetHeatingCoolingState).value
71
+ this.updateHeatCoolProps()
59
72
 
60
73
  this.service
61
74
  .getCharacteristic(this.hapChar.TargetTemperature)
@@ -282,10 +295,13 @@ export default class {
282
295
  this.cacheTarg = value
283
296
  this.accessory.log(`${platformLang.curTarg} [${value}°C]`)
284
297
 
285
- // Update the current heating state
298
+ // Update the current heating state (optimistic -- confirmed by the next poll).
299
+ // Direction depends on SummerMode: heating calls when the target is above the
300
+ // current temperature, cooling calls when it's below.
301
+ const isCalling = this.cacheSummerMode ? value < this.cacheTemp : value > this.cacheTemp
286
302
  this.service.updateCharacteristic(
287
303
  this.hapChar.CurrentHeatingCoolingState,
288
- value > this.cacheTemp ? 1 : 0,
304
+ isCalling ? this.heatCoolValue() : this.hapChar.CurrentHeatingCoolingState.OFF,
289
305
  )
290
306
 
291
307
  // Turn the modes off as back to manual mode
@@ -323,6 +339,30 @@ export default class {
323
339
  }
324
340
  }
325
341
 
342
+ // Returns which HomeKit HeatingCoolingState value ("calling") means on this
343
+ // device right now: Cool if Appliance.Control.Thermostat.SummerMode says this is
344
+ // wired to cooling equipment, Heat otherwise.
345
+ heatCoolValue() {
346
+ return this.cacheSummerMode ? this.hapChar.CurrentHeatingCoolingState.COOL : this.hapChar.CurrentHeatingCoolingState.HEAT
347
+ }
348
+
349
+ // Restricts TargetHeatingCoolingState to Off + whichever of Heat/Cool actually
350
+ // applies given the current SummerMode -- offering the other one in HomeKit would
351
+ // let the user pick a state the device can't actually honour (SummerMode is
352
+ // read-only here, see the constructor for why).
353
+ updateHeatCoolProps() {
354
+ const validValues = this.cacheSummerMode
355
+ ? [this.hapChar.TargetHeatingCoolingState.OFF, this.hapChar.TargetHeatingCoolingState.COOL]
356
+ : [this.hapChar.TargetHeatingCoolingState.OFF, this.hapChar.TargetHeatingCoolingState.HEAT]
357
+ this.service
358
+ .getCharacteristic(this.hapChar.TargetHeatingCoolingState)
359
+ .setProps({
360
+ minValue: 0,
361
+ maxValue: Math.max(...validValues),
362
+ validValues,
363
+ })
364
+ }
365
+
326
366
  // Creates or removes a ContactSensor based on whether the corresponding protection
327
367
  // is armed on the device. Only called on the first poll after startup: these are
328
368
  // manually-configured device settings that don't change often, and re-evaluating on
@@ -426,6 +466,11 @@ export default class {
426
466
  try {
427
467
  const humRes = await this.platform.sendUpdate(this.accessory, {
428
468
  namespace: 'Appliance.Control.Sensor.Latest',
469
+ // Explicit GET: sendUpdate() defaults to SET whenever the payload
470
+ // isn't empty, but this payload only selects the channel to read --
471
+ // the device accepts GET for this namespace and closes the
472
+ // connection outright if sent SET (confirmed empirically).
473
+ method: 'GET',
429
474
  payload: { latest: [{ channel: 0 }] },
430
475
  })
431
476
  this.applyUpdate(humRes.data.payload)
@@ -490,6 +535,18 @@ export default class {
490
535
 
491
536
  applyUpdate(data) {
492
537
  try {
538
+ // Read SummerMode before mode/state so the heat-vs-cool direction used below
539
+ // is already up to date if both arrived in the same digest (the normal case).
540
+ const summerModeData = data.summerMode?.[0]
541
+ if (summerModeData && hasProperty(summerModeData, 'mode')) {
542
+ const newSummerMode = summerModeData.mode === 2
543
+ if (this.cacheSummerMode !== newSummerMode) {
544
+ this.cacheSummerMode = newSummerMode
545
+ this.updateHeatCoolProps()
546
+ this.accessory.log(`${platformLang.curSummerMode} [${newSummerMode ? 'cool' : 'heat'}]`)
547
+ }
548
+ }
549
+
493
550
  const modeData = data.mode?.[0]
494
551
  if (modeData) {
495
552
  let needsUpdate = false
@@ -498,7 +555,10 @@ export default class {
498
555
 
499
556
  // Check against the cache and update HomeKit and the cache if needed
500
557
  if (this.cacheState !== newState) {
501
- this.service.updateCharacteristic(this.hapChar.TargetHeatingCoolingState, newState)
558
+ this.service.updateCharacteristic(
559
+ this.hapChar.TargetHeatingCoolingState,
560
+ newState === 1 ? this.heatCoolValue() : this.hapChar.TargetHeatingCoolingState.OFF,
561
+ )
502
562
  this.cacheState = newState
503
563
  this.accessory.log(`${platformLang.curState} [${newState === 1 ? 'on' : 'off'}]`)
504
564
  needsUpdate = true
@@ -531,11 +591,15 @@ export default class {
531
591
  }
532
592
  }
533
593
 
534
- // Update the current heating state
594
+ // Update the current heating state. Direction depends on SummerMode: heating
595
+ // calls when the target is above the current temperature, cooling calls when
596
+ // it's below -- but this.cacheState (from the device's own "state" field)
597
+ // already reflects whether it's actually calling either way, so it's trusted
598
+ // directly rather than re-derived from the temperatures here.
535
599
  if (needsUpdate) {
536
600
  this.service.updateCharacteristic(
537
601
  this.hapChar.CurrentHeatingCoolingState,
538
- this.cacheState === 1 && this.cacheTarg > this.cacheTemp ? 1 : 0,
602
+ this.cacheState === 1 ? this.heatCoolValue() : this.hapChar.CurrentHeatingCoolingState.OFF,
539
603
  )
540
604
  }
541
605
  }
@@ -0,0 +1,78 @@
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
+ })
@@ -44,6 +44,7 @@ export default {
44
44
  curSpeed: 'current speed',
45
45
  curSpray: 'current spray',
46
46
  curState: 'current state',
47
+ curSummerMode: 'current summer mode',
47
48
  curTarg: 'current target',
48
49
  curTemp: 'current temperature',
49
50
  curVol: 'current volume',
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.17.0",
5
+ "version": "10.18.0",
6
6
  "description": "Homebridge plugin to integrate Meross devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",
@@ -50,11 +50,15 @@
50
50
  "main": "lib/index.js",
51
51
  "engines": {
52
52
  "homebridge": "^1.6.0 || ^2.0.0",
53
- "node": "^22.12.0 || ^24.0.0"
53
+ "node": "^22.12.0 || ^24.0.0 || ^26.0.0"
54
54
  },
55
55
  "scripts": {
56
56
  "lint": "eslint . --max-warnings=0",
57
- "lint:fix": "npm run lint -- --fix"
57
+ "lint:fix": "npm run lint -- --fix",
58
+ "test": "vitest run",
59
+ "test:watch": "vitest watch",
60
+ "test-coverage": "npm run test -- --coverage",
61
+ "changelog:sync": "node .github/scripts/sync-changelog.mjs"
58
62
  },
59
63
  "dependencies": {
60
64
  "@homebridge/plugin-ui-utils": "^2.2.5",
@@ -65,6 +69,8 @@
65
69
  "p-timeout": "^7.0.1"
66
70
  },
67
71
  "devDependencies": {
68
- "@antfu/eslint-config": "^9.2.0"
72
+ "@antfu/eslint-config": "^9.2.0",
73
+ "@vitest/coverage-v8": "^4.1.10",
74
+ "vitest": "^4.1.10"
69
75
  }
70
76
  }