@homebridge-plugins/homebridge-govee 11.32.0-beta.9 → 11.32.1

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,16 +2,53 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-govee` will be documented in this file.
4
4
 
5
- ## v11.32.0 (Pending Release)
5
+ ## v11.32.1 (2026-08-02)
6
+
7
+ ### Changed
8
+
9
+ - docs: add node 26 to the supported node versions
10
+ - fix: read the H5106 monitor's temperature and humidity from the http device list too, so accounts whose aws channel never delivers still get readings (#1322)
11
+
12
+ ## v11.32.0 (2026-08-02)
6
13
 
7
14
  ### Changed
8
15
 
9
16
  - chore(github): use the shared homebridge action to deprecate past pre-releases
17
+ - chore(github): allow the pre-release keep count to be set when running the deprecation workflow
10
18
  - feat(ui): hide or show a device in homekit from the my devices tab
11
19
  - chore(deps): dependency updates
12
20
  - style(ui): standardise the custom ui layout and sync the support tab with the readme
13
21
  - chore(deps): override help-me to v5 to clear the high brace-expansion security advisory from the aws-iot dependency chain
14
22
  - feat(ui): add a remove all devices action to the my devices tab (#1323)
23
+ - feat: add support for the H5089 smart plug, exposing its outlet and built-in light as separate switches (#1323)
24
+ - chore: declare the supports-hap transport keyword for the homebridge ui
25
+ - feat: add support for the H60B3 uplighter as an rgb light (#1325)
26
+ - feat: add support for the H5901 water timer through the govee openapi (#1324)
27
+ - fix(fans): map rotation speed as a percentage on every fan model, so homekit stops showing speeds like 7% and the top of the slider means full power (#1326) (@cwhett)
28
+ - chore: log what each device can do according to the govee openapi, in debug mode, to make working out new device support quicker
29
+ - chore: decode the status codes a device reports into readable hex in debug mode, so support for new device features can be worked out from a normal log
30
+ - feat: add the H5089 night light as a homekit light tile with on, off and brightness (#1323)
31
+ - fix: send the H5089 night light commands as the newer ptIot type the plug listens for, instead of being silently ignored (#1323)
32
+ - fix: also send the H5089 night light the command shape the supported govee purifiers use, since mirroring the plug's own status code was not enough on its own (#1323)
33
+ - fix: stop sending a per-outlet value to the govee api, which rejected every press with a parameter out of range error on two-outlet plugs (#1323)
34
+ - fix: hide the H5089 night light tile until its commands are worked out, rather than showing a control that does nothing (#1323)
35
+ - feat: add support for the H3001 solar string lights as an rgb light (#1328)
36
+ - fix(fans): rebuild the fan service once when it was cached under the old speed scale, so the top of the slider stops setting the slowest speed (#1326) (@cwhett)
37
+ - fix: show the H5089 night light tile again, but refuse control commands, so it reports the real state instead of quietly ignoring taps (#1323)
38
+ - fix(ice-makers): stop the home app showing an ice size of 0 when one has never been set (#1329) (@cwhett)
39
+ - chore: add a test setup with tests for the shared helper functions
40
+ - chore(github): run the build and tests in ci, on node 22, 24 and 26
41
+ - chore: use the same lint setup across every plugin
42
+ - chore: add a changelog:sync script to populate the pending section from the commits
43
+ - chore(deps): refresh the lock file for the eslint config bump
44
+ - fix(ble): size the update queue timeout from the connection stages, so a slow device still has time to send its command (#1328)
45
+ - chore: count a repeated commit subject once when syncing the changelog
46
+ - chore(github): check the changelog against the commits in ci
47
+ - fix(openapi): report govee's own reason when it refuses a command, instead of the command appearing to succeed (#1324)
48
+ - fix: report why a command failed instead of claiming no connection method was available (#1324)
49
+ - fix(ble): use the segment-based colour command for the H617A, whose LEDs ignore the simple RGB colour commands (#1332)
50
+ - feat: add support for the H1250 ceiling light pro as an rgb light (#1333)
51
+ - fix: stop rescaling the H6022's aws brightness reports, which are already a percentage, so its brightness stops bouncing to a wrong value after every change
15
52
 
16
53
  ## v11.31.0 (2026-07-24)
17
54
 
package/README.md CHANGED
@@ -27,7 +27,7 @@ Homebridge plugin to integrate Govee devices into HomeKit
27
27
  ### Prerequisites
28
28
 
29
29
  - To use this plugin, you will need to already have:
30
- - [Node](https://nodejs.org): latest version of `v22` or `v24` - any other major version is not supported.
30
+ - [Node](https://nodejs.org): latest version of `v22`, `v24` or `v26` - any other major version is not supported.
31
31
  - [Homebridge](https://homebridge.io): `v1.6` or above - refer to link for more information and installation instructions.
32
32
  - For bluetooth connectivity, it may be necessary to install extra packages on your system, see [Bluetooth Control](https://github.com/homebridge-plugins/homebridge-govee/wiki/Bluetooth-Control). Bluetooth works best when using a Raspberry Pi, not been tested on Windows, and Mac devices are unsupported.
33
33
 
@@ -3,7 +3,7 @@ import process from 'node:process'
3
3
 
4
4
  import btClient from '@stoprocent/noble'
5
5
 
6
- import { UUID } from '../utils/ble-protocol.js'
6
+ import { BLE_STAGE_TIMEOUTS, UUID } from '../utils/ble-protocol.js'
7
7
  import { decodeAny } from '../utils/decode.js'
8
8
  import { base64ToHex, generateCodeFromHexValues, hexToTwoItems, sleep } from '../utils/functions.js'
9
9
  import platformLang from '../utils/lang-en.js'
@@ -15,8 +15,11 @@ process.env.NOBLE_REPORT_ALL_HCI_EVENTS = '1' // needed on Linux including Raspb
15
15
 
16
16
  const H5075_UUID = 'ec88'
17
17
  const H5101_UUID = '0001'
18
- const CONNECTION_TIMEOUT = 10000 // 10 seconds
19
- const WRITE_TIMEOUT = 5000 // 5 seconds
18
+
19
+ const POWER_ON_TIMEOUT = BLE_STAGE_TIMEOUTS.powerOn
20
+ const CONNECTION_TIMEOUT = BLE_STAGE_TIMEOUTS.connect
21
+ const WRITE_TIMEOUT = BLE_STAGE_TIMEOUTS.write
22
+ const SETTLE_DELAY = BLE_STAGE_TIMEOUTS.settle
20
23
 
21
24
  /*
22
25
  The necessary commands to send and functions are taken from and credit to:
@@ -169,7 +172,7 @@ export default class BLEConnection {
169
172
  }
170
173
  }
171
174
 
172
- async waitForPowerOn(timeout = 5000) {
175
+ async waitForPowerOn(timeout = POWER_ON_TIMEOUT) {
173
176
  if (this.btState === 'poweredOn') {
174
177
  return true
175
178
  }
@@ -240,7 +243,7 @@ export default class BLEConnection {
240
243
  // Write without response then allow time for the BLE controller to transmit before disconnecting
241
244
  await this.writeWithTimeout(characteristic, finalBuffer, WRITE_TIMEOUT)
242
245
  accessory.logDebug('command sent successfully')
243
- await sleep(100)
246
+ await sleep(SETTLE_DELAY)
244
247
  } catch (err) {
245
248
  accessory.logWarn(`BLE update failed: ${err.message}`)
246
249
  throw err
@@ -2,7 +2,7 @@ import axios from 'axios'
2
2
  import mqtt from 'mqtt'
3
3
 
4
4
  import { k2rgb } from '../utils/colour.js'
5
- import { parseError } from '../utils/functions.js'
5
+ import { describeControlFailure, parseError } from '../utils/functions.js'
6
6
 
7
7
  const BASE_URL = 'https://openapi.api.govee.com'
8
8
 
@@ -500,7 +500,7 @@ export default class {
500
500
  async updateDevice(accessory, params) {
501
501
  const capability = this.buildCapabilityPayload(accessory, params)
502
502
 
503
- await this.request({
503
+ const response = await this.request({
504
504
  method: 'post',
505
505
  path: '/router/api/v1/device/control',
506
506
  data: {
@@ -512,6 +512,18 @@ export default class {
512
512
  },
513
513
  },
514
514
  })
515
+
516
+ // Govee can accept the request and still refuse the command, reporting it
517
+ // only inside `capability.state`. Without this the plugin treated that as
518
+ // success and the device quietly ignored the command (#1324).
519
+ const refusal = describeControlFailure(response)
520
+ if (refusal) {
521
+ const error = new Error(refusal)
522
+ // Their wording is aimed at the user and describes the device's state, so
523
+ // log it on its own — a stack trace here suggests a fault in the plugin.
524
+ error.deviceRefusal = true
525
+ throw error
526
+ }
515
527
  }
516
528
 
517
529
  async connectMQTT() {
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  base64ToHex,
3
+ fanSpeedToHkPercent,
3
4
  getTwoItemPosition,
4
5
  hexToTwoItems,
6
+ hkPercentToFanSpeed,
5
7
  parseError,
6
8
  } from '../utils/functions.js'
7
9
  import platformLang from '../utils/lang-en.js'
@@ -30,16 +32,20 @@ export default class {
30
32
  9: 'MwUAAwAAAAAAAAAAAAAAAAAAADU=', // auto mode
31
33
  }
32
34
  this.autoSpeed = 9
35
+ // Real speed steps, excluding auto - HomeKit percentages map onto these
36
+ this.speedSteps = 8
33
37
 
34
38
  // Remove any old original Fan services
35
39
  if (this.accessory.getService(this.hapServ.Fan)) {
36
40
  this.accessory.removeService(this.accessory.getService(this.hapServ.Fan))
37
41
  }
38
42
 
39
- // Migrate old %-rotation speed to unitless
43
+ // Rebuild the fan service if it was cached under an older speed scale, as
44
+ // HomeKit keeps the old props until the service itself is replaced
40
45
  const existingService = this.accessory.getService(this.hapServ.Fanv2)
41
46
  if (existingService) {
42
- if (existingService.getCharacteristic(this.hapChar.RotationSpeed).props.unit === 'percentage') {
47
+ const { props } = existingService.getCharacteristic(this.hapChar.RotationSpeed)
48
+ if (props.unit === 'percentage' || props.maxValue !== 100) {
43
49
  this.accessory.removeService(existingService)
44
50
  }
45
51
  }
@@ -57,7 +63,7 @@ export default class {
57
63
  this.service
58
64
  .getCharacteristic(this.hapChar.RotationSpeed)
59
65
  .setProps({
60
- maxValue: this.autoSpeed,
66
+ maxValue: 100,
61
67
  minStep: 1,
62
68
  minValue: 0,
63
69
  unit: 'unitless',
@@ -115,6 +121,9 @@ export default class {
115
121
  return
116
122
  }
117
123
 
124
+ // HomeKit sends a percentage - map it onto this model's real speed steps
125
+ value = hkPercentToFanSpeed(value, this.speedSteps)
126
+
118
127
  // Don't continue if the new value is the same as before
119
128
  if (this.cacheSpeed === value) {
120
129
  return
@@ -142,7 +151,7 @@ export default class {
142
151
 
143
152
  // Throw a 'no response' error and set a timeout to revert this after 2 seconds
144
153
  setTimeout(() => {
145
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
154
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, fanSpeedToHkPercent(this.cacheSpeed, this.speedSteps, this.autoSpeed))
146
155
  }, 2000)
147
156
  throw new this.hapErr(-70402)
148
157
  }
@@ -199,8 +208,9 @@ export default class {
199
208
  }
200
209
  if (Number.isFinite(newSpeed) && newSpeed >= 1 && newSpeed <= this.autoSpeed && this.cacheSpeed !== newSpeed) {
201
210
  this.cacheSpeed = newSpeed
202
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
203
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
211
+ const hkSpeed = fanSpeedToHkPercent(newSpeed, this.speedSteps, this.autoSpeed)
212
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
213
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
204
214
  }
205
215
  }
206
216
 
@@ -237,10 +247,11 @@ export default class {
237
247
  case '0501': {
238
248
  // Fan speed
239
249
  const newSpeed = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
240
- if (newSpeed >= 1 && newSpeed <= 8 && this.cacheSpeed !== newSpeed) {
250
+ if (newSpeed >= 1 && newSpeed <= this.speedSteps && this.cacheSpeed !== newSpeed) {
241
251
  this.cacheSpeed = newSpeed
242
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
243
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
252
+ const hkSpeed = fanSpeedToHkPercent(newSpeed, this.speedSteps, this.autoSpeed)
253
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
254
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
244
255
  }
245
256
  break
246
257
  }
@@ -248,7 +259,8 @@ export default class {
248
259
  // Mode change (auto = 0x03)
249
260
  if (getTwoItemPosition(hexParts, 4) === '03' && this.cacheSpeed !== this.autoSpeed) {
250
261
  this.cacheSpeed = this.autoSpeed
251
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
262
+ // Cache auto so we can tell it apart from "user asked for max speed"
263
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, 100)
252
264
  this.accessory.log(`${platformLang.curSpeed} [auto]`)
253
265
  }
254
266
  break
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  base64ToHex,
3
+ fanSpeedToHkPercent,
3
4
  generateCodeFromHexValues,
4
5
  generateRandomString,
5
6
  getTwoItemPosition,
6
7
  hexToTwoItems,
8
+ hkPercentToFanSpeed,
7
9
  parseError,
8
10
  sleep,
9
11
  } from '../utils/functions.js'
@@ -32,6 +34,16 @@ export default class {
32
34
  const deviceConf = platform.deviceConf[accessory.context.gvDeviceId]
33
35
  this.hideLight = deviceConf && deviceConf.hideLight
34
36
 
37
+ // Rebuild the fan service if it was cached under an older speed scale, as
38
+ // HomeKit keeps the old props until the service itself is replaced
39
+ const existingService = this.accessory.getService(this.hapServ.Fanv2)
40
+ if (existingService) {
41
+ const { props } = existingService.getCharacteristic(this.hapChar.RotationSpeed)
42
+ if (props.unit === 'percentage' || props.maxValue !== 100) {
43
+ this.accessory.removeService(existingService)
44
+ }
45
+ }
46
+
35
47
  // Add the fan service if it doesn't already exist
36
48
  this.service = this.accessory.getService(this.hapServ.Fanv2) || this.accessory.addService(this.hapServ.Fanv2)
37
49
 
@@ -41,11 +53,14 @@ export default class {
41
53
  .onSet(async value => this.internalStateUpdate(value))
42
54
  this.cacheState = this.service.getCharacteristic(this.hapChar.Active).value ? 'on' : 'off'
43
55
 
56
+ // Real speed steps - HomeKit percentages map onto these
57
+ this.speedSteps = 12
58
+
44
59
  // Add the set handler to the fan rotation speed characteristic
45
60
  this.service
46
61
  .getCharacteristic(this.hapChar.RotationSpeed)
47
62
  .setProps({
48
- maxValue: 12,
63
+ maxValue: 100,
49
64
  minStep: 1,
50
65
  minValue: 0,
51
66
  unit: 'unitless',
@@ -140,6 +155,9 @@ export default class {
140
155
  return
141
156
  }
142
157
 
158
+ // HomeKit sends a percentage - map it onto this model's real speed steps
159
+ value = hkPercentToFanSpeed(value, this.speedSteps)
160
+
143
161
  // Don't continue if the new value is the same as before
144
162
  if (this.cacheSpeed === value) {
145
163
  return
@@ -162,7 +180,7 @@ export default class {
162
180
 
163
181
  // Throw a 'no response' error and set a timeout to revert this after 2 seconds
164
182
  setTimeout(() => {
165
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
183
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, fanSpeedToHkPercent(this.cacheSpeed, this.speedSteps))
166
184
  }, 2000)
167
185
  throw new this.hapErr(-70402)
168
186
  }
@@ -320,10 +338,11 @@ export default class {
320
338
  case '3101': {
321
339
  // Fan speed
322
340
  const newSpeed = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
323
- if (newSpeed >= 1 && newSpeed <= 12 && this.cacheSpeed !== newSpeed) {
341
+ if (newSpeed >= 1 && newSpeed <= this.speedSteps && this.cacheSpeed !== newSpeed) {
324
342
  this.cacheSpeed = newSpeed
325
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
326
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
343
+ const hkSpeed = fanSpeedToHkPercent(newSpeed, this.speedSteps)
344
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
345
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
327
346
  }
328
347
  break
329
348
  }
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  base64ToHex,
3
+ fanSpeedToHkPercent,
3
4
  getTwoItemPosition,
4
5
  hexToTwoItems,
6
+ hkPercentToFanSpeed,
5
7
  parseError,
6
8
  } from '../utils/functions.js'
7
9
  import platformLang from '../utils/lang-en.js'
@@ -30,16 +32,20 @@ export default class {
30
32
  9: 'MwUAAwAAAAAAAAAAAAAAAAAAADU=', // auto mode
31
33
  }
32
34
  this.autoSpeed = 9
35
+ // Real speed steps, excluding auto - HomeKit percentages map onto these
36
+ this.speedSteps = 8
33
37
 
34
38
  // Remove any old original Fan services
35
39
  if (this.accessory.getService(this.hapServ.Fan)) {
36
40
  this.accessory.removeService(this.accessory.getService(this.hapServ.Fan))
37
41
  }
38
42
 
39
- // Migrate old %-rotation speed to unitless
43
+ // Rebuild the fan service if it was cached under an older speed scale, as
44
+ // HomeKit keeps the old props until the service itself is replaced
40
45
  const existingService = this.accessory.getService(this.hapServ.Fanv2)
41
46
  if (existingService) {
42
- if (existingService.getCharacteristic(this.hapChar.RotationSpeed).props.unit === 'percentage') {
47
+ const { props } = existingService.getCharacteristic(this.hapChar.RotationSpeed)
48
+ if (props.unit === 'percentage' || props.maxValue !== 100) {
43
49
  this.accessory.removeService(existingService)
44
50
  }
45
51
  }
@@ -57,7 +63,7 @@ export default class {
57
63
  this.service
58
64
  .getCharacteristic(this.hapChar.RotationSpeed)
59
65
  .setProps({
60
- maxValue: this.autoSpeed,
66
+ maxValue: 100,
61
67
  minStep: 1,
62
68
  minValue: 0,
63
69
  unit: 'unitless',
@@ -115,6 +121,9 @@ export default class {
115
121
  return
116
122
  }
117
123
 
124
+ // HomeKit sends a percentage - map it onto this model's real speed steps
125
+ value = hkPercentToFanSpeed(value, this.speedSteps)
126
+
118
127
  // Don't continue if the new value is the same as before
119
128
  if (this.cacheSpeed === value) {
120
129
  return
@@ -142,7 +151,7 @@ export default class {
142
151
 
143
152
  // Throw a 'no response' error and set a timeout to revert this after 2 seconds
144
153
  setTimeout(() => {
145
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
154
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, fanSpeedToHkPercent(this.cacheSpeed, this.speedSteps, this.autoSpeed))
146
155
  }, 2000)
147
156
  throw new this.hapErr(-70402)
148
157
  }
@@ -199,8 +208,9 @@ export default class {
199
208
  }
200
209
  if (Number.isFinite(newSpeed) && newSpeed >= 1 && newSpeed <= this.autoSpeed && this.cacheSpeed !== newSpeed) {
201
210
  this.cacheSpeed = newSpeed
202
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
203
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
211
+ const hkSpeed = fanSpeedToHkPercent(newSpeed, this.speedSteps, this.autoSpeed)
212
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
213
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
204
214
  }
205
215
  }
206
216
 
@@ -237,10 +247,11 @@ export default class {
237
247
  case '0501': {
238
248
  // Fan speed
239
249
  const newSpeed = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
240
- if (newSpeed >= 1 && newSpeed <= 8 && this.cacheSpeed !== newSpeed) {
250
+ if (newSpeed >= 1 && newSpeed <= this.speedSteps && this.cacheSpeed !== newSpeed) {
241
251
  this.cacheSpeed = newSpeed
242
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
243
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
252
+ const hkSpeed = fanSpeedToHkPercent(newSpeed, this.speedSteps, this.autoSpeed)
253
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
254
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
244
255
  }
245
256
  break
246
257
  }
@@ -248,7 +259,8 @@ export default class {
248
259
  // Mode change (auto = 0x03)
249
260
  if (getTwoItemPosition(hexParts, 4) === '03' && this.cacheSpeed !== this.autoSpeed) {
250
261
  this.cacheSpeed = this.autoSpeed
251
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
262
+ // Cache auto so we can tell it apart from "user asked for max speed"
263
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, 100)
252
264
  this.accessory.log(`${platformLang.curSpeed} [auto]`)
253
265
  }
254
266
  break
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  base64ToHex,
3
+ fanSpeedToHkPercent,
3
4
  getTwoItemPosition,
4
5
  hexToTwoItems,
6
+ hkPercentToFanSpeed,
5
7
  parseError,
6
8
  } from '../utils/functions.js'
7
9
  import platformLang from '../utils/lang-en.js'
@@ -86,16 +88,20 @@ export default class {
86
88
  9: 'MwUAAwAAAAAAAAAAAAAAAAAAADU=', // auto mode
87
89
  }
88
90
  this.autoSpeed = 9
91
+ // Real speed steps, excluding auto - HomeKit percentages map onto these
92
+ this.speedSteps = 8
89
93
 
90
94
  // Remove any old original Fan services
91
95
  if (this.accessory.getService(this.hapServ.Fan)) {
92
96
  this.accessory.removeService(this.accessory.getService(this.hapServ.Fan))
93
97
  }
94
98
 
95
- // Migrate old %-rotation speed to unitless
99
+ // Rebuild the fan service if it was cached under an older speed scale, as
100
+ // HomeKit keeps the old props until the service itself is replaced
96
101
  const existingService = this.accessory.getService(this.hapServ.Fanv2)
97
102
  if (existingService) {
98
- if (existingService.getCharacteristic(this.hapChar.RotationSpeed).props.unit === 'percentage') {
103
+ const { props } = existingService.getCharacteristic(this.hapChar.RotationSpeed)
104
+ if (props.unit === 'percentage' || props.maxValue !== 100) {
99
105
  this.accessory.removeService(existingService)
100
106
  }
101
107
  }
@@ -113,7 +119,7 @@ export default class {
113
119
  this.service
114
120
  .getCharacteristic(this.hapChar.RotationSpeed)
115
121
  .setProps({
116
- maxValue: this.autoSpeed,
122
+ maxValue: 100,
117
123
  minStep: 1,
118
124
  minValue: 0,
119
125
  unit: 'unitless',
@@ -171,6 +177,9 @@ export default class {
171
177
  return
172
178
  }
173
179
 
180
+ // HomeKit sends a percentage - map it onto this model's real speed steps
181
+ value = hkPercentToFanSpeed(value, this.speedSteps)
182
+
174
183
  // Don't continue if the new value is the same as before
175
184
  if (this.cacheSpeed === value) {
176
185
  return
@@ -198,7 +207,7 @@ export default class {
198
207
 
199
208
  // Throw a 'no response' error and set a timeout to revert this after 2 seconds
200
209
  setTimeout(() => {
201
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
210
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, fanSpeedToHkPercent(this.cacheSpeed, this.speedSteps, this.autoSpeed))
202
211
  }, 2000)
203
212
  throw new this.hapErr(-70402)
204
213
  }
@@ -255,8 +264,9 @@ export default class {
255
264
  }
256
265
  if (Number.isFinite(newSpeed) && newSpeed >= 1 && newSpeed <= this.autoSpeed && this.cacheSpeed !== newSpeed) {
257
266
  this.cacheSpeed = newSpeed
258
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
259
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
267
+ const hkSpeed = fanSpeedToHkPercent(newSpeed, this.speedSteps, this.autoSpeed)
268
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
269
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
260
270
  }
261
271
  }
262
272
 
@@ -293,10 +303,11 @@ export default class {
293
303
  case '0501': {
294
304
  // Fan speed
295
305
  const newSpeed = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
296
- if (newSpeed >= 1 && newSpeed <= 8 && this.cacheSpeed !== newSpeed) {
306
+ if (newSpeed >= 1 && newSpeed <= this.speedSteps && this.cacheSpeed !== newSpeed) {
297
307
  this.cacheSpeed = newSpeed
298
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
299
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
308
+ const hkSpeed = fanSpeedToHkPercent(newSpeed, this.speedSteps, this.autoSpeed)
309
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
310
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
300
311
  }
301
312
  break
302
313
  }
@@ -304,7 +315,8 @@ export default class {
304
315
  // Mode change (auto = 0x03)
305
316
  if (getTwoItemPosition(hexParts, 4) === '03' && this.cacheSpeed !== this.autoSpeed) {
306
317
  this.cacheSpeed = this.autoSpeed
307
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
318
+ // Cache auto so we can tell it apart from "user asked for max speed"
319
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, 100)
308
320
  this.accessory.log(`${platformLang.curSpeed} [auto]`)
309
321
  }
310
322
  break
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  base64ToHex,
3
+ fanSpeedToHkPercent,
3
4
  getTwoItemPosition,
4
5
  hexToTwoItems,
6
+ hkPercentToFanSpeed,
5
7
  parseError,
6
8
  } from '../utils/functions.js'
7
9
  import platformLang from '../utils/lang-en.js'
@@ -30,16 +32,20 @@ export default class {
30
32
  9: 'MwUAAwAAAAAAAAAAAAAAAAAAADU=', // auto mode
31
33
  }
32
34
  this.autoSpeed = 9
35
+ // Real speed steps, excluding auto - HomeKit percentages map onto these
36
+ this.speedSteps = 8
33
37
 
34
38
  // Remove any old original Fan services
35
39
  if (this.accessory.getService(this.hapServ.Fan)) {
36
40
  this.accessory.removeService(this.accessory.getService(this.hapServ.Fan))
37
41
  }
38
42
 
39
- // Migrate old %-rotation speed to unitless
43
+ // Rebuild the fan service if it was cached under an older speed scale, as
44
+ // HomeKit keeps the old props until the service itself is replaced
40
45
  const existingService = this.accessory.getService(this.hapServ.Fanv2)
41
46
  if (existingService) {
42
- if (existingService.getCharacteristic(this.hapChar.RotationSpeed).props.unit === 'percentage') {
47
+ const { props } = existingService.getCharacteristic(this.hapChar.RotationSpeed)
48
+ if (props.unit === 'percentage' || props.maxValue !== 100) {
43
49
  this.accessory.removeService(existingService)
44
50
  }
45
51
  }
@@ -57,7 +63,7 @@ export default class {
57
63
  this.service
58
64
  .getCharacteristic(this.hapChar.RotationSpeed)
59
65
  .setProps({
60
- maxValue: this.autoSpeed,
66
+ maxValue: 100,
61
67
  minStep: 1,
62
68
  minValue: 0,
63
69
  unit: 'unitless',
@@ -115,6 +121,9 @@ export default class {
115
121
  return
116
122
  }
117
123
 
124
+ // HomeKit sends a percentage - map it onto this model's real speed steps
125
+ value = hkPercentToFanSpeed(value, this.speedSteps)
126
+
118
127
  // Don't continue if the new value is the same as before
119
128
  if (this.cacheSpeed === value) {
120
129
  return
@@ -142,7 +151,7 @@ export default class {
142
151
 
143
152
  // Throw a 'no response' error and set a timeout to revert this after 2 seconds
144
153
  setTimeout(() => {
145
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
154
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, fanSpeedToHkPercent(this.cacheSpeed, this.speedSteps, this.autoSpeed))
146
155
  }, 2000)
147
156
  throw new this.hapErr(-70402)
148
157
  }
@@ -199,8 +208,9 @@ export default class {
199
208
  }
200
209
  if (Number.isFinite(newSpeed) && newSpeed >= 1 && newSpeed <= this.autoSpeed && this.cacheSpeed !== newSpeed) {
201
210
  this.cacheSpeed = newSpeed
202
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
203
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
211
+ const hkSpeed = fanSpeedToHkPercent(newSpeed, this.speedSteps, this.autoSpeed)
212
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
213
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
204
214
  }
205
215
  }
206
216
 
@@ -237,10 +247,11 @@ export default class {
237
247
  case '0501': {
238
248
  // Fan speed
239
249
  const newSpeed = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
240
- if (newSpeed >= 1 && newSpeed <= 8 && this.cacheSpeed !== newSpeed) {
250
+ if (newSpeed >= 1 && newSpeed <= this.speedSteps && this.cacheSpeed !== newSpeed) {
241
251
  this.cacheSpeed = newSpeed
242
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
243
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
252
+ const hkSpeed = fanSpeedToHkPercent(newSpeed, this.speedSteps, this.autoSpeed)
253
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
254
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
244
255
  }
245
256
  break
246
257
  }
@@ -248,7 +259,8 @@ export default class {
248
259
  // Mode change (auto = 0x03)
249
260
  if (getTwoItemPosition(hexParts, 4) === '03' && this.cacheSpeed !== this.autoSpeed) {
250
261
  this.cacheSpeed = this.autoSpeed
251
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
262
+ // Cache auto so we can tell it apart from "user asked for max speed"
263
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, 100)
252
264
  this.accessory.log(`${platformLang.curSpeed} [auto]`)
253
265
  }
254
266
  break