@homebridge-plugins/homebridge-meross 10.17.1 → 10.19.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,26 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-meross` will be documented in this file.
4
4
 
5
+ ## v10.19.0 (2026-08-01)
6
+
7
+ ### Changed
8
+
9
+ - feat: add support for the mss110c single channel plug (#802)
10
+ - chore(deps): dependency updates
11
+
12
+ ## v10.18.0 (2026-07-30)
13
+
14
+ ### Changed
15
+
16
+ - chore: add a test setup with tests for the shared helper functions
17
+ - chore(github): run the build and tests in ci, on node 22, 24 and 26
18
+ - chore: use the same lint setup across every plugin
19
+ - chore: add a changelog:sync script to populate the pending section from the commits
20
+ - chore: count a repeated commit subject once when syncing the changelog
21
+ - chore(github): check the changelog against the commits in ci
22
+ - feat(thermostat): honour SummerMode on MTS215/MTS215B (@marcaicart)
23
+ - chore(deps): dependency updates
24
+
5
25
  ## v10.17.1 (2026-07-28)
6
26
 
7
27
  ### Changed
@@ -197,6 +197,10 @@
197
197
  "title": "MSS115",
198
198
  "enum": ["MSS115"]
199
199
  },
200
+ {
201
+ "title": "MSS110C",
202
+ "enum": ["MSS110C"]
203
+ },
200
204
  {
201
205
  "title": "MSS110NR",
202
206
  "enum": ["MSS110NR"]
@@ -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
@@ -495,6 +535,18 @@ export default class {
495
535
 
496
536
  applyUpdate(data) {
497
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
+
498
550
  const modeData = data.mode?.[0]
499
551
  if (modeData) {
500
552
  let needsUpdate = false
@@ -503,7 +555,10 @@ export default class {
503
555
 
504
556
  // Check against the cache and update HomeKit and the cache if needed
505
557
  if (this.cacheState !== newState) {
506
- 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
+ )
507
562
  this.cacheState = newState
508
563
  this.accessory.log(`${platformLang.curState} [${newState === 1 ? 'on' : 'off'}]`)
509
564
  needsUpdate = true
@@ -536,11 +591,15 @@ export default class {
536
591
  }
537
592
  }
538
593
 
539
- // 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.
540
599
  if (needsUpdate) {
541
600
  this.service.updateCharacteristic(
542
601
  this.hapChar.CurrentHeatingCoolingState,
543
- this.cacheState === 1 && this.cacheTarg > this.cacheTemp ? 1 : 0,
602
+ this.cacheState === 1 ? this.heatCoolValue() : this.hapChar.CurrentHeatingCoolingState.OFF,
544
603
  )
545
604
  }
546
605
  }
@@ -202,6 +202,7 @@ export default {
202
202
  'MSS115',
203
203
  'MSS1101',
204
204
  'MSS1102',
205
+ 'MSS110C',
205
206
  'MSS110NR',
206
207
  'MSS110R',
207
208
  'MSS110RTL',
@@ -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.1",
5
+ "version": "10.19.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
  }