"""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.setenv("ESPHOME_DATA_DIR", str(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 / "gsl3670" 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.setenv("ESPHOME_DATA_DIR", str(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.setenv("ESPHOME_DATA_DIR", str(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.setenv("ESPHOME_DATA_DIR", str(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.setenv("ESPHOME_DATA_DIR", str(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_guition_model_applies_defaults(tmp_path: Path) -> None: """The GUITION model populates transform and calibration defaults.""" fw = _write_firmware(tmp_path) result = gsl.CONFIG_SCHEMA( { "model": "guition-jc8012p4a1", "firmware": {"file": str(fw)}, } ) assert result[CONF_MODEL] == "GUITION-JC8012P4A1" # Transform defaults from the model. assert result[CONF_TRANSFORM] == { "swap_xy": True, "mirror_x": True, "mirror_y": False, } # Calibration defaults from the model. assert result[CONF_CALIBRATION]["x_min"] == 20 assert result[CONF_CALIBRATION]["x_max"] == 880 assert result[CONF_CALIBRATION]["y_min"] == 20 assert result[CONF_CALIBRATION]["y_max"] == 1648 assert result[CONF_INTERRUPT_PIN]["number"] == 21 assert result[CONF_RESET_PIN]["number"] == 22 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")