Files
esphome/tests/component_tests/vnc/test_init.py
T
18348b8794 [vnc] Add VNC client component (#20623)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-21 21:14:37 -10:00

407 lines
9.6 KiB
Python

"""Code generation and validation tests for the vnc component."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from esphome.config import read_config
from esphome.core import CORE
TEST_CARD = "show_test_card()"
UPDATE_NEVER = "set_update_interval(4294967295UL)"
BASE = """
esphome:
name: vnc-test
esp32:
board: esp32dev
logger:
wifi:
ssid: MySSID
password: password1
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
"""
IT8951 = """
display:
- platform: it8951
id: epd
model: m5stack-m5paper
"""
VNC = """
vnc:
- host: 192.168.1.20
"""
EPAPER_SPI = """
- platform: epaper_spi
id: other
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO25
"""
S3_BASE = """
esphome:
name: vnc-test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
wifi:
ssid: MySSID
password: password1
"""
RPI_DPI_RGB = """
display:
- platform: rpi_dpi_rgb
id: rgb
dimensions:
width: 800
height: 480
de_pin: 40
hsync_pin: 39
vsync_pin: 41
pclk_pin: 42
data_pins:
red: [45, 48, 47, 21, 14]
green: [5, 6, 7, 15, 16, 4]
blue: [8, 3, 46, 9, 1]
"""
OPENTHREAD_BASE = """
esphome:
name: vnc-test
esp32:
variant: esp32c6
framework:
type: esp-idf
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
spi:
clk_pin: GPIO6
mosi_pin: GPIO7
"""
C6_EPAPER = """
display:
- platform: epaper_spi
id: epd
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO4
busy_pin: GPIO3
reset_pin: GPIO2
"""
@pytest.fixture(autouse=True)
def _clear_toolchain() -> None:
# A host build earlier in the same session leaves the host toolchain selected.
CORE.toolchain = None
@pytest.fixture
def write_yaml(tmp_path: Path) -> Callable[..., Path]:
def writer(*parts: str, base: str = BASE) -> Path:
path = tmp_path / "vnc.yaml"
path.write_text(base + "".join(parts), encoding="utf-8")
return path
return writer
@pytest.fixture
def config_error(
write_yaml: Callable[..., Path], capsys: pytest.CaptureFixture[str]
) -> Callable[..., str]:
"""Validate a config that must fail and return the printed errors."""
def validate(*parts: str, base: str = BASE) -> str:
CORE.config_path = write_yaml(*parts, base=base)
assert read_config({}) is None
return capsys.readouterr().out
return validate
def test_it8951_owned_by_vnc(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(write_yaml(IT8951, VNC))
assert TEST_CARD not in main_cpp
assert f"epd->{UPDATE_NEVER}" in main_cpp
assert re.search(r"new\(\w+\) vnc::VncClient\(epd, \w+\);", main_cpp)
assert 'set_host("192.168.1.20");' in main_cpp
assert "set_port(5900);" in main_cpp
assert "set_reconnect_interval(5000);" in main_cpp
assert "set_update_delay(200);" in main_cpp
assert "set_max_update_delay(1000);" in main_cpp
def test_epaper_spi_owned_by_vnc(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml("display:", EPAPER_SPI, "\nvnc:\n - host: 192.168.1.20\n")
)
assert TEST_CARD not in main_cpp
assert f"other->{UPDATE_NEVER}" in main_cpp
assert re.search(r"new\(\w+\) vnc::VncClient\(other, \w+\);", main_cpp)
def test_display_with_generated_id(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml("display:\n - platform: it8951\n model: m5stack-m5paper\n", VNC)
)
assert TEST_CARD not in main_cpp
assert UPDATE_NEVER in main_cpp
assert "vnc::VncClient(" in main_cpp
def test_only_the_vnc_display_loses_test_card(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml(
IT8951, EPAPER_SPI, "\nvnc:\n - host: 192.168.1.20\n display_id: epd\n"
)
)
assert f"other->{TEST_CARD}" in main_cpp
assert f"epd->{TEST_CARD}" not in main_cpp
assert f"epd->{UPDATE_NEVER}" in main_cpp
def test_options(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml(
IT8951,
"""
vnc:
- host: vnc.local
port: 5901
reconnect_interval: 2s
update_delay: 0ms
max_update_delay: 3s
on_connect:
- logger.log: up
on_disconnect:
- logger.log: down
""",
)
)
assert 'set_host("vnc.local");' in main_cpp
assert "set_port(5901);" in main_cpp
assert "set_reconnect_interval(2000);" in main_cpp
assert "set_update_delay(0);" in main_cpp
assert "set_max_update_delay(3000);" in main_cpp
assert "add_on_connect_callback(" in main_cpp
assert "add_on_disconnect_callback(" in main_cpp
def test_two_clients_on_two_displays(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
main_cpp = generate_main(
write_yaml(
IT8951,
EPAPER_SPI,
"""
vnc:
- host: 192.168.1.20
display_id: epd
- host: 192.168.1.21
display_id: other
""",
)
)
assert re.search(r"vnc::VncClient\(epd, \w+\);", main_cpp)
assert re.search(r"vnc::VncClient\(other, \w+\);", main_cpp)
assert TEST_CARD not in main_cpp
@pytest.mark.parametrize(
("display_options", "error"),
[
pytest.param(
" lambda: |-\n it.fill(Color::BLACK);\n",
"vnc draws the whole of display 'epd'",
id="lambda",
),
pytest.param(
" pages:\n - id: page1\n lambda: |-\n"
" it.fill(Color::BLACK);\n",
"vnc draws the whole of display 'epd'",
id="pages",
),
pytest.param(
" show_test_card: true\n",
"vnc draws the whole of display 'epd'",
id="show_test_card",
),
pytest.param(
" auto_clear_enabled: true\n",
"vnc draws the whole of display 'epd'",
id="auto_clear_enabled",
),
],
)
def test_display_with_own_writer_rejected(
display_options: str, error: str, config_error: Callable[..., str]
) -> None:
assert error in config_error(IT8951, display_options, VNC)
def test_lvgl_on_same_display_rejected(config_error: Callable[..., str]) -> None:
out = config_error(IT8951, VNC, "\nlvgl:\n displays: [epd]\n")
assert "Display 'epd' is also used by LVGL" in out
def test_lvgl_on_other_display_accepted(
write_yaml: Callable[..., Path],
) -> None:
CORE.config_path = write_yaml(
IT8951,
EPAPER_SPI,
"\nvnc:\n - host: 192.168.1.20\n display_id: epd\n",
"\nlvgl:\n displays: [other]\n",
)
assert read_config({}) is not None
def test_two_clients_on_one_display_rejected(
config_error: Callable[..., str],
) -> None:
out = config_error(
IT8951, "\nvnc:\n - host: 192.168.1.20\n - host: 192.168.1.21\n"
)
assert "Display 'epd' is drawn by more than one component: vnc, vnc" in out
def test_mipi_spi_rejected(config_error: Callable[..., str]) -> None:
out = config_error(
"""
display:
- platform: mipi_spi
id: epd
model: st7735
cs_pin: GPIO15
dc_pin: GPIO27
reset_pin: GPIO26
""",
VNC,
)
assert "Display 'epd' uses the mipi_spi platform" in out
def test_rotated_rpi_dpi_rgb_accepted(write_yaml: Callable[..., Path]) -> None:
CORE.config_path = write_yaml(RPI_DPI_RGB, " rotation: 90\n", VNC, base=S3_BASE)
assert read_config({}) is not None
def test_ipv4_kept_on_with_openthread(
generate_main: Callable[[str | Path], str], write_yaml: Callable[..., Path]
) -> None:
generate_main(write_yaml(C6_EPAPER, VNC, base=OPENTHREAD_BASE))
define = next(d for d in CORE.defines if d.name == "USE_NETWORK_IPV4")
assert str(define.value) == "true"
def test_ipv4_disabled_rejected(config_error: Callable[..., str]) -> None:
out = config_error(
"network:\n enable_ipv4: false\n", C6_EPAPER, VNC, base=OPENTHREAD_BASE
)
assert "IPv4 is required by vnc" in out
def test_waveshare_epaper_rejected(config_error: Callable[..., str]) -> None:
out = config_error(
"""
display:
- platform: waveshare_epaper
id: epd
model: 1.54in
cs_pin: GPIO15
dc_pin: GPIO27
busy_pin: GPIO26
reset_pin: GPIO25
""",
VNC,
)
assert "Display 'epd' uses the waveshare_epaper platform" in out
assert "use epaper_spi" in out
@pytest.mark.parametrize(
("vnc_options", "error"),
[
pytest.param(
" reconnect_interval: 500ms\n",
"value must be at least 1s",
id="reconnect",
),
pytest.param(" port: 0\n", "value must be at least 1", id="port"),
],
)
def test_option_rejected(
vnc_options: str, error: str, config_error: Callable[..., str]
) -> None:
out = config_error(IT8951, VNC, vnc_options)
assert error in out
@pytest.mark.parametrize("host", ["::1", "fe80::1", "2001:db8::10"])
def test_ipv6_host_rejected(host: str, config_error: Callable[..., str]) -> None:
out = config_error(IT8951, f'\nvnc:\n - host: "{host}"\n')
assert "IPv6 addresses are not supported" in out