mirror of
https://github.com/esphome/esphome.git
synced 2026-09-19 02:58:38 +00:00
[gsl3670] Add new touchscreen component (#16285)
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user