Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ repository = "https://github.com/puddly/serialx"
documentation = "https://puddly.github.io/serialx/"

[project.optional-dependencies]
esphome = ["aioesphomeapi>=46.12.0; python_version >= '3.11'"]
esphome = ["aioesphomeapi>=46.21.0; python_version >= '3.11'"]

[dependency-groups]
dev = [
Expand All @@ -49,7 +49,7 @@ dev = [
"types-pywin32>=311.0.0.20260508",
"types-setuptools>=82.0.0.20260508",
"setuptools-rust>=1.7.0",
"aioesphomeapi>=46.12.0 ; python_version >= '3.11'",
"aioesphomeapi>=46.21.0 ; python_version >= '3.11'",
]
docs = [
"sphinx>=7,<8.2.3; python_version < '3.11'",
Expand Down
29 changes: 27 additions & 2 deletions serialx/platforms/serial_esphome.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,15 @@
from aioesphomeapi.client import MIN_VERSION_PROXY_ACK, APIClient
from aioesphomeapi.core import ( # type: ignore[attr-defined]
APIConnectionError,
ConnectionNotEstablishedAPIError,
HandshakeAPIError,
PingFailedAPIError,
PingRequest,
PingResponse,
ReadFailedAPIError,
ResolveAPIError,
ResolveTimeoutAPIError,
SocketAPIError,
TimeoutAPIError,
)
from aioesphomeapi.model import (
Expand Down Expand Up @@ -212,9 +219,27 @@ def translate_esphome_errors(
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
try:
return await func(*args, **kwargs)
except TimeoutAPIError as exc:
except (TimeoutAPIError, ResolveTimeoutAPIError) as exc:
raise TimeoutError(str(exc)) from exc
except (
ConnectionNotEstablishedAPIError,
SocketAPIError,
ResolveAPIError,
ReadFailedAPIError,
PingFailedAPIError,
) as exc:
raise ConnectionError(str(exc)) from exc
except APIConnectionError as exc:
# A clean disconnect (device reboot, `api.disconnect()`) fails pending
# requests with the bare base class.
if type(exc) is APIConnectionError:
raise ConnectionError(str(exc)) from exc

# A socket error mid-handshake is wrapped in the bare `HandshakeAPIError`,
# unlike its subclasses, which report encryption misconfiguration.
if type(exc) is HandshakeAPIError and isinstance(exc.__cause__, OSError):
raise ConnectionError(str(exc)) from exc

raise SerialException(str(exc)) from exc

return cast(Callable[_P, Coroutine[Any, Any, _T]], wrapper)
Expand Down Expand Up @@ -1215,7 +1240,7 @@ async def _connect(
# Nothing is replayed for a connection that closed while we were setting
# up, so the state has to be re-checked once the callback is installed.
if not self._serial._api.is_connected:
raise SerialException("ESPHome API connection closed while connecting")
raise ConnectionError("ESPHome API connection closed while connecting")

await self._serial._async_configure_port()

Expand Down
170 changes: 170 additions & 0 deletions tests/test_serial_esphome.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@
import urllib.parse
import warnings

from aioesphomeapi.core import (
APIConnectionError,
BadMACAddressAPIError,
BadNameAPIError,
ConnectionNotEstablishedAPIError,
EncryptionHelloAPIError,
HandshakeAPIError,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
PingFailedAPIError,
ProtocolAPIError,
ReadFailedAPIError,
RequiresEncryptionAPIError,
ResolveAPIError,
ResolveTimeoutAPIError,
SocketAPIError,
SocketClosedAPIError,
TimeoutAPIError,
)
from aioesphomeapi.model import (
APIVersion,
DeviceInfo,
Expand All @@ -44,6 +63,7 @@
async_serial_for_url,
create_serial_connection,
list_serial_ports,
open_serial_connection,
redact_uri,
udev_serial_by_id_stem,
)
Expand Down Expand Up @@ -341,6 +361,131 @@ async def test_connect_timeout_raises_timeout_error() -> None:
pass


@pytest.mark.parametrize("connection_exists", [False, True])
async def test_open_disconnected_api_raises_connection_error(
connection_exists: bool,
) -> None:
"""An offline externally managed API is an I/O failure callers can retry."""
api = APIClient(address="127.0.0.1", port=ESPHOME_DEFAULT_PORT, password=None)
if connection_exists:
await api.start_resolve_host(log_errors=False)

with pytest.raises(
ConnectionError, match="Not connected|Authenticated connection not ready"
) as exc_info:
await open_serial_connection(
url=None,
transport_cls=ESPHomeSerialTransport,
api=api,
port_name="test",
baudrate=115200,
)

assert isinstance(exc_info.value.__cause__, ConnectionNotEstablishedAPIError)
assert not api.is_connected


@pytest.mark.parametrize(
("error", "expected_type"),
[
(APIConnectionError("Connection closed"), ConnectionError),
(ConnectionNotEstablishedAPIError("Not ready"), ConnectionError),
(SocketAPIError("Connection refused"), ConnectionError),
(SocketClosedAPIError("Connection closed"), ConnectionError),
(ResolveAPIError("Host unavailable"), ConnectionError),
(ReadFailedAPIError("Read failed"), ConnectionError),
(PingFailedAPIError("Ping failed"), ConnectionError),
(TimeoutAPIError("Connect timed out"), TimeoutError),
(ResolveTimeoutAPIError("Resolve timed out"), TimeoutError),
(InvalidAuthAPIError("Invalid password"), SerialException),
(InvalidEncryptionKeyAPIError("Invalid key"), SerialException),
(HandshakeAPIError("Invalid handshake"), SerialException),
(ProtocolAPIError("Invalid protocol"), SerialException),
(RequiresEncryptionAPIError("Encryption required"), SerialException),
(BadNameAPIError("Wrong device", "other-device"), SerialException),
(
BadMACAddressAPIError("Wrong device", "other-device", "00:11:22:33:44:55"),
SerialException,
),
],
)
async def test_open_api_error_classification(
error: APIConnectionError, expected_type: type[Exception]
) -> None:
"""Opening preserves the distinction between transport and protocol failures."""
with patch.object(APIClient, "connect", side_effect=error):
with pytest.raises(expected_type) as exc_info:
await open_serial_connection(
url="esphome://127.0.0.1:6053?port_name=test", baudrate=115200
)

assert type(exc_info.value) is expected_type
assert exc_info.value.__cause__ is error


@pytest.mark.parametrize(
("error", "cause", "expected_type"),
[
(
HandshakeAPIError("Handshake failed"),
ConnectionResetError(),
ConnectionError,
),
(HandshakeAPIError("Handshake failed"), ValueError(), SerialException),
# aioesphomeapi reports this as a likely encryption mismatch
(
EncryptionHelloAPIError("Dropped after hello"),
ConnectionResetError(),
SerialException,
),
(
InvalidEncryptionKeyAPIError("Invalid key"),
ConnectionResetError(),
SerialException,
),
],
)
async def test_open_handshake_error_classification(
error: HandshakeAPIError, cause: Exception, expected_type: type[Exception]
) -> None:
"""Only a socket error wrapped in the bare `HandshakeAPIError` is retryable."""
error.__cause__ = cause

with patch.object(APIClient, "connect", side_effect=error):
with pytest.raises(expected_type) as exc_info:
await open_serial_connection(
url="esphome://127.0.0.1:6053?port_name=test", baudrate=115200
)

assert type(exc_info.value) is expected_type
assert exc_info.value.__cause__ is error


async def test_open_api_disconnect_during_setup() -> None:
"""A connection lost before setup completes is still an I/O failure."""
api = mock_api_client("test")
api.is_connected = True
unsub = MagicMock()

def disconnect_during_setup(*args: object) -> MagicMock:
api.is_connected = False
return unsub

api.subscribe_serial_proxy_data.side_effect = disconnect_during_setup

with pytest.raises(ConnectionError, match="closed while connecting"):
await open_serial_connection(
url=None,
transport_cls=ESPHomeSerialTransport,
api=api,
port_name="test",
baudrate=115200,
)

unsub.assert_called_once_with()
api.disconnect.assert_not_called()


@pytest.mark.skipif(not ESPHOME_HOST_BINARY, reason="esphome host binary not available")
async def test_noise_psk_key_alias() -> None:
"""Test that connecting without encryption to an encrypted server raises."""
Expand Down Expand Up @@ -770,6 +915,31 @@ async def subscribe(instance: int, *, timeout: float) -> None:
await transport.wait_closed()


async def test_flush_clean_disconnect_raises_connection_error() -> None:
"""A device-initiated disconnect (e.g. reboot) fails a pending flush retryably."""
api = mock_api_client("Zigbee")
error = APIConnectionError("Connection closed")
api.attach_mock(AsyncMock(side_effect=error), "serial_proxy_flush")

transport, _ = await create_serial_connection(
asyncio.get_running_loop(),
asyncio.Protocol,
url=None,
baudrate=115200,
transport_cls=ESPHomeSerialTransport,
api=api,
port_name="Zigbee",
)

with pytest.raises(ConnectionError, match="Connection closed") as exc_info:
await transport.flush()

assert exc_info.value.__cause__ is error

transport.close()
await transport.wait_closed()


async def test_mode_protocol_kwarg_with_external_api() -> None:
"""The `mode` kwarg is honored when the API client is passed in externally."""
api = mock_api_client("Zigbee")
Expand Down
Loading
Loading