@homebridge-plugins/homebridge-govee 11.26.1 → 11.27.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,13 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-govee` will be documented in this file.
4
4
 
5
+ ## v11.27.0 (2026-07-13)
6
+
7
+ ### Changes
8
+
9
+ - feat: add fan and light control for the H1370 ceiling fan (#1307)
10
+ - feat: add support for the new R-prefix light and fan models (#1313)
11
+
5
12
  ## v11.26.1 (2026-07-12)
6
13
 
7
14
  ### Changes
@@ -0,0 +1,354 @@
1
+ import {
2
+ base64ToHex,
3
+ generateCodeFromHexValues,
4
+ generateRandomString,
5
+ getTwoItemPosition,
6
+ hexToTwoItems,
7
+ parseError,
8
+ sleep,
9
+ } from '../utils/functions.js'
10
+ import platformLang from '../utils/lang-en.js'
11
+
12
+ /*
13
+ Codes below were decoded from a real device's AWS status reports in
14
+ https://github.com/homebridge-plugins/homebridge-govee/issues/1307
15
+ Status aa 31 01 [speed] = fan speed (1-12)
16
+ Status aa 36 01 01 / aa 36 00 00 = fan power on/off
17
+ The light reports through the standard state/brightness/kelvin fields
18
+ (colour temperature only, 2700-6500K)
19
+ */
20
+ export default class {
21
+ constructor(platform, accessory) {
22
+ // Set up variables from the platform
23
+ this.hapChar = platform.api.hap.Characteristic
24
+ this.hapErr = platform.api.hap.HapStatusError
25
+ this.hapServ = platform.api.hap.Service
26
+ this.platform = platform
27
+
28
+ // Set up variables from the accessory
29
+ this.accessory = accessory
30
+
31
+ // Set up custom variables for this device type
32
+ const deviceConf = platform.deviceConf[accessory.context.gvDeviceId]
33
+ this.hideLight = deviceConf && deviceConf.hideLight
34
+
35
+ // Add the fan service if it doesn't already exist
36
+ this.service = this.accessory.getService(this.hapServ.Fanv2) || this.accessory.addService(this.hapServ.Fanv2)
37
+
38
+ // Add the set handler to the fan on/off characteristic
39
+ this.service
40
+ .getCharacteristic(this.hapChar.Active)
41
+ .onSet(async value => this.internalStateUpdate(value))
42
+ this.cacheState = this.service.getCharacteristic(this.hapChar.Active).value ? 'on' : 'off'
43
+
44
+ // Add the set handler to the fan rotation speed characteristic
45
+ this.service
46
+ .getCharacteristic(this.hapChar.RotationSpeed)
47
+ .setProps({
48
+ maxValue: 12,
49
+ minStep: 1,
50
+ minValue: 0,
51
+ unit: 'unitless',
52
+ })
53
+ .onSet(async value => this.internalSpeedUpdate(value))
54
+ this.cacheSpeed = this.service.getCharacteristic(this.hapChar.RotationSpeed).value
55
+
56
+ if (this.hideLight) {
57
+ if (this.accessory.getService(this.hapServ.Lightbulb)) {
58
+ // Remove the light service if it exists
59
+ this.accessory.removeService(this.accessory.getService(this.hapServ.Lightbulb))
60
+ }
61
+ } else {
62
+ // Add the light service if it doesn't already exist
63
+ this.lightService = this.accessory.getService(this.hapServ.Lightbulb) || this.accessory.addService(this.hapServ.Lightbulb)
64
+
65
+ // Add the set handler to the lightbulb on/off characteristic
66
+ this.lightService.getCharacteristic(this.hapChar.On).onSet(async (value) => {
67
+ await this.internalLightStateUpdate(value)
68
+ })
69
+ this.cacheLightState = this.lightService.getCharacteristic(this.hapChar.On).value ? 'on' : 'off'
70
+
71
+ // Add the set handler to the lightbulb brightness characteristic
72
+ this.lightService
73
+ .getCharacteristic(this.hapChar.Brightness)
74
+ .onSet(async (value) => {
75
+ await this.internalBrightnessUpdate(value)
76
+ })
77
+ this.cacheBright = this.lightService.getCharacteristic(this.hapChar.Brightness).value
78
+
79
+ // Add the set handler to the lightbulb colour temperature characteristic
80
+ // The light is colour temperature only, 2700K (370 mired) to 6500K (154 mired)
81
+ this.lightService
82
+ .getCharacteristic(this.hapChar.ColorTemperature)
83
+ .setProps({
84
+ maxValue: 370,
85
+ minValue: 154,
86
+ })
87
+ .onSet(async (value) => {
88
+ await this.internalCTUpdate(value)
89
+ })
90
+ this.cacheMired = this.lightService.getCharacteristic(this.hapChar.ColorTemperature).value
91
+ this.cacheKelvin = 0
92
+ }
93
+
94
+ // Output the customised options to the log
95
+ const opts = JSON.stringify({
96
+ hideLight: this.hideLight,
97
+ })
98
+ platform.log('[%s] %s %s.', accessory.displayName, platformLang.devInitOpts, opts)
99
+ }
100
+
101
+ async internalStateUpdate(value) {
102
+ try {
103
+ const newValue = value ? 'on' : 'off'
104
+
105
+ // Don't continue if the new value is the same as before
106
+ if (this.cacheState === newValue) {
107
+ return
108
+ }
109
+
110
+ // Mirrors the fan power status code aa 36 01 01 (on) / aa 36 00 00 (off)
111
+ const hexValues = value ? [0x33, 0x36, 0x01, 0x01] : [0x33, 0x36, 0x00, 0x00]
112
+
113
+ // Send the request to the platform sender function
114
+ await this.platform.sendDeviceUpdate(this.accessory, {
115
+ cmd: 'ptReal',
116
+ value: generateCodeFromHexValues(hexValues),
117
+ })
118
+
119
+ // Cache the new state and log if appropriate
120
+ if (this.cacheState !== newValue) {
121
+ this.cacheState = newValue
122
+ this.accessory.log(`${platformLang.curState} [${newValue}]`)
123
+ }
124
+ } catch (err) {
125
+ // Catch any errors during the process
126
+ this.accessory.logWarn(`${platformLang.devNotUpdated} ${parseError(err)}`)
127
+
128
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
129
+ setTimeout(() => {
130
+ this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
131
+ }, 2000)
132
+ throw new this.hapErr(-70402)
133
+ }
134
+ }
135
+
136
+ async internalSpeedUpdate(value) {
137
+ try {
138
+ // Don't continue if the value is 0
139
+ if (value === 0) {
140
+ return
141
+ }
142
+
143
+ // Don't continue if the new value is the same as before
144
+ if (this.cacheSpeed === value) {
145
+ return
146
+ }
147
+
148
+ // Mirrors the fan speed status code aa 31 01 [speed]
149
+ await this.platform.sendDeviceUpdate(this.accessory, {
150
+ cmd: 'ptReal',
151
+ value: generateCodeFromHexValues([0x33, 0x31, 0x01, value]),
152
+ })
153
+
154
+ // Cache the new state and log if appropriate
155
+ if (this.cacheSpeed !== value) {
156
+ this.cacheSpeed = value
157
+ this.accessory.log(`${platformLang.curSpeed} [${value}]`)
158
+ }
159
+ } catch (err) {
160
+ // Catch any errors during the process
161
+ this.accessory.logWarn(`${platformLang.devNotUpdated} ${parseError(err)}`)
162
+
163
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
164
+ setTimeout(() => {
165
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
166
+ }, 2000)
167
+ throw new this.hapErr(-70402)
168
+ }
169
+ }
170
+
171
+ async internalLightStateUpdate(value) {
172
+ try {
173
+ const newValue = value ? 'on' : 'off'
174
+
175
+ // Don't continue if the new value is the same as before
176
+ if (this.cacheLightState === newValue) {
177
+ return
178
+ }
179
+
180
+ // Send the request to the platform sender function
181
+ await this.platform.sendDeviceUpdate(this.accessory, {
182
+ cmd: 'state',
183
+ value: newValue,
184
+ })
185
+
186
+ // Cache the new state and log if appropriate
187
+ if (this.cacheLightState !== newValue) {
188
+ this.cacheLightState = newValue
189
+ this.accessory.log(`${platformLang.curLight} [${newValue}]`)
190
+ }
191
+ } catch (err) {
192
+ // Catch any errors during the process
193
+ this.accessory.logWarn(`${platformLang.devNotUpdated} ${parseError(err)}`)
194
+
195
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
196
+ setTimeout(() => {
197
+ this.lightService.updateCharacteristic(this.hapChar.On, this.cacheLightState === 'on')
198
+ }, 2000)
199
+ throw new this.hapErr(-70402)
200
+ }
201
+ }
202
+
203
+ async internalBrightnessUpdate(value) {
204
+ try {
205
+ // This acts like a debounce function when endlessly sliding the brightness scale
206
+ const updateKeyBright = generateRandomString(5)
207
+ this.updateKeyBright = updateKeyBright
208
+ await sleep(350)
209
+ if (updateKeyBright !== this.updateKeyBright) {
210
+ return
211
+ }
212
+
213
+ // Don't continue if the new value is the same as before
214
+ if (value === this.cacheBright) {
215
+ return
216
+ }
217
+
218
+ // Send the request to the platform sender function
219
+ await this.platform.sendDeviceUpdate(this.accessory, {
220
+ cmd: 'brightness',
221
+ value,
222
+ })
223
+
224
+ // Cache the new state and log if appropriate
225
+ if (this.cacheBright !== value) {
226
+ this.cacheBright = value
227
+ this.accessory.log(`${platformLang.curBright} [${value}%]`)
228
+ }
229
+ } catch (err) {
230
+ // Catch any errors during the process
231
+ this.accessory.logWarn(`${platformLang.devNotUpdated} ${parseError(err)}`)
232
+
233
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
234
+ setTimeout(() => {
235
+ this.lightService.updateCharacteristic(this.hapChar.Brightness, this.cacheBright)
236
+ }, 2000)
237
+ throw new this.hapErr(-70402)
238
+ }
239
+ }
240
+
241
+ async internalCTUpdate(value) {
242
+ try {
243
+ // This acts like a debounce function when endlessly sliding the colour wheel
244
+ const updateKeyCT = generateRandomString(5)
245
+ this.updateKeyCT = updateKeyCT
246
+ await sleep(300)
247
+ if (updateKeyCT !== this.updateKeyCT) {
248
+ return
249
+ }
250
+
251
+ // Convert mired to kelvin to nearest 100, clamped to the light's range
252
+ const kelvin = Math.round(1000000 / value / 100) * 100
253
+ const k = Math.min(Math.max(kelvin, 2700), 6500)
254
+
255
+ // Don't continue if the new value is the same as before
256
+ if (this.cacheKelvin === k) {
257
+ return
258
+ }
259
+
260
+ // Send the request to the platform sender function
261
+ await this.platform.sendDeviceUpdate(this.accessory, {
262
+ cmd: 'colorTem',
263
+ value: k,
264
+ })
265
+
266
+ // Cache the new state and log if appropriate
267
+ this.cacheMired = value
268
+ this.cacheKelvin = k
269
+ this.accessory.log(`${platformLang.curColour} [${k}K]`)
270
+ } catch (err) {
271
+ // Catch any errors during the process
272
+ this.accessory.logWarn(`${platformLang.devNotUpdated} ${parseError(err)}`)
273
+
274
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
275
+ setTimeout(() => {
276
+ this.lightService.updateCharacteristic(this.hapChar.ColorTemperature, this.cacheMired)
277
+ }, 2000)
278
+ throw new this.hapErr(-70402)
279
+ }
280
+ }
281
+
282
+ externalUpdate(params) {
283
+ // The standard state/brightness/kelvin fields report the light
284
+ if (!this.hideLight) {
285
+ if (params.state && params.state !== this.cacheLightState) {
286
+ this.cacheLightState = params.state
287
+ this.lightService.updateCharacteristic(this.hapChar.On, this.cacheLightState === 'on')
288
+ this.accessory.log(`${platformLang.curLight} [${this.cacheLightState}]`)
289
+ }
290
+
291
+ if (params.brightness && params.brightness !== this.cacheBright) {
292
+ this.cacheBright = params.brightness
293
+ this.lightService.updateCharacteristic(this.hapChar.Brightness, this.cacheBright)
294
+ this.accessory.log(`${platformLang.curBright} [${this.cacheBright}%]`)
295
+ }
296
+
297
+ if (params.kelvin && params.kelvin !== this.cacheKelvin) {
298
+ const k = Math.min(Math.max(params.kelvin, 2700), 6500)
299
+ const mired = Math.min(Math.max(Math.round(1000000 / k), 154), 370)
300
+ this.cacheKelvin = k
301
+ this.cacheMired = mired
302
+ this.lightService.updateCharacteristic(this.hapChar.ColorTemperature, this.cacheMired)
303
+ this.accessory.log(`${platformLang.curColour} [${k}K]`)
304
+ }
305
+ }
306
+
307
+ // Check for fan status codes
308
+ (params.commands || []).forEach((command) => {
309
+ const hexString = base64ToHex(command)
310
+ const hexParts = hexToTwoItems(hexString)
311
+
312
+ // Return now if not a device query update code
313
+ if (getTwoItemPosition(hexParts, 1) !== 'aa') {
314
+ return
315
+ }
316
+
317
+ const deviceFunction = `${getTwoItemPosition(hexParts, 2)}${getTwoItemPosition(hexParts, 3)}`
318
+
319
+ switch (deviceFunction) {
320
+ case '3101': {
321
+ // Fan speed
322
+ const newSpeed = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
323
+ if (newSpeed >= 1 && newSpeed <= 12 && this.cacheSpeed !== newSpeed) {
324
+ this.cacheSpeed = newSpeed
325
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
326
+ this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
327
+ }
328
+ break
329
+ }
330
+ case '3601': {
331
+ // Fan on
332
+ if (this.cacheState !== 'on') {
333
+ this.cacheState = 'on'
334
+ this.service.updateCharacteristic(this.hapChar.Active, 1)
335
+ this.accessory.log(`${platformLang.curState} [on]`)
336
+ }
337
+ break
338
+ }
339
+ case '3600': {
340
+ // Fan off
341
+ if (this.cacheState !== 'off') {
342
+ this.cacheState = 'off'
343
+ this.service.updateCharacteristic(this.hapChar.Active, 0)
344
+ this.accessory.log(`${platformLang.curState} [off]`)
345
+ }
346
+ break
347
+ }
348
+ default:
349
+ this.accessory.logDebugWarn(`${platformLang.newScene}: [${command}] [${hexString}]`)
350
+ break
351
+ }
352
+ })
353
+ }
354
+ }
@@ -5,6 +5,7 @@ import deviceDehumidifierH7152 from './dehumidifier-H7152.js'
5
5
  import deviceDiffuserH7161 from './diffuser-H7161.js'
6
6
  import deviceDiffuserH7162 from './diffuser-H7162.js'
7
7
  import deviceFanH1310 from './fan-H1310.js'
8
+ import deviceFanH1370 from './fan-H1370.js'
8
9
  import deviceFanH7100 from './fan-H7100.js'
9
10
  import deviceFanH7101 from './fan-H7101.js'
10
11
  import deviceFanH7102 from './fan-H7102.js'
@@ -71,6 +72,8 @@ export default {
71
72
  deviceDiffuserH7161,
72
73
  deviceDiffuserH7162,
73
74
  deviceFanH1310,
75
+ deviceFanH1370,
76
+ deviceFanR1310: deviceFanH1310, // R1310 is the newer naming of the H1310
74
77
  deviceFanH7100,
75
78
  deviceFanH7101,
76
79
  deviceFanH7102,
@@ -492,6 +492,16 @@ export default {
492
492
  'H8840',
493
493
  'H8841',
494
494
  'HXXXX', // placeholder for LAN-only configured models
495
+ 'R1250',
496
+ 'R1401',
497
+ 'R14A1',
498
+ 'R14C0',
499
+ 'R14C1',
500
+ 'R14C2',
501
+ 'R1501',
502
+ 'R1630',
503
+ 'R16D0',
504
+ 'R1741',
495
505
  ],
496
506
  switchSingle: ['H5001', 'H5080', 'H5081', 'H5083', 'H5086', 'H7014'],
497
507
  switchDouble: ['H5082'],
@@ -529,7 +539,7 @@ export default {
529
539
  sensorThermo4: ['H5198'],
530
540
  sensorMonitor: ['H5106'],
531
541
  sensorCO2: ['H5140'], // CO2 + temp + humidity monitor, AWS opcode 0x0A — closes #1179
532
- fan: ['H1310', 'H7100', 'H7101', 'H7102', 'H7105', 'H7106', 'H7107', 'H7111'],
542
+ fan: ['H1310', 'H1370', 'H7100', 'H7101', 'H7102', 'H7105', 'H7106', 'H7107', 'H7111', 'R1310'],
533
543
  heater1: ['H7130', 'H713A', 'H713B', 'H713C'],
534
544
  heater2: ['H7131', 'H7132', 'H7133', 'H7134', 'H7135'],
535
545
  dehumidifier: ['H7150', 'H7151', 'H7152'],
@@ -543,7 +553,6 @@ export default {
543
553
  kettle: ['H7170', 'H7171', 'H7173', 'H7175', 'H717A'],
544
554
  template: [
545
555
  'H1162', // https://github.com/homebridge-plugins/homebridge-govee/issues/422
546
- 'H1370', // https://github.com/homebridge-plugins/homebridge-govee/issues/1307
547
556
  'H1167', // https://github.com/homebridge-plugins/homebridge-govee/issues/1142
548
557
  'H5010', // https://github.com/homebridge-plugins/homebridge-govee/issues/1235
549
558
  'H5024', // https://github.com/homebridge-plugins/homebridge-govee/issues/835
@@ -600,6 +609,8 @@ export default {
600
609
  'H70C4',
601
610
  'H70C5',
602
611
  'H8015',
612
+ 'R1401',
613
+ 'R14C0',
603
614
  ],
604
615
 
605
616
  httpRetryCodes: ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'ECONNABORTED'],
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@homebridge-plugins/homebridge-govee",
3
3
  "alias": "Govee",
4
4
  "type": "module",
5
- "version": "11.26.1",
5
+ "version": "11.27.0",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",