@homebridge-plugins/homebridge-govee 11.36.1-beta.9 → 11.37.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 +14 -1
- package/lib/device/fan-H1310.js +357 -9
- package/lib/device/fan-H1370.js +49 -5
- package/lib/platform.js +6 -1
- package/lib/utils/lang-en.js +2 -0
- package/lib/utils/response-parser.js +18 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@homebridge-plugins/homebridge-govee` will be documented in this file.
|
|
4
4
|
|
|
5
|
-
## v11.
|
|
5
|
+
## v11.37.0 (2026-08-14)
|
|
6
6
|
|
|
7
7
|
### Changed
|
|
8
8
|
|
|
@@ -18,6 +18,19 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
|
|
|
18
18
|
- fix: only give the H1310 and R1310 two light tiles when each has a switch of its own to send (#1352)
|
|
19
19
|
- fix: lead a thermo sensor's tile with its temperature, not its humidity, when shown as a thermostat (#1354)
|
|
20
20
|
- docs(schema): describe what the thermo sensor extra switch setting actually does now (#1354)
|
|
21
|
+
- feat: let a ceiling fan that can reverse be switched between forward and reverse from the fan tile (#1352)
|
|
22
|
+
- fix: stop calling a ceiling fan's speed report unrecognised while the fan is off (#1352)
|
|
23
|
+
- fix: read a ceiling fan's airflow direction from what the fan reports, so the tile follows the govee app (#1352)
|
|
24
|
+
- fix: show each of a ceiling fan's two lights as on or off from what the fan reports (#1352)
|
|
25
|
+
- fix: lead a ceiling fan's tile with the fan rather than one of its lights (#1352)
|
|
26
|
+
- fix: read a ceiling fan's light from what it says about its lights, not from whether the fan has power (#1352)
|
|
27
|
+
- fix: stop calling a ceiling fan's light status unrecognised on a handler that reads it (#1352)
|
|
28
|
+
- fix: stop reporting govee's command receipts as messages the plugin did not understand (#1352)
|
|
29
|
+
- fix: read whether a ceiling fan is turning from the fan itself, not from its lights (#1352)
|
|
30
|
+
- fix: switch a ceiling fan on with its own switch, not with a command that turns its lights on (#1352)
|
|
31
|
+
- fix: put a ceiling fan's shared brightness and colour on one light tile, as the fan has only one of each (#1352)
|
|
32
|
+
- fix: switch a ceiling fan's light off instead of dimming both its lights to nothing (#1352)
|
|
33
|
+
- chore(deps): dependency updates
|
|
21
34
|
|
|
22
35
|
## v11.36.0 (2026-08-11)
|
|
23
36
|
|
package/lib/device/fan-H1310.js
CHANGED
|
@@ -1,17 +1,116 @@
|
|
|
1
1
|
import { hs2rgb } from '../utils/colour.js'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
base64ToHex,
|
|
4
|
+
generateCodeFromHexValues,
|
|
5
|
+
generateRandomString,
|
|
6
|
+
getTwoItemPosition,
|
|
7
|
+
hexToTwoItems,
|
|
8
|
+
sleep,
|
|
9
|
+
} from '../utils/functions.js'
|
|
10
|
+
import platformLang from '../utils/lang-en.js'
|
|
3
11
|
import deviceFanH1370 from './fan-H1370.js'
|
|
4
12
|
|
|
5
13
|
/**
|
|
6
|
-
* The two lights an H1310 has,
|
|
7
|
-
*
|
|
8
|
-
*
|
|
14
|
+
* The two lights an H1310 has, the api switch each one answers to, and the bit
|
|
15
|
+
* each one sets in the `aa 42` status.
|
|
16
|
+
*
|
|
17
|
+
* Those switches are the only way to reach one light without the other -
|
|
18
|
+
* `powerSwitch` is the whole unit, fan included.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* ⚠️ The fan has ONE brightness and ONE colour, shared by both lights - the
|
|
22
|
+
* govee app offers a single set too. So only the main light carries them, and
|
|
23
|
+
* the background light is an on/off tile.
|
|
24
|
+
*
|
|
25
|
+
* Two sliders that secretly move together read as a fault, but the real cost
|
|
26
|
+
* was worse: HomeKit sends a brightness of 0 when a bulb is switched off, and
|
|
27
|
+
* the brightness command is device-wide, so switching one light off dimmed the
|
|
28
|
+
* pair to nothing (#1352).
|
|
9
29
|
*/
|
|
10
30
|
const LIGHTS = [
|
|
11
|
-
{ name: 'Main Light', instance: 'mainLightToggle' },
|
|
12
|
-
{ name: 'Background Light', instance: 'backgroundLightToggle' },
|
|
31
|
+
{ name: 'Main Light', instance: 'mainLightToggle', bit: 0x40, carriesSharedControls: true },
|
|
32
|
+
{ name: 'Background Light', instance: 'backgroundLightToggle', bit: 0x20, carriesSharedControls: false },
|
|
13
33
|
]
|
|
14
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The two status frames carrying the lights. This fan sends both, every time,
|
|
37
|
+
* saying the same thing two different ways:
|
|
38
|
+
*
|
|
39
|
+
* - `aa 42 <mask>` - one byte, bit 6 main, bit 5 background, bit 7 either
|
|
40
|
+
* - `aa 36 <main> <background>` - one byte each
|
|
41
|
+
*
|
|
42
|
+
* They agree in all 69 statuses posted on #1352:
|
|
43
|
+
*
|
|
44
|
+
* | `aa 36` | `aa 42` | lit |
|
|
45
|
+
* |---------|---------|--------------------|
|
|
46
|
+
* | `01 01` | `e0` | both |
|
|
47
|
+
* | `01 00` | `c0` | main only |
|
|
48
|
+
* | `00 01` | `a0` | background only |
|
|
49
|
+
* | `00 00` | `00` | neither |
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ `aa 36` is fan power on the H1370 and the lights here, which is why this
|
|
52
|
+
* handler has to claim the code rather than let the parent read it.
|
|
53
|
+
*/
|
|
54
|
+
const LIGHT_MASK_CODE = '42'
|
|
55
|
+
const LIGHT_PAIR_CODE = '36'
|
|
56
|
+
|
|
57
|
+
/** Set in that mask whenever either light is lit, whichever one it is. */
|
|
58
|
+
const ANY_LIGHT_BIT = 0x80
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The speed frame, which also carries whether the fan is turning:
|
|
62
|
+
* `aa 31 <running> <speed> <direction>`.
|
|
63
|
+
*/
|
|
64
|
+
const FAN_CODE = '31'
|
|
65
|
+
|
|
66
|
+
/** The api switches for the fan itself and for running it backwards. */
|
|
67
|
+
const FAN_TOGGLE = 'fanToggle'
|
|
68
|
+
const REVERSE_AIRFLOW = 'reverseAirflowToggle'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reads which lights are lit out of a status, from whichever of the two frames
|
|
72
|
+
* the fan sent, normalised to the `aa 42` mask.
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]} commands base64 status codes from the fan
|
|
75
|
+
* @returns {number|undefined} the mask, or undefined if the fan said neither
|
|
76
|
+
*/
|
|
77
|
+
function lightMaskFrom(commands) {
|
|
78
|
+
let mask
|
|
79
|
+
;(commands || []).forEach((command) => {
|
|
80
|
+
const hexParts = hexToTwoItems(base64ToHex(command))
|
|
81
|
+
if (getTwoItemPosition(hexParts, 1) !== 'aa') {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (getTwoItemPosition(hexParts, 2) === LIGHT_MASK_CODE) {
|
|
85
|
+
mask = Number.parseInt(getTwoItemPosition(hexParts, 3), 16)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (getTwoItemPosition(hexParts, 2) === LIGHT_PAIR_CODE) {
|
|
89
|
+
const main = getTwoItemPosition(hexParts, 3) === '01'
|
|
90
|
+
const background = getTwoItemPosition(hexParts, 4) === '01'
|
|
91
|
+
mask = (main ? 0x40 : 0) | (background ? 0x20 : 0) | (main || background ? ANY_LIGHT_BIT : 0)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
return mask
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Reads whether the fan is turning, from the third byte of its speed frame.
|
|
99
|
+
*
|
|
100
|
+
* @param {string[]} commands base64 status codes from the fan
|
|
101
|
+
* @returns {boolean|undefined} true if turning, or undefined if the fan did not say
|
|
102
|
+
*/
|
|
103
|
+
function fanIsRunningFrom(commands) {
|
|
104
|
+
let running
|
|
105
|
+
;(commands || []).forEach((command) => {
|
|
106
|
+
const hexParts = hexToTwoItems(base64ToHex(command))
|
|
107
|
+
if (getTwoItemPosition(hexParts, 1) === 'aa' && getTwoItemPosition(hexParts, 2) === FAN_CODE) {
|
|
108
|
+
running = getTwoItemPosition(hexParts, 3) === '01'
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
return running
|
|
112
|
+
}
|
|
113
|
+
|
|
15
114
|
export default class GoveeFanH1310 extends deviceFanH1370 {
|
|
16
115
|
constructor(platform, accessory) {
|
|
17
116
|
super(platform, accessory)
|
|
@@ -20,10 +119,42 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
|
|
|
20
119
|
// ceiling-fan range. (speedSteps now comes from device-capabilities or the
|
|
21
120
|
// device's OpenAPI fanSpeedMode; do not overwrite the superclass value here.)
|
|
22
121
|
|
|
122
|
+
// Both light frames are read here. `aa 36` especially has to be claimed:
|
|
123
|
+
// the parent reads it as fan power, so left to it the fan tile followed
|
|
124
|
+
// the lights - off whenever they were off, whatever the fan was doing
|
|
125
|
+
this.handledStatusCodes.add(LIGHT_MASK_CODE)
|
|
126
|
+
this.handledStatusCodes.add(LIGHT_PAIR_CODE)
|
|
127
|
+
|
|
128
|
+
// The fan has its own api switch, which is the only way to reach the fan
|
|
129
|
+
// without touching the lights. `33 36 ...`, which the parent sends for fan
|
|
130
|
+
// power, is the two lights on this model
|
|
131
|
+
this.canSendFanToggle = !!accessory.context.openApiCapabilities?.[FAN_TOGGLE]
|
|
132
|
+
&& !!accessory.context.useOpenApiControl
|
|
133
|
+
|
|
23
134
|
if (this.service.testCharacteristic(this.hapChar.SwingMode)) {
|
|
24
135
|
this.service.removeCharacteristic(this.service.getCharacteristic(this.hapChar.SwingMode))
|
|
25
136
|
}
|
|
26
137
|
|
|
138
|
+
// Airflow direction. HomeKit's fan already has somewhere to put this, so it
|
|
139
|
+
// goes on the tile the owner already has rather than adding another one.
|
|
140
|
+
//
|
|
141
|
+
// Gated on its own capability rather than on the lights below: a fan could
|
|
142
|
+
// reasonably have one and not the other, and a control that cannot be sent
|
|
143
|
+
// is worse than no control (#1352)
|
|
144
|
+
this.canReverseAirflow = !!accessory.context.openApiCapabilities?.[REVERSE_AIRFLOW]
|
|
145
|
+
&& !!accessory.context.useOpenApiControl
|
|
146
|
+
|
|
147
|
+
if (this.canReverseAirflow) {
|
|
148
|
+
this.service
|
|
149
|
+
.getCharacteristic(this.hapChar.RotationDirection)
|
|
150
|
+
.onSet(async value => this.internalDirectionUpdate(value))
|
|
151
|
+
this.cacheDirection = this.service.getCharacteristic(this.hapChar.RotationDirection).value
|
|
152
|
+
} else if (this.service.testCharacteristic(this.hapChar.RotationDirection)) {
|
|
153
|
+
// Left over from a device that used to report the switch, or from a
|
|
154
|
+
// config that has since lost api access
|
|
155
|
+
this.service.removeCharacteristic(this.service.getCharacteristic(this.hapChar.RotationDirection))
|
|
156
|
+
}
|
|
157
|
+
|
|
27
158
|
// Two tiles are only honest when each one has a switch of its own to send.
|
|
28
159
|
// Those switches exist solely over the api, so without it both tiles would
|
|
29
160
|
// fall back to the device-wide power switch - and either would take the fan
|
|
@@ -46,19 +177,227 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
|
|
|
46
177
|
}
|
|
47
178
|
|
|
48
179
|
this.lightServices = {}
|
|
49
|
-
LIGHTS.forEach(({ name }) => {
|
|
180
|
+
LIGHTS.forEach(({ name, carriesSharedControls }) => {
|
|
50
181
|
const service = this.accessory.getService(name)
|
|
51
182
|
|| this.accessory.addService(this.hapServ.Lightbulb, name, name)
|
|
52
183
|
this.lightServices[name] = service
|
|
53
|
-
this.setupLightService(service, name)
|
|
184
|
+
this.setupLightService(service, name, carriesSharedControls)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Switching the fan on and off.
|
|
190
|
+
*
|
|
191
|
+
* The parent sends `33 36 01 01`, which on an H1370 is its fan. Here `aa 36`
|
|
192
|
+
* is the two lights, so that command turns both lights on and never touches
|
|
193
|
+
* the fan. The fan's own api switch is used instead where it can be sent,
|
|
194
|
+
* and otherwise the speed frame - `33 31 <running> <speed>` - which is the
|
|
195
|
+
* frame this fan reports its own running state in (#1352).
|
|
196
|
+
*
|
|
197
|
+
* @param {number} value 1 active, 0 inactive
|
|
198
|
+
*/
|
|
199
|
+
async internalStateUpdate(value) {
|
|
200
|
+
try {
|
|
201
|
+
const newValue = value ? 'on' : 'off'
|
|
202
|
+
if (this.cacheState === newValue) {
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (this.canSendFanToggle) {
|
|
207
|
+
await this.platform.sendDeviceUpdate(this.accessory, {
|
|
208
|
+
cmd: 'openApi',
|
|
209
|
+
openApi: {
|
|
210
|
+
instance: FAN_TOGGLE,
|
|
211
|
+
capabilityType: 'devices.capabilities.toggle',
|
|
212
|
+
value: value ? 1 : 0,
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
} else {
|
|
216
|
+
// Keep the speed the fan already had, so switching it back on returns
|
|
217
|
+
// it to where its owner left it rather than to a speed of nothing
|
|
218
|
+
const speed = this.cacheSpeed || 1
|
|
219
|
+
await this.platform.sendDeviceUpdate(this.accessory, {
|
|
220
|
+
cmd: 'ptReal',
|
|
221
|
+
value: generateCodeFromHexValues([0x33, 0x31, value ? 0x01 : 0x00, speed]),
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this.cacheState = newValue
|
|
226
|
+
this.accessory.log(`${platformLang.curState} [${newValue}]`)
|
|
227
|
+
} catch (err) {
|
|
228
|
+
this.failUpdate(err, () => {
|
|
229
|
+
this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* HomeKit gives a fan two directions and Govee gives it one switch, so the
|
|
236
|
+
* two map onto each other directly. Which way round the fan physically turns
|
|
237
|
+
* for "reverse" is Govee's business and is not written down anywhere - if an
|
|
238
|
+
* owner reports the two being the wrong way round, swap them here.
|
|
239
|
+
*
|
|
240
|
+
* @param {number} value 0 clockwise, 1 counter-clockwise
|
|
241
|
+
*/
|
|
242
|
+
async internalDirectionUpdate(value) {
|
|
243
|
+
try {
|
|
244
|
+
if (this.cacheDirection === value) {
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await this.platform.sendDeviceUpdate(this.accessory, {
|
|
249
|
+
cmd: 'openApi',
|
|
250
|
+
openApi: {
|
|
251
|
+
instance: REVERSE_AIRFLOW,
|
|
252
|
+
capabilityType: 'devices.capabilities.toggle',
|
|
253
|
+
value: value === 1 ? 1 : 0,
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
this.cacheDirection = value
|
|
258
|
+
this.accessory.log(`${platformLang.curDirection} [${value === 1 ? 'upward' : 'downward'}]`)
|
|
259
|
+
} catch (err) {
|
|
260
|
+
this.failUpdate(err, () => {
|
|
261
|
+
this.service.updateCharacteristic(this.hapChar.RotationDirection, this.cacheDirection)
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The device-wide on/off field is the whole unit having power here, not the
|
|
268
|
+
* light: a fan reporting `onOff` 1 has been seen with `aa 36 00` (fan off)
|
|
269
|
+
* and `aa 42 00` (both lights off) in the same status. Letting it speak for
|
|
270
|
+
* the light lit the light tile every time the fan was switched on (#1352).
|
|
271
|
+
*
|
|
272
|
+
* The `aa 42` mask is what this fan actually says about its lights, so read
|
|
273
|
+
* the single tile off the bit that is set whenever either one is lit.
|
|
274
|
+
*
|
|
275
|
+
* @param {object} params a parsed device update
|
|
276
|
+
* @returns {'on'|'off'|undefined} the light's state, or undefined if unsaid
|
|
277
|
+
*/
|
|
278
|
+
readLightState(params) {
|
|
279
|
+
const mask = lightMaskFrom(params.commands)
|
|
280
|
+
if (mask === undefined) {
|
|
281
|
+
return undefined
|
|
282
|
+
}
|
|
283
|
+
return (mask & ANY_LIGHT_BIT) === ANY_LIGHT_BIT ? 'on' : 'off'
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
externalUpdate(params) {
|
|
287
|
+
super.externalUpdate(params)
|
|
288
|
+
|
|
289
|
+
// Whether the fan is turning. The parent cannot read this: on an H1370 it
|
|
290
|
+
// comes from `aa 36`, which here belongs to the lights. This fan says so in
|
|
291
|
+
// the third byte of its speed frame instead, and the api switch is read as
|
|
292
|
+
// a fallback for a device answering over the api rather than aws (#1352)
|
|
293
|
+
let running = fanIsRunningFrom(params.commands)
|
|
294
|
+
if (running === undefined && params.toggles?.[FAN_TOGGLE] !== undefined) {
|
|
295
|
+
running = !!params.toggles[FAN_TOGGLE]
|
|
296
|
+
}
|
|
297
|
+
if (running !== undefined) {
|
|
298
|
+
const newState = running ? 'on' : 'off'
|
|
299
|
+
if (this.cacheState !== newState) {
|
|
300
|
+
this.cacheState = newState
|
|
301
|
+
this.service.updateCharacteristic(this.hapChar.Active, running ? 1 : 0)
|
|
302
|
+
this.accessory.log(`${platformLang.curState} [${newState}]`)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Each light's own on/off, read off the same mask
|
|
307
|
+
const mask = lightMaskFrom(params.commands)
|
|
308
|
+
if (mask !== undefined) {
|
|
309
|
+
LIGHTS.forEach(({ name, bit }) => {
|
|
310
|
+
const service = this.lightServices?.[name]
|
|
311
|
+
if (!service) {
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
const newValue = (mask & bit) === bit ? 'on' : 'off'
|
|
315
|
+
const cacheKey = `${name}:state`
|
|
316
|
+
if (this[cacheKey] !== newValue) {
|
|
317
|
+
this[cacheKey] = newValue
|
|
318
|
+
service.updateCharacteristic(this.hapChar.On, newValue === 'on')
|
|
319
|
+
this.accessory.log(`${name} ${newValue}`)
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The one shared brightness and colour, reported onto the one tile that
|
|
325
|
+
// carries them. The parent puts these on its single light, which the named
|
|
326
|
+
// tiles replaced
|
|
327
|
+
if (this.lightServices) {
|
|
328
|
+
LIGHTS.filter(light => light.carriesSharedControls).forEach(({ name }) => {
|
|
329
|
+
const service = this.lightServices[name]
|
|
330
|
+
if (params.brightness && this[`${name}:brightness`] !== params.brightness) {
|
|
331
|
+
this[`${name}:brightness`] = params.brightness
|
|
332
|
+
service.updateCharacteristic(this.hapChar.Brightness, params.brightness)
|
|
333
|
+
}
|
|
334
|
+
if (params.kelvin) {
|
|
335
|
+
const k = Math.min(Math.max(params.kelvin, 2700), 6500)
|
|
336
|
+
const mired = Math.min(Math.max(Math.round(1000000 / k), 154), 370)
|
|
337
|
+
if (this[`${name}:ct`] !== mired) {
|
|
338
|
+
this[`${name}:ct`] = mired
|
|
339
|
+
service.updateCharacteristic(this.hapChar.ColorTemperature, mired)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Airflow direction is a separate capability from the lights, so it gets
|
|
346
|
+
// its own guard rather than sharing theirs
|
|
347
|
+
if (!this.canReverseAirflow) {
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// The fan reports its direction as the fifth byte of its speed frame -
|
|
352
|
+
// `aa 31 <running> <speed> <direction>` - rather than as a status of its
|
|
353
|
+
// own. Captured on a real H1310 in #1352: flowing downward reported
|
|
354
|
+
// `aa 31 01 01 00` and upward `aa 31 01 01 01`, with nothing else in the
|
|
355
|
+
// status differing between the two.
|
|
356
|
+
let reported
|
|
357
|
+
;(params.commands || []).forEach((command) => {
|
|
358
|
+
const hexParts = hexToTwoItems(base64ToHex(command))
|
|
359
|
+
if (getTwoItemPosition(hexParts, 1) === 'aa' && getTwoItemPosition(hexParts, 2) === '31') {
|
|
360
|
+
reported = getTwoItemPosition(hexParts, 5) === '01' ? 1 : 0
|
|
361
|
+
}
|
|
54
362
|
})
|
|
363
|
+
|
|
364
|
+
// The api toggle is the only way to SEND a direction, so it is still read
|
|
365
|
+
// as a fallback for a device answering over the api rather than aws
|
|
366
|
+
if (reported === undefined && params.toggles?.[REVERSE_AIRFLOW] !== undefined) {
|
|
367
|
+
reported = params.toggles[REVERSE_AIRFLOW] ? 1 : 0
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (reported !== undefined && this.cacheDirection !== reported) {
|
|
371
|
+
this.cacheDirection = reported
|
|
372
|
+
this.service.updateCharacteristic(this.hapChar.RotationDirection, reported)
|
|
373
|
+
this.accessory.log(`${platformLang.curDirection} [${reported === 1 ? 'upward' : 'downward'}]`)
|
|
374
|
+
}
|
|
55
375
|
}
|
|
56
376
|
|
|
57
|
-
setupLightService(service, name) {
|
|
377
|
+
setupLightService(service, name, carriesSharedControls) {
|
|
58
378
|
service.getCharacteristic(this.hapChar.On).onSet(async (value) => {
|
|
59
379
|
await this.internalLightStateUpdate(service, name, value)
|
|
60
380
|
})
|
|
61
381
|
|
|
382
|
+
if (!carriesSharedControls) {
|
|
383
|
+
// An on/off tile. The brightness and colour live on the other light, as
|
|
384
|
+
// the fan has only one of each. Anything left over from a cached version
|
|
385
|
+
// that gave both tiles the full set has to go, or HomeKit keeps showing
|
|
386
|
+
// a slider that moves the other light too
|
|
387
|
+
const shared = [
|
|
388
|
+
this.hapChar.Brightness,
|
|
389
|
+
this.hapChar.Hue,
|
|
390
|
+
this.hapChar.Saturation,
|
|
391
|
+
this.hapChar.ColorTemperature,
|
|
392
|
+
]
|
|
393
|
+
shared.forEach((characteristic) => {
|
|
394
|
+
if (service.testCharacteristic(characteristic)) {
|
|
395
|
+
service.removeCharacteristic(service.getCharacteristic(characteristic))
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
62
401
|
service
|
|
63
402
|
.getCharacteristic(this.hapChar.Brightness)
|
|
64
403
|
.setProps({ minStep: 1 })
|
|
@@ -113,6 +452,15 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
|
|
|
113
452
|
return
|
|
114
453
|
}
|
|
115
454
|
|
|
455
|
+
// HomeKit sends a brightness of 0 when a bulb is switched off, and the
|
|
456
|
+
// brightness command is device-wide - so obeying it dimmed BOTH lights to
|
|
457
|
+
// nothing. Switch this light off with its own switch instead, and leave
|
|
458
|
+
// the shared brightness where its owner had it (#1352)
|
|
459
|
+
if (value === 0) {
|
|
460
|
+
await this.internalLightStateUpdate(service, name, false)
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
|
|
116
464
|
const cacheKey = `${name}:brightness`
|
|
117
465
|
if (this[cacheKey] === value) {
|
|
118
466
|
return
|
package/lib/device/fan-H1370.js
CHANGED
|
@@ -17,7 +17,7 @@ import GoveeDevice from './base.js'
|
|
|
17
17
|
/*
|
|
18
18
|
Codes below were decoded from a real device's AWS status reports in
|
|
19
19
|
https://github.com/homebridge-plugins/homebridge-govee/issues/1307
|
|
20
|
-
Status aa 31
|
|
20
|
+
Status aa 31 [?] [speed] [direction] = fan speed and airflow direction
|
|
21
21
|
Status aa 36 01 01 / aa 36 00 00 = fan power on/off
|
|
22
22
|
The light reports through the standard state/brightness/kelvin fields
|
|
23
23
|
(colour temperature only, 2700-6500K)
|
|
@@ -42,9 +42,20 @@ export default class GoveeFanH1370 extends GoveeDevice {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// Status codes a subclass reads for itself. Without this the parent still
|
|
46
|
+
// reaches its default branch and announces a code that was in fact handled,
|
|
47
|
+
// which is the same unrecognised-status noise this model was reported for
|
|
48
|
+
this.handledStatusCodes = new Set()
|
|
49
|
+
|
|
45
50
|
// Add the fan service if it doesn't already exist
|
|
46
51
|
this.service = this.accessory.getService(this.hapServ.Fanv2) || this.accessory.addService(this.hapServ.Fanv2)
|
|
47
52
|
|
|
53
|
+
// This accessory is a fan that happens to have lights on it, so the fan is
|
|
54
|
+
// what its tile should lead with. Without saying so the Home app picks for
|
|
55
|
+
// itself and lands on a light, leaving an owner tapping through to reach
|
|
56
|
+
// the fan they came for (#1352)
|
|
57
|
+
this.service.setPrimaryService(true)
|
|
58
|
+
|
|
48
59
|
// Add the set handler to the fan on/off characteristic
|
|
49
60
|
this.service
|
|
50
61
|
.getCharacteristic(this.hapChar.Active)
|
|
@@ -281,11 +292,29 @@ export default class GoveeFanH1370 extends GoveeDevice {
|
|
|
281
292
|
}
|
|
282
293
|
}
|
|
283
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Whether the light is lit, as this model reports it.
|
|
297
|
+
*
|
|
298
|
+
* On the H1370 the device-wide on/off field is the only word there is on the
|
|
299
|
+
* light, so it is taken at face value. A model that says so separately should
|
|
300
|
+
* override this rather than let that field speak for the light - see the
|
|
301
|
+
* H1310, where it stays on while both lights are off.
|
|
302
|
+
*
|
|
303
|
+
* @param {object} params a parsed device update
|
|
304
|
+
* @returns {'on'|'off'|undefined} the light's state, or undefined if unsaid
|
|
305
|
+
*/
|
|
306
|
+
readLightState(params) {
|
|
307
|
+
return params.state
|
|
308
|
+
}
|
|
309
|
+
|
|
284
310
|
externalUpdate(params) {
|
|
285
|
-
// The standard state/brightness/kelvin fields report the light
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
311
|
+
// The standard state/brightness/kelvin fields report the light. A subclass
|
|
312
|
+
// that replaces the single light with named ones leaves lightService unset,
|
|
313
|
+
// and updating it would throw on every status the fan sends
|
|
314
|
+
if (!this.hideLight && this.lightService) {
|
|
315
|
+
const lightState = this.readLightState(params)
|
|
316
|
+
if (lightState && lightState !== this.cacheLightState) {
|
|
317
|
+
this.cacheLightState = lightState
|
|
289
318
|
this.lightService.updateCharacteristic(this.hapChar.On, this.cacheLightState === 'on')
|
|
290
319
|
this.accessory.log(`${platformLang.curLight} [${this.cacheLightState}]`)
|
|
291
320
|
}
|
|
@@ -316,9 +345,24 @@ export default class GoveeFanH1370 extends GoveeDevice {
|
|
|
316
345
|
return
|
|
317
346
|
}
|
|
318
347
|
|
|
348
|
+
// A subclass that reads a code for itself owns it outright, so the parent
|
|
349
|
+
// must neither act on it nor call it unrecognised. The same code can mean
|
|
350
|
+
// different things on different fans - `aa 36` is fan power on an H1370
|
|
351
|
+
// and the two lights on an H1310 - so acting on it here would be wrong
|
|
352
|
+
if (this.handledStatusCodes.has(getTwoItemPosition(hexParts, 2))) {
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
319
356
|
const deviceFunction = `${getTwoItemPosition(hexParts, 2)}${getTwoItemPosition(hexParts, 3)}`
|
|
320
357
|
|
|
321
358
|
switch (deviceFunction) {
|
|
359
|
+
// aa 31 <?> <speed> <direction>. The third byte is not part of the
|
|
360
|
+
// code: an H1310 has been seen reporting both 00 and 01 for it with the
|
|
361
|
+
// fan running either way, so what it means is still unknown - but
|
|
362
|
+
// matching only 3101 announced every other status as unrecognised,
|
|
363
|
+
// which is the noise that sent this model's owner here (#1352).
|
|
364
|
+
// `aa 36` is the reliable word on whether the fan is running.
|
|
365
|
+
case '3100':
|
|
322
366
|
case '3101': {
|
|
323
367
|
// Fan speed
|
|
324
368
|
const newSpeed = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
|
package/lib/platform.js
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from './utils/functions.js'
|
|
31
31
|
import platformLang from './utils/lang-en.js'
|
|
32
32
|
import { isFirstReport, logUnknownData } from './utils/report-unknown.js'
|
|
33
|
-
import { parseDeviceUpdate } from './utils/response-parser.js'
|
|
33
|
+
import { isCommandAcknowledgement, parseDeviceUpdate } from './utils/response-parser.js'
|
|
34
34
|
|
|
35
35
|
const require = createRequire(import.meta.url)
|
|
36
36
|
const plugin = require('../package.json')
|
|
@@ -1422,6 +1422,11 @@ export default class {
|
|
|
1422
1422
|
} catch (err) {
|
|
1423
1423
|
this.log.warn('[%s] %s %s.', accessory.displayName, platformLang.devNotUpdated, parseError(err))
|
|
1424
1424
|
}
|
|
1425
|
+
} else if (isCommandAcknowledgement(params)) {
|
|
1426
|
+
// Govee answers a command with a receipt carrying nothing but whether it
|
|
1427
|
+
// worked. There is no status in it to miss, so reporting it as a payload
|
|
1428
|
+
// nobody understood buries the ones that are worth reading (#1352)
|
|
1429
|
+
accessory.logDebug(`[${params.source}] ${platformLang.cmdAcknowledged} [${params.cmd}]`)
|
|
1425
1430
|
} else {
|
|
1426
1431
|
// Nothing in the payload was understood. This is the most useful thing a
|
|
1427
1432
|
// user can send in when asking for a device or a function to be added, so
|
package/lib/utils/lang-en.js
CHANGED
|
@@ -65,6 +65,7 @@ export default {
|
|
|
65
65
|
curOcc: 'current occupancy',
|
|
66
66
|
curPM25: 'current PM2.5',
|
|
67
67
|
curScene: 'current scene',
|
|
68
|
+
curDirection: 'current airflow direction',
|
|
68
69
|
curSpeed: 'current speed',
|
|
69
70
|
curState: 'current state',
|
|
70
71
|
curSwing: 'current swing',
|
|
@@ -140,4 +141,5 @@ export default {
|
|
|
140
141
|
syncStillRunning: 'skipping this sync as the previous one has not finished yet',
|
|
141
142
|
viaAL: 'via adaptive lighting',
|
|
142
143
|
statusCodes: 'device status codes',
|
|
144
|
+
cmdAcknowledged: 'command acknowledged',
|
|
143
145
|
}
|
|
@@ -47,6 +47,24 @@ function resolveOpenApiTempUnit(value, context) {
|
|
|
47
47
|
return null
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Whether a payload is only govee's receipt for a command we sent.
|
|
52
|
+
*
|
|
53
|
+
* These carry a command name and a `result` and nothing else - no status at
|
|
54
|
+
* all - so there is nothing in them to have failed to understand. Anything
|
|
55
|
+
* that names even one other field is a real payload and is not this.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} params - Raw payload with source, cmd, state, etc.
|
|
58
|
+
* @returns {boolean} true if the payload is a bare acknowledgement
|
|
59
|
+
*/
|
|
60
|
+
export function isCommandAcknowledgement(params) {
|
|
61
|
+
if (!params?.cmd || !params.state || typeof params.state !== 'object') {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
const keys = Object.keys(params.state)
|
|
65
|
+
return keys.length > 0 && keys.every(key => key === 'result')
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
/**
|
|
51
69
|
* Parses a raw device update payload into a standardised data object.
|
|
52
70
|
* Handles different payload structures from AWS, LAN, OpenAPI, and legacy devices.
|
package/package.json
CHANGED