@homebridge-plugins/homebridge-meross 10.20.1 → 10.20.2-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,14 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-meross` will be documented in this file.
4
4
 
5
+ ## v10.20.2 (Pending Release)
6
+
7
+ ### Changed
8
+
9
+ - fix(ui): let a hub's sensors be hidden one by one from the my devices tab (#816)
10
+ - feat: add support for the mss815 in-wall switch (#813)
11
+ - fix(thermostat): give the mts960 its own handler, so it shows its temperature and mode (#809)
12
+
5
13
  ## v10.20.1 (2026-10-18)
6
14
 
7
15
  ### Changed
@@ -325,6 +325,10 @@
325
325
  "title": "MSS810",
326
326
  "enum": ["MSS810"]
327
327
  },
328
+ {
329
+ "title": "MSS815",
330
+ "enum": ["MSS815"]
331
+ },
328
332
  {
329
333
  "title": "P11",
330
334
  "enum": ["P11"]
@@ -30,6 +30,7 @@ import deviceSwitchSingle from './switch-single.js'
30
30
  import deviceTemplate from './template.js'
31
31
  import deviceThermostatMts215 from './thermostat-mts215.js'
32
32
  import deviceThermostatMts300 from './thermostat-mts300.js'
33
+ import deviceThermostatMts960 from './thermostat-mts960.js'
33
34
  import deviceThermostat from './thermostat.js'
34
35
 
35
36
  export default {
@@ -65,5 +66,6 @@ export default {
65
66
  deviceTemplate,
66
67
  deviceThermostat,
67
68
  deviceThermostatMts300,
69
+ deviceThermostatMts960,
68
70
  deviceThermostatMts215,
69
71
  }
@@ -0,0 +1,213 @@
1
+ import PQueue from 'p-queue'
2
+
3
+ import mqttClient from '../connection/mqtt.js'
4
+ import platformConsts from '../utils/constants.js'
5
+ import { hasProperty, isTimeoutError, parseError } from '../utils/functions.js'
6
+ import platformLang from '../utils/lang-en.js'
7
+
8
+ // The MTS960 plug-in thermostat speaks Appliance.Control.Thermostat.ModeB, not
9
+ // the .Mode the MTS200 uses. Its digest arrives as `thermostat.modeB`, its
10
+ // temperatures are hundredths of a degree, and it uses 1 for on and 2 for off.
11
+ // Read with the MTS200 handler, none of that matched, so the temperature sat at
12
+ // HomeKit's default of 0 and the heat switch never moved (#809).
13
+ const ON = 1
14
+ const OFF = 2
15
+ const WORKING_HEAT = 1
16
+ const WORKING_COOL = 2
17
+ const MODE_HEAT_COOL = 1
18
+
19
+ export default class {
20
+ constructor(platform, accessory) {
21
+ this.hapChar = platform.api.hap.Characteristic
22
+ this.hapErr = platform.api.hap.HapStatusError
23
+ this.hapServ = platform.api.hap.Service
24
+ this.platform = platform
25
+ this.accessory = accessory
26
+ this.name = accessory.displayName
27
+ const cloudRefreshRate = hasProperty(platform.config, 'cloudRefreshRate')
28
+ ? platform.config.cloudRefreshRate
29
+ : platformConsts.defaultValues.cloudRefreshRate
30
+ const localRefreshRate = hasProperty(platform.config, 'refreshRate')
31
+ ? platform.config.refreshRate
32
+ : platformConsts.defaultValues.refreshRate
33
+ this.pollInterval = accessory.context.connection === 'local'
34
+ ? localRefreshRate
35
+ : cloudRefreshRate
36
+ this.tempScale = 100
37
+
38
+ this.service = this.accessory.getService(this.hapServ.Thermostat)
39
+ || this.accessory.addService(this.hapServ.Thermostat)
40
+
41
+ // Off, heat and cool - the plug drives either a heater or a cooler
42
+ this.service
43
+ .getCharacteristic(this.hapChar.TargetHeatingCoolingState)
44
+ .setProps({ minValue: 0, maxValue: 2, validValues: [0, 1, 2] })
45
+ .onSet(async value => this.internalStateUpdate(value))
46
+ this.cacheState = this.service.getCharacteristic(this.hapChar.TargetHeatingCoolingState).value
47
+
48
+ this.service
49
+ .getCharacteristic(this.hapChar.TargetTemperature)
50
+ .setProps({ minValue: 5, maxValue: 35, minStep: 0.5 })
51
+ .onSet(async value => this.internalTargetUpdate(value))
52
+ this.cacheTarg = this.service.getCharacteristic(this.hapChar.TargetTemperature).value
53
+ this.cacheTemp = this.service.getCharacteristic(this.hapChar.CurrentTemperature).value
54
+
55
+ this.accessory.eveService = new platform.eveService('custom', this.accessory, { log: () => {} })
56
+
57
+ this.updateInProgress = false
58
+ this.queue = new PQueue({ concurrency: 1, interval: 250, intervalCap: 1, timeout: 10000, throwOnTimeout: true })
59
+ this.queue.addEventListener('idle', () => {
60
+ this.updateInProgress = false
61
+ })
62
+
63
+ if (accessory.context.connection !== 'local') {
64
+ this.accessory.mqtt = new mqttClient(platform, this.accessory)
65
+ this.accessory.mqtt.connect()
66
+ }
67
+
68
+ setTimeout(() => this.requestUpdate(true), 2000)
69
+ this.accessory.refreshInterval = setInterval(() => this.requestUpdate(), this.pollInterval * 1000)
70
+
71
+ platform.log('[%s] %s %s.', this.name, platformLang.devInitOpts, JSON.stringify({
72
+ connection: this.accessory.context.connection,
73
+ }))
74
+ }
75
+
76
+ async sendModeB(fields) {
77
+ await this.platform.sendUpdate(this.accessory, {
78
+ namespace: 'Appliance.Control.Thermostat.ModeB',
79
+ payload: { modeB: [{ channel: 0, ...fields }] },
80
+ })
81
+ }
82
+
83
+ async internalStateUpdate(value) {
84
+ try {
85
+ await this.queue.add(async () => {
86
+ if (value === this.cacheState) {
87
+ return
88
+ }
89
+ this.updateInProgress = true
90
+ await this.sendModeB(value === 0
91
+ ? { onoff: OFF }
92
+ : { onoff: ON, mode: MODE_HEAT_COOL, working: value === 2 ? WORKING_COOL : WORKING_HEAT })
93
+ this.cacheState = value
94
+ this.accessory.log(`${platformLang.curState} [${['off', 'heat', 'cool'][value]}]`)
95
+ })
96
+ } catch (err) {
97
+ const eText = isTimeoutError(err) ? platformLang.timeout : parseError(err)
98
+ this.accessory.logWarn(`${platformLang.sendFailed} ${eText}`)
99
+ setTimeout(() => {
100
+ this.service.updateCharacteristic(this.hapChar.TargetHeatingCoolingState, this.cacheState)
101
+ }, 2000)
102
+ throw new this.hapErr(-70402)
103
+ }
104
+ }
105
+
106
+ async internalTargetUpdate(value) {
107
+ try {
108
+ await this.queue.add(async () => {
109
+ if (value === this.cacheTarg) {
110
+ return
111
+ }
112
+ this.updateInProgress = true
113
+ await this.sendModeB({
114
+ mode: MODE_HEAT_COOL,
115
+ working: this.cacheState === 2 ? WORKING_COOL : WORKING_HEAT,
116
+ targetTemp: Math.round(value * this.tempScale),
117
+ })
118
+ this.cacheTarg = value
119
+ this.accessory.log(`${platformLang.curTarg} [${value}°C]`)
120
+ })
121
+ } catch (err) {
122
+ const eText = isTimeoutError(err) ? platformLang.timeout : parseError(err)
123
+ this.accessory.logWarn(`${platformLang.sendFailed} ${eText}`)
124
+ setTimeout(() => {
125
+ this.service.updateCharacteristic(this.hapChar.TargetTemperature, this.cacheTarg)
126
+ }, 2000)
127
+ throw new this.hapErr(-70402)
128
+ }
129
+ }
130
+
131
+ async requestUpdate(firstRun = false) {
132
+ try {
133
+ if (this.updateInProgress) {
134
+ return
135
+ }
136
+ await this.queue.add(async () => {
137
+ this.updateInProgress = true
138
+ const res = await this.platform.sendUpdate(this.accessory, {
139
+ namespace: 'Appliance.System.All',
140
+ payload: {},
141
+ })
142
+ this.accessory.logDebug(`${platformLang.incPoll}: ${JSON.stringify(res.data)}`)
143
+ const data = res.data.payload
144
+ if (data.all?.digest?.thermostat) {
145
+ this.applyUpdate(data.all.digest.thermostat)
146
+ }
147
+ if (firstRun && data.all?.system?.hardware) {
148
+ this.accessory.context.macAddress = data.all.system.hardware.macAddress.toUpperCase()
149
+ this.accessory.context.hardware = data.all.system.hardware.version
150
+ this.accessory.context.firmware = data.all.system.firmware?.version
151
+ this.platform.updateAccessory(this.accessory)
152
+ }
153
+ })
154
+ } catch (err) {
155
+ const eText = isTimeoutError(err) ? platformLang.timeout : parseError(err)
156
+ this.accessory.logDebugWarn(`${platformLang.reqFailed}: ${eText}`)
157
+ }
158
+ }
159
+
160
+ receiveUpdate(params) {
161
+ try {
162
+ this.accessory.logDebug(`${platformLang.incMQTT}: ${JSON.stringify(params)}`)
163
+ if (params.payload) {
164
+ this.applyUpdate(params.payload)
165
+ }
166
+ } catch (err) {
167
+ this.accessory.logWarn(`${platformLang.refFailed} ${parseError(err)}`)
168
+ }
169
+ }
170
+
171
+ applyUpdate(data) {
172
+ const modeData = data.modeB?.[0]
173
+ if (!modeData) {
174
+ return
175
+ }
176
+ if (hasProperty(modeData, 'onoff') || hasProperty(modeData, 'working')) {
177
+ const onoff = modeData.onoff ?? (this.cacheState === 0 ? OFF : ON)
178
+ const working = modeData.working ?? (this.cacheState === 2 ? WORKING_COOL : WORKING_HEAT)
179
+ const newState = onoff === ON ? (working === WORKING_COOL ? 2 : 1) : 0
180
+ if (this.cacheState !== newState) {
181
+ this.service.updateCharacteristic(this.hapChar.TargetHeatingCoolingState, newState)
182
+ this.cacheState = newState
183
+ this.accessory.log(`${platformLang.curState} [${['off', 'heat', 'cool'][newState]}]`)
184
+ }
185
+ }
186
+ // Schedule and timer modes report a target of 0, which is not a setpoint
187
+ if (modeData.targetTemp > 0) {
188
+ const newTarg = Math.min(35, Math.max(5, Math.round(modeData.targetTemp / 50) / 2))
189
+ if (this.cacheTarg !== newTarg) {
190
+ this.service.updateCharacteristic(this.hapChar.TargetTemperature, newTarg)
191
+ this.cacheTarg = newTarg
192
+ this.accessory.log(`${platformLang.curTarg} [${newTarg}°C]`)
193
+ }
194
+ }
195
+ if (hasProperty(modeData, 'currentTemp')) {
196
+ const newTemp = modeData.currentTemp / this.tempScale
197
+ if (this.cacheTemp !== newTemp) {
198
+ this.service.updateCharacteristic(this.hapChar.CurrentTemperature, newTemp)
199
+ this.cacheTemp = newTemp
200
+ this.accessory.eveService.addEntry({ temp: newTemp })
201
+ this.accessory.log(`${platformLang.curTemp} [${newTemp}°C]`)
202
+ }
203
+ }
204
+ // `state` is the relay: 1 when the plug is powering the heater or cooler
205
+ if (hasProperty(modeData, 'state')) {
206
+ const relayOn = modeData.state === ON && this.cacheState !== 0
207
+ this.service.updateCharacteristic(
208
+ this.hapChar.CurrentHeatingCoolingState,
209
+ relayOn ? this.cacheState : 0,
210
+ )
211
+ }
212
+ }
213
+ }
@@ -0,0 +1,57 @@
1
+ import platformConsts from '../utils/constants.js'
2
+
3
+ /**
4
+ * Which config array the settings page should put a device in, by its model.
5
+ *
6
+ * A model missing here cannot be added or hidden from the My Devices tab -
7
+ * the page just reports it as unsupported, even though the plugin itself runs
8
+ * it fine (#816).
9
+ *
10
+ * @param {string} model the device model, as the plugin stores it
11
+ * @returns {string|null} the config array, or null when the model is unknown
12
+ */
13
+ export function getDeviceConfigKey(model) {
14
+ if (!model) {
15
+ return null
16
+ }
17
+ const m = platformConsts.models
18
+ if (m.switchSingle.includes(model)) {
19
+ return 'singleDevices'
20
+ }
21
+ if (Object.hasOwn(m.switchMulti, model)) {
22
+ return 'multiDevices'
23
+ }
24
+ if (m.lightDimmer.includes(model) || m.lightRGB.includes(model) || m.lightCCT.includes(model)) {
25
+ return 'lightDevices'
26
+ }
27
+ if (m.fan.includes(model)) {
28
+ return 'fanDevices'
29
+ }
30
+ if (m.diffuser.includes(model)) {
31
+ return 'diffuserDevices'
32
+ }
33
+ if (m.purifier.includes(model)) {
34
+ return 'purifierDevices'
35
+ }
36
+ if (m.humidifier.includes(model)) {
37
+ return 'humidifierDevices'
38
+ }
39
+ if (m.thermostat.includes(model) || m.thermostatMts215.includes(model) || m.thermostatMts300.includes(model) || m.thermostatMts960.includes(model)) {
40
+ return 'thermostatDevices'
41
+ }
42
+ if (m.garage.includes(model)) {
43
+ return 'garageDevices'
44
+ }
45
+ if (m.roller.includes(model)) {
46
+ return 'rollerDevices'
47
+ }
48
+ if (m.baby.includes(model)) {
49
+ return 'babyDevices'
50
+ }
51
+ // A hub's config entry is where its sensors' settings live, including the
52
+ // list of sensors to hide (#816)
53
+ if (m.sensorPresence.includes(model) || m.hubMain.includes(model)) {
54
+ return 'sensorDevices'
55
+ }
56
+ return null
57
+ }
@@ -270,6 +270,14 @@
270
270
  // by hand in a different format still counts as the same device
271
271
  const looseId = id => (id || '').toString().toLowerCase().replace(/[^a-z0-9]+/g, '')
272
272
 
273
+ // A hub's sensors share the hub's config entry, so hiding one sensor
274
+ // lists its id in the entry's ignoreSubdevices - ignoreDevice there would
275
+ // hide the hub and every sensor on it (#816)
276
+ const subIdsOf = entry => (entry?.ignoreSubdevices || '').split(',').map(id => id.trim()).filter(Boolean)
277
+ const isHiddenIn = (entry, acc) => acc.context.subSerialNumber
278
+ ? subIdsOf(entry).includes(acc.context.subSerialNumber)
279
+ : !!entry?.ignoreDevice
280
+
273
281
  const getConfigBlocks = async () => {
274
282
  const blocks = await homebridge.getPluginConfig()
275
283
  if (!blocks.length) {
@@ -332,7 +340,9 @@
332
340
  if (!Array.isArray(config[arrayKey])) {
333
341
  config[arrayKey] = []
334
342
  }
335
- config[arrayKey].push({ name: acc.displayName, serialNumber: looseId(acc.context.serialNumber) })
343
+ // A hub sensor's entry is the hub's own, so name it after the hub
344
+ const name = acc.context.subSerialNumber ? `${acc.context.model} hub` : acc.displayName
345
+ config[arrayKey].push({ name, serialNumber: looseId(acc.context.serialNumber) })
336
346
  return true
337
347
  }
338
348
 
@@ -351,7 +361,7 @@
351
361
  // re-check and never add a duplicate - just correct the button
352
362
  if (found) {
353
363
  setConfigButton(true)
354
- setHideControl(!!config[found.key][found.index].ignoreDevice)
364
+ setHideControl(isHiddenIn(config[found.key][found.index], acc))
355
365
  homebridge.toast.info(acc.displayName + ' is already in the config.', 'Not Added')
356
366
  return
357
367
  }
@@ -418,7 +428,13 @@
418
428
  }
419
429
  found = findInConfig(config, acc.context.serialNumber)
420
430
  }
421
- config[found.key][found.index].ignoreDevice = true
431
+ const entry = config[found.key][found.index]
432
+ const subId = acc.context.subSerialNumber
433
+ if (subId) {
434
+ entry.ignoreSubdevices = [...new Set([...subIdsOf(entry), subId])].join(',')
435
+ } else {
436
+ entry.ignoreDevice = true
437
+ }
422
438
  await homebridge.updatePluginConfig(blocks)
423
439
  setConfigButton(true)
424
440
  setHideControl(true)
@@ -427,7 +443,18 @@
427
443
  if (found) {
428
444
  // Drop the flag rather than storing ignoreDevice false, to keep
429
445
  // the entry clean
430
- delete config[found.key][found.index].ignoreDevice
446
+ const entry = config[found.key][found.index]
447
+ const subId = acc.context.subSerialNumber
448
+ if (subId) {
449
+ const rest = subIdsOf(entry).filter(id => id !== subId)
450
+ if (rest.length > 0) {
451
+ entry.ignoreSubdevices = rest.join(',')
452
+ } else {
453
+ delete entry.ignoreSubdevices
454
+ }
455
+ } else {
456
+ delete entry.ignoreDevice
457
+ }
431
458
  await homebridge.updatePluginConfig(blocks)
432
459
  }
433
460
  setConfigButton(!!found)
@@ -475,7 +502,7 @@
475
502
  const nowFound = findInConfig(config, acc.context.serialNumber)
476
503
  setConfigButton(!!nowFound)
477
504
  if (nowFound) {
478
- setHideControl(!!config[nowFound.key][nowFound.index].ignoreDevice)
505
+ setHideControl(isHiddenIn(config[nowFound.key][nowFound.index], acc))
479
506
  }
480
507
  }
481
508
  } catch (err) {
@@ -607,7 +634,7 @@
607
634
  const configBlocks = await getConfigBlocks()
608
635
  const configEntry = findInConfig(configBlocks[0], context.serialNumber)
609
636
  setConfigButton(!!configEntry)
610
- setHideControl(!!(configEntry && configBlocks[0][configEntry.key][configEntry.index].ignoreDevice))
637
+ setHideControl(!!configEntry && isHiddenIn(configBlocks[0][configEntry.key][configEntry.index], { context }))
611
638
  document.getElementById('configActionList').style.display = 'block'
612
639
  homebridge.hideSpinner()
613
640
  }
@@ -1,50 +1,6 @@
1
1
  import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils'
2
2
 
3
- import platformConsts from '../utils/constants.js'
4
-
5
- function getDeviceConfigKey(model) {
6
- if (!model) {
7
- return null
8
- }
9
- const m = platformConsts.models
10
- if (m.switchSingle.includes(model)) {
11
- return 'singleDevices'
12
- }
13
- if (Object.hasOwn(m.switchMulti, model)) {
14
- return 'multiDevices'
15
- }
16
- if (m.lightDimmer.includes(model) || m.lightRGB.includes(model) || m.lightCCT.includes(model)) {
17
- return 'lightDevices'
18
- }
19
- if (m.fan.includes(model)) {
20
- return 'fanDevices'
21
- }
22
- if (m.diffuser.includes(model)) {
23
- return 'diffuserDevices'
24
- }
25
- if (m.purifier.includes(model)) {
26
- return 'purifierDevices'
27
- }
28
- if (m.humidifier.includes(model)) {
29
- return 'humidifierDevices'
30
- }
31
- if (m.thermostat.includes(model) || m.thermostatMts215.includes(model) || m.thermostatMts300.includes(model)) {
32
- return 'thermostatDevices'
33
- }
34
- if (m.garage.includes(model)) {
35
- return 'garageDevices'
36
- }
37
- if (m.roller.includes(model)) {
38
- return 'rollerDevices'
39
- }
40
- if (m.baby.includes(model)) {
41
- return 'babyDevices'
42
- }
43
- if (m.sensorPresence.includes(model)) {
44
- return 'sensorDevices'
45
- }
46
- return null
47
- }
3
+ import { getDeviceConfigKey } from './config-key.js'
48
4
 
49
5
  class PluginUiServer extends HomebridgePluginUiServer {
50
6
  constructor() {
package/lib/platform.js CHANGED
@@ -863,6 +863,17 @@ export default class {
863
863
  this.applyAccessoryLogging(accessory)
864
864
  accessory.control = new deviceTypes.deviceThermostatMts300(this, accessory)
865
865
  /** */
866
+ } else if (platformConsts.models.thermostatMts960.includes(device.model)) {
867
+ /**
868
+ ********
869
+ THERMOSTATS (MTS960)
870
+ *********
871
+ */
872
+ accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
873
+ accessory.context = { ...accessory.context, ...context }
874
+ this.applyAccessoryLogging(accessory)
875
+ accessory.control = new deviceTypes.deviceThermostatMts960(this, accessory)
876
+ /** */
866
877
  } else if (platformConsts.models.thermostatMts215.includes(device.model)) {
867
878
  /**
868
879
  ********
@@ -240,6 +240,7 @@ export default {
240
240
  'MSS710',
241
241
  'MSS710R',
242
242
  'MSS810',
243
+ 'MSS815',
243
244
  'P11',
244
245
  'R10',
245
246
  'R11',
@@ -316,8 +317,9 @@ export default {
316
317
  garage: ['MSG100', 'MSG150', 'MSG200'],
317
318
  roller: ['MRS100', 'MRS105', 'MRS200'],
318
319
  baby: ['HP110A', 'HP110AHK'],
319
- thermostat: ['MTS200', 'MTS200B', 'MTS205', 'MTS205B', 'MTS960'],
320
+ thermostat: ['MTS200', 'MTS200B', 'MTS205', 'MTS205B'],
320
321
  thermostatMts300: ['MTS300'],
322
+ thermostatMts960: ['MTS960'],
321
323
  thermostatMts215: ['MTS215', 'MTS215B'],
322
324
  sensorPresence: ['MS600'],
323
325
  hubMain: ['MSH300', 'MSH300HK', 'MSH400', 'MSH450'],
@@ -387,6 +389,7 @@ export default {
387
389
  MSP844: ['5', '9'], // https://github.com/homebridge-plugins/homebridge-meross/issues/578, https://github.com/homebridge-plugins/homebridge-meross/issues/585
388
390
  MSS115: ['9'], // https://github.com/homebridge-plugins/homebridge-meross/issues/549
389
391
  MSS315: ['9'], // https://github.com/homebridge-plugins/homebridge-meross/issues/537
392
+ MSS815: ['9'], // https://github.com/homebridge-plugins/homebridge-meross/issues/813
390
393
  },
391
394
 
392
395
  noLocalControl: ['MSH300', 'MSH300HK', 'MSH400', 'MSH450'],
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.20.1",
5
+ "version": "10.20.2-beta.0",
6
6
  "description": "Homebridge plugin to integrate Meross devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",