Skip to content
Draft
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
23 changes: 21 additions & 2 deletions serialx/platforms/serial_esphome.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,14 @@
from aioesphomeapi.client import MIN_VERSION_PROXY_ACK, APIClient
from aioesphomeapi.core import ( # type: ignore[attr-defined]
APIConnectionError,
ConnectionNotEstablishedAPIError,
PingFailedAPIError,
PingRequest,
PingResponse,
ReadFailedAPIError,
ResolveAPIError,
ResolveTimeoutAPIError,
SocketAPIError,
TimeoutAPIError,
)
from aioesphomeapi.model import (
Expand Down Expand Up @@ -211,9 +217,22 @@ 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:
# The client raises the base class when it is not connected. Only
# transport failures should become I/O errors: authentication and
# protocol errors also inherit from APIConnectionError.
if type(exc) is APIConnectionError:
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 @@ -1214,7 +1233,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
100 changes: 100 additions & 0 deletions tests/test_serial_esphome.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@
import urllib.parse
import warnings

from aioesphomeapi.core import (
APIConnectionError,
BadMACAddressAPIError,
BadNameAPIError,
ConnectionNotEstablishedAPIError,
HandshakeAPIError,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
PingFailedAPIError,
ProtocolAPIError,
ReadFailedAPIError,
RequiresEncryptionAPIError,
ResolveAPIError,
ResolveTimeoutAPIError,
SocketAPIError,
SocketClosedAPIError,
TimeoutAPIError,
)
from aioesphomeapi.model import (
APIVersion,
DeviceInfo,
Expand All @@ -44,6 +62,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 +360,87 @@ async def test_connect_timeout_raises_timeout_error() -> None:
pass


async def test_open_disconnected_api_raises_connection_error() -> 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)

with pytest.raises(OSError, match="Not connected") as exc_info:
await open_serial_connection(
url=None,
transport_cls=ESPHomeSerialTransport,
api=api,
port_name="test",
baudrate=115200,
)

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


@pytest.mark.parametrize(
("error", "expected_type"),
[
(APIConnectionError("Not connected"), 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


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
Loading