From 35415f79f7ea9943c34c3ba640409d6abaec5902 Mon Sep 17 00:00:00 2001 From: Andrzej Pomirski Date: Fri, 27 Mar 2026 00:48:10 +0100 Subject: [PATCH 1/5] Add configurable handshake timeout to replace lazy_discover Some devices need a recent handshake to respond, but the current lazy_discover flag only offers two extremes: handshake once (True) or before every request (False). This adds a handshake_timeout parameter (in seconds) that re-handshakes only when enough time has passed since the last one. The lazy_discover flag is preserved for backward compatibility and translated into the new timeout internally. Addresses #1683 --- miio/device.py | 9 ++++- miio/miioprotocol.py | 43 ++++++++++++++++++++- miio/tests/test_handshake_timeout.py | 58 ++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 miio/tests/test_handshake_timeout.py diff --git a/miio/device.py b/miio/device.py index 06aa895cc..097c284bb 100644 --- a/miio/device.py +++ b/miio/device.py @@ -57,6 +57,7 @@ def __init__( timeout: int | None = None, *, model: str | None = None, + handshake_timeout: int | None = None, ) -> None: self.ip = ip self.token: str | None = token @@ -68,7 +69,13 @@ def __init__( timeout = timeout if timeout is not None else self.timeout self._debug = debug self._protocol = MiIOProtocol( - ip, token, start_id, debug, lazy_discover, timeout + ip, + token, + start_id, + debug, + lazy_discover, + timeout, + handshake_timeout=handshake_timeout, ) def send( diff --git a/miio/miioprotocol.py b/miio/miioprotocol.py index 21614f0a7..3db3d7c10 100644 --- a/miio/miioprotocol.py +++ b/miio/miioprotocol.py @@ -34,6 +34,8 @@ def __init__( debug: int = 0, lazy_discover: bool = True, timeout: int = 5, + *, + handshake_timeout: Optional[int] = None, ) -> None: """Create a :class:`Device` instance. @@ -41,6 +43,12 @@ def __init__( :param token: Token used for encryption :param start_id: Running message id sent to the device :param debug: Wanted debug level + :param lazy_discover: If True, only handshake once on first request. + If False, handshake before every request. Deprecated in favor of + handshake_timeout, which allows finer control. + :param handshake_timeout: How many seconds can pass before a new handshake + is needed. Set to 0 to handshake before every request. When not set, + falls back to the lazy_discover behavior for backward compatibility. """ self.ip = ip self.port = 54321 @@ -48,11 +56,20 @@ def __init__( token = 32 * "0" self.token = bytes.fromhex(token) self.debug = debug - self.lazy_discover = lazy_discover self._timeout = timeout self.__id = start_id + if handshake_timeout is not None: + self._handshake_timeout: Optional[timedelta] = timedelta( + seconds=handshake_timeout + ) + elif lazy_discover: + self._handshake_timeout = None # handshake once, never re-handshake + else: + self._handshake_timeout = timedelta(seconds=0) # every request + self._discovered = False + self._last_handshake: Optional[datetime] = None # these come from the device, but we initialize them here to make mypy happy self._device_ts: datetime = datetime.now(tz=UTC) self._device_id = b"" @@ -84,6 +101,7 @@ def send_handshake(self, *, retry_count=3) -> Message: self._device_id = header.device_id self._device_ts = header.ts self._discovered = True + self._last_handshake = datetime.now(tz=timezone.utc) if self.debug > 1: _LOGGER.debug(m) @@ -96,6 +114,27 @@ def send_handshake(self, *, retry_count=3) -> Message: return m + def _needs_handshake(self) -> bool: + """Return True if a handshake is needed before sending. + + The decision is based on _handshake_timeout, which is set in __init__ + either from the explicit handshake_timeout parameter, or derived from + the legacy lazy_discover flag: + - lazy_discover=True -> _handshake_timeout=None (handshake once) + - lazy_discover=False -> _handshake_timeout=0s (every request) + """ + if not self._discovered: + return True + + if self._handshake_timeout is None: + return False + + if self._last_handshake is None: + return True + + elapsed = datetime.now(tz=timezone.utc) - self._last_handshake + return elapsed >= self._handshake_timeout + @staticmethod def discover(addr: str | None = None, timeout: int = 5) -> Any: """Scan for devices in the network. This method is used to discover supported @@ -164,7 +203,7 @@ def send( :raises DeviceException: if an error has occurred during communication. """ - if not self.lazy_discover or not self._discovered: + if self._needs_handshake(): self.send_handshake() request = self._create_request(command, parameters, extra_parameters) diff --git a/miio/tests/test_handshake_timeout.py b/miio/tests/test_handshake_timeout.py new file mode 100644 index 000000000..bce04c193 --- /dev/null +++ b/miio/tests/test_handshake_timeout.py @@ -0,0 +1,58 @@ +from datetime import datetime, timedelta, timezone + +from miio.miioprotocol import MiIOProtocol + + +class TestNeedsHandshake: + """Tests for _needs_handshake with different configurations.""" + + def test_always_needs_handshake_when_not_discovered(self) -> None: + proto = MiIOProtocol(handshake_timeout=3600) + assert proto._discovered is False + assert proto._needs_handshake() is True + + def test_lazy_discover_true_no_rehandshake(self) -> None: + """lazy_discover=True: after first handshake, never re-handshake.""" + proto = MiIOProtocol(lazy_discover=True) + proto._discovered = True + proto._last_handshake = datetime.now(tz=timezone.utc) - timedelta(hours=24) + assert proto._needs_handshake() is False + + def test_lazy_discover_false_always_rehandshake(self) -> None: + """lazy_discover=False: always re-handshake (timeout=0).""" + proto = MiIOProtocol(lazy_discover=False) + proto._discovered = True + proto._last_handshake = datetime.now(tz=timezone.utc) + assert proto._needs_handshake() is True + + def test_handshake_timeout_not_expired(self) -> None: + proto = MiIOProtocol(handshake_timeout=60) + proto._discovered = True + proto._last_handshake = datetime.now(tz=timezone.utc) - timedelta(seconds=30) + assert proto._needs_handshake() is False + + def test_handshake_timeout_expired(self) -> None: + proto = MiIOProtocol(handshake_timeout=60) + proto._discovered = True + proto._last_handshake = datetime.now(tz=timezone.utc) - timedelta(seconds=90) + assert proto._needs_handshake() is True + + def test_handshake_timeout_zero_always_rehandshake(self) -> None: + proto = MiIOProtocol(handshake_timeout=0) + proto._discovered = True + proto._last_handshake = datetime.now(tz=timezone.utc) + assert proto._needs_handshake() is True + + def test_handshake_timeout_overrides_lazy_discover(self) -> None: + """Explicit handshake_timeout takes precedence over lazy_discover.""" + proto = MiIOProtocol(lazy_discover=True, handshake_timeout=10) + proto._discovered = True + proto._last_handshake = datetime.now(tz=timezone.utc) - timedelta(seconds=20) + assert proto._needs_handshake() is True + + def test_no_last_handshake_recorded(self) -> None: + """Should need handshake if discovered but no timestamp recorded.""" + proto = MiIOProtocol(handshake_timeout=60) + proto._discovered = True + proto._last_handshake = None + assert proto._needs_handshake() is True From 4314e3526ec675830bdc0da51b8721605b2194b6 Mon Sep 17 00:00:00 2001 From: Andrzej Pomirski Date: Fri, 27 Mar 2026 01:48:03 +0100 Subject: [PATCH 2/5] Add tests covering send_handshake timestamp and send() handshake gating Covers the two lines flagged by Codecov: the _last_handshake assignment in send_handshake() and the _needs_handshake() call in send(). --- miio/tests/test_handshake_timeout.py | 59 ++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/miio/tests/test_handshake_timeout.py b/miio/tests/test_handshake_timeout.py index bce04c193..ba78cc7d6 100644 --- a/miio/tests/test_handshake_timeout.py +++ b/miio/tests/test_handshake_timeout.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch from miio.miioprotocol import MiIOProtocol @@ -56,3 +57,61 @@ def test_no_last_handshake_recorded(self) -> None: proto._discovered = True proto._last_handshake = None assert proto._needs_handshake() is True + + +class TestHandshakeTimestamp: + """Tests that send_handshake records _last_handshake and send uses it.""" + + def test_send_handshake_records_timestamp(self) -> None: + """send_handshake should set _last_handshake to current time.""" + proto = MiIOProtocol("127.0.0.1", handshake_timeout=60) + assert proto._last_handshake is None + + mock_msg: MagicMock = MagicMock() + mock_msg.header.value.device_id = b"\x01\x02\x03\x04" + mock_msg.header.value.ts = datetime.now(tz=timezone.utc) + mock_msg.checksum = b"\x00" * 16 + + before: datetime = datetime.now(tz=timezone.utc) + with patch.object(MiIOProtocol, "discover", return_value=mock_msg): + proto.send_handshake() + after: datetime = datetime.now(tz=timezone.utc) + + assert proto._last_handshake is not None + assert before <= proto._last_handshake <= after + assert proto._discovered is True + + def test_send_calls_handshake_when_needed(self) -> None: + """send() should call send_handshake when _needs_handshake is True.""" + proto = MiIOProtocol("127.0.0.1", handshake_timeout=0) + proto._discovered = True + proto._last_handshake = datetime.now(tz=timezone.utc) + + with patch.object(proto, "send_handshake") as mock_hs, \ + patch.object(proto, "_create_request", return_value={"id": 1}), \ + patch("socket.socket") as mock_sock: + mock_instance: MagicMock = mock_sock.return_value + mock_instance.recvfrom.side_effect = OSError("mocked") + try: + proto.send("test_cmd", retry_count=0) + except Exception: + pass + mock_hs.assert_called_once() + + def test_send_skips_handshake_when_not_needed(self) -> None: + """send() should not call send_handshake when timeout hasn't expired.""" + proto = MiIOProtocol("127.0.0.1", handshake_timeout=3600) + proto._discovered = True + proto._last_handshake = datetime.now(tz=timezone.utc) + proto._device_id = b"\x01\x02\x03\x04" + + with patch.object(proto, "send_handshake") as mock_hs, \ + patch.object(proto, "_create_request", return_value={"id": 1}), \ + patch("socket.socket") as mock_sock: + mock_instance: MagicMock = mock_sock.return_value + mock_instance.recvfrom.side_effect = OSError("mocked") + try: + proto.send("test_cmd", retry_count=0) + except Exception: + pass + mock_hs.assert_not_called() From f4664508b7bd7b2172a282136439bfe829a95e84 Mon Sep 17 00:00:00 2001 From: Andrzej Pomirski Date: Fri, 27 Mar 2026 02:05:19 +0100 Subject: [PATCH 3/5] Run ruff format on changed files --- miio/tests/test_handshake_timeout.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/miio/tests/test_handshake_timeout.py b/miio/tests/test_handshake_timeout.py index ba78cc7d6..0bb615c91 100644 --- a/miio/tests/test_handshake_timeout.py +++ b/miio/tests/test_handshake_timeout.py @@ -87,9 +87,11 @@ def test_send_calls_handshake_when_needed(self) -> None: proto._discovered = True proto._last_handshake = datetime.now(tz=timezone.utc) - with patch.object(proto, "send_handshake") as mock_hs, \ - patch.object(proto, "_create_request", return_value={"id": 1}), \ - patch("socket.socket") as mock_sock: + with ( + patch.object(proto, "send_handshake") as mock_hs, + patch.object(proto, "_create_request", return_value={"id": 1}), + patch("socket.socket") as mock_sock, + ): mock_instance: MagicMock = mock_sock.return_value mock_instance.recvfrom.side_effect = OSError("mocked") try: @@ -105,9 +107,11 @@ def test_send_skips_handshake_when_not_needed(self) -> None: proto._last_handshake = datetime.now(tz=timezone.utc) proto._device_id = b"\x01\x02\x03\x04" - with patch.object(proto, "send_handshake") as mock_hs, \ - patch.object(proto, "_create_request", return_value={"id": 1}), \ - patch("socket.socket") as mock_sock: + with ( + patch.object(proto, "send_handshake") as mock_hs, + patch.object(proto, "_create_request", return_value={"id": 1}), + patch("socket.socket") as mock_sock, + ): mock_instance: MagicMock = mock_sock.return_value mock_instance.recvfrom.side_effect = OSError("mocked") try: From 236b1c53602108b5fd768efb40c5e6008341be75 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 27 Jun 2026 17:28:52 +0200 Subject: [PATCH 4/5] Fix ruff/mypy issues: replace Optional with union syntax, timezone.utc with UTC --- miio/miioprotocol.py | 10 ++++---- miio/tests/test_handshake_timeout.py | 34 +++++++++++++--------------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/miio/miioprotocol.py b/miio/miioprotocol.py index 3db3d7c10..f44123ada 100644 --- a/miio/miioprotocol.py +++ b/miio/miioprotocol.py @@ -35,7 +35,7 @@ def __init__( lazy_discover: bool = True, timeout: int = 5, *, - handshake_timeout: Optional[int] = None, + handshake_timeout: int | None = None, ) -> None: """Create a :class:`Device` instance. @@ -60,7 +60,7 @@ def __init__( self.__id = start_id if handshake_timeout is not None: - self._handshake_timeout: Optional[timedelta] = timedelta( + self._handshake_timeout: timedelta | None = timedelta( seconds=handshake_timeout ) elif lazy_discover: @@ -69,7 +69,7 @@ def __init__( self._handshake_timeout = timedelta(seconds=0) # every request self._discovered = False - self._last_handshake: Optional[datetime] = None + self._last_handshake: datetime | None = None # these come from the device, but we initialize them here to make mypy happy self._device_ts: datetime = datetime.now(tz=UTC) self._device_id = b"" @@ -101,7 +101,7 @@ def send_handshake(self, *, retry_count=3) -> Message: self._device_id = header.device_id self._device_ts = header.ts self._discovered = True - self._last_handshake = datetime.now(tz=timezone.utc) + self._last_handshake = datetime.now(tz=UTC) if self.debug > 1: _LOGGER.debug(m) @@ -132,7 +132,7 @@ def _needs_handshake(self) -> bool: if self._last_handshake is None: return True - elapsed = datetime.now(tz=timezone.utc) - self._last_handshake + elapsed = datetime.now(tz=UTC) - self._last_handshake return elapsed >= self._handshake_timeout @staticmethod diff --git a/miio/tests/test_handshake_timeout.py b/miio/tests/test_handshake_timeout.py index 0bb615c91..5e2cce4c4 100644 --- a/miio/tests/test_handshake_timeout.py +++ b/miio/tests/test_handshake_timeout.py @@ -1,6 +1,8 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch +import pytest + from miio.miioprotocol import MiIOProtocol @@ -16,39 +18,39 @@ def test_lazy_discover_true_no_rehandshake(self) -> None: """lazy_discover=True: after first handshake, never re-handshake.""" proto = MiIOProtocol(lazy_discover=True) proto._discovered = True - proto._last_handshake = datetime.now(tz=timezone.utc) - timedelta(hours=24) + proto._last_handshake = datetime.now(tz=UTC) - timedelta(hours=24) assert proto._needs_handshake() is False def test_lazy_discover_false_always_rehandshake(self) -> None: """lazy_discover=False: always re-handshake (timeout=0).""" proto = MiIOProtocol(lazy_discover=False) proto._discovered = True - proto._last_handshake = datetime.now(tz=timezone.utc) + proto._last_handshake = datetime.now(tz=UTC) assert proto._needs_handshake() is True def test_handshake_timeout_not_expired(self) -> None: proto = MiIOProtocol(handshake_timeout=60) proto._discovered = True - proto._last_handshake = datetime.now(tz=timezone.utc) - timedelta(seconds=30) + proto._last_handshake = datetime.now(tz=UTC) - timedelta(seconds=30) assert proto._needs_handshake() is False def test_handshake_timeout_expired(self) -> None: proto = MiIOProtocol(handshake_timeout=60) proto._discovered = True - proto._last_handshake = datetime.now(tz=timezone.utc) - timedelta(seconds=90) + proto._last_handshake = datetime.now(tz=UTC) - timedelta(seconds=90) assert proto._needs_handshake() is True def test_handshake_timeout_zero_always_rehandshake(self) -> None: proto = MiIOProtocol(handshake_timeout=0) proto._discovered = True - proto._last_handshake = datetime.now(tz=timezone.utc) + proto._last_handshake = datetime.now(tz=UTC) assert proto._needs_handshake() is True def test_handshake_timeout_overrides_lazy_discover(self) -> None: """Explicit handshake_timeout takes precedence over lazy_discover.""" proto = MiIOProtocol(lazy_discover=True, handshake_timeout=10) proto._discovered = True - proto._last_handshake = datetime.now(tz=timezone.utc) - timedelta(seconds=20) + proto._last_handshake = datetime.now(tz=UTC) - timedelta(seconds=20) assert proto._needs_handshake() is True def test_no_last_handshake_recorded(self) -> None: @@ -69,13 +71,13 @@ def test_send_handshake_records_timestamp(self) -> None: mock_msg: MagicMock = MagicMock() mock_msg.header.value.device_id = b"\x01\x02\x03\x04" - mock_msg.header.value.ts = datetime.now(tz=timezone.utc) + mock_msg.header.value.ts = datetime.now(tz=UTC) mock_msg.checksum = b"\x00" * 16 - before: datetime = datetime.now(tz=timezone.utc) + before: datetime = datetime.now(tz=UTC) with patch.object(MiIOProtocol, "discover", return_value=mock_msg): proto.send_handshake() - after: datetime = datetime.now(tz=timezone.utc) + after: datetime = datetime.now(tz=UTC) assert proto._last_handshake is not None assert before <= proto._last_handshake <= after @@ -85,7 +87,7 @@ def test_send_calls_handshake_when_needed(self) -> None: """send() should call send_handshake when _needs_handshake is True.""" proto = MiIOProtocol("127.0.0.1", handshake_timeout=0) proto._discovered = True - proto._last_handshake = datetime.now(tz=timezone.utc) + proto._last_handshake = datetime.now(tz=UTC) with ( patch.object(proto, "send_handshake") as mock_hs, @@ -94,17 +96,15 @@ def test_send_calls_handshake_when_needed(self) -> None: ): mock_instance: MagicMock = mock_sock.return_value mock_instance.recvfrom.side_effect = OSError("mocked") - try: + with pytest.raises(Exception): # noqa: B017 proto.send("test_cmd", retry_count=0) - except Exception: - pass mock_hs.assert_called_once() def test_send_skips_handshake_when_not_needed(self) -> None: """send() should not call send_handshake when timeout hasn't expired.""" proto = MiIOProtocol("127.0.0.1", handshake_timeout=3600) proto._discovered = True - proto._last_handshake = datetime.now(tz=timezone.utc) + proto._last_handshake = datetime.now(tz=UTC) proto._device_id = b"\x01\x02\x03\x04" with ( @@ -114,8 +114,6 @@ def test_send_skips_handshake_when_not_needed(self) -> None: ): mock_instance: MagicMock = mock_sock.return_value mock_instance.recvfrom.side_effect = OSError("mocked") - try: + with pytest.raises(Exception): # noqa: B017 proto.send("test_cmd", retry_count=0) - except Exception: - pass mock_hs.assert_not_called() From 2a88a6496513a9b5e5e09430471ae2ad03fb1aad Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 27 Jun 2026 18:11:50 +0200 Subject: [PATCH 5/5] Simplify _needs_handshake docstring, add why to __init__ docstring --- miio/miioprotocol.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/miio/miioprotocol.py b/miio/miioprotocol.py index f44123ada..edb657562 100644 --- a/miio/miioprotocol.py +++ b/miio/miioprotocol.py @@ -39,6 +39,10 @@ def __init__( ) -> None: """Create a :class:`Device` instance. + Some devices require a recent handshake to accept commands, but + handshaking before every request adds unnecessary latency. Use + handshake_timeout to control how often the handshake is renewed. + :param ip: IP address or a hostname for the device :param token: Token used for encryption :param start_id: Running message id sent to the device @@ -117,11 +121,9 @@ def send_handshake(self, *, retry_count=3) -> Message: def _needs_handshake(self) -> bool: """Return True if a handshake is needed before sending. - The decision is based on _handshake_timeout, which is set in __init__ - either from the explicit handshake_timeout parameter, or derived from - the legacy lazy_discover flag: - - lazy_discover=True -> _handshake_timeout=None (handshake once) - - lazy_discover=False -> _handshake_timeout=0s (every request) + Handshake is required on first contact or when the configured timeout + has elapsed since the last handshake. If no timeout is set, handshake + only once and never repeat it. """ if not self._discovered: return True