[gsl3670] Add new touchscreen component (#16285)

This commit is contained in:
Clyde Stubbs
2026-07-09 10:04:43 +10:00
committed by GitHub
parent 7c130fc970
commit 8ccf0dbd37
9 changed files with 780 additions and 25 deletions
+1
View File
@@ -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
+1
View File
@@ -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
+209
View File
@@ -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))
+64 -25
View File
@@ -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),
+260
View File
@@ -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