Let a tap survive a centimetre of finger drift and log every refused tap (0.2.28)

The touch guard dropped a tap as soon as the finger drifted more than
18 px (under 3 mm) from its very first contact point, on both boards and
regardless of the page-swipe setting; LVGL itself never cancelled these
clicks (no scrollable ancestors, gestures do not suppress clicks). A finger
that flattens or rolls while pressing was enough, and the tap vanished
without a trace in the log.

The limit is now about one centimetre per board (TOUCH_MOVE_LIMIT_PX:
Guition 67 px, CYD 56 px), measured from a reference that settles over the
first four samples; only the contact that started the touch is followed,
so a second finger no longer counts as movement; the minimum contact time
is per board too (TOUCH_MIN_PRESS_MS: 60 ms resistive, 20 ms capacitive).
Every refused tap is logged on INFO with its reason (moved, too short,
already used, bounce). Firmware 0.2.23 for CYD and Guition.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-14 11:30:23 +02:00
parent 2327d31261
commit ea0b423fab
13 changed files with 236 additions and 40 deletions
+74 -19
View File
@@ -27,49 +27,104 @@ inline int quantize_temperature(int value, int minimum, int maximum, int step) {
class TouchGuard {
public:
void begin(uint32_t now, int x = 0, int y = 0) {
enum Reject { NONE, MOVED, TOO_SHORT, USED, BOUNCE };
// Board tuning, set from the profile on boot: how far the finger may drift (pixels, about
// one centimetre) before a tap is dropped, and the shortest contact that counts. A resistive
// panel (CYD) bounces on landing and lift-off; a capacitive one (Guition) does not.
void configure(int move_limit_px, uint32_t min_press_ms) {
move_limit_ = move_limit_px;
min_press_ = min_press_ms;
}
int move_limit() const { return move_limit_; }
uint32_t min_press() const { return min_press_; }
void begin(uint32_t now, int x = 0, int y = 0, int contact = 0) {
started_ = now;
accepted_ = false;
moved_ = false;
start_x_ = x;
start_y_ = y;
contact_ = contact;
samples_ = 1;
sum_x_ = anchor_x_ = x;
sum_y_ = anchor_y_ = y;
distance_ = 0;
reject_ = NONE;
}
void update(int x, int y) {
// A contact that jumps to another target must never become a tile tap.
if (std::abs(x - start_x_) > 18 || std::abs(y - start_y_) > 18) moved_ = true;
int contact() const { return contact_; }
// Follows only the contact that started this touch: a second finger elsewhere is no movement.
// The reference point settles over the first samples, because a finger flattens as it lands;
// a tap is dropped once the finger is farther than the limit from that point.
void update(int x, int y, int contact = 0) {
if (contact != contact_) return;
if (samples_ < SETTLE_SAMPLES) {
++samples_;
sum_x_ += x;
sum_y_ += y;
anchor_x_ = sum_x_ / samples_;
anchor_y_ = sum_y_ / samples_;
}
const long dx = x - anchor_x_, dy = y - anchor_y_;
const int distance = static_cast<int>(std::lround(std::sqrt(static_cast<double>(dx * dx + dy * dy))));
distance_ = std::max(distance_, distance);
if (distance > move_limit_) moved_ = true;
}
int distance() const { return distance_; }
void consume() { accepted_ = true; moved_ = true; }
bool accept(uint32_t now, int tile) {
return !moved_ && accept_slider(now, tile);
if (moved_) { reject_ = MOVED; return false; }
return accept_slider(now, tile);
}
// For -/+ keys: every clean tap counts, even the third within a second, so a
// setpoint moves several steps in one go. Only bounce (same key within `gap`) is dropped.
bool accept_repeat(uint32_t now, int tile, uint32_t gap = 150) {
if (moved_ || accepted_ || now - started_ < 40) return false;
if (has_previous_ && tile == previous_tile_ && now - previous_ < gap) return false;
accepted_ = true;
has_previous_ = true;
previous_tile_ = tile;
previous_ = now;
if (moved_) { reject_ = MOVED; return false; }
if (accepted_) { reject_ = USED; return false; }
if (!long_enough(now, std::min<uint32_t>(min_press_, 40))) return false;
if (has_previous_ && tile == previous_tile_ && now - previous_ < gap) { reject_ = BOUNCE; return false; }
remember(now, tile);
return true;
}
// Only for a slider which captured this contact and did not lose the press.
// Consume the gesture so its parent can never also turn it into a tile tap.
bool accept_slider(uint32_t now, int tile) {
if (accepted_ || now - started_ < 60) return false;
if (has_previous_ && tile == previous_tile_ && now - previous_ < 600) return false;
if (accepted_) { reject_ = USED; return false; }
if (!long_enough(now, min_press_)) return false;
if (has_previous_ && tile == previous_tile_ && now - previous_ < 600) { reject_ = BOUNCE; return false; }
remember(now, tile);
return true;
}
// Why the last accept() refused, for the touch log; empty after a success.
std::string reason() const {
switch (reject_) {
case MOVED: return "verplaatst " + std::to_string(distance_) + " px (grens " + std::to_string(move_limit_) + ")";
case TOO_SHORT: return "te kort (" + std::to_string(duration_) + " ms, minimaal " + std::to_string(min_press_) + ")";
case USED: return "al verwerkt in dit contact";
case BOUNCE: return "dezelfde knop binnen de dendertijd";
default: return "";
}
}
private:
static constexpr int SETTLE_SAMPLES = 4;
bool long_enough(uint32_t now, uint32_t minimum) {
duration_ = now - started_;
if (duration_ < minimum) { reject_ = TOO_SHORT; return false; }
return true;
}
void remember(uint32_t now, int tile) {
accepted_ = true;
has_previous_ = true;
previous_tile_ = tile;
previous_ = now;
return true;
reject_ = NONE;
}
private:
uint32_t started_{0}, previous_{0};
int move_limit_{18};
uint32_t min_press_{60};
uint32_t started_{0}, previous_{0}, duration_{0};
int previous_tile_{-1};
int start_x_{0}, start_y_{0};
int contact_{0}, samples_{1};
long sum_x_{0}, sum_y_{0};
int anchor_x_{0}, anchor_y_{0}, distance_{0};
bool moved_{false};
bool accepted_{false}, has_previous_{false};
Reject reject_{NONE};
};
inline TouchGuard touch_guard;
} // namespace cyd
+11 -4
View File
@@ -109,6 +109,13 @@ inline bool ha_connected() { return esphome::api_is_connected(); }
// loop slack and the sending itself are tolerated before the feed counts as gone.
inline bool feed_alive() { return esphome::millis() - last_received < keepalive_seconds * 2000 + 60000; }
inline bool fresh() { return model.ready() && ha_connected() && feed_alive(); }
// A dropped tap is logged with its reason, so a missed touch can be read from the ESPHome log
// instead of guessed: moved too far, too short, already used by this contact, or bounce.
inline bool allowed(uint32_t now, int tile, const std::string &what) {
if (cyd::touch_guard.accept(now, tile)) return true;
ESP_LOGI("touch", "tik op %s genegeerd: %s", what.c_str(), cyd::touch_guard.reason().c_str());
return false;
}
inline float number(JsonVariant value, float fallback = NAN) {
if (!value.is<float>() && !value.is<int>()) return fallback;
float n = value.as<float>();
@@ -388,7 +395,7 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
auto *label=detail_label(button,text,6,0,width-12);lv_obj_center(label);lv_obj_set_style_text_align(label,LV_TEXT_ALIGN_CENTER,0);if(command==0)lv_obj_set_style_text_color(label,lv_color_hex(0xFFFFFF),0);lv_obj_remove_flag(label,LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(button,[](lv_event_t *e){
int cmd=(intptr_t)lv_event_get_user_data(e);if(cmd==-1){hide_detail();return;}
if(!fresh()||detail_index>=model.count || !cyd::touch_guard.accept(esphome::millis(),300+cmd))return;
if(!fresh()||detail_index>=model.count || !allowed(esphome::millis(),300+cmd,"kaartknop "+model.tiles[detail_index].entity))return;
auto &t=model.tiles[detail_index];if(!t.available()||t.loading(esphome::millis()))return;
if(cmd<4){const char *services[]={"vacuum.start","vacuum.pause","vacuum.return_to_base","vacuum.locate"};action(services[cmd],t.entity);}
if(cmd>=10 && cmd<14 && cmd-10<(int)t.fan_speed_count)action("vacuum.set_fan_speed",t.entity,"fan_speed",t.fan_speeds[cmd-10]);
@@ -723,7 +730,7 @@ inline void event(lv_event_t *event) {
if (!enabled || !fresh() || w.index >= model.count) return;
auto code = lv_event_get_code(event);
if (code != LV_EVENT_SHORT_CLICKED && code != LV_EVENT_LONG_PRESSED) return;
if (!cyd::touch_guard.accept(esphome::millis(), 100 + w.index)) return;
if (!allowed(esphome::millis(), 100 + w.index, model.tiles[w.index].entity)) return;
auto &tile = model.tiles[w.index];
auto d = tile.domain();
// Scenes/scripts often have timestamps or 'off'; unavailable devices never act.
@@ -1227,8 +1234,8 @@ inline void control_event(lv_event_t *e) {
bool step=command==tile_controls::STEP_DOWN || command==tile_controls::STEP_UP;
bool held=lv_event_get_code(e)==LV_EVENT_LONG_PRESSED_REPEAT;
if(held){ if(!step || now-t.edit_since<300)return; } // three steps a second while holding
else if(step){ if(!cyd::touch_guard.accept_repeat(now,400+slot*16+n))return; }
else if(!cyd::touch_guard.accept(now,400+slot*16+n))return;
else if(step){ if(!cyd::touch_guard.accept_repeat(now,400+slot*16+n)){ESP_LOGI("touch","tik op bediening %u genegeerd: %s",(unsigned)slot,cyd::touch_guard.reason().c_str());return;} }
else if(!allowed(now,400+slot*16+n,"bediening "+std::to_string(slot)))return;
if(!t.available())return;
if(step){
// Local at once, tap after tap; tick() sends the last value after a short pause.