@homebridge-plugins/homebridge-meross 10.17.1 → 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 +13 -0
- package/lib/device/thermostat-mts215.js +69 -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,19 @@
|
|
|
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
|
+
|
|
5
18
|
## v10.17.1 (2026-07-28)
|
|
6
19
|
|
|
7
20
|
### 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
|
|
@@ -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(
|
|
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
|
|
602
|
+
this.cacheState === 1 ? this.heatCoolValue() : this.hapChar.CurrentHeatingCoolingState.OFF,
|
|
544
603
|
)
|
|
545
604
|
}
|
|
546
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
|
}
|