Goal: Create a "Universal" Input Subsystem for ESPHome that fixes the fragmentation and poor user experience of cheap embedded displays (CYD, Sunton, etc.). The component will be called sentio and MUST work with both ESP-IDF and Arduino Framework.
To avoid writing 50 different hardware drivers, we will use a Man-in-the-Middle (Proxy) architecture.
- The Source: An existing ESPHome touchscreen platform (GT911, CST816, etc.) configured as
internal. - The Component (
sentio): Reads the source, sanitizes the data, applies logic, and publishes clean events. - The Consumer: LVGL, Home Assistant, or Lambda scripts listen to
sentio, not the hardware.
| The Plague | The Symptom | The sentio Solution |
|---|---|---|
| The "Wake-up Click" | Tapping a dark screen to wake it up accidentally turns on a light/switch located at that coordinate. | Suppression Logic: If state is SLEEPING, the first touch event wakes the system but is swallowed (not passed to the application). |
| Sleep of Death | Touch chips (CST816) sleep deeply and cannot be woken via I2C, requiring a hardware RST toggle. If RST isn't wired, the screen is bricked. |
Safety Check: If user does not define a reset_pin in config, force "Soft Sleep" (stop polling data, but keep chip powered) instead of sending I2C Sleep commands. |
| Ghost Touches | Noisy WiFi power supplies cause random single-frame clicks or jitter. | Min-Frame Filter: Ignore any touch event that lasts < N ms (e.g., 2 frames). |
| Coordinate Hell | Screen rotation doesn't match touch coordinates. Users struggle with swap_xy and mirror. |
Pipeline Calibration: Apply mathematical transforms (Swap -> Invert -> Offset) before the gesture engine sees the data. |
| Swipe is a Click | Scrolling a list in LVGL accidentally triggers on_press for buttons. |
Gesture Debounce: A State Machine that separates "Taps" from "Drags." |
- Goal: Get the component to compile and successfully forward touches from
gt911tolvgl. - Verify: Using the "Red Dot" visualization tip.
- Tip: In your display lambda:
it.filled_circle(touch.x, touch.y, 5, id(red));Example:
display: lambda: |- // draw UI... // Debug Dot for (auto touch : id(main_touchscreen).touches) { it.filled_circle(touch.x, touch.y, 5, id(my_red_color)); }
- Tip: In your display lambda:
- Test: Ensure
internal: trueworks and you don't get duplicate inputs.
- Goal: Implement the Timeout and Wake-up logic.
- Critical Test: Let screen sleep. Tap a button.
- Success: Screen wakes, button DOES NOT toggle.
- Fail: Screen wakes, button toggles.
- Safety Implementation: Add the check for
reset_pin. If null, implement "Soft Sleep" (just stop updatinglast_activity_timelogic but don't send I2C commands).
- Goal: Implement
swap_xy,invert_x,invert_y. - Debug Mode: Implement
debug_raw_touch: truewhich dumpsRAW(x,y) -> CALIB(x,y)to serial logs. This replaces the need for a runtime wizard.
- Goal: Add
on_swipeandon_tap. - Logic: Implement the State Machine (
IDLE->START->DRAG).- If movement >
swipe_threshold, fire Swipe event. - If movement <
swipe_thresholdAND released, fire Tap event.
- If movement >
-
I2C Address Scanning:
- Don't trust the ESPHome boot scanner.
- If implementing a "Universal Hardware Driver" later, always toggle
RST/INTpins manually beforeWire.begin()to latch the correct address.
-
Visual Debugging:
- Drawing the "Red Dot" on the screen is 10x faster than reading Serial Logs to verify coordinates.
-
The "Poll" Order:
- Your
sentiocomponent is technically a consumer. Ensure it processes data after the hardware driver updates. Usually, placing the YAML config forsentiobelow the hardware driver is enough, but keep this in mind if you see "lag".
- Your
-
Multi-Touch:
- Stick to single-point touch for the MVP. Most cheap screens (CST816, XPT2046) only support one point properly. Handling multi-touch arrays complicates the "Ghost Touch" filters significantly.
- Create the folder .agent/workflows if not exists. Inside put the file sentio-dev.md that you will use to feed your knowledge base using the relevant information you have found in this file.
- All the file for the component you create will be saved on SentIO folder.
What follow is the Master Implementation Blueprint. It contains the specific logic, algorithms, and code structures required to build the Sentio external component.
You will create a generic External Component. Directory Structure:
components/
Sentio/
__init__.py # Python Configuration Logic
Sentio.h # C++ Header (Class Definition)
Sentio.cpp # C++ Implementation (The Logic)
This file defines how the YAML is validated and mapped to C++.
Key Details Missing Previously:
- We must capture the Resolution (
display_width/height) to performinvert_x/ymath correctly. - We define Triggers (
on_swipe,on_sleep) to allow users to attach automations.
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import touchscreen, binary_sensor
from esphome.const import CONF_ID, CONF_SOURCE, CONF_OUTPUT_ID
# Namespace
Sentio_ns = cg.esphome_ns.namespace('Sentio')
SmartTouchComponent = Sentio_ns.class_('SmartTouchComponent', touchscreen.Touchscreen, cg.Component)
# Configuration Constants
CONF_DISPLAY_WIDTH = "display_width"
CONF_DISPLAY_HEIGHT = "display_height"
CONF_SLEEP_TIMEOUT = "sleep_timeout"
CONF_SUPPRESS_WAKE_CLICK = "suppress_wake_click"
CONF_SWAP_XY = "swap_xy"
CONF_INVERT_X = "invert_x"
CONF_INVERT_Y = "invert_y"
CONF_DEBOUNCE_THRESHOLD = "debounce_threshold"
CONF_DEBUG_RAW = "debug_raw_touch"
# Triggers
CONF_ON_SWIPE_LEFT = "on_swipe_left"
CONF_ON_SWIPE_RIGHT = "on_swipe_right"
CONF_ON_TAP = "on_tap"
CONF_ON_WAKE = "on_wake"
CONF_ON_SLEEP = "on_sleep"
CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend({
cv.GenerateID(): cv.declare_id(SmartTouchComponent),
cv.Required(CONF_SOURCE): cv.use_id(touchscreen.Touchscreen),
# Resolution (Required for Inversion Math)
cv.Required(CONF_DISPLAY_WIDTH): cv.int_,
cv.Required(CONF_DISPLAY_HEIGHT): cv.int_,
# Power Management
cv.Optional(CONF_SLEEP_TIMEOUT, default="30s"): cv.positive_time_period_milliseconds,
cv.Optional(CONF_SUPPRESS_WAKE_CLICK, default=True): cv.boolean,
# Calibration
cv.Optional(CONF_SWAP_XY, default=False): cv.boolean,
cv.Optional(CONF_INVERT_X, default=False): cv.boolean,
cv.Optional(CONF_INVERT_Y, default=False): cv.boolean,
cv.Optional(CONF_DEBOUNCE_THRESHOLD, default="20ms"): cv.positive_time_period_milliseconds,
cv.Optional(CONF_DEBUG_RAW, default=False): cv.boolean,
# Gestures
cv.Optional(CONF_ON_SWIPE_LEFT): cv.automation_schema,
cv.Optional(CONF_ON_SWIPE_RIGHT): cv.automation_schema,
cv.Optional(CONF_ON_TAP): cv.automation_schema,
cv.Optional(CONF_ON_WAKE): cv.automation_schema,
cv.Optional(CONF_ON_SLEEP): cv.automation_schema,
}).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await touchscreen.register_touchscreen(var, config)
# Link the Source Driver
source = await cg.get_variable(config[CONF_SOURCE])
cg.add(var.set_source_driver(source))
# Set Configuration
cg.add(var.set_resolution(config[CONF_DISPLAY_WIDTH], config[CONF_DISPLAY_HEIGHT]))
cg.add(var.set_sleep_timeout(config[CONF_SLEEP_TIMEOUT]))
cg.add(var.set_suppress_wake_click(config[CONF_SUPPRESS_WAKE_CLICK]))
cg.add(var.set_calibration(config[CONF_SWAP_XY], config[CONF_INVERT_X], config[CONF_INVERT_Y]))
cg.add(var.set_debounce_threshold(config[CONF_DEBOUNCE_THRESHOLD]))
cg.add(var.set_debug_raw(config[CONF_DEBUG_RAW]))
# Register Triggers
for conf, trigger_fn in [
(CONF_ON_SWIPE_LEFT, var.set_on_swipe_left),
(CONF_ON_SWIPE_RIGHT, var.set_on_swipe_right),
(CONF_ON_TAP, var.set_on_tap),
(CONF_ON_WAKE, var.set_on_wake),
(CONF_ON_SLEEP, var.set_on_sleep),
]:
if conf in config:
await cv.automation.build_automation(var.get_trigger(conf), [], config[conf])This file defines the State Machine and the Trigger objects.
Key Details Added:
AutomationTrigger: Standard way to fire YAML actions from C++.TouchState: The enum for the gesture engine.
#pragma once
#include "esphome.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/automation.h"
namespace esphome {
namespace Sentio {
// The Brain: State Machine
enum TouchState {
STATE_IDLE, // Waiting
STATE_START, // Touched, calculating intent
STATE_DRAGGING, // Moving > threshold (Swipe)
STATE_RELEASED // Let go
};
class SmartTouchComponent : public touchscreen::Touchscreen, public Component {
public:
// --- Setup & Config ---
void set_source_driver(touchscreen::Touchscreen *source) { source_driver_ = source; }
void set_resolution(int w, int h) { display_width_ = w; display_height_ = h; }
void set_sleep_timeout(uint32_t t) { sleep_timeout_ms_ = t; }
void set_suppress_wake_click(bool b) { suppress_wake_click_ = b; }
void set_calibration(bool swap, bool inv_x, bool inv_y) {
swap_xy_ = swap; invert_x_ = inv_x; invert_y_ = inv_y;
}
void set_debounce_threshold(uint32_t ms) { debounce_ms_ = ms; }
void set_debug_raw(bool b) { debug_raw_ = b; }
// --- Triggers (Automation hooks) ---
Trigger<> *get_trigger(const std::string &conf);
void set_on_swipe_left(Trigger<> *t) { on_swipe_left_ = t; }
void set_on_swipe_right(Trigger<> *t) { on_swipe_right_ = t; }
void set_on_tap(Trigger<> *t) { on_tap_ = t; }
void set_on_wake(Trigger<> *t) { on_wake_ = t; }
void set_on_sleep(Trigger<> *t) { on_sleep_ = t; }
// --- Lifecycle ---
void setup() override;
void loop() override;
protected:
// Internal Logic
touchscreen::Touchscreen *source_driver_{nullptr};
// Config Variables
int display_width_, display_height_;
uint32_t sleep_timeout_ms_;
bool suppress_wake_click_, swap_xy_, invert_x_, invert_y_, debug_raw_;
uint32_t debounce_ms_;
// Runtime State
uint32_t last_activity_time_{0};
bool is_sleeping_{false};
bool ignore_next_release_{false}; // The Trap Flag
// Gesture State
TouchState state_{STATE_IDLE};
uint32_t gesture_start_time_{0};
int16_t start_x_{0}, start_y_{0};
// Triggers
Trigger<> *on_swipe_left_{nullptr};
Trigger<> *on_swipe_right_{nullptr};
Trigger<> *on_tap_{nullptr};
Trigger<> *on_wake_{nullptr};
Trigger<> *on_sleep_{nullptr};
// Helpers
touchscreen::TouchPoint apply_calibration(touchscreen::TouchPoint p);
void process_gestures(touchscreen::TouchPoint p);
void handle_release();
};
} // namespace Sentio
} // namespace esphomeHere is where the magic happens.
Key Algorithms Explained:
- Calibration: Note the order.
swapfirst, theninvert. This is standard. - Debounce: We track
gesture_start_time_. If the touch is released too fast (<debounce_ms_), we treat it as noise (Ghost Touch) and ignore it. - Swipe vs Tap: We check
abs(current_x - start_x). If > 30px (hardcoded reasonable threshold), it becomes a SWIPE. If you release before moving that much, it's a TAP.
#include "Sentio.h"
namespace esphome {
namespace Sentio {
static const int SWIPE_THRESHOLD = 30; // Pixels to trigger a swipe
static const int MAX_TAP_TIME = 400; // Max ms for a tap (otherwise it's a hold)
void SmartTouchComponent::setup() {
this->last_activity_time_ = millis();
}
void SmartTouchComponent::loop() {
if (this->source_driver_ == nullptr) return;
// 1. SLEEP CHECK
if (millis() - this->last_activity_time_ > this->sleep_timeout_ms_) {
if (!this->is_sleeping_) {
this->is_sleeping_ = true;
ESP_LOGI("Sentio", "Entering Sleep Mode");
if (this->on_sleep_) this->on_sleep_->trigger();
}
}
// 2. READ SOURCE
auto &src_touches = this->source_driver_->touches;
// 3. RELEASE LOGIC (Finger up)
if (src_touches.empty()) {
if (this->state_ != STATE_IDLE) {
this->handle_release(); // Logic for Tap detection
this->state_ = STATE_IDLE;
// Clear output to consumers
this->touches.clear();
// Reset the wake-up trap
this->ignore_next_release_ = false;
}
return;
}
// 4. TOUCH DETECTED (Finger down)
auto raw_p = src_touches[0]; // Logic for single point
// --- DEBUGGING ---
if (this->debug_raw_) {
ESP_LOGD("Sentio", "Raw: x=%d y=%d", raw_p.x, raw_p.y);
}
// 5. WAKE LOGIC
if (this->is_sleeping_) {
this->is_sleeping_ = false;
this->last_activity_time_ = millis();
ESP_LOGI("Sentio", "Waking Up");
if (this->on_wake_) this->on_wake_->trigger();
if (this->suppress_wake_click_) {
this->ignore_next_release_ = true; // Set trap
return; // Swallow this frame
}
}
// Reset timer
this->last_activity_time_ = millis();
// If trap is set (wake-up click), ignore everything until release
if (this->ignore_next_release_) return;
// 6. CALIBRATE
auto p = this->apply_calibration(raw_p);
// 7. GESTURE & DEBOUNCE ENGINE
this->process_gestures(p);
// 8. OUTPUT TO CONSUMERS (LVGL)
// Only update LVGL if we passed the debounce check (handled in process_gestures)
// For the MVP, we just pass it through, but ideally, we wait `debounce_ms`
this->add_raw_touch_position_(p.id, p.x, p.y, p.pressure);
}
touchscreen::TouchPoint SmartTouchComponent::apply_calibration(touchscreen::TouchPoint p) {
int x = p.x;
int y = p.y;
// 1. Swap
if (this->swap_xy_) std::swap(x, y);
// 2. Invert (Requires display resolution)
// Note: If swapped, x is now relative to the *height* dimension
int width = this->swap_xy_ ? this->display_height_ : this->display_width_;
int height = this->swap_xy_ ? this->display_width_ : this->display_height_;
if (this->invert_x_) x = width - x;
if (this->invert_y_) y = height - y;
// Clamp to 0
if (x < 0) x = 0;
if (y < 0) y = 0;
p.x = x;
p.y = y;
return p;
}
void SmartTouchComponent::process_gestures(touchscreen::TouchPoint p) {
switch (this->state_) {
case STATE_IDLE:
// Start of a touch
this->state_ = STATE_START;
this->start_x_ = p.x;
this->start_y_ = p.y;
this->gesture_start_time_ = millis();
break;
case STATE_START:
// Check for Swipe
int dx = p.x - this->start_x_;
int dy = p.y - this->start_y_;
// Horizontal Swipe Detection
if (abs(dx) > SWIPE_THRESHOLD) {
this->state_ = STATE_DRAGGING;
if (dx > 0) {
if(this->on_swipe_right_) this->on_swipe_right_->trigger();
} else {
if(this->on_swipe_left_) this->on_swipe_left_->trigger();
}
}
break;
case STATE_DRAGGING:
// We already triggered the swipe, just wait for release
break;
}
}
void SmartTouchComponent::handle_release() {
// If we are releasing, and we never left STATE_START, it's a TAP
if (this->state_ == STATE_START) {
uint32_t duration = millis() - this->gesture_start_time_;
// Ghost Touch Filter: If touch was too short (WiFi noise), ignore it
if (duration < this->debounce_ms_) {
ESP_LOGD("Sentio", "Ignored noise pulse (<%dms)", this->debounce_ms_);
// Also clear the 'touches' buffer so LVGL doesn't see it
this->touches.clear();
return;
}
if (duration < MAX_TAP_TIME) {
if (this->on_tap_) this->on_tap_->trigger();
}
}
}
// Boilerplate to register triggers
Trigger<> *SmartTouchComponent::get_trigger(const std::string &conf) {
if (conf == "on_swipe_left") return this->on_swipe_left_;
// ... (implement others)
return nullptr;
}
} // namespace Sentio
} // namespace esphomeThis is the copy-paste YAML for your user documentation.
# 1. Define the Raw Hardware (Internal)
touchscreen:
- platform: gt911
id: my_hardware_touch
internal: true # Hide from HA
i2c_id: bus_a
interrupt_pin: GPIO4
reset_pin: GPIO16
# 2. Define the Smart Proxy
- platform: Sentio
id: my_Sentio
source: my_hardware_touch
# Matching your display resolution
display_width: 320
display_height: 240
# Power Settings
sleep_timeout: 15s
suppress_wake_click: true
# Calibration
swap_xy: true
invert_x: true
# Noise Filter
debounce_threshold: 20ms
# Gestures
on_swipe_left:
- logger.log: "Previous Page"
on_swipe_right:
- logger.log: "Next Page"
on_tap:
- logger.log: "Tap Detected"
# Advanced Power Control
on_sleep:
- light.turn_off: backlight
on_wake:
- light.turn_on: backlight
# 3. Connect Display to Smart Proxy
display:
- platform: ili9341
# ...
touchscreen_id: my_Sentio # <--- Connects to proxyThere is one tricky part in __init__.py regarding triggers. Because the component generates triggers dynamically based on the YAML, you need to ensure the C++ get_trigger method handles the string mapping.
In the to_code python function, I used var.set_on_swipe_left setters. This is cleaner. In C++, ensure the member variables (on_swipe_left_) are initialized to nullptr and checked before calling ->trigger().
This blueprint provides the complete loop logic, the math for calibration, and the specific architecture to hide the dirty hardware details from the clean UI layer.
- SentIO will be uploaded to https://github.com/effelle/SentIO you will init the repository and upload all the relevant files there:
git init
git add .
git commit -m "Initial Release of SentIO v1.0.0"Then
git remote add origin https://github.com/effelle/SentIO.git
git branch -M main
git push -u origin main- Create the library/manifest files (e.g. library.json) to reflect the name 'SentIO' and the new GitHub URL.
- You will add to .gitignore the local folder Esphome. It contain the developer wiki (developers.esphome.io-main) and the latest esphome code (esphome-dev folder).