|
| 1 | +"""Test the MQTT session and channel over TCP with a real broker.""" |
| 2 | + |
| 3 | +import asyncio |
| 4 | +import json |
| 5 | +from collections.abc import AsyncGenerator |
| 6 | +from uuid import uuid4 |
| 7 | + |
| 8 | +import pytest |
| 9 | +from zmqtt import MQTTClientV5, QoS, ReconnectConfig, create_client |
| 10 | + |
| 11 | +from roborock.data import UserData |
| 12 | +from roborock.devices.transport.mqtt_channel import MqttChannel |
| 13 | +from roborock.mqtt.roborock_session import create_lazy_mqtt_session, create_mqtt_session |
| 14 | +from roborock.mqtt.session import MqttParams, MqttQos, MqttSession, MqttSessionException |
| 15 | +from roborock.protocol import MessageParser |
| 16 | +from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol |
| 17 | +from tests.mock_data import LOCAL_KEY, USER_DATA |
| 18 | + |
| 19 | +pytestmark = pytest.mark.mqtt_broker |
| 20 | + |
| 21 | + |
| 22 | +@pytest.fixture(name="mqtt_params") |
| 23 | +def mqtt_params_fixture() -> MqttParams: |
| 24 | + """Use the broker exposed by Docker Compose or GitHub Actions.""" |
| 25 | + return MqttParams( |
| 26 | + host="127.0.0.1", |
| 27 | + port=1888, |
| 28 | + tls=False, |
| 29 | + username="username", |
| 30 | + password="password", |
| 31 | + timeout=5.0, |
| 32 | + ) |
| 33 | + |
| 34 | + |
| 35 | +@pytest.fixture(name="session") |
| 36 | +async def session_fixture(mqtt_params: MqttParams) -> AsyncGenerator[MqttSession, None]: |
| 37 | + """Create and close the production MQTT session.""" |
| 38 | + session = await create_mqtt_session(mqtt_params) |
| 39 | + try: |
| 40 | + assert session.connected |
| 41 | + yield session |
| 42 | + finally: |
| 43 | + await session.close() |
| 44 | + |
| 45 | + |
| 46 | +@pytest.fixture(name="peer") |
| 47 | +async def peer_fixture(mqtt_params: MqttParams) -> AsyncGenerator[MQTTClientV5, None]: |
| 48 | + """Represent a device using an independent MQTT client.""" |
| 49 | + async with create_client( |
| 50 | + mqtt_params.host, |
| 51 | + mqtt_params.port, |
| 52 | + version="5.0", |
| 53 | + mqtt_connect_timeout=mqtt_params.timeout, |
| 54 | + reconnect=ReconnectConfig(enabled=False), |
| 55 | + ) as peer: |
| 56 | + yield peer |
| 57 | + |
| 58 | + |
| 59 | +@pytest.fixture(name="topic") |
| 60 | +def topic_fixture() -> str: |
| 61 | + """Isolate each test's traffic on the shared broker.""" |
| 62 | + return f"roborock-tests/{uuid4().hex}" |
| 63 | + |
| 64 | + |
| 65 | +async def test_receive_message(session: MqttSession, peer: MQTTClientV5, topic: str) -> None: |
| 66 | + """Deliver an encrypted Roborock response to the session callback.""" |
| 67 | + messages: asyncio.Queue[bytes] = asyncio.Queue() |
| 68 | + await session.subscribe(topic, messages.put_nowait) |
| 69 | + response = RoborockMessage( |
| 70 | + protocol=RoborockMessageProtocol.RPC_RESPONSE, |
| 71 | + payload=b'{"result":"ok"}', |
| 72 | + seq=123, |
| 73 | + ) |
| 74 | + payload = MessageParser.build(response, local_key=LOCAL_KEY, prefixed=False) |
| 75 | + |
| 76 | + await peer.publish(topic, payload) |
| 77 | + received = await asyncio.wait_for(messages.get(), timeout=5) |
| 78 | + |
| 79 | + assert received == payload |
| 80 | + parsed, remaining = MessageParser.parse(received, local_key=LOCAL_KEY) |
| 81 | + assert not remaining |
| 82 | + assert len(parsed) == 1 |
| 83 | + assert parsed[0].protocol == response.protocol |
| 84 | + assert parsed[0].seq == response.seq |
| 85 | + assert parsed[0].payload == response.payload |
| 86 | + |
| 87 | + |
| 88 | +@pytest.mark.parametrize("qos", list(MqttQos)) |
| 89 | +async def test_publish_message(session: MqttSession, peer: MQTTClientV5, topic: str, qos: MqttQos) -> None: |
| 90 | + """Deliver the full payload and requested QoS to another MQTT client.""" |
| 91 | + payload = MessageParser.build( |
| 92 | + RoborockMessage(protocol=RoborockMessageProtocol.RPC_REQUEST, payload=b'{"method":"get_status"}'), |
| 93 | + local_key=LOCAL_KEY, |
| 94 | + prefixed=False, |
| 95 | + ) |
| 96 | + async with peer.subscribe(topic, qos=QoS.EXACTLY_ONCE) as subscription: |
| 97 | + await session.publish(topic, payload, qos=qos) |
| 98 | + received = await asyncio.wait_for(subscription.get_message(), timeout=5) |
| 99 | + |
| 100 | + assert received.topic == topic |
| 101 | + assert received.payload == payload |
| 102 | + assert received.qos == qos |
| 103 | + |
| 104 | + |
| 105 | +async def test_subscriber_lifecycle(session: MqttSession, peer: MQTTClientV5, topic: str) -> None: |
| 106 | + """Fan out messages, remove callbacks, and reuse an idle subscription.""" |
| 107 | + first: asyncio.Queue[bytes] = asyncio.Queue() |
| 108 | + second: asyncio.Queue[bytes] = asyncio.Queue() |
| 109 | + unsub_first = await session.subscribe(topic, first.put_nowait) |
| 110 | + unsub_second = await session.subscribe(topic, second.put_nowait) |
| 111 | + |
| 112 | + await peer.publish(topic, b"both") |
| 113 | + assert await asyncio.wait_for(first.get(), timeout=5) == b"both" |
| 114 | + assert await asyncio.wait_for(second.get(), timeout=5) == b"both" |
| 115 | + |
| 116 | + unsub_first() |
| 117 | + await peer.publish(topic, b"second only") |
| 118 | + assert await asyncio.wait_for(second.get(), timeout=5) == b"second only" |
| 119 | + assert first.empty() |
| 120 | + |
| 121 | + unsub_second() |
| 122 | + await session.subscribe(topic, first.put_nowait) |
| 123 | + await peer.publish(topic, b"first again") |
| 124 | + assert await asyncio.wait_for(first.get(), timeout=5) == b"first again" |
| 125 | + assert first.empty() |
| 126 | + assert second.empty() |
| 127 | + |
| 128 | + |
| 129 | +async def test_topic_routing(session: MqttSession, peer: MQTTClientV5, topic: str) -> None: |
| 130 | + """Keep messages for different devices separate in a shared session.""" |
| 131 | + first: asyncio.Queue[bytes] = asyncio.Queue() |
| 132 | + second: asyncio.Queue[bytes] = asyncio.Queue() |
| 133 | + await session.subscribe(f"{topic}/first", first.put_nowait) |
| 134 | + await session.subscribe(f"{topic}/second", second.put_nowait) |
| 135 | + |
| 136 | + await peer.publish(f"{topic}/first", b"first") |
| 137 | + await peer.publish(f"{topic}/second", b"second") |
| 138 | + |
| 139 | + assert await asyncio.wait_for(first.get(), timeout=5) == b"first" |
| 140 | + assert await asyncio.wait_for(second.get(), timeout=5) == b"second" |
| 141 | + assert first.empty() |
| 142 | + assert second.empty() |
| 143 | + |
| 144 | + |
| 145 | +async def test_restart_restores_subscriptions(session: MqttSession, peer: MQTTClientV5, topic: str) -> None: |
| 146 | + """Restore message delivery after the session reconnects.""" |
| 147 | + messages: asyncio.Queue[bytes] = asyncio.Queue() |
| 148 | + await session.subscribe(topic, messages.put_nowait) |
| 149 | + await peer.publish(topic, b"before restart") |
| 150 | + assert await asyncio.wait_for(messages.get(), timeout=5) == b"before restart" |
| 151 | + |
| 152 | + await session.restart() |
| 153 | + async with asyncio.timeout(20): |
| 154 | + while session.connected: |
| 155 | + await asyncio.sleep(0.01) |
| 156 | + while not session.connected: |
| 157 | + await asyncio.sleep(0.01) |
| 158 | + |
| 159 | + await peer.publish(topic, b"after restart") |
| 160 | + assert await asyncio.wait_for(messages.get(), timeout=5) == b"after restart" |
| 161 | + async with peer.subscribe(topic) as subscription: |
| 162 | + await session.publish(topic, b"outbound after restart") |
| 163 | + received = await asyncio.wait_for(subscription.get_message(), timeout=5) |
| 164 | + assert received.payload == b"outbound after restart" |
| 165 | + |
| 166 | + |
| 167 | +async def test_close(session: MqttSession, topic: str) -> None: |
| 168 | + """Close a connected session and reject further publications.""" |
| 169 | + await session.close() |
| 170 | + |
| 171 | + assert not session.connected |
| 172 | + with pytest.raises(MqttSessionException): |
| 173 | + await session.publish(topic, b"closed") |
| 174 | + |
| 175 | + |
| 176 | +async def test_lazy_session_subscribe(mqtt_params: MqttParams, peer: MQTTClientV5, topic: str) -> None: |
| 177 | + """Connect a lazy session on its first subscription and receive a message.""" |
| 178 | + session = await create_lazy_mqtt_session(mqtt_params) |
| 179 | + assert not session.connected |
| 180 | + |
| 181 | + messages: asyncio.Queue[bytes] = asyncio.Queue() |
| 182 | + await session.subscribe(topic, messages.put_nowait) |
| 183 | + assert session.connected |
| 184 | + |
| 185 | + await peer.publish(topic, b"inbound") |
| 186 | + assert await asyncio.wait_for(messages.get(), timeout=5) == b"inbound" |
| 187 | + |
| 188 | + await session.close() |
| 189 | + assert not session.connected |
| 190 | + |
| 191 | + |
| 192 | +async def test_lazy_session_publish(mqtt_params: MqttParams, peer: MQTTClientV5, topic: str) -> None: |
| 193 | + """Connect a lazy session on its first publication and deliver a message.""" |
| 194 | + session = await create_lazy_mqtt_session(mqtt_params) |
| 195 | + assert not session.connected |
| 196 | + |
| 197 | + async with peer.subscribe(topic) as subscription: |
| 198 | + await session.publish(topic, b"outbound") |
| 199 | + assert session.connected |
| 200 | + received = await asyncio.wait_for(subscription.get_message(), timeout=5) |
| 201 | + assert received.payload == b"outbound" |
| 202 | + |
| 203 | + await session.close() |
| 204 | + assert not session.connected |
| 205 | + |
| 206 | + |
| 207 | +async def test_channel_request_response(session: MqttSession, mqtt_params: MqttParams, peer: MQTTClientV5) -> None: |
| 208 | + """Exchange encrypted commands and responses on the device's Roborock topics.""" |
| 209 | + user_data = UserData.from_dict(USER_DATA) |
| 210 | + duid = uuid4().hex |
| 211 | + channel = MqttChannel(session, duid, LOCAL_KEY, user_data.rriot, mqtt_params) |
| 212 | + request_topic = f"rr/m/i/{user_data.rriot.u}/{mqtt_params.username}/{duid}" |
| 213 | + response_topic = f"rr/m/o/{user_data.rriot.u}/{mqtt_params.username}/{duid}" |
| 214 | + messages: asyncio.Queue[RoborockMessage] = asyncio.Queue() |
| 215 | + unsub = await channel.subscribe(messages.put_nowait) |
| 216 | + async with peer.subscribe(request_topic) as subscription: |
| 217 | + command = RoborockMessage( |
| 218 | + protocol=RoborockMessageProtocol.RPC_REQUEST, |
| 219 | + payload=json.dumps({"dps": {"101": json.dumps({"id": 123, "method": "get_status"})}}).encode(), |
| 220 | + seq=456, |
| 221 | + ) |
| 222 | + await channel.publish(command) |
| 223 | + request = await asyncio.wait_for(subscription.get_message(), timeout=5) |
| 224 | + parsed, remaining = MessageParser.parse(request.payload, local_key=LOCAL_KEY) |
| 225 | + assert request.topic == request_topic |
| 226 | + assert not remaining |
| 227 | + assert len(parsed) == 1 |
| 228 | + assert parsed[0].protocol == RoborockMessageProtocol.RPC_REQUEST |
| 229 | + assert parsed[0].seq == 456 |
| 230 | + assert parsed[0].payload == command.payload |
| 231 | + |
| 232 | + response = RoborockMessage( |
| 233 | + protocol=RoborockMessageProtocol.RPC_RESPONSE, |
| 234 | + payload=json.dumps({"dps": {"102": json.dumps({"id": 123, "result": [{"state": 8}]})}}).encode(), |
| 235 | + ) |
| 236 | + await peer.publish(response_topic, MessageParser.build(response, local_key=LOCAL_KEY, prefixed=False)) |
| 237 | + received = await asyncio.wait_for(messages.get(), timeout=5) |
| 238 | + assert received.protocol == response.protocol |
| 239 | + assert received.payload == response.payload |
| 240 | + unsub() |
0 commit comments