Skip to content

Commit d0abf72

Browse files
authored
fix(esp-vocat): tolerate peripheral I2C read failures (#2287)
Co-authored-by: Wei Li <2232328+weilinear@users.noreply.github.com>
1 parent 8b032f4 commit d0abf72

3 files changed

Lines changed: 242 additions & 51 deletions

File tree

‎main/boards/espressif/esp-vocat/README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,28 @@ idf.py build
8383
```bash
8484
idf.py flash
8585
```
86+
87+
## Peripheral I2C error handling
88+
89+
Battery register reads run on the battery worker, with at most three attempts
90+
for timeout/invalid-response/transfer failures, a 50 ms transfer timeout and
91+
10/20 ms retry delays. Other errors return immediately. Successful sampling
92+
runs every two seconds; a failed cycle backs off for five seconds and does not
93+
trigger battery emotes. The worker's own failure warning is limited to once
94+
per ten seconds; driver logging is unchanged.
95+
96+
Battery status queries use a mutex-protected, complete last-good snapshot
97+
instead of performing transfers in UI callbacks. Samples expire after ten
98+
seconds; absent/stale readings return `false`, never a fabricated empty battery.
99+
Touch performs one checked transfer and skips gesture processing on failure,
100+
so a failed read cannot synthesize a release. The first valid battery sample
101+
establishes the initial charging state without a false charger-insertion event.
102+
103+
This contains peripheral read errors; it does not diagnose power instability,
104+
reset the shared bus, change codec startup or disable watchdog/brownout checks.
105+
Temperature sensor handling is unchanged. Host regression tests use an I2C
106+
failure double and do not replace physical fault injection:
107+
108+
```bash
109+
python3 -m unittest discover -s scripts/tests -p test_esp_vocat_i2c.py -v
110+
```

‎main/boards/espressif/esp-vocat/esp_vocat.cc‎

Lines changed: 83 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <esp_system.h>
1313
#include <esp_timer.h>
1414
#include <cinttypes>
15+
#include <mutex>
1516
#include "esp_idf_version.h"
1617

1718
#define ESP_VOCAT_ENABLE_CAP_TOUCH_SENSOR (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0))
@@ -289,6 +290,23 @@ static RTC_NOINIT_ATTR uint8_t s_pcb_version_cached;
289290

290291
class EspVocat;
291292

293+
// Battery retries run only on its worker. Touch uses one checked transfer so
294+
// a failed sample cannot synthesize a press/release or stall other callbacks.
295+
static esp_err_t ReadPeripheralRegs(i2c_master_dev_handle_t device, uint8_t reg, uint8_t* data,
296+
size_t length, int attempts) {
297+
esp_err_t error = ESP_FAIL;
298+
for (int attempt = 0; attempt < attempts; ++attempt) {
299+
error = i2c_master_transmit_receive(device, &reg, 1, data, length, 50);
300+
if (error == ESP_OK)
301+
return ESP_OK;
302+
if (error != ESP_ERR_TIMEOUT && error != ESP_ERR_INVALID_RESPONSE && error != ESP_FAIL)
303+
break;
304+
if (attempt + 1 < attempts)
305+
vTaskDelay(pdMS_TO_TICKS(10 << attempt));
306+
}
307+
return error;
308+
}
309+
292310
class Charge : public I2cDevice {
293311
public:
294312
static constexpr uint8_t kRegVoltage = 0x08;
@@ -299,55 +317,61 @@ class Charge : public I2cDevice {
299317
static constexpr int kChargingCurrentMa = 30;
300318
static constexpr uint16_t kBatteryStatusDsg = BIT0;
301319

302-
Charge(i2c_master_bus_handle_t i2c_bus, uint8_t addr) : I2cDevice(i2c_bus, addr) {
303-
read_buffer_ = new uint8_t[8];
304-
}
305-
~Charge() { delete[] read_buffer_; }
320+
Charge(i2c_master_bus_handle_t i2c_bus, uint8_t addr) : I2cDevice(i2c_bus, addr) {}
306321

307-
int16_t ReadWord(uint8_t reg) {
322+
bool ReadWord(uint8_t reg, uint16_t& value) {
308323
uint8_t data[2] = {0};
309-
ReadRegs(reg, data, 2);
310-
return static_cast<int16_t>(static_cast<uint16_t>(data[0]) |
311-
(static_cast<uint16_t>(data[1]) << 8));
312-
}
313-
314-
int GetBatteryLevel() {
315-
int level = ReadWord(kRegStateOfCharge);
316-
if (level < 0) {
317-
return 0;
318-
}
319-
if (level > 100) {
320-
return 100;
321-
}
322-
return level;
323-
}
324-
325-
bool IsCharging() {
326-
const uint16_t status = static_cast<uint16_t>(ReadWord(kRegBatteryStatus));
327-
if ((status & kBatteryStatusDsg) == 0) {
328-
return true;
324+
const esp_err_t error = ReadPeripheralRegs(i2c_device_, reg, data, sizeof(data), 3);
325+
if (error != ESP_OK) {
326+
const int64_t now = esp_timer_get_time() / 1000;
327+
if (now >= next_error_log_ms_) {
328+
ESP_LOGW(TAG, "Battery read unavailable reg=0x%02x error=%s", reg,
329+
esp_err_to_name(error));
330+
next_error_log_ms_ = now + 10000;
331+
}
332+
return false;
329333
}
330-
331-
const int16_t avg_current_ma = ReadWord(kRegAverageCurrent);
332-
const int16_t current_ma = ReadWord(kRegCurrent);
333-
// Current is a fallback only: AverageCurrent can lag charger insertion noticeably.
334-
return avg_current_ma > kChargingCurrentMa || current_ma > kChargingCurrentMa;
334+
value = static_cast<uint16_t>(data[0]) | (static_cast<uint16_t>(data[1]) << 8);
335+
return true;
335336
}
336337

337-
bool IsDischarging() {
338-
return (static_cast<uint16_t>(ReadWord(kRegBatteryStatus)) & kBatteryStatusDsg) != 0;
338+
bool GetSnapshot(int& level, bool& charging, bool& discharging) {
339+
std::lock_guard<std::mutex> lock(sample_mutex_);
340+
const int64_t now = esp_timer_get_time() / 1000;
341+
if (!sample_valid_ || now - sample_at_ms_ > 10000)
342+
return false;
343+
level = level_;
344+
charging = charging_;
345+
discharging = discharging_;
346+
return true;
339347
}
340348

341-
void Update() {
342-
const int16_t voltage = ReadWord(kRegVoltage);
343-
const int16_t current = ReadWord(kRegCurrent);
349+
bool Update() {
350+
uint16_t level, status, average, current;
351+
if (!ReadWord(kRegStateOfCharge, level) || !ReadWord(kRegBatteryStatus, status) ||
352+
!ReadWord(kRegAverageCurrent, average) || !ReadWord(kRegCurrent, current) ||
353+
level > 100)
354+
return false;
344355
ESP_ERROR_CHECK(temperature_sensor_get_celsius(temp_sensor, &tsens_value));
345-
(void)voltage;
346-
(void)current;
356+
std::lock_guard<std::mutex> lock(sample_mutex_);
357+
level_ = level;
358+
discharging_ = (status & kBatteryStatusDsg) != 0;
359+
// Current is a fallback: AverageCurrent can lag charger insertion.
360+
charging_ = !discharging_ || static_cast<int16_t>(average) > kChargingCurrentMa ||
361+
static_cast<int16_t>(current) > kChargingCurrentMa;
362+
sample_at_ms_ = esp_timer_get_time() / 1000;
363+
sample_valid_ = true;
364+
return true;
347365
}
348366

349367
private:
350-
uint8_t* read_buffer_ = nullptr;
368+
std::mutex sample_mutex_;
369+
int level_ = 0;
370+
bool charging_ = false;
371+
bool discharging_ = false;
372+
bool sample_valid_ = false;
373+
int64_t sample_at_ms_ = 0;
374+
int64_t next_error_log_ms_ = 0;
351375
};
352376

353377
class Cst816s : public I2cDevice {
@@ -382,11 +406,13 @@ class Cst816s : public I2cDevice {
382406
}
383407
}
384408

385-
void UpdateTouchPoint() {
386-
ReadRegs(0x02, read_buffer_, 6);
409+
bool UpdateTouchPoint() {
410+
if (ReadPeripheralRegs(i2c_device_, 0x02, read_buffer_, 6, 1) != ESP_OK)
411+
return false;
387412
tp_.num = read_buffer_[0] & 0x0F;
388413
tp_.x = ((read_buffer_[1] & 0x0F) << 8) | read_buffer_[2];
389414
tp_.y = ((read_buffer_[3] & 0x0F) << 8) | read_buffer_[4];
415+
return true;
390416
}
391417

392418
const TouchPoint_t& GetTouchPoint() { return tp_; }
@@ -470,6 +496,7 @@ class EspVocat : public WifiBoard {
470496
esp_timer_handle_t emotion_reset_timer_ = nullptr;
471497
bool bmi270_ready_ = false;
472498
bool was_charging_ = false;
499+
bool battery_status_initialized_ = false;
473500
uint8_t low_battery_alert_mask_ = 0;
474501
int low_battery_plays_left_ = 0;
475502
int64_t next_low_battery_play_ms_ = 0;
@@ -534,8 +561,14 @@ class EspVocat : public WifiBoard {
534561
return;
535562
}
536563

537-
const int level = charge_->GetBatteryLevel();
538-
const bool charging = charge_->IsCharging();
564+
int level;
565+
bool charging, discharging;
566+
if (!charge_->GetSnapshot(level, charging, discharging))
567+
return;
568+
if (!battery_status_initialized_) {
569+
was_charging_ = charging;
570+
battery_status_initialized_ = true;
571+
}
539572

540573
if (charging && !was_charging_) {
541574
PlayBatteryEmotion("battery_connected", 4000);
@@ -580,11 +613,13 @@ class EspVocat : public WifiBoard {
580613
static void battery_task(void* arg) {
581614
auto* self = static_cast<EspVocat*>(arg);
582615
while (true) {
616+
bool healthy = false;
583617
if (self != nullptr && self->charge_ != nullptr) {
584-
self->charge_->Update();
585-
self->HandleBatteryEmotions();
618+
healthy = self->charge_->Update();
619+
if (healthy)
620+
self->HandleBatteryEmotions();
586621
}
587-
vTaskDelay(pdMS_TO_TICKS(300));
622+
vTaskDelay(pdMS_TO_TICKS(healthy ? 2000 : 5000));
588623
}
589624
}
590625

@@ -774,7 +809,8 @@ class EspVocat : public WifiBoard {
774809
auto& board = (EspVocat&)Board::GetInstance();
775810

776811
ESP_LOGD(TAG, "Touch event, TP_PIN_NUM_INT: %d", gpio_get_level(TP_PIN_NUM_INT));
777-
touchpad->UpdateTouchPoint();
812+
if (!touchpad->UpdateTouchPoint())
813+
continue;
778814
auto touch_event = touchpad->CheckTouchEvent();
779815

780816
if (touch_event == Cst816s::TOUCH_RELEASE) {
@@ -790,7 +826,6 @@ class EspVocat : public WifiBoard {
790826

791827
void InitializeCharge() {
792828
charge_ = new Charge(i2c_bus_, 0x55);
793-
was_charging_ = charge_->IsCharging();
794829
xTaskCreatePinnedToCore(battery_task, "batteryTask", 3 * 1024, this, 6,
795830
&charge_task_handle_, 0);
796831
}
@@ -1170,10 +1205,7 @@ class EspVocat : public WifiBoard {
11701205
if (charge_ == nullptr) {
11711206
return false;
11721207
}
1173-
level = charge_->GetBatteryLevel();
1174-
charging = charge_->IsCharging();
1175-
discharging = charge_->IsDischarging();
1176-
return true;
1208+
return charge_->GetSnapshot(level, charging, discharging);
11771209
}
11781210
};
11791211

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Run ESP-VoCat's peripheral reader against a host-side I2C failure double."""
2+
import pathlib
3+
import shutil
4+
import subprocess
5+
import tempfile
6+
import unittest
7+
8+
ROOT = pathlib.Path(__file__).resolve().parents[2]
9+
BOARD = ROOT / "main/boards/espressif/esp-vocat/esp_vocat.cc"
10+
11+
12+
def function(source, signature):
13+
start = source.index(signature)
14+
brace = source.index("{", start)
15+
depth, end = 1, brace + 1
16+
while depth:
17+
depth += (source[end] == "{") - (source[end] == "}")
18+
end += 1
19+
return source[start:end]
20+
21+
22+
class EspVocatI2cTests(unittest.TestCase):
23+
@unittest.skipUnless(shutil.which("c++"), "requires a host C++ compiler")
24+
def test_actual_reader_cache_and_touch_with_failed_transfers(self):
25+
board = BOARD.read_text()
26+
reader = function(board, "static esp_err_t ReadPeripheralRegs(")
27+
charge = "class Charge :" + board.split("class Charge :", 1)[1].split("class Cst816s", 1)[0]
28+
source = r'''
29+
#include <cassert>
30+
#include <cstdint>
31+
#include <cstddef>
32+
#include <cstring>
33+
#include <mutex>
34+
#include <vector>
35+
using esp_err_t = int;
36+
using i2c_master_dev_handle_t = void*;
37+
using i2c_master_bus_handle_t = void*;
38+
constexpr int ESP_OK=0, ESP_ERR_TIMEOUT=1, ESP_ERR_INVALID_RESPONSE=2, ESP_FAIL=3;
39+
#define BIT0 1
40+
#define ESP_LOGW(...)
41+
#define ESP_LOGI(...)
42+
#define ESP_LOGD(...)
43+
#define ESP_ERROR_CHECK(result) assert((result)==ESP_OK)
44+
#define pdMS_TO_TICKS(ms) (ms)
45+
int64_t now_ms=100;
46+
int reads=0, permanent_error=0;
47+
std::vector<int> errors, waits;
48+
uint16_t registers[256] = {};
49+
uint8_t touch_data[6] = {1,0,42,0,24,0};
50+
void* temp_sensor=nullptr; float tsens_value=0;
51+
int temperature_sensor_get_celsius(void*,float*) { return 0; }
52+
void vTaskDelay(int ms) { waits.push_back(ms); }
53+
int64_t esp_timer_get_time() { return now_ms*1000; }
54+
const char* esp_err_to_name(int) { return "fake"; }
55+
int i2c_master_transmit_receive(void*,const uint8_t* reg,size_t,uint8_t* data,size_t len,int timeout) {
56+
assert(timeout==50);
57+
const int error=reads < static_cast<int>(errors.size()) ? errors[reads] : permanent_error;
58+
++reads;
59+
if(error) { std::memset(data,0xff,len); return error; }
60+
if(len==6) std::memcpy(data,touch_data,6);
61+
else { data[0]=registers[*reg]&255; data[1]=registers[*reg]>>8; }
62+
return 0;
63+
}
64+
class I2cDevice {
65+
public: I2cDevice(void*,uint8_t) {}
66+
protected: void* i2c_device_=nullptr;
67+
};
68+
'''
69+
source += reader + charge + r'''
70+
class Touch {
71+
public:
72+
struct Point { int num=0,x=-1,y=-1; } tp_;
73+
enum TouchEvent { TOUCH_NONE,TOUCH_PRESS,TOUCH_RELEASE,TOUCH_HOLD };
74+
bool was_touched_=false; int press_count_=0;
75+
uint8_t data_[6] = {}; uint8_t* read_buffer_=data_; void* i2c_device_=nullptr;
76+
'''
77+
source += function(board, "bool UpdateTouchPoint()")
78+
source += function(board, "TouchEvent CheckTouchEvent()") + "};\n"
79+
source += r'''
80+
int main() {
81+
Charge charge(nullptr,0x55);
82+
int level=99; bool charging=true,discharging=false;
83+
assert(!charge.GetSnapshot(level,charging,discharging) && reads==0);
84+
registers[0x2c]=50; registers[0x0a]=1;
85+
registers[0x14]=static_cast<uint16_t>(-40); registers[0x0c]=static_cast<uint16_t>(-50);
86+
errors={ESP_ERR_TIMEOUT,ESP_ERR_INVALID_RESPONSE,0};
87+
assert(charge.Update() && reads==6 && waits==std::vector<int>({10,20}));
88+
assert(charge.GetSnapshot(level,charging,discharging) && reads==6);
89+
assert(level==50 && !charging && discharging);
90+
errors.clear(); reads=0; waits.clear(); permanent_error=ESP_ERR_TIMEOUT;
91+
assert(!charge.Update() && reads==3 && waits==std::vector<int>({10,20}));
92+
assert(charge.GetSnapshot(level,charging,discharging) && level==50);
93+
reads=0; waits.clear(); permanent_error=42;
94+
assert(!charge.Update() && reads==1 && waits.empty());
95+
now_ms=10100; assert(charge.GetSnapshot(level,charging,discharging));
96+
now_ms=10101; assert(!charge.GetSnapshot(level,charging,discharging));
97+
permanent_error=0; registers[0x2c]=60; registers[0x0a]=0;
98+
assert(charge.Update() && charge.GetSnapshot(level,charging,discharging));
99+
assert(level==60 && charging && !discharging);
100+
registers[0x2c]=65535;
101+
assert(!charge.Update() && charge.GetSnapshot(level,charging,discharging) && level==60);
102+
registers[0x2c]=0; registers[0x0a]=1;
103+
assert(charge.Update() && charge.GetSnapshot(level,charging,discharging) && level==0);
104+
105+
Touch touch; reads=0; waits.clear();
106+
assert(touch.UpdateTouchPoint() && touch.CheckTouchEvent()==Touch::TOUCH_PRESS);
107+
assert(touch.tp_.x==42 && touch.tp_.y==24 && touch.press_count_==1);
108+
permanent_error=ESP_ERR_INVALID_RESPONSE;
109+
assert(!touch.UpdateTouchPoint() && reads==2 && waits.empty());
110+
assert(touch.tp_.num==1 && touch.was_touched_ && touch.press_count_==1);
111+
permanent_error=0; touch_data[0]=0;
112+
assert(touch.UpdateTouchPoint() && touch.CheckTouchEvent()==Touch::TOUCH_RELEASE);
113+
assert(touch.press_count_==1);
114+
}
115+
'''
116+
with tempfile.TemporaryDirectory() as tmp:
117+
binary = str(pathlib.Path(tmp) / "peripheral-test")
118+
subprocess.run(["c++", "-std=c++17", "-fsanitize=undefined", "-x", "c++", "-",
119+
"-o", binary], input=source, text=True, check=True)
120+
subprocess.run([binary], check=True)
121+
122+
def test_failed_samples_do_not_dispatch_touch_or_battery_alerts(self):
123+
board = BOARD.read_text()
124+
self.assertIn("if (!touchpad->UpdateTouchPoint())", board)
125+
touch = function(board, "static void touch_event_task(")
126+
self.assertLess(touch.index("if (!touchpad->UpdateTouchPoint())"),
127+
touch.index("CheckTouchEvent()"))
128+
battery = function(board, "static void battery_task(")
129+
self.assertIn("if (healthy)", battery)
130+
self.assertIn("healthy ? 2000 : 5000", battery)
131+
getter = function(board, "virtual bool GetBatteryLevel(")
132+
self.assertIn("GetSnapshot", getter)
133+
self.assertNotIn("ReadWord", getter)
134+
self.assertNotIn("ReadPeripheralRegs", getter)

0 commit comments

Comments
 (0)