@homebridge-plugins/homebridge-govee 11.32.2-beta.4 → 11.32.2-beta.5
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 +1 -0
- package/lib/device/fan-basic.js +143 -0
- package/lib/device/index.js +2 -0
- package/lib/device/template.js +8 -1
- package/lib/platform.js +4 -2
- package/lib/utils/constants.js +4 -4
- package/lib/utils/report-unknown.js +45 -0
- package/lib/utils/report-unknown.test.js +47 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,7 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
|
|
|
17
17
|
- feat: work out for itself whether a heater reports the room temperature, and add the H7137 and H713E
|
|
18
18
|
- feat: add support for 85 more light models, covering everything currently sold that was missing
|
|
19
19
|
- feat: add 24 more light models, three thermo-hygrometers and a leak detector
|
|
20
|
+
- feat: add the H7103 and H7112 fans and the H5085 plug, with unknown controls saying so instead of guessing
|
|
20
21
|
|
|
21
22
|
## v11.32.1 (2026-08-02)
|
|
22
23
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { base64ToHex, getTwoItemPosition, hexToTwoItems, parseError } from '../utils/functions.js'
|
|
2
|
+
import platformLang from '../utils/lang-en.js'
|
|
3
|
+
import { logUnknownData, reportUnsupportedControl } from '../utils/report-unknown.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A fan whose power command is known but whose speeds are not.
|
|
7
|
+
*
|
|
8
|
+
* Govee's power command is the same across every appliance the plugin handles,
|
|
9
|
+
* so on and off can be offered with confidence for a model nobody has tested.
|
|
10
|
+
* The speeds are the part that differs per model, and getting the number of
|
|
11
|
+
* them wrong is worse than not offering them - so the slider reports what it
|
|
12
|
+
* needs and fails the request rather than quietly sending a guess.
|
|
13
|
+
*
|
|
14
|
+
* Once an owner sends in what their fan reports, the model gets its own handler
|
|
15
|
+
* with real speeds and stops using this one.
|
|
16
|
+
*/
|
|
17
|
+
export default class {
|
|
18
|
+
constructor(platform, accessory) {
|
|
19
|
+
// Set up variables from the platform
|
|
20
|
+
this.hapChar = platform.api.hap.Characteristic
|
|
21
|
+
this.hapErr = platform.api.hap.HapStatusError
|
|
22
|
+
this.hapServ = platform.api.hap.Service
|
|
23
|
+
this.platform = platform
|
|
24
|
+
|
|
25
|
+
// Set up variables from the accessory
|
|
26
|
+
this.accessory = accessory
|
|
27
|
+
|
|
28
|
+
// Add the fan service if it doesn't already exist
|
|
29
|
+
this.service = this.accessory.getService(this.hapServ.Fanv2)
|
|
30
|
+
|| this.accessory.addService(this.hapServ.Fanv2)
|
|
31
|
+
|
|
32
|
+
// Add the set handler to the fan active characteristic
|
|
33
|
+
this.service
|
|
34
|
+
.getCharacteristic(this.hapChar.Active)
|
|
35
|
+
.onSet(async value => this.internalStateUpdate(value))
|
|
36
|
+
this.cacheState = this.service.getCharacteristic(this.hapChar.Active).value ? 'on' : 'off'
|
|
37
|
+
|
|
38
|
+
// Offer the speed slider so the fan can report what it wants, but make it
|
|
39
|
+
// clear it does nothing yet rather than sending a guessed command
|
|
40
|
+
this.service
|
|
41
|
+
.getCharacteristic(this.hapChar.RotationSpeed)
|
|
42
|
+
.setProps({
|
|
43
|
+
maxValue: 100,
|
|
44
|
+
minStep: 1,
|
|
45
|
+
minValue: 0,
|
|
46
|
+
unit: 'unitless',
|
|
47
|
+
})
|
|
48
|
+
.onSet(async value => this.internalSpeedUpdate(value))
|
|
49
|
+
this.cacheSpeed = this.service.getCharacteristic(this.hapChar.RotationSpeed).value
|
|
50
|
+
|
|
51
|
+
// Output the customised options to the log
|
|
52
|
+
const opts = JSON.stringify({})
|
|
53
|
+
platform.log('[%s] %s %s.', accessory.displayName, platformLang.devInitOpts, opts)
|
|
54
|
+
this.accessory.log('on and off are supported for this model, the speeds are not known yet')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async internalStateUpdate(value) {
|
|
58
|
+
try {
|
|
59
|
+
const newValue = value ? 'on' : 'off'
|
|
60
|
+
|
|
61
|
+
// Don't continue if the new value is the same as before
|
|
62
|
+
if (this.cacheState === newValue) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Send the request to the platform sender function. This power command is
|
|
67
|
+
// the same for every govee appliance, so it is safe for an untested model
|
|
68
|
+
await this.platform.sendDeviceUpdate(this.accessory, {
|
|
69
|
+
cmd: 'ptReal',
|
|
70
|
+
value: value ? 'MwEBAAAAAAAAAAAAAAAAAAAAADM=' : 'MwEAAAAAAAAAAAAAAAAAAAAAADI=',
|
|
71
|
+
openApi: this.accessory.context.openApiCapabilities?.powerSwitch
|
|
72
|
+
? { instance: 'powerSwitch', capabilityType: 'devices.capabilities.on_off', value: value ? 1 : 0 }
|
|
73
|
+
: undefined,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Cache the new state and log if appropriate
|
|
77
|
+
this.cacheState = newValue
|
|
78
|
+
this.accessory.log(`${platformLang.curState} [${newValue}]`)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Catch any errors during the process
|
|
81
|
+
this.accessory.logWarn(`${platformLang.devNotUpdated} ${parseError(err)}`)
|
|
82
|
+
|
|
83
|
+
// Throw a 'no response' error and set a timeout to revert this after 2 seconds
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
|
|
86
|
+
}, 2000)
|
|
87
|
+
throw new this.hapErr(-70402)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async internalSpeedUpdate(value) {
|
|
92
|
+
// 0 is the slider following the fan being turned off, not a speed request
|
|
93
|
+
if (value === 0) {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
reportUnsupportedControl(this.accessory, { control: 'fan speed', value })
|
|
98
|
+
|
|
99
|
+
// Put the slider back where it was, and fail the request so HomeKit shows
|
|
100
|
+
// it as not responding - that is what sends the owner to the log
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
|
|
103
|
+
}, 2000)
|
|
104
|
+
throw new this.hapErr(-70402)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
externalUpdate(params) {
|
|
108
|
+
// Power is the one thing that can be read back with confidence
|
|
109
|
+
if (params.state && params.state !== this.cacheState) {
|
|
110
|
+
this.cacheState = params.state
|
|
111
|
+
this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
|
|
112
|
+
this.accessory.log(`${platformLang.curState} [${this.cacheState}]`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Everything else is reported so the model can be finished off
|
|
116
|
+
;(params.commands || []).forEach((command) => {
|
|
117
|
+
const hexString = base64ToHex(command)
|
|
118
|
+
const hexParts = hexToTwoItems(hexString)
|
|
119
|
+
|
|
120
|
+
if (getTwoItemPosition(hexParts, 1) !== 'aa') {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The power report is understood, anything else is worth sending in
|
|
125
|
+
if (getTwoItemPosition(hexParts, 2) === '01') {
|
|
126
|
+
const newState = getTwoItemPosition(hexParts, 3) === '01' ? 'on' : 'off'
|
|
127
|
+
if (this.cacheState !== newState) {
|
|
128
|
+
this.cacheState = newState
|
|
129
|
+
this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
|
|
130
|
+
this.accessory.log(`${platformLang.curState} [${this.cacheState}]`)
|
|
131
|
+
}
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
logUnknownData(this.accessory, {
|
|
136
|
+
kind: 'scene',
|
|
137
|
+
source: params.source,
|
|
138
|
+
raw: command,
|
|
139
|
+
hex: hexString,
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
package/lib/device/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import deviceDehumidifierH7151 from './dehumidifier-H7151.js'
|
|
|
4
4
|
import deviceDehumidifierH7152 from './dehumidifier-H7152.js'
|
|
5
5
|
import deviceDiffuserH7161 from './diffuser-H7161.js'
|
|
6
6
|
import deviceDiffuserH7162 from './diffuser-H7162.js'
|
|
7
|
+
import deviceFanBasic from './fan-basic.js'
|
|
7
8
|
import deviceFanH1310 from './fan-H1310.js'
|
|
8
9
|
import deviceFanH1370 from './fan-H1370.js'
|
|
9
10
|
import deviceFanH7100 from './fan-H7100.js'
|
|
@@ -73,6 +74,7 @@ export default {
|
|
|
73
74
|
deviceDehumidifierH7152,
|
|
74
75
|
deviceDiffuserH7161,
|
|
75
76
|
deviceDiffuserH7162,
|
|
77
|
+
deviceFanBasic,
|
|
76
78
|
deviceFanH1310,
|
|
77
79
|
deviceFanH1370,
|
|
78
80
|
deviceFanR1310: deviceFanH1310, // R1310 is the newer naming of the H1310
|
package/lib/device/template.js
CHANGED
|
@@ -20,7 +20,14 @@ export default class {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
externalUpdate(params) {
|
|
23
|
-
|
|
23
|
+
// These are the models actively being worked out, so every payload is worth
|
|
24
|
+
// reporting in the full form rather than as a bare dump - it is the whole
|
|
25
|
+
// reason the accessory exists at this stage
|
|
26
|
+
logUnknownData(this.accessory, {
|
|
27
|
+
kind: 'payload',
|
|
28
|
+
source: params.source,
|
|
29
|
+
raw: params,
|
|
30
|
+
})
|
|
24
31
|
|
|
25
32
|
// Check for some other scene/mode change
|
|
26
33
|
;(params.commands || []).forEach((command) => {
|
package/lib/platform.js
CHANGED
|
@@ -1236,8 +1236,10 @@ export default class {
|
|
|
1236
1236
|
doAWSPolling = true
|
|
1237
1237
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1238
1238
|
} else if (platformConsts.models.fan.includes(device.model)) {
|
|
1239
|
-
// Device is a fan
|
|
1240
|
-
|
|
1239
|
+
// Device is a fan. Models whose speeds have not been worked out yet fall
|
|
1240
|
+
// back to the basic handler, which offers on and off and asks for what
|
|
1241
|
+
// it needs rather than leaving the device unsupported altogether
|
|
1242
|
+
devInstance = deviceTypes[`deviceFan${device.model}`] || deviceTypes.deviceFanBasic
|
|
1241
1243
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1242
1244
|
} else if (platformConsts.models.heater1.includes(device.model)) {
|
|
1243
1245
|
// Some of these heaters report the room temperature and some do not,
|
package/lib/utils/constants.js
CHANGED
|
@@ -621,7 +621,7 @@ export default {
|
|
|
621
621
|
// H5901 is a water timer, controlled only through the OpenAPI - it has no
|
|
622
622
|
// usable AWS, BLE or LAN path, so it needs an API key configured. Set
|
|
623
623
|
// showAs to 'valve' or 'tap' for a more fitting HomeKit tile (#1324)
|
|
624
|
-
switchSingle: ['H5001', 'H5080', 'H5081', 'H5083', 'H5086', 'H5901', 'H7014'],
|
|
624
|
+
switchSingle: ['H5001', 'H5080', 'H5081', 'H5083', 'H5085', 'H5086', 'H5901', 'H7014'],
|
|
625
625
|
switchDouble: ['H5082', 'H5089'],
|
|
626
626
|
switchTriple: ['H5160'],
|
|
627
627
|
sensorLeak: ['H5054', 'H5058', 'H5059', 'H5830'],
|
|
@@ -660,7 +660,7 @@ export default {
|
|
|
660
660
|
sensorThermo4: ['H5198'],
|
|
661
661
|
sensorMonitor: ['H5106'],
|
|
662
662
|
sensorCO2: ['H5140'], // CO2 + temp + humidity monitor, AWS opcode 0x0A — closes #1179
|
|
663
|
-
fan: ['H1310', 'H1370', 'H7100', 'H7101', 'H7102', 'H7105', 'H7106', 'H7107', 'H7111', 'R1310'],
|
|
663
|
+
fan: ['H1310', 'H1370', 'H7100', 'H7101', 'H7102', 'H7103', 'H7105', 'H7106', 'H7107', 'H7111', 'H7112', 'R1310'],
|
|
664
664
|
heater1: ['H7130', 'H713A', 'H713B', 'H713C', 'H7137', 'H713E'],
|
|
665
665
|
heater2: ['H7131', 'H7132', 'H7133', 'H7134', 'H7135'],
|
|
666
666
|
dehumidifier: ['H7150', 'H7151', 'H7152'],
|
|
@@ -679,8 +679,8 @@ export default {
|
|
|
679
679
|
'H5024', // https://github.com/homebridge-plugins/homebridge-govee/issues/835
|
|
680
680
|
'H5042', // https://github.com/homebridge-plugins/homebridge-govee/issues/849
|
|
681
681
|
'H5043', // https://github.com/homebridge-plugins/homebridge-govee/issues/558
|
|
682
|
-
|
|
683
|
-
|
|
682
|
+
// https://github.com/homebridge-plugins/homebridge-govee/issues/951
|
|
683
|
+
// https://github.com/homebridge-plugins/homebridge-govee/issues/1117
|
|
684
684
|
'H5121', // https://github.com/homebridge-plugins/homebridge-govee/issues/913
|
|
685
685
|
'H5124', // https://github.com/homebridge-plugins/homebridge-govee/issues/1119
|
|
686
686
|
'H5126', // https://github.com/homebridge-plugins/homebridge-govee/issues/910
|
|
@@ -43,6 +43,51 @@ export function isFirstReport(fingerprint) {
|
|
|
43
43
|
return true
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Report a control the plugin knows the device has, but does not yet know the
|
|
48
|
+
* command for, and then fail the request.
|
|
49
|
+
*
|
|
50
|
+
* This is how a model gets added before every last detail is known. The things
|
|
51
|
+
* that are certain - usually power - work straight away, and anything still
|
|
52
|
+
* unknown says so out loud instead of pretending to work. Failing the request
|
|
53
|
+
* matters: HomeKit shows the control as not responding, which sends the owner
|
|
54
|
+
* to the log, where they find the line to paste into an issue. A control that
|
|
55
|
+
* silently did nothing would just look broken.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} accessory the accessory the control belongs to
|
|
58
|
+
* @param {object} details
|
|
59
|
+
* @param {string} details.control what was asked for, eg 'fan speed'
|
|
60
|
+
* @param {*} [details.value] the value HomeKit asked for
|
|
61
|
+
* @returns {string} the message that was logged, mainly for tests
|
|
62
|
+
*/
|
|
63
|
+
export function reportUnsupportedControl(accessory, { control, value } = {}) {
|
|
64
|
+
const context = accessory?.context || {}
|
|
65
|
+
const report = {
|
|
66
|
+
model: context.gvModel,
|
|
67
|
+
firmware: context.firmware,
|
|
68
|
+
hardware: context.hardware,
|
|
69
|
+
control,
|
|
70
|
+
requested: value,
|
|
71
|
+
}
|
|
72
|
+
Object.keys(report).forEach((key) => {
|
|
73
|
+
if (report[key] === undefined || report[key] === null || report[key] === '') {
|
|
74
|
+
delete report[key]
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const line = `the ${control} for this model is not known yet, so this did not happen - please report this line so it can be added: ${JSON.stringify(report)}`
|
|
79
|
+
const fingerprint = `${context.gvDeviceId || accessory?.displayName}|unsupported|${control}`
|
|
80
|
+
|
|
81
|
+
if (alreadyReported.has(fingerprint)) {
|
|
82
|
+
accessory?.logDebug?.(line)
|
|
83
|
+
} else {
|
|
84
|
+
alreadyReported.add(fingerprint)
|
|
85
|
+
accessory?.logWarn?.(line)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return line
|
|
89
|
+
}
|
|
90
|
+
|
|
46
91
|
/**
|
|
47
92
|
* Log an unrecognised payload with everything needed to add support for it.
|
|
48
93
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import { isFirstReport, logUnknownData, resetReportedUnknowns } from './report-unknown.js'
|
|
3
|
+
import { isFirstReport, logUnknownData, reportUnsupportedControl, resetReportedUnknowns } from './report-unknown.js'
|
|
4
4
|
|
|
5
5
|
function makeAccessory(context = {}) {
|
|
6
6
|
return {
|
|
@@ -131,6 +131,52 @@ describe('reporting unrecognised data', () => {
|
|
|
131
131
|
expect(() => logUnknownData(undefined, { kind: 'scene', raw: 'abc' })).not.toThrow()
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
+
describe('a control that is not worked out yet', () => {
|
|
135
|
+
it('names the control and what was asked for', () => {
|
|
136
|
+
const accessory = makeAccessory()
|
|
137
|
+
reportUnsupportedControl(accessory, { control: 'fan speed', value: 50 })
|
|
138
|
+
|
|
139
|
+
const line = reported(accessory)
|
|
140
|
+
expect(line).toContain('fan speed')
|
|
141
|
+
expect(line).toContain('did not happen')
|
|
142
|
+
|
|
143
|
+
const payload = JSON.parse(line.slice(line.indexOf('{')))
|
|
144
|
+
expect(payload.model).toBe('H6102')
|
|
145
|
+
expect(payload.firmware).toBe('1.03.01')
|
|
146
|
+
expect(payload.requested).toBe(50)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('asks the user to report it', () => {
|
|
150
|
+
const accessory = makeAccessory()
|
|
151
|
+
reportUnsupportedControl(accessory, { control: 'fan speed', value: 1 })
|
|
152
|
+
|
|
153
|
+
expect(reported(accessory)).toContain('please report this line')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('warns once per control, then keeps quiet', () => {
|
|
157
|
+
const accessory = makeAccessory()
|
|
158
|
+
|
|
159
|
+
reportUnsupportedControl(accessory, { control: 'fan speed', value: 25 })
|
|
160
|
+
reportUnsupportedControl(accessory, { control: 'fan speed', value: 50 })
|
|
161
|
+
|
|
162
|
+
expect(accessory.logWarn).toHaveBeenCalledTimes(1)
|
|
163
|
+
expect(accessory.logDebug).toHaveBeenCalledTimes(1)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('still reports a different control on the same device', () => {
|
|
167
|
+
const accessory = makeAccessory()
|
|
168
|
+
|
|
169
|
+
reportUnsupportedControl(accessory, { control: 'fan speed', value: 25 })
|
|
170
|
+
reportUnsupportedControl(accessory, { control: 'ice size', value: 2 })
|
|
171
|
+
|
|
172
|
+
expect(accessory.logWarn).toHaveBeenCalledTimes(2)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('does not throw when there is no accessory', () => {
|
|
176
|
+
expect(() => reportUnsupportedControl(undefined, { control: 'fan speed' })).not.toThrow()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
134
180
|
describe('isFirstReport', () => {
|
|
135
181
|
it('is true once and false after', () => {
|
|
136
182
|
expect(isFirstReport('a')).toBe(true)
|
package/package.json
CHANGED