@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 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
 
@@ -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 presets is set
394
- // to, one frame per preset: aa 05 <slot> <degrees F>. That is a different
395
- // thing from aa 05 00 <mode> below, which is a mode change - the slot
396
- // byte is what tells them apart. Nothing here needs the values, so they
397
- // are logged for reference rather than reported as unrecognised (#1337)
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 line = `preset ${slot} is set to ${tempInF}°F [${Math.round(((tempInF - 32) * 5) / 9)}°C]`
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
- if (loaded) {
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
- if (!err) {
599
- if (data) {
600
- try {
601
- // this.log('[%s] read data [%s].', this.accessoryName, data)
602
- const jsonFile = typeof data === 'object' ? data : JSON.parse(data)
603
- this.firstEntry = jsonFile.firstEntry
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(fileLoc, params.data, 'utf8', (...args) => {
77
- this.writing = false
78
- callBack(args)
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
@@ -2,7 +2,7 @@
2
2
  "name": "@homebridge-plugins/homebridge-govee",
3
3
  "alias": "Govee",
4
4
  "type": "module",
5
- "version": "11.35.1-beta.0",
5
+ "version": "11.35.1-beta.2",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",