diff --git a/CODEOWNERS b/CODEOWNERS index 356cf07e2b..b8f00aa368 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -123,3 +123,4 @@ esphome/components/web_server_base/* @OttoWinter esphome/components/whirlpool/* @glmnet esphome/components/xiaomi_lywsd03mmc/* @ahpohl esphome/components/xiaomi_mhoc401/* @vevsvevs +esphome/components/xpt2046/* @numo68 diff --git a/esphome/components/xpt2046/__init__.py b/esphome/components/xpt2046/__init__.py new file mode 100644 index 0000000000..fc440bcbba --- /dev/null +++ b/esphome/components/xpt2046/__init__.py @@ -0,0 +1,129 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import automation +from esphome import pins +from esphome.components import spi +from esphome.const import CONF_ID, CONF_ON_STATE, CONF_THRESHOLD, CONF_TRIGGER_ID + +CODEOWNERS = ["@numo68"] +AUTO_LOAD = ["binary_sensor"] +DEPENDENCIES = ["spi"] +MULTI_CONF = True + +CONF_REPORT_INTERVAL = "report_interval" +CONF_CALIBRATION_X_MIN = "calibration_x_min" +CONF_CALIBRATION_X_MAX = "calibration_x_max" +CONF_CALIBRATION_Y_MIN = "calibration_y_min" +CONF_CALIBRATION_Y_MAX = "calibration_y_max" +CONF_DIMENSION_X = "dimension_x" +CONF_DIMENSION_Y = "dimension_y" +CONF_SWAP_X_Y = "swap_x_y" +CONF_IRQ_PIN = "irq_pin" + +xpt2046_ns = cg.esphome_ns.namespace("xpt2046") +CONF_XPT2046_ID = "xpt2046_id" + +XPT2046Component = xpt2046_ns.class_( + "XPT2046Component", cg.PollingComponent, spi.SPIDevice +) + +XPT2046OnStateTrigger = xpt2046_ns.class_( + "XPT2046OnStateTrigger", automation.Trigger.template(cg.int_, cg.int_, cg.bool_) +) + + +def validate_xpt2046(config): + if ( + abs( + cv.int_(config[CONF_CALIBRATION_X_MAX]) + - cv.int_(config[CONF_CALIBRATION_X_MIN]) + ) + < 1000 + ): + raise cv.Invalid("Calibration X values difference < 1000") + + if ( + abs( + cv.int_(config[CONF_CALIBRATION_Y_MAX]) + - cv.int_(config[CONF_CALIBRATION_Y_MIN]) + ) + < 1000 + ): + raise cv.Invalid("Calibration Y values difference < 1000") + + return config + + +def report_interval(value): + if value == "never": + return 4294967295 # uint32_t max + return cv.positive_time_period_milliseconds(value) + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XPT2046Component), + cv.Optional(CONF_IRQ_PIN): pins.gpio_input_pin_schema, + cv.Optional(CONF_CALIBRATION_X_MIN, default=0): cv.int_range( + min=0, max=4095 + ), + cv.Optional(CONF_CALIBRATION_X_MAX, default=4095): cv.int_range( + min=0, max=4095 + ), + cv.Optional(CONF_CALIBRATION_Y_MIN, default=0): cv.int_range( + min=0, max=4095 + ), + cv.Optional(CONF_CALIBRATION_Y_MAX, default=4095): cv.int_range( + min=0, max=4095 + ), + cv.Optional(CONF_DIMENSION_X, default=100): cv.positive_not_null_int, + cv.Optional(CONF_DIMENSION_Y, default=100): cv.positive_not_null_int, + cv.Optional(CONF_THRESHOLD, default=400): cv.int_range(min=0, max=4095), + cv.Optional(CONF_REPORT_INTERVAL, default="never"): report_interval, + cv.Optional(CONF_SWAP_X_Y, default=False): cv.boolean, + cv.Optional(CONF_ON_STATE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + XPT2046OnStateTrigger + ), + } + ), + } + ) + .extend(cv.polling_component_schema("50ms")) + .extend(spi.spi_device_schema()), + validate_xpt2046, +) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield cg.register_component(var, config) + yield spi.register_spi_device(var, config) + + cg.add(var.set_threshold(config[CONF_THRESHOLD])) + cg.add(var.set_report_interval(config[CONF_REPORT_INTERVAL])) + cg.add(var.set_dimensions(config[CONF_DIMENSION_X], config[CONF_DIMENSION_Y])) + cg.add( + var.set_calibration( + config[CONF_CALIBRATION_X_MIN], + config[CONF_CALIBRATION_X_MAX], + config[CONF_CALIBRATION_Y_MIN], + config[CONF_CALIBRATION_Y_MAX], + ) + ) + + if CONF_SWAP_X_Y in config: + cg.add(var.set_swap_x_y(config[CONF_SWAP_X_Y])) + + if CONF_IRQ_PIN in config: + pin = yield cg.gpio_pin_expression(config[CONF_IRQ_PIN]) + cg.add(var.set_irq_pin(pin)) + + for conf in config.get(CONF_ON_STATE, []): + yield automation.build_automation( + var.get_on_state_trigger(), + [(cg.int_, "x"), (cg.int_, "y"), (cg.bool_, "touched")], + conf, + ) diff --git a/esphome/components/xpt2046/binary_sensor.py b/esphome/components/xpt2046/binary_sensor.py new file mode 100644 index 0000000000..457b706caf --- /dev/null +++ b/esphome/components/xpt2046/binary_sensor.py @@ -0,0 +1,57 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import binary_sensor +from esphome.const import CONF_ID +from . import ( + xpt2046_ns, + XPT2046Component, + CONF_XPT2046_ID, +) + +CONF_X_MIN = "x_min" +CONF_X_MAX = "x_max" +CONF_Y_MIN = "y_min" +CONF_Y_MAX = "y_max" + +DEPENDENCIES = ["xpt2046"] +XPT2046Button = xpt2046_ns.class_("XPT2046Button", binary_sensor.BinarySensor) + + +def validate_xpt2046_button(config): + if cv.int_(config[CONF_X_MAX]) < cv.int_(config[CONF_X_MIN]) or cv.int_( + config[CONF_Y_MAX] + ) < cv.int_(config[CONF_Y_MIN]): + raise cv.Invalid("x_max is less than x_min or y_max is less than y_min") + + return config + + +CONFIG_SCHEMA = cv.All( + binary_sensor.BINARY_SENSOR_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(XPT2046Button), + cv.GenerateID(CONF_XPT2046_ID): cv.use_id(XPT2046Component), + cv.Required(CONF_X_MIN): cv.int_range(min=0, max=4095), + cv.Required(CONF_X_MAX): cv.int_range(min=0, max=4095), + cv.Required(CONF_Y_MIN): cv.int_range(min=0, max=4095), + cv.Required(CONF_Y_MAX): cv.int_range(min=0, max=4095), + } + ), + validate_xpt2046_button, +) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield binary_sensor.register_binary_sensor(var, config) + hub = yield cg.get_variable(config[CONF_XPT2046_ID]) + cg.add( + var.set_area( + config[CONF_X_MIN], + config[CONF_X_MAX], + config[CONF_Y_MIN], + config[CONF_Y_MAX], + ) + ) + + cg.add(hub.register_button(var)) diff --git a/esphome/components/xpt2046/xpt2046.cpp b/esphome/components/xpt2046/xpt2046.cpp new file mode 100644 index 0000000000..3e22e82859 --- /dev/null +++ b/esphome/components/xpt2046/xpt2046.cpp @@ -0,0 +1,217 @@ +#include "xpt2046.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome { +namespace xpt2046 { + +static const char *TAG = "xpt2046"; + +void XPT2046Component::setup() { + if (this->irq_pin_ != nullptr) { + // The pin reports a touch with a falling edge. Unfortunately the pin goes also changes state + // while the channels are read and wiring it as an interrupt is not straightforward and would + // need careful masking. A GPIO poll is cheap so we'll just use that. + this->irq_pin_->setup(); // INPUT + } + spi_setup(); + read_adc_(0xD0); // ADC powerdown, enable PENIRQ pin +} + +void XPT2046Component::loop() { + if (this->irq_pin_ != nullptr) { + // Force immediate update if a falling edge (= touched is seen) Ignore if still active + // (that would mean that we missed the release because of a too long update interval) + bool val = this->irq_pin_->digital_read(); + if (!val && this->last_irq_ && !this->touched) { + ESP_LOGD(TAG, "Falling penirq edge, forcing update"); + update(); + } + this->last_irq_ = val; + } +} + +void XPT2046Component::update() { + int16_t data[6]; + bool touch = false; + unsigned long now = millis(); + + this->z_raw = 0; + + // In case the penirq pin is present only do the SPI transaction if it reports a touch (is low). + // The touch has to be also confirmed with checking the pressure over threshold + if (this->irq_pin_ == nullptr || !this->irq_pin_->digital_read()) { + enable(); + + int16_t z1 = read_adc_(0xB1 /* Z1 */); + int16_t z2 = read_adc_(0xC1 /* Z2 */); + + this->z_raw = z1 + 4095 - z2; + + touch = (this->z_raw >= this->threshold_); + if (touch) { + read_adc_(0x91 /* Y */); // dummy Y measure, 1st is always noisy + data[0] = read_adc_(0xD1 /* X */); + data[1] = read_adc_(0x91 /* Y */); // make 3 x-y measurements + data[2] = read_adc_(0xD1 /* X */); + data[3] = read_adc_(0x91 /* Y */); + data[4] = read_adc_(0xD1 /* X */); + } + + data[5] = read_adc_(0x90 /* Y */); // Last Y touch power down + + disable(); + } + + if (touch) { + this->x_raw = best_two_avg(data[0], data[2], data[4]); + this->y_raw = best_two_avg(data[1], data[3], data[5]); + } else { + this->x_raw = this->y_raw = 0; + } + + ESP_LOGV(TAG, "Update [x, y] = [%d, %d], z = %d%s", this->x_raw, this->y_raw, this->z_raw, (touch ? " touched" : "")); + + if (touch) { + // Normalize raw data according to calibration min and max + + int16_t x_val = normalize(this->x_raw, this->x_raw_min_, this->x_raw_max_); + int16_t y_val = normalize(this->y_raw, this->y_raw_min_, this->y_raw_max_); + + if (this->swap_x_y_) { + std::swap(x_val, y_val); + } + + if (this->invert_x_) { + x_val = 0x7fff - x_val; + } + + if (this->invert_y_) { + y_val = 0x7fff - y_val; + } + + x_val = (int16_t)((int) x_val * this->x_dim_ / 0x7fff); + y_val = (int16_t)((int) y_val * this->y_dim_ / 0x7fff); + + if (!this->touched || (now - this->last_pos_ms_) >= this->report_millis_) { + ESP_LOGD(TAG, "Raw [x, y] = [%d, %d], transformed = [%d, %d]", this->x_raw, this->y_raw, x_val, y_val); + + this->x = x_val; + this->y = y_val; + this->touched = true; + this->last_pos_ms_ = now; + + this->on_state_trigger_->process(this->x, this->y, true); + for (auto *button : this->buttons_) + button->touch(this->x, this->y); + } + } else { + if (this->touched) { + ESP_LOGD(TAG, "Released [%d, %d]", this->x, this->y); + + this->touched = false; + + this->on_state_trigger_->process(this->x, this->y, false); + for (auto *button : this->buttons_) + button->release(); + } + } +} + +void XPT2046Component::set_calibration(int16_t x_min, int16_t x_max, int16_t y_min, int16_t y_max) { + this->x_raw_min_ = std::min(x_min, x_max); + this->x_raw_max_ = std::max(x_min, x_max); + this->y_raw_min_ = std::min(y_min, y_max); + this->y_raw_max_ = std::max(y_min, y_max); + this->invert_x_ = (x_min > x_max); + this->invert_y_ = (y_min > y_max); +} + +void XPT2046Component::dump_config() { + ESP_LOGCONFIG(TAG, "XPT2046:"); + + LOG_PIN(" IRQ Pin: ", this->irq_pin_); + ESP_LOGCONFIG(TAG, " X min: %d", this->x_raw_min_); + ESP_LOGCONFIG(TAG, " X max: %d", this->x_raw_max_); + ESP_LOGCONFIG(TAG, " Y min: %d", this->y_raw_min_); + ESP_LOGCONFIG(TAG, " Y max: %d", this->y_raw_max_); + ESP_LOGCONFIG(TAG, " X dim: %d", this->x_dim_); + ESP_LOGCONFIG(TAG, " Y dim: %d", this->y_dim_); + if (this->swap_x_y_) { + ESP_LOGCONFIG(TAG, " Swap X/Y"); + } + ESP_LOGCONFIG(TAG, " threshold: %d", this->threshold_); + ESP_LOGCONFIG(TAG, " Report interval: %u", this->report_millis_); + + LOG_UPDATE_INTERVAL(this); +} + +float XPT2046Component::get_setup_priority() const { return setup_priority::DATA; } + +int16_t XPT2046Component::best_two_avg(int16_t x, int16_t y, int16_t z) { + int16_t da, db, dc; + int16_t reta = 0; + + da = (x > y) ? x - y : y - x; + db = (x > z) ? x - z : z - x; + dc = (z > y) ? z - y : y - z; + + if (da <= db && da <= dc) { + reta = (x + y) >> 1; + } else if (db <= da && db <= dc) { + reta = (x + z) >> 1; + } else { + reta = (y + z) >> 1; + } + + return reta; +} + +int16_t XPT2046Component::normalize(int16_t val, int16_t min_val, int16_t max_val) { + int16_t ret; + + if (val <= min_val) { + ret = 0; + } else if (val >= max_val) { + ret = 0x7fff; + } else { + ret = (int16_t)((int) 0x7fff * (val - min_val) / (max_val - min_val)); + } + + return ret; +} + +int16_t XPT2046Component::read_adc_(uint8_t ctrl) { + uint8_t data[2]; + + write_byte(ctrl); + data[0] = read_byte(); + data[1] = read_byte(); + + return ((data[0] << 8) | data[1]) >> 3; +} + +void XPT2046OnStateTrigger::process(int x, int y, bool touched) { this->trigger(x, y, touched); } + +void XPT2046Button::touch(int16_t x, int16_t y) { + bool touched = (x >= this->x_min_ && x <= this->x_max_ && y >= this->y_min_ && y <= this->y_max_); + + if (touched) { + this->publish_state(true); + this->state_ = true; + } else { + release(); + } +} + +void XPT2046Button::release() { + if (this->state_) { + this->publish_state(false); + this->state_ = false; + } +} + +} // namespace xpt2046 +} // namespace esphome diff --git a/esphome/components/xpt2046/xpt2046.h b/esphome/components/xpt2046/xpt2046.h new file mode 100644 index 0000000000..7fd80c3228 --- /dev/null +++ b/esphome/components/xpt2046/xpt2046.h @@ -0,0 +1,124 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/spi/spi.h" +#include "esphome/components/binary_sensor/binary_sensor.h" + +namespace esphome { +namespace xpt2046 { + +class XPT2046OnStateTrigger : public Trigger { + public: + void process(int x, int y, bool touched); +}; + +class XPT2046Button : public binary_sensor::BinarySensor { + public: + /// Set the touch screen area where the button will detect the touch. + void set_area(int16_t x_min, int16_t x_max, int16_t y_min, int16_t y_max) { + this->x_min_ = x_min; + this->x_max_ = x_max; + this->y_min_ = y_min; + this->y_max_ = y_max; + } + + void touch(int16_t x, int16_t y); + void release(); + + protected: + int16_t x_min_, x_max_, y_min_, y_max_; + bool state_{false}; +}; + +class XPT2046Component : public PollingComponent, + public spi::SPIDevice { + public: + /// Set the logical touch screen dimensions. + void set_dimensions(int16_t x, int16_t y) { + this->x_dim_ = x; + this->y_dim_ = y; + } + /// Set the coordinates for the touch screen edges. + void set_calibration(int16_t x_min, int16_t x_max, int16_t y_min, int16_t y_max); + /// If true the x and y axes will be swapped + void set_swap_x_y(bool val) { this->swap_x_y_ = val; } + + /// Set the interval to report the touch point perodically. + void set_report_interval(uint32_t interval) { this->report_millis_ = interval; } + /// Set the threshold for the touch detection. + void set_threshold(int16_t threshold) { this->threshold_ = threshold; } + /// Set the pin used to detect the touch. + void set_irq_pin(GPIOPin *pin) { this->irq_pin_ = pin; } + /// Get an access to the on_state automation trigger + XPT2046OnStateTrigger *get_on_state_trigger() const { return this->on_state_trigger_; } + /// Register a virtual button to the component. + void register_button(XPT2046Button *button) { this->buttons_.push_back(button); } + + void setup() override; + void dump_config() override; + float get_setup_priority() const override; + + /** Detect the touch if the irq pin is specified. + * + * If the touch is detected and the component does not already know about it + * the update() is called immediately. If the irq pin is not specified + * the loop() is a no-op. + */ + void loop() override; + + /** Read and process the values from the hardware. + * + * Read the raw x, y and touch pressure values from the chip, detect the touch, + * and if touched, transform to the user x and y coordinates. If the state has + * changed or if the value should be reported again due to the + * report interval, run the action and inform the virtual buttons. + */ + void update() override; + + /**@{*/ + /** Coordinates of the touch position. + * + * The values are set immediately before the on_state action with touched == true + * is triggered. The action with touched == false sends the coordinates of the last + * reported touch. + */ + int16_t x{0}, y{0}; + /**@}*/ + + /// True if the component currently detects the touch + bool touched{false}; + + /**@{*/ + /** Raw sensor values of the coordinates and the pressure. + * + * The values are set each time the update() method is called. + */ + int16_t x_raw{0}, y_raw{0}, z_raw{0}; + /**@}*/ + + protected: + static int16_t best_two_avg(int16_t x, int16_t y, int16_t z); + static int16_t normalize(int16_t val, int16_t min_val, int16_t max_val); + + int16_t read_adc_(uint8_t ctrl); + + int16_t threshold_; + int16_t x_raw_min_, x_raw_max_, y_raw_min_, y_raw_max_; + int16_t x_dim_, y_dim_; + bool invert_x_, invert_y_; + bool swap_x_y_; + + uint32_t report_millis_; + unsigned long last_pos_ms_{0}; + + GPIOPin *irq_pin_{nullptr}; + bool last_irq_{true}; + + XPT2046OnStateTrigger *on_state_trigger_{new XPT2046OnStateTrigger()}; + std::vector buttons_{}; +}; + +} // namespace xpt2046 +} // namespace esphome diff --git a/tests/test4.yaml b/tests/test4.yaml index d2dcb8b682..fb2bce82a4 100644 --- a/tests/test4.yaml +++ b/tests/test4.yaml @@ -100,6 +100,15 @@ binary_sensor: on_state: then: - lambda: 'ESP_LOGI("ar1:", "%d", x);' + - platform: xpt2046 + xpt2046_id: touchscreen + id: touch_key0 + x_min: 80 + x_max: 160 + y_min: 106 + y_max: 212 + on_state: + - lambda: 'ESP_LOGI("main", "key0: %s", (x ? "touch" : "release"));' climate: - platform: tuya @@ -180,3 +189,28 @@ external_components: components: ["bh1750"] - source: ../esphome/components components: ["sntp"] +xpt2046: + id: touchscreen + cs_pin: 17 + irq_pin: 16 + update_interval: 50ms + report_interval: 1s + threshold: 400 + dimension_x: 240 + dimension_y: 320 + calibration_x_min: 3860 + calibration_x_max: 280 + calibration_y_min: 340 + calibration_y_max: 3860 + swap_x_y: False + on_state: + - lambda: |- + ESP_LOGI("main", "args x=%d, y=%d, touched=%s", x, y, (touched ? "touch" : "release")); + ESP_LOGI("main", "member x=%d, y=%d, touched=%d, x_raw=%d, y_raw=%d, z_raw=%d", + id(touchscreen).x, + id(touchscreen).y, + (int) id(touchscreen).touched, + id(touchscreen).x_raw, + id(touchscreen).y_raw, + id(touchscreen).z_raw + );