@homebridge-plugins/homebridge-govee 11.22.0 → 11.22.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-govee` will be documented in this file.
4
4
 
5
+ ## v11.22.1 (Pending Release)
6
+
7
+ ### Changes
8
+
9
+ - feat: add support for Floor Lamp H16B0 and Table Lamp H1741 (#1279) (@gzimbric)
10
+ - feat: add H5140 Smart CO2 Monitor support (closes #1179) (#1281) (@itskevinb)
11
+ - chore: dependency updates
12
+ - fix: self-heal invalid TTR token without full re-login
13
+
5
14
  ## v11.22.0 (2026-05-05)
6
15
 
7
16
  ### Changed
@@ -107,20 +107,11 @@ export default class {
107
107
  throw new Error(res.data.message || platformLang.noToken)
108
108
  }
109
109
 
110
- // Also grab an access token specifically for the get tap to run endpoint
111
- const ttrRes = await axios({
112
- url: 'https://community-api.govee.com/os/v1/login',
113
- method: 'post',
114
- data: {
115
- email: this.username,
116
- password: this.password,
117
- },
118
- timeout: 30000,
119
- })
120
-
121
110
  // Make the token available in other functions
122
111
  this.token = res.data.client.token
123
- this.tokenTTR = ttrRes.data?.data?.token
112
+
113
+ // Also grab an access token specifically for the get-tap-to-run endpoint
114
+ await this.loginTTR()
124
115
 
125
116
  // Mark this request complete if in debug mode
126
117
  this.log.debug('[HTTP] %s.', platformLang.loginSuccess)
@@ -166,6 +157,24 @@ export default class {
166
157
  }
167
158
  }
168
159
 
160
+ async loginTTR() {
161
+ // The tap-to-run endpoint uses a separate, shorter-lived token obtained from
162
+ // a different login endpoint to the main Govee account token. This is split
163
+ // out so it can be refreshed on its own without a full account re-login.
164
+ const ttrRes = await axios({
165
+ url: 'https://community-api.govee.com/os/v1/login',
166
+ method: 'post',
167
+ data: {
168
+ email: this.username,
169
+ password: this.password,
170
+ },
171
+ timeout: 30000,
172
+ })
173
+
174
+ this.tokenTTR = ttrRes.data?.data?.token
175
+ return this.tokenTTR
176
+ }
177
+
169
178
  async logout() {
170
179
  try {
171
180
  await axios({
@@ -232,7 +241,17 @@ export default class {
232
241
  }
233
242
  }
234
243
 
235
- async getTapToRuns() {
244
+ async getTapToRuns(isRetry = false) {
245
+ // The TTR token is separate to the main account token and shorter-lived. If
246
+ // we don't have a usable one (eg. restored from an old cache where it was
247
+ // never saved, saved as the literal string 'undefined', or expired) then
248
+ // fetch a fresh one before continuing. This lets a user with a valid main
249
+ // token but a bad TTR token self-heal without a full account re-login.
250
+ if (!this.tokenTTR || this.tokenTTR === 'undefined') {
251
+ await this.loginTTR()
252
+ this.tokenTTRRefreshed = true
253
+ }
254
+
236
255
  // Build and send the request
237
256
  const res = await axios({
238
257
  url: 'https://app2.govee.com/bff-app/v1/exec-plat/home',
@@ -251,6 +270,13 @@ export default class {
251
270
 
252
271
  // Check to see we got a response
253
272
  if (!res?.data?.data?.components) {
273
+ // The token may have expired since we obtained it - try one fresh TTR
274
+ // login and retry once before giving up
275
+ if (!isRetry) {
276
+ await this.loginTTR()
277
+ this.tokenTTRRefreshed = true
278
+ return this.getTapToRuns(true)
279
+ }
254
280
  throw new Error('not a valid response')
255
281
  }
256
282
 
@@ -46,6 +46,7 @@ import devicePurifierH7128 from './purifier-H7128.js'
46
46
  import devicePurifierH7129 from './purifier-H7129.js'
47
47
  import devicePurifierSingle from './purifier-single.js'
48
48
  import deviceSensorButton from './sensor-button.js'
49
+ import deviceSensorCO2 from './sensor-co2.js'
49
50
  import deviceSensorContact from './sensor-contact.js'
50
51
  import deviceSensorLeak from './sensor-leak.js'
51
52
  import deviceSensorMonitor from './sensor-monitor.js'
@@ -110,6 +111,7 @@ export default {
110
111
  devicePurifierH7129,
111
112
  devicePurifierSingle,
112
113
  deviceSensorButton,
114
+ deviceSensorCO2,
113
115
  deviceSensorContact,
114
116
  deviceSensorLeak,
115
117
  deviceSensorMonitor,
@@ -0,0 +1,156 @@
1
+ import {
2
+ base64ToHex,
3
+ cenToFar,
4
+ getTwoItemPosition,
5
+ hexToTwoItems,
6
+ parseError,
7
+ } from '../utils/functions.js'
8
+ import platformLang from '../utils/lang-en.js'
9
+
10
+ // HomeKit triggers "CO2 detected" abnormal flag at this threshold (ppm).
11
+ // Govee app default warn level is 1000 ppm; override via deviceConf.co2AbnormalThreshold.
12
+ const DEFAULT_CO2_ABNORMAL_PPM = 1000
13
+
14
+ export default class {
15
+ constructor(platform, accessory) {
16
+ // Set up variables from the platform
17
+ this.hapChar = platform.api.hap.Characteristic
18
+ this.hapErr = platform.api.hap.HapStatusError
19
+ this.hapServ = platform.api.hap.Service
20
+ this.platform = platform
21
+
22
+ // Set up variables from the accessory
23
+ this.accessory = accessory
24
+
25
+ // Set up custom variables for this device type
26
+ const deviceConf = platform.deviceConf[accessory.context.gvDeviceId] || {}
27
+ this.co2AbnormalThreshold = deviceConf.co2AbnormalThreshold || DEFAULT_CO2_ABNORMAL_PPM
28
+
29
+ // Add the CO2 sensor service (with level + peak characteristics) if it doesn't already exist
30
+ this.co2Service = this.accessory.getService(this.hapServ.CarbonDioxideSensor)
31
+ || this.accessory.addService(this.hapServ.CarbonDioxideSensor)
32
+ if (!this.co2Service.testCharacteristic(this.hapChar.CarbonDioxideLevel)) {
33
+ this.co2Service.addCharacteristic(this.hapChar.CarbonDioxideLevel)
34
+ }
35
+ if (!this.co2Service.testCharacteristic(this.hapChar.CarbonDioxidePeakLevel)) {
36
+ this.co2Service.addCharacteristic(this.hapChar.CarbonDioxidePeakLevel)
37
+ }
38
+ this.cacheCO2 = this.co2Service.getCharacteristic(this.hapChar.CarbonDioxideLevel).value || 0
39
+ this.cacheCO2Peak = this.co2Service.getCharacteristic(this.hapChar.CarbonDioxidePeakLevel).value || 0
40
+ this.cacheCO2Detected = this.co2Service.getCharacteristic(this.hapChar.CarbonDioxideDetected).value || 0
41
+
42
+ // Add the temperature service if it doesn't already exist
43
+ this.tempService = this.accessory.getService(this.hapServ.TemperatureSensor)
44
+ || this.accessory.addService(this.hapServ.TemperatureSensor)
45
+ this.cacheTemp = this.tempService.getCharacteristic(this.hapChar.CurrentTemperature).value
46
+
47
+ // Add the humidity service if it doesn't already exist
48
+ this.humiService = this.accessory.getService(this.hapServ.HumiditySensor)
49
+ || this.accessory.addService(this.hapServ.HumiditySensor)
50
+ this.cacheHumi = this.humiService.getCharacteristic(this.hapChar.CurrentRelativeHumidity).value
51
+
52
+ // No Battery service — H5140 is mains-powered via USB; the Govee cloud
53
+ // stream doesn't carry a meaningful battery level. Remove any stale service
54
+ // left over from earlier versions of this handler.
55
+ const staleBattery = this.accessory.getService(this.hapServ.Battery)
56
+ if (staleBattery) {
57
+ this.accessory.removeService(staleBattery)
58
+ }
59
+
60
+ this.updateCache()
61
+
62
+ // Pass the accessory to Fakegato to set up with Eve
63
+ this.accessory.eveService = new platform.eveService('custom', this.accessory, {
64
+ log: () => {},
65
+ })
66
+
67
+ // Output the customised options to the log
68
+ const opts = JSON.stringify({
69
+ co2AbnormalThreshold: this.co2AbnormalThreshold,
70
+ })
71
+ platform.log('[%s] %s %s.', accessory.displayName, platformLang.devInitOpts, opts)
72
+ }
73
+
74
+ async externalUpdate(params) {
75
+ // Parse AWS reading packets — opcode 0x0A carries live CO2 / temp / humidity
76
+ const commands = params.commands || []
77
+ commands.forEach((command) => {
78
+ const hexString = base64ToHex(command)
79
+ const hexParts = hexToTwoItems(hexString)
80
+ if (!hexParts || hexParts.length < 20) {
81
+ return
82
+ }
83
+ if (getTwoItemPosition(hexParts, 1) !== 'aa') {
84
+ return
85
+ }
86
+ if (getTwoItemPosition(hexParts, 2) !== '0a') {
87
+ return
88
+ }
89
+
90
+ // 1-indexed: position N -> byte (N-1). LE u16: low byte at lower position.
91
+ const u16le = (lsbPos, msbPos) => Number.parseInt(
92
+ `${getTwoItemPosition(hexParts, msbPos)}${getTwoItemPosition(hexParts, lsbPos)}`,
93
+ 16,
94
+ )
95
+
96
+ const offTemp = this.accessory.context.offTemp || 0
97
+ const offHumi = this.accessory.context.offHumi || 0
98
+
99
+ const tempRaw = u16le(3, 4) // bytes 2-3 of packet, °C × 100
100
+ const humiRaw = u16le(5, 6) // bytes 4-5, %RH × 100
101
+ const co2Raw = u16le(7, 8) // bytes 6-7, ppm
102
+
103
+ const newTemp = Math.round(tempRaw + offTemp) / 100
104
+ const newHumi = Math.max(0, Math.min(100, Math.round((humiRaw + offHumi) / 100)))
105
+ const newCO2 = co2Raw
106
+
107
+ if (newTemp !== this.cacheTemp && newTemp > -40 && newTemp < 100) {
108
+ this.cacheTemp = newTemp
109
+ this.tempService.updateCharacteristic(this.hapChar.CurrentTemperature, this.cacheTemp)
110
+ this.accessory.eveService.addEntry({ temp: this.cacheTemp })
111
+ this.accessory.log(`${platformLang.curTemp} [${this.cacheTemp}°C / ${cenToFar(this.cacheTemp)}°F]`)
112
+ this.updateCache()
113
+ }
114
+
115
+ if (newHumi !== this.cacheHumi) {
116
+ this.cacheHumi = newHumi
117
+ this.humiService.updateCharacteristic(this.hapChar.CurrentRelativeHumidity, this.cacheHumi)
118
+ this.accessory.eveService.addEntry({ humidity: this.cacheHumi })
119
+ this.accessory.log(`${platformLang.curHumi} [${this.cacheHumi}%]`)
120
+ }
121
+
122
+ if (newCO2 !== this.cacheCO2 && newCO2 >= 0 && newCO2 <= 40000) {
123
+ this.cacheCO2 = newCO2
124
+ this.co2Service.updateCharacteristic(this.hapChar.CarbonDioxideLevel, this.cacheCO2)
125
+
126
+ if (newCO2 > this.cacheCO2Peak) {
127
+ this.cacheCO2Peak = newCO2
128
+ this.co2Service.updateCharacteristic(this.hapChar.CarbonDioxidePeakLevel, this.cacheCO2Peak)
129
+ }
130
+
131
+ const detected = newCO2 >= this.co2AbnormalThreshold ? 1 : 0
132
+ if (detected !== this.cacheCO2Detected) {
133
+ this.cacheCO2Detected = detected
134
+ this.co2Service.updateCharacteristic(this.hapChar.CarbonDioxideDetected, detected)
135
+ }
136
+
137
+ this.accessory.eveService.addEntry({ ppm: this.cacheCO2 })
138
+ this.accessory.log(`${platformLang.curCO2} [${this.cacheCO2} ppm]`)
139
+ }
140
+ })
141
+ }
142
+
143
+ async updateCache() {
144
+ if (!this.platform.storageClientData) {
145
+ return
146
+ }
147
+ try {
148
+ await this.platform.storageData.setItem(
149
+ `${this.accessory.context.gvDeviceId}_temp`,
150
+ this.cacheTemp,
151
+ )
152
+ } catch (err) {
153
+ this.accessory.logWarn(`${platformLang.storageWriteErr} ${parseError(err)}`)
154
+ }
155
+ }
156
+ }
package/lib/platform.js CHANGED
@@ -479,8 +479,8 @@ export default class {
479
479
 
480
480
  this.accountId = data.accountId
481
481
  this.accountTopic = data.topic
482
- const accountToken = data.token
483
- const accountTokenTTR = data.tokenTTR
482
+ this.accountToken = data.token
483
+ this.accountTokenTTR = data.tokenTTR
484
484
  this.clientId = data.client
485
485
  this.iotEndpoint = data.endpoint
486
486
  this.iotPass = data.iotPass
@@ -489,14 +489,7 @@ export default class {
489
489
  await promises.writeFile(iotFile, Buffer.from(data.iot, 'base64'))
490
490
 
491
491
  // Try and save these to the cache for future reference
492
- try {
493
- await this.storageData.setItem(
494
- 'Govee_All_Devices_temp',
495
- `${this.accountTopic}:::${accountToken}:::${this.config.username}:::${this.accountId}:::${this.iotEndpoint}:::${this.iotPass}:::${accountTokenTTR}`,
496
- )
497
- } catch (e) {
498
- this.log.warn('[HTTP] %s %s.', platformLang.accTokenStoreErr, parseError(e))
499
- }
492
+ await this.persistAccountCache()
500
493
  await getDevices()
501
494
  }
502
495
 
@@ -806,6 +799,16 @@ export default class {
806
799
  if (this.httpClient) {
807
800
  try {
808
801
  const scenes = await this.httpClient.getTapToRuns()
802
+
803
+ // If the TTR token had to be refreshed (eg. it was missing or expired
804
+ // in the cache), write the new one back so the next startup is fixed
805
+ if (this.httpClient.tokenTTRRefreshed) {
806
+ this.accountTokenTTR = this.httpClient.tokenTTR
807
+ this.httpClient.tokenTTRRefreshed = false
808
+ await this.persistAccountCache()
809
+ this.log.debug('[HTTP] refreshed TTR token saved to cache.')
810
+ }
811
+
809
812
  scenes.forEach((scene) => {
810
813
  if (scene.oneClicks) {
811
814
  scene.oneClicks.forEach((oneClick) => {
@@ -946,6 +949,20 @@ export default class {
946
949
  this.log('----------------------------')
947
950
  }
948
951
 
952
+ async persistAccountCache() {
953
+ // Persist the account details to the cache for future startups, so we can
954
+ // skip a full login. Kept in one place so the TTR self-heal path can also
955
+ // re-save once it has refreshed the (separate, shorter-lived) TTR token.
956
+ try {
957
+ await this.storageData.setItem(
958
+ 'Govee_All_Devices_temp',
959
+ `${this.accountTopic}:::${this.accountToken}:::${this.config.username}:::${this.accountId}:::${this.iotEndpoint}:::${this.iotPass}:::${this.accountTokenTTR}`,
960
+ )
961
+ } catch (err) {
962
+ this.log.warn('[HTTP] %s %s.', platformLang.accTokenStoreErr, parseError(err))
963
+ }
964
+ }
965
+
949
966
  pluginShutdown() {
950
967
  // A function that is called when the plugin fails to load or Homebridge restarts
951
968
  try {
@@ -1150,6 +1167,11 @@ export default class {
1150
1167
  devInstance = deviceTypes.deviceSensorMonitor
1151
1168
  doAWSPolling = true
1152
1169
  accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1170
+ } else if (platformConsts.models.sensorCO2.includes(device.model)) {
1171
+ // Device is a CO2 + temperature + humidity monitor (H5140)
1172
+ devInstance = deviceTypes.deviceSensorCO2
1173
+ doAWSPolling = true
1174
+ accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1153
1175
  } else if (platformConsts.models.fan.includes(device.model)) {
1154
1176
  // Device is a fan
1155
1177
  devInstance = deviceTypes[`deviceFan${device.model}`]
@@ -129,6 +129,8 @@ export default {
129
129
  models: {
130
130
  rgb: [
131
131
  'H1401',
132
+ 'H16B0', // https://github.com/homebridge-plugins/homebridge-govee/issues/1278
133
+ 'H1741', // https://github.com/homebridge-plugins/homebridge-govee/issues/1278
132
134
  'H6001',
133
135
  'H6002',
134
136
  'H6003',
@@ -515,6 +517,7 @@ export default {
515
517
  ],
516
518
  sensorThermo4: ['H5198'],
517
519
  sensorMonitor: ['H5106'],
520
+ sensorCO2: ['H5140'], // CO2 + temp + humidity monitor, AWS opcode 0x0A — closes #1179
518
521
  fan: ['H7100', 'H7101', 'H7102', 'H7105', 'H7106', 'H7107', 'H7111'],
519
522
  heater1: ['H7130', 'H713A', 'H713B', 'H713C'],
520
523
  heater2: ['H7131', 'H7132', 'H7133', 'H7134', 'H7135'],
@@ -542,7 +545,6 @@ export default {
542
545
  'H5126', // https://github.com/homebridge-plugins/homebridge-govee/issues/910
543
546
  'H5129', // https://github.com/homebridge-plugins/homebridge-govee/issues/1084
544
547
  'H5125', // https://github.com/homebridge-plugins/homebridge-govee/issues/981
545
- 'H5140', // https://github.com/homebridge-plugins/homebridge-govee/issues/1179
546
548
  'H5185', // https://github.com/homebridge-plugins/homebridge-govee/issues/804
547
549
  'H5191', // https://github.com/homebridge-plugins/homebridge-govee/issues/1121
548
550
  ],
@@ -44,6 +44,7 @@ export default {
44
44
  curAmp: 'current amperage',
45
45
  curBatt: 'current battery',
46
46
  curBright: 'current brightness',
47
+ curCO2: 'current CO2',
47
48
  curColour: 'current colour',
48
49
  curCool: 'current cooling',
49
50
  curDisplay: 'current display',
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.22.0",
5
+ "version": "11.22.1-beta.0",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",
@@ -58,19 +58,19 @@
58
58
  "dependencies": {
59
59
  "@homebridge/plugin-ui-utils": "^2.2.3",
60
60
  "aws-iot-device-sdk": "^2.2.16",
61
- "axios": "^1.16.0",
61
+ "axios": "^1.16.1",
62
62
  "mqtt": "^5.12.1",
63
63
  "node-persist": "^4.0.4",
64
64
  "node-rsa": "^1.1.1",
65
- "p-queue": "^9.2.0",
65
+ "p-queue": "^9.3.0",
66
66
  "patch-package": "^8.0.1",
67
67
  "pem": "^1.14.8"
68
68
  },
69
69
  "optionalDependencies": {
70
70
  "@stoprocent/bluetooth-hci-socket": "^2.2.6",
71
- "@stoprocent/noble": "^2.4.1"
71
+ "@stoprocent/noble": "^2.5.3"
72
72
  },
73
73
  "devDependencies": {
74
- "@antfu/eslint-config": "^8.2.0"
74
+ "@antfu/eslint-config": "^9.0.0"
75
75
  }
76
76
  }