@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 +19 -0
- package/lib/device/thermostat-mts215.js +74 -10
- package/lib/utils/functions.test.js +78 -0
- package/lib/utils/lang-en.js +1 -0
- package/package.json +10 -4
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
|
-
|
|
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(
|
|
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
|
|
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
|
+
})
|
package/lib/utils/lang-en.js
CHANGED
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.
|
|
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
|
}
|