File: test_ip_pairing.py

package info (click to toggle)
python-aiohomekit 3.2.1-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 1,620 kB
  • sloc: python: 16,560; sh: 14; makefile: 8
file content (371 lines) | stat: -rw-r--r-- 11,842 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
import asyncio
from datetime import timedelta
from typing import Any
from unittest import mock

import pytest

from aiohomekit.controller.ip.pairing import IpPairing
from aiohomekit.exceptions import AccessoryDisconnectedError
from aiohomekit.model import Transport
from aiohomekit.protocol.statuscodes import HapStatusCode


async def test_list_accessories(pairing: IpPairing):
    accessories = await pairing.list_accessories_and_characteristics()
    assert accessories[0]["aid"] == 1
    assert accessories[0]["services"][0]["iid"] == 1

    char = accessories[0]["services"][0]["characteristics"][0]

    assert char["description"] == "Identify"
    assert char["iid"] == 2
    assert char["format"] == "bool"
    assert char["perms"] == ["pw"]
    assert char["type"] == "00000014-0000-1000-8000-0026BB765291"


async def test_get_characteristics(pairing: IpPairing):
    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics[(1, 9)] == {"value": False}


async def test_duplicate_get_characteristics(pairing):
    characteristics = await pairing.get_characteristics([(1, 9), (1, 9)])
    assert characteristics[(1, 9)] == {"value": False}


async def test_get_characteristics_after_failure(pairing: IpPairing):
    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics[(1, 9)] == {"value": False}

    pairing.connection.transport.close()
    await asyncio.sleep(0)
    assert not pairing.connection.is_connected
    assert not pairing.is_available

    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics[(1, 9)] == {"value": False}


async def test_reconnect_soon_after_disconnected(pairing: IpPairing):
    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics[(1, 9)] == {"value": False}

    assert pairing.connection.is_connected
    assert pairing.is_available

    pairing.connection.transport.close()
    await asyncio.sleep(0)
    assert not pairing.connection.is_connected
    assert not pairing.is_available

    # Ensure we can safely call multiple times
    pairing._async_description_update(None)
    pairing._async_description_update(None)
    pairing._async_description_update(None)

    await asyncio.sleep(
        0
    )  # ensure the callback has a chance to run and create _connector
    await asyncio.wait_for(pairing.connection._connector, timeout=0.5)
    assert pairing.connection.is_connected

    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics[(1, 9)] == {"value": False}


async def test_reconnect_soon_after_device_is_offline_for_a_bit(pairing: IpPairing):
    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics[(1, 9)] == {"value": False}

    assert pairing.connection.is_connected
    assert pairing.is_available

    with mock.patch(
        "aiohomekit.controller.ip.connection.HomeKitConnection._connect_once",
        side_effect=asyncio.TimeoutError,
    ):
        pairing.connection.transport.close()
        await asyncio.sleep(0)
        assert not pairing.connection.is_connected
        assert not pairing.is_available

        for _ in range(3):
            pairing._async_description_update(None)
            await asyncio.sleep(
                0
            )  # ensure the callback has a chance to run and create _connector
            with pytest.raises(asyncio.TimeoutError):
                await asyncio.wait_for(
                    asyncio.shield(pairing.connection._connector), timeout=0.2
                )
            assert not pairing.connection.is_connected

    pairing._async_description_update(None)
    await asyncio.wait_for(pairing.connection._connector, timeout=0.5)
    assert pairing.connection.is_connected
    assert pairing.is_available

    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics[(1, 9)] == {"value": False}


async def test_put_characteristics(pairing: IpPairing):
    characteristics = await pairing.put_characteristics([(1, 9, True)])

    assert characteristics == {}

    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics[(1, 9)] == {"value": True}


async def test_put_characteristics_cancelled(pairing: IpPairing):
    characteristics = await pairing.put_characteristics([(1, 9, True)])
    characteristics = await pairing.get_characteristics([(1, 9)])

    with mock.patch.object(pairing.connection.transport, "write"):
        task = asyncio.create_task(pairing.put_characteristics([(1, 9, False)]))
        await asyncio.sleep(0)
        for future in pairing.connection.protocol.result_cbs:
            future.cancel()
        await asyncio.sleep(0)
        with pytest.raises(asyncio.CancelledError):
            await task

    # We should wait a few seconds to see if the
    # connection can be re-established and the write can be
    # completed. But this is not currently possible because
    # we do not wait for the connection to be re-established
    # before we try to write the data. When we implement
    # reconnection we should remove this pytest.raises
    # and the sleep below.
    with pytest.raises(AccessoryDisconnectedError):
        await pairing.get_characteristics([(1, 9)])

    await asyncio.sleep(0)
    characteristics = await pairing.get_characteristics([(1, 9)])
    assert characteristics[(1, 9)] == {"value": True}


async def test_put_characteristics_callbacks(pairing: IpPairing):
    events = []

    def process_new_events(
        new_values_dict: dict[tuple[int, int], dict[str, Any]]
    ) -> None:
        events.append(new_values_dict)

    pairing.dispatcher_connect(process_new_events)
    assert events == []

    characteristics = await pairing.put_characteristics([(1, 9, True)])
    assert events == [{}, {(1, 9): {"value": True}}]
    assert characteristics == {}

    # Identify is a write only characteristic, so we should not get a callback
    characteristics = await pairing.put_characteristics([(1, 2, True)])
    assert events == [{}, {(1, 9): {"value": True}}]

    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics[(1, 9)] == {"value": True}

    characteristics = await pairing.get_characteristics({(1, 9)})

    assert characteristics[(1, 9)] == {"value": True}


async def test_subscribe(pairing: IpPairing):
    assert pairing.subscriptions == set()

    await pairing.subscribe([(1, 9)])

    assert pairing.subscriptions == {(1, 9)}

    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics == {(1, 9): {"value": False}}


async def test_unsubscribe(pairing: IpPairing):
    await pairing.subscribe([(1, 9)])

    assert pairing.subscriptions == {(1, 9)}

    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics == {(1, 9): {"value": False}}

    await pairing.unsubscribe([(1, 9)])

    assert pairing.subscriptions == set()

    characteristics = await pairing.get_characteristics([(1, 9)])

    assert characteristics == {(1, 9): {"value": False}}


async def test_dispatcher_connect(pairing: IpPairing):
    assert pairing.listeners == set()

    def callback(x):
        pass

    cancel = pairing.dispatcher_connect(callback)
    assert pairing.listeners == {callback}

    cancel()
    assert pairing.listeners == set()


async def test_receiving_events(pairings):
    """
    Test that can receive events when change happens in another session.

    We set up 2 controllers both with active secure sessions. One
    subscribes and then other does put() calls.

    This test is currently skipped because accessory server doesnt
    support events.
    """
    left: IpPairing = pairings[0]
    right: IpPairing = pairings[1]

    event_value = None
    ev = asyncio.Event()

    def handler(data):
        print(data)
        nonlocal event_value
        event_value = data
        ev.set()

    # Set where to send events
    right.dispatcher_connect(handler)

    # Set what events to get
    await right.subscribe([(1, 9)])

    # Trigger an event by writing a change on the other connection
    await left.put_characteristics([(1, 9, True)])

    # Wait for event to be received for up to 5s
    await asyncio.wait_for(ev.wait(), 5)

    assert event_value == {(1, 9): {"value": True}}


async def test_subscribe_invalid_iid(pairing: IpPairing):
    """
    Test that can get an error when subscribing to an invalid iid.
    """
    result = await pairing.subscribe([(1, 999999)])
    assert result == {
        (1, 999999): {
            "description": "Resource does not exist.",
            "status": HapStatusCode.RESOURCE_NOT_EXIST.value,
        }
    }


async def test_list_pairings(pairing: IpPairing):
    pairings = await pairing.list_pairings()
    assert pairings == [
        {
            "controllerType": "admin",
            "pairingId": "decc6fa3-de3e-41c9-adba-ef7409821bfc",
            "permissions": 1,
            "publicKey": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8",
        }
    ]


async def test_add_pairings(pairing: IpPairing):
    await pairing.add_pairing(
        "decc6fa3-de3e-41c9-adba-ef7409821bfe",
        "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed7",
        "User",
    )

    pairings = await pairing.list_pairings()
    assert pairings == [
        {
            "controllerType": "admin",
            "pairingId": "decc6fa3-de3e-41c9-adba-ef7409821bfc",
            "permissions": 1,
            "publicKey": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8",
        },
        {
            "controllerType": "regular",
            "pairingId": "decc6fa3-de3e-41c9-adba-ef7409821bfe",
            "permissions": 0,
            "publicKey": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed7",
        },
    ]


async def test_add_and_remove_pairings(pairing: IpPairing):
    await pairing.add_pairing(
        "decc6fa3-de3e-41c9-adba-ef7409821bfe",
        "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed7",
        "User",
    )

    pairings = await pairing.list_pairings()
    assert pairings == [
        {
            "controllerType": "admin",
            "pairingId": "decc6fa3-de3e-41c9-adba-ef7409821bfc",
            "permissions": 1,
            "publicKey": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8",
        },
        {
            "controllerType": "regular",
            "pairingId": "decc6fa3-de3e-41c9-adba-ef7409821bfe",
            "permissions": 0,
            "publicKey": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed7",
        },
    ]

    await pairing.remove_pairing("decc6fa3-de3e-41c9-adba-ef7409821bfe")

    pairings = await pairing.list_pairings()
    assert pairings == [
        {
            "controllerType": "admin",
            "pairingId": "decc6fa3-de3e-41c9-adba-ef7409821bfc",
            "permissions": 1,
            "publicKey": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8",
        }
    ]


async def test_identify(pairing):
    identified = await pairing.identify()
    assert identified is True


async def test_transport_property(pairing: IpPairing):
    assert pairing.transport == Transport.IP


async def test_polling_property(pairing: IpPairing):
    assert pairing.poll_interval == timedelta(seconds=60)


async def test_put_characteristics_invalid_value(pairing: IpPairing):
    aid, iid = (1, 2)
    characteristics = [(aid, iid, 100)]
    status_code = await pairing.put_characteristics(characteristics)
    assert status_code is not None
    assert status_code[(aid, iid)] is not None
    assert status_code[(aid, iid)]["status"] == HapStatusCode.INVALID_VALUE.value