mirror of
https://github.com/home-assistant/core.git
synced 2026-05-17 14:01:34 +01:00
219 lines
7.3 KiB
Python
219 lines
7.3 KiB
Python
"""Tests for the ESPHome serial proxy helper."""
|
|
|
|
from unittest.mock import AsyncMock, call, patch
|
|
|
|
from aioesphomeapi import APIClient
|
|
from aioesphomeapi.model import SerialProxyInfo, SerialProxyPortType
|
|
import pytest
|
|
from serialx.platforms.serial_esphome import InvalidSettingsError
|
|
from yarl import URL
|
|
|
|
from homeassistant.components.esphome import _async_scan_serial_ports, serial_proxy
|
|
from homeassistant.components.esphome.const import DOMAIN
|
|
from homeassistant.components.usb import SerialDevice
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.setup import async_setup_component
|
|
|
|
from .conftest import MockESPHomeDeviceType
|
|
|
|
from tests.common import MockConfigEntry
|
|
|
|
|
|
def test_build_url_basic() -> None:
|
|
"""Build a URL with a simple port name."""
|
|
url = serial_proxy.build_url("abc123DEF456", "uart0")
|
|
assert url == URL("esphome-hass://esphome/abc123DEF456?port_name=uart0")
|
|
|
|
|
|
def test_build_url_escapes_port_name() -> None:
|
|
"""Port names with special characters are URL-encoded."""
|
|
url = serial_proxy.build_url("abc123", "uart 0/main")
|
|
# Round-trip via yarl recovers the original port name
|
|
assert URL(str(url)).query["port_name"] == "uart 0/main"
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_zeroconf")
|
|
async def test_async_setup_stores_event_loop(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""async_setup registers hass.loop on the serial_proxy module."""
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
assert serial_proxy._HASS_LOOP is hass.loop
|
|
|
|
|
|
async def test_resolve_client_unknown_entry(hass: HomeAssistant) -> None:
|
|
"""An unknown entry_id raises InvalidSettingsError."""
|
|
with (
|
|
patch.object(serial_proxy, "async_get_hass", return_value=hass),
|
|
pytest.raises(InvalidSettingsError),
|
|
):
|
|
await serial_proxy._resolve_client("does-not-exist")
|
|
|
|
|
|
async def test_resolve_client_wrong_domain(hass: HomeAssistant) -> None:
|
|
"""A config entry from a different domain raises InvalidSettingsError."""
|
|
entry = MockConfigEntry(domain="other", data={})
|
|
entry.add_to_hass(hass)
|
|
|
|
with (
|
|
patch.object(serial_proxy, "async_get_hass", return_value=hass),
|
|
pytest.raises(InvalidSettingsError),
|
|
):
|
|
await serial_proxy._resolve_client(entry.entry_id)
|
|
|
|
|
|
async def test_resolve_client_unloaded_entry(hass: HomeAssistant) -> None:
|
|
"""An ESPHome entry that isn't loaded raises InvalidSettingsError."""
|
|
entry = MockConfigEntry(domain=DOMAIN, data={})
|
|
entry.add_to_hass(hass)
|
|
|
|
with (
|
|
patch.object(serial_proxy, "async_get_hass", return_value=hass),
|
|
pytest.raises(InvalidSettingsError),
|
|
):
|
|
await serial_proxy._resolve_client(entry.entry_id)
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_zeroconf")
|
|
async def test_resolve_client_loaded_entry(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""A loaded ESPHome entry returns its APIClient."""
|
|
device = await mock_esphome_device(mock_client=mock_client)
|
|
|
|
with patch.object(serial_proxy, "async_get_hass", return_value=hass):
|
|
client = await serial_proxy._resolve_client(device.entry.entry_id)
|
|
|
|
assert client is mock_client
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_zeroconf")
|
|
async def test_scan_serial_ports_no_entries(hass: HomeAssistant) -> None:
|
|
"""No loaded ESPHome entries yields no ports."""
|
|
assert _async_scan_serial_ports(hass) == []
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_zeroconf")
|
|
async def test_scan_serial_ports_happy_path(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""A loaded entry with serial proxies emits a SerialDevice per proxy."""
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info={
|
|
"mac_address": "AA:BB:CC:DD:EE:FF",
|
|
"manufacturer": "Espressif",
|
|
"model": "ESP32",
|
|
"serial_proxies": [
|
|
SerialProxyInfo(name="Left Port", port_type=SerialProxyPortType.TTL),
|
|
SerialProxyInfo(name="Right Port", port_type=SerialProxyPortType.TTL),
|
|
],
|
|
},
|
|
)
|
|
|
|
ports = _async_scan_serial_ports(hass)
|
|
|
|
entry_id = device.entry.entry_id
|
|
assert ports == [
|
|
SerialDevice(
|
|
device=str(serial_proxy.build_url(entry_id, "Left Port")),
|
|
serial_number="AABBCCDDEEFF-left_port",
|
|
manufacturer="Espressif",
|
|
description="ESP32 (Left Port)",
|
|
),
|
|
SerialDevice(
|
|
device=str(serial_proxy.build_url(entry_id, "Right Port")),
|
|
serial_number="AABBCCDDEEFF-right_port",
|
|
manufacturer="Espressif",
|
|
description="ESP32 (Right Port)",
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_zeroconf")
|
|
async def test_scan_serial_ports_skips_unavailable(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Unavailable entries are skipped by the scanner."""
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info={
|
|
"serial_proxies": [
|
|
SerialProxyInfo(name="uart0", port_type=SerialProxyPortType.TTL)
|
|
],
|
|
},
|
|
)
|
|
# Mark the entry as unavailable
|
|
device.entry.runtime_data.available = False
|
|
|
|
assert _async_scan_serial_ports(hass) == []
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_zeroconf")
|
|
async def test_async_open_missing_host(hass: HomeAssistant) -> None:
|
|
"""A URL with an invalid entry_id raises InvalidSettingsError."""
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
proxy = serial_proxy.HassESPHomeSerial("esphome-hass://unknown/?port_name=uart0")
|
|
|
|
with pytest.raises(InvalidSettingsError):
|
|
await proxy._async_open()
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_zeroconf")
|
|
async def test_async_open_missing_port_name(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""A URL with a missing port name raises InvalidSettingsError."""
|
|
assert await async_setup_component(hass, DOMAIN, {})
|
|
|
|
device = await mock_esphome_device(
|
|
mock_client=mock_client,
|
|
device_info={
|
|
"mac_address": "AA:BB:CC:DD:EE:FF",
|
|
"manufacturer": "Espressif",
|
|
"model": "ESP32",
|
|
"serial_proxies": [
|
|
SerialProxyInfo(name="uart0", port_type=SerialProxyPortType.TTL),
|
|
],
|
|
},
|
|
)
|
|
|
|
entry_id = device.entry.entry_id
|
|
proxy = serial_proxy.HassESPHomeSerial(f"esphome-hass://{entry_id}")
|
|
|
|
with pytest.raises(InvalidSettingsError):
|
|
await proxy._async_open()
|
|
|
|
|
|
@pytest.mark.usefixtures("mock_zeroconf")
|
|
async def test_async_open_happy_path(
|
|
hass: HomeAssistant,
|
|
mock_client: APIClient,
|
|
mock_esphome_device: MockESPHomeDeviceType,
|
|
) -> None:
|
|
"""Happy path sets _api from the loaded entry and applies port_name from query."""
|
|
device = await mock_esphome_device(mock_client=mock_client)
|
|
mock_client._loop = hass.loop
|
|
|
|
url = str(serial_proxy.build_url(device.entry.entry_id, "uart0"))
|
|
proxy = serial_proxy.HassESPHomeSerial(url)
|
|
|
|
with patch(
|
|
"homeassistant.components.esphome.serial_proxy.ESPHomeSerial._async_open",
|
|
AsyncMock(),
|
|
) as mock_super_open:
|
|
await proxy._async_open()
|
|
|
|
assert proxy._api is mock_client
|
|
assert proxy._port_name == "uart0"
|
|
assert proxy._client_loop is hass.loop
|
|
assert mock_super_open.mock_calls == [call()]
|