@homebridge-plugins/homebridge-govee 11.35.1-beta.0 → 11.35.1-beta.2
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 +5 -0
- package/lib/device/kettle.js +80 -7
- package/lib/fakegato/fakegato-history.js +37 -24
- package/lib/fakegato/fakegato-storage.js +47 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,11 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
|
|
|
7
7
|
### Changed
|
|
8
8
|
|
|
9
9
|
- fix: stop reading an h7173 startup frame as a kettle preset temperature (#1351)
|
|
10
|
+
- fix: call the kettle's first temperature slot the target, not preset 1 (#1337)
|
|
11
|
+
- feat: show how long the kettle will keep the water warm for (#1337)
|
|
12
|
+
- fix: keep saving eve history for a device with a slash in its name
|
|
13
|
+
- fix: write eve history atomically, so an interrupted write cannot corrupt it
|
|
14
|
+
- fix: recover from an unreadable eve history instead of retrying forever
|
|
10
15
|
|
|
11
16
|
## v11.35.0 (2026-08-09)
|
|
12
17
|
|
package/lib/device/kettle.js
CHANGED
|
@@ -82,6 +82,11 @@ function readingToCelsius(hexParts) {
|
|
|
82
82
|
const KETTLE_PRESET_MIN_F = 90
|
|
83
83
|
const KETTLE_PRESET_MAX_F = 212
|
|
84
84
|
|
|
85
|
+
// The app offers a keep warm of 30 minutes to 2 hours. A day is far past
|
|
86
|
+
// anything a kettle would offer, while leaving room for a model that allows
|
|
87
|
+
// more than the H7171 does.
|
|
88
|
+
const KETTLE_KEEP_WARM_MAX_MINS = 1440
|
|
89
|
+
|
|
85
90
|
const HEATING_STATES = {
|
|
86
91
|
'00': { label: 'idle', heating: false },
|
|
87
92
|
'01': { label: 'heating', heating: true },
|
|
@@ -371,6 +376,55 @@ export default class extends GoveeDevice {
|
|
|
371
376
|
this.accessory.log(`current status [${state.label}]`)
|
|
372
377
|
}
|
|
373
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Show how long the kettle will hold the water once it has boiled.
|
|
381
|
+
*
|
|
382
|
+
* An `aa 22 01` frame carries the keep warm duration twice over, as two
|
|
383
|
+
* sixteen-bit counts of minutes. The owner in #1337 proved that outright:
|
|
384
|
+
* with the app set to two hours the frame read `aa 22 01 00 78 00 78`, and
|
|
385
|
+
* seconds after they changed it to thirty minutes the kettle sent
|
|
386
|
+
* `aa 22 01 00 1e 00 1e`. 0x78 is 120 and 0x1e is 30.
|
|
387
|
+
*
|
|
388
|
+
* The frame's checksum did not move between those two, which fits: the two
|
|
389
|
+
* copies are equal, so whatever one contributes the other takes back out.
|
|
390
|
+
*
|
|
391
|
+
* What the second copy is for is still unknown - a remaining time alongside
|
|
392
|
+
* the configured one would explain it, but it has only ever been seen equal
|
|
393
|
+
* to the first. So a pair that disagrees is treated as a frame this plugin
|
|
394
|
+
* cannot read rather than quietly ignored, which is what would get it
|
|
395
|
+
* decoded.
|
|
396
|
+
*
|
|
397
|
+
* @param {string} hexString the whole frame, for the log if it cannot be read
|
|
398
|
+
* @param {string[]} hexParts the frame split into bytes
|
|
399
|
+
*/
|
|
400
|
+
updateKeepWarmDuration(hexString, hexParts) {
|
|
401
|
+
const configured = hexToDecimal(`${getTwoItemPosition(hexParts, 4)}${getTwoItemPosition(hexParts, 5)}`)
|
|
402
|
+
const second = hexToDecimal(`${getTwoItemPosition(hexParts, 6)}${getTwoItemPosition(hexParts, 7)}`)
|
|
403
|
+
|
|
404
|
+
// A keep warm that runs for no time at all, or for more than a day, is not
|
|
405
|
+
// a duration - it means this model packs the frame differently
|
|
406
|
+
const plausible = Number.isFinite(configured)
|
|
407
|
+
&& configured > 0
|
|
408
|
+
&& configured <= KETTLE_KEEP_WARM_MAX_MINS
|
|
409
|
+
|
|
410
|
+
if (!plausible || configured !== second) {
|
|
411
|
+
logUnknownData(this.accessory, {
|
|
412
|
+
kind: 'status',
|
|
413
|
+
source: 'AWS',
|
|
414
|
+
raw: hexString,
|
|
415
|
+
hex: hexParts.join(''),
|
|
416
|
+
extra: { note: 'aa 22 01 frame that does not carry one keep warm duration twice' },
|
|
417
|
+
})
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (this.cacheKeepWarmMins === configured) {
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
this.cacheKeepWarmMins = configured
|
|
425
|
+
this.accessory.log(`keep warm is set to ${configured} minutes`)
|
|
426
|
+
}
|
|
427
|
+
|
|
374
428
|
externalUpdate(params) {
|
|
375
429
|
// Handle OpenAPI workMode
|
|
376
430
|
if (params.workMode) {
|
|
@@ -390,11 +444,26 @@ export default class extends GoveeDevice {
|
|
|
390
444
|
|
|
391
445
|
const deviceFunction = `${getTwoItemPosition(hexParts, 2)}${getTwoItemPosition(hexParts, 3)}`
|
|
392
446
|
|
|
393
|
-
// On startup the kettle lists the temperature each of its
|
|
394
|
-
//
|
|
395
|
-
// thing from aa 05 00 <mode> below, which is a mode
|
|
396
|
-
// byte is what tells them apart. Nothing here needs the
|
|
397
|
-
// are logged for reference rather than reported as
|
|
447
|
+
// On startup the kettle lists the temperature held in each of its
|
|
448
|
+
// temperature slots, one frame per slot: aa 05 <slot> <degrees F>. That
|
|
449
|
+
// is a different thing from aa 05 00 <mode> below, which is a mode
|
|
450
|
+
// change - the slot byte is what tells them apart. Nothing here needs the
|
|
451
|
+
// values, so they are logged for reference rather than reported as
|
|
452
|
+
// unrecognised (#1337)
|
|
453
|
+
//
|
|
454
|
+
// Slot 01 is NOT a memory preset, it is the target the kettle is set to
|
|
455
|
+
// right now. Two runs from the owner in #1337 show it moving on its own:
|
|
456
|
+
// 212°F on 3 Aug, then 142°F on both 8 and 11 Aug, matching the `setTem`
|
|
457
|
+
// of 14200 carried in the same message, while slots 02-05 never budged.
|
|
458
|
+
// The memory presets are therefore slots 02-05, which is why the number
|
|
459
|
+
// printed below is one less than the slot.
|
|
460
|
+
//
|
|
461
|
+
// Those four are also not in the same order as the mode commands at the
|
|
462
|
+
// top of this file. The owner's app lists M1-M4 as black tea 212, green
|
|
463
|
+
// tea 180, oolong 195, coffee 205, which is the order slots 02-05 arrive
|
|
464
|
+
// in - whereas the switch commands number green tea 02, oolong 03, coffee
|
|
465
|
+
// 04 and black tea 05. So the slots follow the app's list rather than the
|
|
466
|
+
// mode ids, and no attempt is made here to name them.
|
|
398
467
|
if (getTwoItemPosition(hexParts, 2) === '05' && getTwoItemPosition(hexParts, 3) !== '00') {
|
|
399
468
|
const slot = Number.parseInt(getTwoItemPosition(hexParts, 3), 16)
|
|
400
469
|
const tempInF = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
|
|
@@ -419,7 +488,8 @@ export default class extends GoveeDevice {
|
|
|
419
488
|
return
|
|
420
489
|
}
|
|
421
490
|
|
|
422
|
-
const
|
|
491
|
+
const label = slot === 1 ? 'target temperature' : `memory preset ${slot - 1}`
|
|
492
|
+
const line = `${label} is set to ${tempInF}°F [${Math.round(((tempInF - 32) * 5) / 9)}°C]`
|
|
423
493
|
// Worth seeing in a normal log, but only once each. The kettle sends
|
|
424
494
|
// these when it connects, and if a firmware ever repeats them on a
|
|
425
495
|
// timer this keeps five lines from becoming five every few minutes
|
|
@@ -461,8 +531,11 @@ export default class extends GoveeDevice {
|
|
|
461
531
|
}
|
|
462
532
|
break
|
|
463
533
|
}
|
|
534
|
+
case '2201': { // keep warm on, and for how long
|
|
535
|
+
this.updateKeepWarmDuration(hexString, hexParts)
|
|
536
|
+
break
|
|
537
|
+
}
|
|
464
538
|
case '2200': // keep warm off
|
|
465
|
-
case '2201': // keep warm on
|
|
466
539
|
case '2300': // scheduled start off
|
|
467
540
|
case '2301': { // scheduled start on
|
|
468
541
|
break
|
|
@@ -141,12 +141,15 @@ export default function (homebridge) {
|
|
|
141
141
|
(err, loaded) => {
|
|
142
142
|
if (err) {
|
|
143
143
|
this.log('FGH load error: %s.', err)
|
|
144
|
-
} else {
|
|
145
|
-
|
|
146
|
-
this.log('FGH loaded from storage.')
|
|
147
|
-
}
|
|
148
|
-
this.loaded = true
|
|
144
|
+
} else if (loaded) {
|
|
145
|
+
this.log('FGH loaded from storage.')
|
|
149
146
|
}
|
|
147
|
+
// Ready either way. A history that could not be read starts
|
|
148
|
+
// empty, which is the only thing left to do with it - leaving
|
|
149
|
+
// this unset instead would park save() in a 100ms retry that
|
|
150
|
+
// never ends, so a single unreadable file cost a busy loop for
|
|
151
|
+
// the lifetime of the plugin
|
|
152
|
+
this.loaded = true
|
|
150
153
|
},
|
|
151
154
|
)
|
|
152
155
|
}.bind(this),
|
|
@@ -595,27 +598,37 @@ export default function (homebridge) {
|
|
|
595
598
|
homebridge.globalFakeGatoStorage.read({
|
|
596
599
|
service: this,
|
|
597
600
|
callback: function (err, data) {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
this.lastEntry = jsonFile.lastEntry
|
|
605
|
-
this.usedMemory = jsonFile.usedMemory
|
|
606
|
-
this.refTime = jsonFile.refTime
|
|
607
|
-
this.initialTime = jsonFile.initialTime
|
|
608
|
-
this.history = jsonFile.history
|
|
609
|
-
this.extra = jsonFile.extra
|
|
610
|
-
} catch (e) {
|
|
611
|
-
this.log('FGH error fetching data - invalid JSON [%s].', e)
|
|
612
|
-
cb(e, false)
|
|
613
|
-
}
|
|
614
|
-
cb(null, true)
|
|
615
|
-
}
|
|
616
|
-
} else {
|
|
601
|
+
// No history stored yet, or a file with nothing in it. There is
|
|
602
|
+
// nothing to restore either way, and the caller still has to be told
|
|
603
|
+
// so: an empty file used to answer nothing at all, which left the
|
|
604
|
+
// service waiting to be loaded while save() retried every 100ms for
|
|
605
|
+
// as long as the plugin ran
|
|
606
|
+
if (err || !data) {
|
|
617
607
|
cb(null, false)
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
try {
|
|
612
|
+
// this.log('[%s] read data [%s].', this.accessoryName, data)
|
|
613
|
+
const jsonFile = typeof data === 'object' ? data : JSON.parse(data)
|
|
614
|
+
this.firstEntry = jsonFile.firstEntry
|
|
615
|
+
this.lastEntry = jsonFile.lastEntry
|
|
616
|
+
this.usedMemory = jsonFile.usedMemory
|
|
617
|
+
this.refTime = jsonFile.refTime
|
|
618
|
+
this.initialTime = jsonFile.initialTime
|
|
619
|
+
this.history = jsonFile.history
|
|
620
|
+
this.extra = jsonFile.extra
|
|
621
|
+
} catch (e) {
|
|
622
|
+
// A file that will not parse was cut short. The entries are gone
|
|
623
|
+
// whatever happens next, so the caller is told once and the service
|
|
624
|
+
// starts empty - this used to report the failure and then
|
|
625
|
+
// immediately report success as well, over the top of it
|
|
626
|
+
this.log('FGH error fetching data - invalid JSON [%s].', e)
|
|
627
|
+
cb(e, false)
|
|
628
|
+
return
|
|
618
629
|
}
|
|
630
|
+
|
|
631
|
+
cb(null, true)
|
|
619
632
|
}.bind(this),
|
|
620
633
|
})
|
|
621
634
|
}
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
4
5
|
|
|
5
6
|
const hostname = os.hostname().split('.')[0]
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Path separators, as the platform actually understands them.
|
|
10
|
+
*
|
|
11
|
+
* A history file is named after the accessory, and an accessory is named by
|
|
12
|
+
* whoever set the device up. A name carrying a separator stops being a name
|
|
13
|
+
* once it reaches `path.join`: it reads as a directory that does not exist, so
|
|
14
|
+
* the write fails with ENOENT and - because nothing here passes a callback -
|
|
15
|
+
* the error goes nowhere. A device called "Kitchen/Diner" simply never saved
|
|
16
|
+
* its history, with nothing in the log to say so. HAP-NodeJS guards its own
|
|
17
|
+
* storage keys against the same thing.
|
|
18
|
+
*
|
|
19
|
+
* Only genuine separators are replaced, and only where they are one. A
|
|
20
|
+
* backslash is an ordinary filename character on Linux, so replacing it there
|
|
21
|
+
* would rename the file of anyone already using one and lose their history.
|
|
22
|
+
*/
|
|
23
|
+
const PATH_SEPARATORS = process.platform === 'win32' ? /[\\/]/g : /\//g
|
|
24
|
+
|
|
25
|
+
function toFileNamePart(name) {
|
|
26
|
+
return String(name ?? '').replace(PATH_SEPARATORS, '-')
|
|
27
|
+
}
|
|
28
|
+
|
|
7
29
|
export default class {
|
|
8
30
|
constructor(params) {
|
|
9
31
|
if (!params) {
|
|
@@ -27,7 +49,7 @@ export default class {
|
|
|
27
49
|
const newWriter = {
|
|
28
50
|
service,
|
|
29
51
|
callback: params.callback,
|
|
30
|
-
fileName: `${hostname}_${service.accessoryName}_persist.json`,
|
|
52
|
+
fileName: `${hostname}_${toFileNamePart(service.accessoryName)}_persist.json`,
|
|
31
53
|
}
|
|
32
54
|
const onReady = typeof params.onReady === 'function' ? params.onReady : () => {}
|
|
33
55
|
newWriter.storageHandler = fs
|
|
@@ -67,15 +89,36 @@ export default class {
|
|
|
67
89
|
? writer.callback
|
|
68
90
|
: () => {}
|
|
69
91
|
const fileLoc = path.join(writer.path, writer.fileName)
|
|
92
|
+
// Written to a temporary file and renamed over the target, so a write cut
|
|
93
|
+
// short by a restart or a power cut leaves the previous history intact
|
|
94
|
+
// rather than a half-written file that will not parse.
|
|
95
|
+
//
|
|
96
|
+
// The leading dot matters: this is HAP-NodeJS's persist directory, and it
|
|
97
|
+
// reads every file that does not start with one into memory at startup -
|
|
98
|
+
// so a temporary file left behind by a crash would be loaded, and kept,
|
|
99
|
+
// for the life of every run after it. The pid keeps child bridges sharing
|
|
100
|
+
// the directory from racing on the same temporary name.
|
|
101
|
+
const tempLoc = path.join(writer.path, `.${writer.fileName}.${process.pid}.tmp`)
|
|
70
102
|
this.log(
|
|
71
103
|
'[%s] FGS write file [%s] [%s].',
|
|
72
104
|
params.service.accessoryName,
|
|
73
105
|
fileLoc,
|
|
74
106
|
params.data.substring(1, 81),
|
|
75
107
|
)
|
|
76
|
-
writer.storageHandler.writeFile(
|
|
77
|
-
|
|
78
|
-
|
|
108
|
+
writer.storageHandler.writeFile(tempLoc, params.data, 'utf8', (writeErr) => {
|
|
109
|
+
if (writeErr) {
|
|
110
|
+
// the temporary file may never have been created, so a failure to
|
|
111
|
+
// remove it is not worth reporting over the write error itself
|
|
112
|
+
writer.storageHandler.unlink(tempLoc, () => {
|
|
113
|
+
this.writing = false
|
|
114
|
+
callBack(writeErr)
|
|
115
|
+
})
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
writer.storageHandler.rename(tempLoc, fileLoc, (renameErr) => {
|
|
119
|
+
this.writing = false
|
|
120
|
+
callBack(renameErr || null)
|
|
121
|
+
})
|
|
79
122
|
})
|
|
80
123
|
} else {
|
|
81
124
|
setTimeout(() => this.write(params), 100)
|
package/package.json
CHANGED