mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 08:55:36 +00:00
[gsl3670] Add new touchscreen component (#16285)
This commit is contained in:
@@ -209,6 +209,7 @@ esphome/components/gree/switch/* @nagyrobi
|
||||
esphome/components/grove_gas_mc_v2/* @YorkshireIoT
|
||||
esphome/components/grove_tb6612fng/* @max246
|
||||
esphome/components/growatt_solar/* @leeuwte
|
||||
esphome/components/gsl3670/* @clydebarrow
|
||||
esphome/components/gt911/* @clydebarrow @jesserockz
|
||||
esphome/components/haier/* @paveldn
|
||||
esphome/components/haier/binary_sensor/* @paveldn
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
@@ -0,0 +1,167 @@
|
||||
#include "gsl3670_touchscreen.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
namespace esphome::gsl3670 {
|
||||
|
||||
static const char *const TAG = "gsl3670.touchscreen";
|
||||
static const size_t MAX_TOUCHES = 3;
|
||||
// ---------------------------------------------------------------------------
|
||||
// setup() – mirrors esp_lcd_touch_gsl3670_init() in the Seeed BSP:
|
||||
// clear_reg → reset → load_fw → startup_chip → reset → startup_chip
|
||||
// ---------------------------------------------------------------------------
|
||||
void GSL3670Touchscreen::setup() {
|
||||
ESP_LOGCONFIG(TAG, "Setting up GSL3670 touchscreen...");
|
||||
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
this->reset_pin_->setup();
|
||||
this->reset_pin_->digital_write(true);
|
||||
}
|
||||
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
this->interrupt_pin_->setup();
|
||||
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
|
||||
}
|
||||
|
||||
if (this->x_raw_max_ == this->x_raw_min_) {
|
||||
this->x_raw_max_ = this->display_->get_native_width();
|
||||
}
|
||||
if (this->y_raw_max_ == this->y_raw_min_) {
|
||||
this->y_raw_max_ = this->display_->get_native_height();
|
||||
}
|
||||
|
||||
this->clear_reg_();
|
||||
this->reset_();
|
||||
this->load_firmware_();
|
||||
this->startup_chip_();
|
||||
this->reset_();
|
||||
this->startup_chip_();
|
||||
|
||||
ESP_LOGCONFIG(TAG, "GSL3670 initialised OK");
|
||||
}
|
||||
|
||||
void GSL3670Touchscreen::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"GSL3670 Touchscreen:\n"
|
||||
" X-raw-max: %d\n"
|
||||
" Y-raw-max: %d\n",
|
||||
this->x_raw_max_, this->y_raw_max_);
|
||||
LOG_I2C_DEVICE(this);
|
||||
LOG_PIN(" Reset Pin: ", this->reset_pin_);
|
||||
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
|
||||
ESP_LOGCONFIG(TAG, " Firmware records: %zu", this->firmware_len_);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// update_touches() – mirrors esp_lcd_touch_gsl3670_read_data() in Seeed BSP
|
||||
// ---------------------------------------------------------------------------
|
||||
void GSL3670Touchscreen::update_touches() {
|
||||
uint8_t buf[44] = {};
|
||||
auto err = this->read_register(0x80, buf, sizeof(buf));
|
||||
if (err != i2c::ERROR_OK) {
|
||||
ESP_LOGW(TAG, "I2C read failed (%d)", err);
|
||||
return;
|
||||
}
|
||||
uint8_t finger_num = clamp_at_most(buf[0], MAX_TOUCHES);
|
||||
|
||||
// Build gsl_touch_info exactly as the Seeed driver does
|
||||
for (uint8_t j = 0; j != finger_num; j++) {
|
||||
// buf[(j+1)*4 + 0..3]: byte0=y_lo, byte1=y_hi, byte2=x_lo, byte3=id|x_hi
|
||||
auto x = (uint16_t) (((buf[(j + 1) * 4 + 3] & 0x0f) << 8) | buf[(j + 1) * 4 + 2]);
|
||||
auto y = (uint16_t) ((buf[(j + 1) * 4 + 1] << 8) | buf[(j + 1) * 4 + 0]);
|
||||
auto id = (buf[(j + 1) * 4 + 3] >> 4) & 0x0f;
|
||||
ESP_LOGV(TAG, "Touch id=%u, x=%u y=%u", id, x, y);
|
||||
if (x <= 8192 && y <= 8192)
|
||||
this->add_raw_touch_position_(id, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clear_reg_() – mirrors esp_lcd_touch_gsl3670_clear_reg()
|
||||
// GPIO reset → write 0x01 to 0x88 → write 0x04 to 0xe4 → write 0x00 to 0xe0
|
||||
// ---------------------------------------------------------------------------
|
||||
void GSL3670Touchscreen::clear_reg_() {
|
||||
ESP_LOGD(TAG, "clear_reg");
|
||||
|
||||
// GPIO reset pulse
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(1);
|
||||
this->reset_pin_->digital_write(true);
|
||||
delay(5);
|
||||
}
|
||||
|
||||
this->write_reg8_(0x88, 0x01);
|
||||
// delay(5);
|
||||
this->write_reg8_(0xe4, 0x04);
|
||||
// delay(5);
|
||||
this->write_reg8_(0xe0, 0x00);
|
||||
// delay(5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reset_() – mirrors touch_gsl3670_reset()
|
||||
// GPIO reset → write 0x04 to 0xe4 → write 4×0x00 to 0xbc
|
||||
// ---------------------------------------------------------------------------
|
||||
void GSL3670Touchscreen::reset_() {
|
||||
ESP_LOGD(TAG, "reset");
|
||||
|
||||
if (this->reset_pin_ != nullptr) {
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(1);
|
||||
this->reset_pin_->digital_write(true);
|
||||
delay(5);
|
||||
}
|
||||
|
||||
this->write_reg8_(0xe4, 0x04);
|
||||
|
||||
uint8_t zeros[4] = {0, 0, 0, 0};
|
||||
this->write_reg_(0xbc, zeros, 4);
|
||||
}
|
||||
|
||||
void GSL3670Touchscreen::load_firmware_() {
|
||||
if (firmware_ == nullptr || firmware_len_ == 0) {
|
||||
ESP_LOGW(TAG, "No firmware supplied – skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Loading firmware (%zu blocks)...", firmware_len_);
|
||||
|
||||
static constexpr size_t FW_BLK_SIZE = 128 + 4;
|
||||
|
||||
for (size_t i = 0; i != this->firmware_len_; i++) {
|
||||
auto offset = i * FW_BLK_SIZE;
|
||||
uint8_t val = this->firmware_[offset + 0];
|
||||
ESP_LOGV(TAG, "Firmware address 0x%02X", val);
|
||||
this->write_reg_(0xf0, &val, 1);
|
||||
this->write_reg_(0, this->firmware_ + offset + 4, 128);
|
||||
}
|
||||
ESP_LOGD(TAG, "Firmware load complete");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// startup_chip_() – mirrors esp_lcd_touch_gsl3670_startup_chip()
|
||||
// write 0x00 to 0xe0
|
||||
// ---------------------------------------------------------------------------
|
||||
void GSL3670Touchscreen::startup_chip_() {
|
||||
ESP_LOGD(TAG, "startup_chip");
|
||||
this->write_reg8_(0xe0, 0x00);
|
||||
delay(5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I2C helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool GSL3670Touchscreen::write_reg_(uint8_t reg, const uint8_t *data, size_t len) {
|
||||
auto err = this->write_register(reg, data, len);
|
||||
if (err != i2c::ERROR_OK) {
|
||||
ESP_LOGW(TAG, "I2C write reg 0x%02X len %zu failed (%d)", reg, len, err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GSL3670Touchscreen::write_reg8_(uint8_t reg, uint8_t val) { return write_reg_(reg, &val, 1); }
|
||||
|
||||
} // namespace esphome::gsl3670
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/components/touchscreen/touchscreen.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::gsl3670 {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GSL3670 touchscreen ESPHome component
|
||||
// ---------------------------------------------------------------------------
|
||||
class GSL3670Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
|
||||
public:
|
||||
/// Supply the firmware table (generated by codegen from the YAML)
|
||||
void set_firmware(const uint8_t *fw, size_t len) {
|
||||
this->firmware_ = fw;
|
||||
this->firmware_len_ = len;
|
||||
}
|
||||
|
||||
void set_interrupt_pin(InternalGPIOPin *pin) { interrupt_pin_ = pin; }
|
||||
void set_reset_pin(GPIOPin *pin) { reset_pin_ = pin; }
|
||||
|
||||
// touchscreen::Touchscreen / Component interface
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
void update_touches() override;
|
||||
|
||||
private:
|
||||
// ---------- init steps (mirrors esp_lcd_touch_gsl3670_init) ----------
|
||||
void clear_reg_(); // GPIO reset + 0x88/0xe4/0xe0 sequence
|
||||
void reset_(); // GPIO reset + 0xe4/0xbc sequence
|
||||
void load_firmware_(); // write GSLX670_FW table
|
||||
void startup_chip_(); // 0x00→0xe0 + gsl_DataInit
|
||||
|
||||
// ---------- I2C helpers ----------
|
||||
bool write_reg_(uint8_t reg, const uint8_t *data, size_t len);
|
||||
bool write_reg8_(uint8_t reg, uint8_t val);
|
||||
|
||||
InternalGPIOPin *interrupt_pin_{nullptr};
|
||||
GPIOPin *reset_pin_{nullptr};
|
||||
|
||||
const uint8_t *firmware_{nullptr};
|
||||
size_t firmware_len_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::gsl3670
|
||||
@@ -0,0 +1,209 @@
|
||||
"""ESPHome codegen for the gsl3670 touchscreen sub-platform."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome import external_files, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c, touchscreen
|
||||
from esphome.components.const import CONF_SHA256
|
||||
from esphome.components.touchscreen import (
|
||||
CONF_X_MAX,
|
||||
CONF_X_MIN,
|
||||
CONF_Y_MAX,
|
||||
CONF_Y_MIN,
|
||||
option_with_default,
|
||||
touchscreen_schema,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_FILE,
|
||||
CONF_ID,
|
||||
CONF_INTERRUPT_PIN,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_RESET_PIN,
|
||||
CONF_SWAP_XY,
|
||||
CONF_URL,
|
||||
)
|
||||
from esphome.core import ID
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
AUTO_LOAD = ["touchscreen"]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "gsl3670"
|
||||
|
||||
gsl3670_ns = cg.esphome_ns.namespace("gsl3670")
|
||||
GSL3670Touchscreen = gsl3670_ns.class_(
|
||||
"GSL3670Touchscreen",
|
||||
touchscreen.Touchscreen,
|
||||
i2c.I2CDevice,
|
||||
)
|
||||
|
||||
CONF_FIRMWARE = "firmware"
|
||||
|
||||
# Firmware blobs are published as release assets of the companion repository
|
||||
# rather than vendored into the ESPHome source tree. The default URL/SHA-256
|
||||
# for each model point at a pinned release artifact; users may override them
|
||||
# (or supply a local file via `firmware: { file: ... }`).
|
||||
FIRMWARE_RELEASE = "v1.0.0"
|
||||
FIRMWARE_BASE_URL = f"https://github.com/esphome-libs/gsl3670-firmware/releases/download/{FIRMWARE_RELEASE}"
|
||||
|
||||
MODELS = {
|
||||
"SEEED-RETERMINAL-D1001": {
|
||||
CONF_SWAP_XY: True,
|
||||
CONF_MIRROR_X: True,
|
||||
CONF_MIRROR_Y: True,
|
||||
CONF_X_MIN: 20,
|
||||
CONF_Y_MIN: 20,
|
||||
CONF_X_MAX: 872,
|
||||
CONF_Y_MAX: 1644,
|
||||
CONF_RESET_PIN: {"xl9535": None, "number": 14},
|
||||
CONF_INTERRUPT_PIN: 16,
|
||||
CONF_FIRMWARE: {
|
||||
CONF_URL: f"{FIRMWARE_BASE_URL}/seeed-d1001-fw.bin",
|
||||
CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4",
|
||||
},
|
||||
},
|
||||
"CUSTOM": {},
|
||||
}
|
||||
|
||||
_FW_BLK_SIZE = 128 + 4
|
||||
|
||||
|
||||
def _validate_firmware_data(data: bytes, source: str) -> None:
|
||||
"""Validate the structure of a decoded GSL3670 firmware blob."""
|
||||
blk_cnt = len(data) // _FW_BLK_SIZE
|
||||
if blk_cnt == 0 or blk_cnt * _FW_BLK_SIZE != len(data):
|
||||
raise cv.Invalid(f"Firmware file length is incorrect: {source}")
|
||||
for i in range(0, len(data), _FW_BLK_SIZE):
|
||||
if data[i] > 0xEF or data[i + 1] != 1 or data[i + 2] != 2 or data[i + 3] != 3:
|
||||
raise cv.Invalid(
|
||||
f"Corrupted firmware at block {i // _FW_BLK_SIZE} in: {source}"
|
||||
)
|
||||
|
||||
|
||||
def _cache_path(url: str) -> Path:
|
||||
"""Cache path for a downloaded firmware blob, keyed by URL."""
|
||||
key = hashlib.sha256(url.encode()).hexdigest()[:8]
|
||||
return external_files.compute_local_file_dir(DOMAIN) / key
|
||||
|
||||
|
||||
def firmware_path(firmware: dict) -> Path:
|
||||
"""Return the path the firmware bytes will be read from at codegen time."""
|
||||
if path := firmware.get(CONF_FILE):
|
||||
return path
|
||||
return _cache_path(firmware[CONF_URL])
|
||||
|
||||
|
||||
def _validate_firmware(firmware: dict) -> dict:
|
||||
"""Require a single source, download (with caching), verify and validate."""
|
||||
if (CONF_FILE in firmware) == (CONF_URL in firmware):
|
||||
raise cv.Invalid(
|
||||
f"Exactly one of '{CONF_URL}' or '{CONF_FILE}' must be provided"
|
||||
)
|
||||
|
||||
if path := firmware.get(CONF_FILE):
|
||||
_validate_firmware_data(path.read_bytes(), str(path.absolute()))
|
||||
return firmware
|
||||
|
||||
url = firmware[CONF_URL]
|
||||
data = external_files.download_content(url, _cache_path(url))
|
||||
|
||||
if expected := firmware.get(CONF_SHA256):
|
||||
actual = hashlib.sha256(data).hexdigest()
|
||||
if actual.lower() != expected.lower():
|
||||
raise cv.Invalid(
|
||||
f"Firmware SHA-256 mismatch for {url}: "
|
||||
f"expected {expected.lower()}, got {actual}",
|
||||
[CONF_SHA256],
|
||||
)
|
||||
else:
|
||||
LOGGER.warning(
|
||||
"No SHA256 provided for gsl3670 firmware - firmware integrity can not be checked"
|
||||
)
|
||||
_validate_firmware_data(data, url)
|
||||
return firmware
|
||||
|
||||
|
||||
FIRMWARE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_URL): cv.url,
|
||||
cv.Optional(CONF_SHA256): cv.string_strict,
|
||||
cv.Optional(CONF_FILE): cv.file_,
|
||||
}
|
||||
),
|
||||
_validate_firmware,
|
||||
)
|
||||
|
||||
|
||||
def _config_schema(config):
|
||||
model_option = {
|
||||
cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True)
|
||||
}
|
||||
config = cv.Schema(model_option, extra=True)(config)
|
||||
defaults = MODELS[config[CONF_MODEL]]
|
||||
schema = (
|
||||
touchscreen_schema(cv.UNDEFINED, False, defaults)
|
||||
.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(GSL3670Touchscreen),
|
||||
option_with_default(
|
||||
CONF_INTERRUPT_PIN, defaults
|
||||
): pins.internal_gpio_input_pin_schema,
|
||||
option_with_default(
|
||||
CONF_RESET_PIN, defaults
|
||||
): pins.gpio_output_pin_schema,
|
||||
**model_option,
|
||||
option_with_default(
|
||||
CONF_FIRMWARE, defaults, required=True
|
||||
): FIRMWARE_SCHEMA,
|
||||
}
|
||||
)
|
||||
.extend(i2c.i2c_device_schema(0x40))
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
)
|
||||
return schema(config)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = _config_schema
|
||||
|
||||
|
||||
def _read_firmware(config) -> bytes:
|
||||
path = firmware_path(config[CONF_FIRMWARE])
|
||||
data = path.read_bytes()
|
||||
LOGGER.info(
|
||||
"Read gsl3670 touchscreen firmware file %s: %d bytes, %d blocks",
|
||||
path.absolute(),
|
||||
len(data),
|
||||
len(data) // _FW_BLK_SIZE,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code generation
|
||||
# ---------------------------------------------------------------------------
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await touchscreen.register_touchscreen(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
|
||||
if CONF_INTERRUPT_PIN in config:
|
||||
pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN])
|
||||
cg.add(var.set_interrupt_pin(pin))
|
||||
|
||||
if CONF_RESET_PIN in config:
|
||||
pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN])
|
||||
cg.add(var.set_reset_pin(pin))
|
||||
|
||||
# Firmware table
|
||||
data = _read_firmware(config)
|
||||
fw_array = cg.progmem_array(
|
||||
ID(config[CONF_ID].id + "_fw", type=cg.uint8), list(data)
|
||||
)
|
||||
cg.add(var.set_firmware(fw_array, len(data) // _FW_BLK_SIZE))
|
||||
@@ -60,40 +60,79 @@ def validate_calibration(calibration_config):
|
||||
return calibration_config
|
||||
|
||||
|
||||
CALIBRATION_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
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_calibration,
|
||||
)
|
||||
def option_with_default(option: str, defaults: dict, required: bool = False):
|
||||
if option in defaults or not required:
|
||||
return cv.Optional(option, default=defaults.get(option, cv.UNDEFINED))
|
||||
return cv.Required(option)
|
||||
|
||||
|
||||
def touchscreen_schema(default_touch_timeout=cv.UNDEFINED, calibration_required=False):
|
||||
calibration = (
|
||||
cv.Required(CONF_CALIBRATION)
|
||||
if calibration_required
|
||||
else cv.Optional(CONF_CALIBRATION)
|
||||
)
|
||||
_CALIBRATION_KEYS = {CONF_X_MIN, CONF_X_MAX, CONF_Y_MIN, CONF_Y_MAX}
|
||||
_TRANSFORM_KEYS = {CONF_SWAP_XY, CONF_MIRROR_X, CONF_MIRROR_Y}
|
||||
|
||||
|
||||
def _calibration_schema(defaults: dict, required: bool) -> dict:
|
||||
"""
|
||||
Generate Calibration schema. If defaults are provided for all suboptions,
|
||||
the entire calibration config is optional with a populated default value.
|
||||
Otherwise, it's optional or required as specified.
|
||||
"""
|
||||
if _CALIBRATION_KEYS.issubset(defaults):
|
||||
key = cv.Optional(
|
||||
CONF_CALIBRATION,
|
||||
default={k: v for k, v in defaults.items() if k in _CALIBRATION_KEYS},
|
||||
)
|
||||
elif required:
|
||||
key = cv.Required(CONF_CALIBRATION)
|
||||
else:
|
||||
key = cv.Optional(CONF_CALIBRATION)
|
||||
return {
|
||||
key: cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
option_with_default(x, defaults, True): cv.int_range(
|
||||
min=0, max=4095
|
||||
)
|
||||
for x in _CALIBRATION_KEYS
|
||||
}
|
||||
),
|
||||
validate_calibration,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _transform_schema(defaults: dict) -> dict:
|
||||
if _TRANSFORM_KEYS.issubset(defaults):
|
||||
key = cv.Optional(
|
||||
CONF_TRANSFORM,
|
||||
default={k: v for k, v in defaults.items() if k in _TRANSFORM_KEYS},
|
||||
)
|
||||
else:
|
||||
key = cv.Optional(CONF_TRANSFORM)
|
||||
return {
|
||||
key: cv.Schema(
|
||||
{
|
||||
cv.Optional(x, default=defaults.get(x, False)): cv.boolean
|
||||
for x in _TRANSFORM_KEYS
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def touchscreen_schema(
|
||||
default_touch_timeout=cv.UNDEFINED,
|
||||
calibration_required=False,
|
||||
defaults: dict = None,
|
||||
) -> cv.Schema:
|
||||
defaults = defaults or {}
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_DISPLAY): cv.use_id(display.Display),
|
||||
cv.Optional(CONF_TRANSFORM): cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_SWAP_XY, default=False): cv.boolean,
|
||||
cv.Optional(CONF_MIRROR_X, default=False): cv.boolean,
|
||||
cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_TOUCH_TIMEOUT, default=default_touch_timeout): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
|
||||
),
|
||||
calibration: CALIBRATION_SCHEMA,
|
||||
**_transform_schema(defaults),
|
||||
**_calibration_schema(defaults, calibration_required),
|
||||
cv.Optional(CONF_ON_TOUCH): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_ON_UPDATE): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_ON_RELEASE): automation.validate_automation(single=True),
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Tests for the gsl3670 touchscreen configuration validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.components.gsl3670 import touchscreen as gsl
|
||||
from esphome.const import (
|
||||
CONF_CALIBRATION,
|
||||
CONF_INTERRUPT_PIN,
|
||||
CONF_MODEL,
|
||||
CONF_RESET_PIN,
|
||||
CONF_TRANSFORM,
|
||||
PlatformFramework,
|
||||
)
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
VALID_URL = "https://example.com/fw.bin"
|
||||
|
||||
|
||||
def _make_firmware(blocks: int = 2) -> bytes:
|
||||
"""Build a structurally valid firmware blob with ``blocks`` blocks.
|
||||
|
||||
Each block is ``_FW_BLK_SIZE`` bytes: a 4-byte header (page address <= 0xEF
|
||||
followed by the 1/2/3 marker bytes) and a 128-byte payload.
|
||||
"""
|
||||
out = bytearray()
|
||||
for i in range(blocks):
|
||||
out += bytes([i, 1, 2, 3]) + bytes(gsl._FW_BLK_SIZE - 4)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _write_firmware(tmp_path: Path, data: bytes | None = None) -> Path:
|
||||
"""Write firmware bytes to a temp file and return its path."""
|
||||
path = tmp_path / "fw.bin"
|
||||
path.write_bytes(_make_firmware() if data is None else data)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_firmware_data - blob structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_firmware_data_accepts_valid_blob() -> None:
|
||||
"""A correctly structured blob passes validation."""
|
||||
gsl._validate_firmware_data(_make_firmware(3), "test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length", [0, gsl._FW_BLK_SIZE - 1, gsl._FW_BLK_SIZE + 1])
|
||||
def test_validate_firmware_data_rejects_bad_length(length: int) -> None:
|
||||
"""The blob length must be a non-zero multiple of the block size."""
|
||||
with pytest.raises(cv.Invalid, match="length is incorrect"):
|
||||
gsl._validate_firmware_data(bytes(length), "test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"index,value",
|
||||
[
|
||||
(0, 0xF0), # page address must be <= 0xEF
|
||||
(1, 0x00), # marker byte must be 1
|
||||
(2, 0x00), # marker byte must be 2
|
||||
(3, 0x00), # marker byte must be 3
|
||||
],
|
||||
)
|
||||
def test_validate_firmware_data_rejects_corrupted_header(
|
||||
index: int, value: int
|
||||
) -> None:
|
||||
"""A block whose header bytes are wrong is reported as corrupted."""
|
||||
data = bytearray(_make_firmware(2))
|
||||
# Corrupt the header of the second block.
|
||||
data[gsl._FW_BLK_SIZE + index] = value
|
||||
with pytest.raises(cv.Invalid, match="Corrupted firmware at block 1"):
|
||||
gsl._validate_firmware_data(bytes(data), "test")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cache_path / firmware_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cache_path_is_deterministic_per_url(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""The cache path is derived from (and stable for) the URL."""
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
|
||||
)
|
||||
first = gsl._cache_path(VALID_URL)
|
||||
assert first == gsl._cache_path(VALID_URL)
|
||||
assert first != gsl._cache_path("https://example.com/other.bin")
|
||||
assert first.parent == tmp_path
|
||||
|
||||
|
||||
def test_firmware_path_prefers_local_file(tmp_path: Path) -> None:
|
||||
"""A ``file`` source is returned as-is, without consulting the cache."""
|
||||
path = _write_firmware(tmp_path)
|
||||
assert gsl.firmware_path({"file": path}) == path
|
||||
|
||||
|
||||
def test_firmware_path_uses_cache_for_url(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A ``url`` source resolves to the cache path for that URL."""
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
|
||||
)
|
||||
assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_firmware / FIRMWARE_SCHEMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_firmware_requires_exactly_one_source(tmp_path: Path) -> None:
|
||||
"""Supplying both, or neither, of url/file is an error."""
|
||||
path = _write_firmware(tmp_path)
|
||||
with pytest.raises(cv.Invalid, match="Exactly one"):
|
||||
gsl._validate_firmware({"url": VALID_URL, "file": path})
|
||||
with pytest.raises(cv.Invalid, match="Exactly one"):
|
||||
gsl._validate_firmware({})
|
||||
|
||||
|
||||
def test_firmware_file_valid(tmp_path: Path) -> None:
|
||||
"""A valid firmware file passes the full FIRMWARE_SCHEMA."""
|
||||
path = _write_firmware(tmp_path)
|
||||
result = gsl.FIRMWARE_SCHEMA({"file": str(path)})
|
||||
assert result["file"] == path
|
||||
|
||||
|
||||
def test_firmware_file_corrupt_rejected(tmp_path: Path) -> None:
|
||||
"""A file whose contents fail the structural check is rejected."""
|
||||
path = _write_firmware(tmp_path, data=b"\x00" * (gsl._FW_BLK_SIZE * 2))
|
||||
with pytest.raises(cv.Invalid, match="Corrupted firmware"):
|
||||
gsl._validate_firmware({"file": path})
|
||||
|
||||
|
||||
def test_firmware_url_downloads_and_validates(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A url source downloads the content and validates its structure."""
|
||||
data = _make_firmware()
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
|
||||
)
|
||||
monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data)
|
||||
assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL}
|
||||
|
||||
|
||||
def test_firmware_url_sha256_mismatch_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A configured SHA-256 that does not match the download is rejected."""
|
||||
data = _make_firmware()
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
|
||||
)
|
||||
monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data)
|
||||
with pytest.raises(cv.Invalid, match="SHA-256 mismatch"):
|
||||
gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32})
|
||||
|
||||
|
||||
def test_firmware_url_invalid_structure_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Downloaded content that is not a valid blob is rejected."""
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02"
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="length is incorrect"):
|
||||
gsl._validate_firmware({"url": VALID_URL})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CONFIG_SCHEMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _esp32_core(set_core_config: SetCoreConfigCallable) -> None:
|
||||
"""Configure the core as an ESP32 target for the schema tests."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
|
||||
def test_config_custom_model_minimal(tmp_path: Path) -> None:
|
||||
"""The CUSTOM model validates with an explicit firmware file and pins."""
|
||||
fw = _write_firmware(tmp_path)
|
||||
result = gsl.CONFIG_SCHEMA(
|
||||
{
|
||||
"model": "custom",
|
||||
"interrupt_pin": 16,
|
||||
"reset_pin": 4,
|
||||
"firmware": {"file": str(fw)},
|
||||
}
|
||||
)
|
||||
assert result[CONF_MODEL] == "CUSTOM"
|
||||
assert "id" in result
|
||||
# The CUSTOM model supplies no transform/calibration defaults.
|
||||
assert CONF_TRANSFORM not in result
|
||||
assert CONF_CALIBRATION not in result
|
||||
|
||||
|
||||
def test_config_custom_model_requires_firmware() -> None:
|
||||
"""The firmware option is required for the CUSTOM model (no default)."""
|
||||
with pytest.raises(cv.Invalid, match=r"required key not provided.*firmware"):
|
||||
gsl.CONFIG_SCHEMA({"model": "custom", "interrupt_pin": 16, "reset_pin": 4})
|
||||
|
||||
|
||||
def test_config_invalid_model_rejected() -> None:
|
||||
"""An unknown model name is rejected."""
|
||||
with pytest.raises(cv.Invalid, match="model"):
|
||||
gsl.CONFIG_SCHEMA({"model": "nonexistent"})
|
||||
|
||||
|
||||
def test_config_seeed_model_applies_defaults(tmp_path: Path) -> None:
|
||||
"""The SEEED model populates transform and calibration defaults.
|
||||
|
||||
``reset_pin`` is overridden with a plain GPIO so the test does not depend on
|
||||
the model's default IO-expander pin.
|
||||
"""
|
||||
fw = _write_firmware(tmp_path)
|
||||
result = gsl.CONFIG_SCHEMA(
|
||||
{
|
||||
"model": "seeed-reterminal-d1001",
|
||||
"reset_pin": 4,
|
||||
"firmware": {"file": str(fw)},
|
||||
}
|
||||
)
|
||||
assert result[CONF_MODEL] == "SEEED-RETERMINAL-D1001"
|
||||
# Transform defaults from the model.
|
||||
assert result[CONF_TRANSFORM] == {
|
||||
"swap_xy": True,
|
||||
"mirror_x": True,
|
||||
"mirror_y": True,
|
||||
}
|
||||
# Calibration defaults from the model.
|
||||
assert result[CONF_CALIBRATION]["x_min"] == 20
|
||||
assert result[CONF_CALIBRATION]["x_max"] == 872
|
||||
assert result[CONF_CALIBRATION]["y_min"] == 20
|
||||
assert result[CONF_CALIBRATION]["y_max"] == 1644
|
||||
# The interrupt pin default (16) is applied without being specified.
|
||||
assert CONF_INTERRUPT_PIN in result
|
||||
assert CONF_RESET_PIN in result
|
||||
|
||||
|
||||
def test_config_rejects_non_dict() -> None:
|
||||
"""A non-dict configuration is rejected."""
|
||||
with pytest.raises(cv.Invalid, match="expected a dictionary"):
|
||||
gsl.CONFIG_SCHEMA("not a dict")
|
||||
@@ -0,0 +1,28 @@
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
|
||||
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
|
||||
|
||||
xl9535:
|
||||
id: expander
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
spi_id: spi_bus
|
||||
model: t-display-s3-pro
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
touchscreen:
|
||||
# Firmware downloaded from the model's default release URL and cached.
|
||||
- platform: gsl3670
|
||||
model: seeed-reterminal-d1001
|
||||
interrupt_pin: 18
|
||||
# Explicit firmware URL + SHA-256 override.
|
||||
- platform: gsl3670
|
||||
model: seeed-reterminal-d1001
|
||||
reset_pin: 10
|
||||
interrupt_pin: 11
|
||||
firmware:
|
||||
url: https://github.com/esphome-libs/gsl3670-firmware/releases/download/v1.0.0/seeed-d1001-fw.bin
|
||||
sha256: 2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4
|
||||
Reference in New Issue
Block a user