forked from ikalchev/HAP-python
-
Notifications
You must be signed in to change notification settings - Fork 0
/
accessory.py
562 lines (438 loc) · 19 KB
/
accessory.py
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
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
import asyncio
import threading
import logging
import itertools
import struct
from os import urandom
import ed25519
import base36
from pyqrcode import QRCode
from pyhap import util
from pyhap.const import (
STANDALONE_AID, HAP_REPR_AID, HAP_REPR_IID, HAP_REPR_SERVICES,
HAP_REPR_VALUE, CATEGORY_OTHER, CATEGORY_BRIDGE)
from pyhap.loader import get_serv_loader
logger = logging.getLogger(__name__)
class IIDManager(object):
"""Maintains a mapping between Service/Characteristic objects and IIDs."""
def __init__(self):
"""Initialise an empty instance."""
self.iids = {}
self.reverse_iids = {}
def assign(self, obj):
"""Assign an IID to the given object.
If the object already has an assigned ID, log a warning and do nothing.
:param obj: The object that will be assigned an IID.
:type obj: Service or Characteristic
"""
if obj in self.reverse_iids:
logger.warning("The given Service or Characteristic with UUID %s "
"already has an assigned IID %s, ignoring.",
obj.type_id, self.reverse_iids[obj])
return
iid = len(self.iids) + 1
self.iids[iid] = obj
self.reverse_iids[obj] = iid
def remove(self, obj=None, iid=None):
"""Remove an object or an object with the given IID."""
if obj is not None:
iid = self.reverse_iids.pop(obj, None)
if iid is None:
logger.error("Object %s not found.", obj)
return
del self.iids[iid]
else:
obj = self.iids.pop(iid, None)
if obj is None:
logger.error("IID %s not found.", iid)
return
del self.reverse_iids[obj]
def get_iid(self, obj):
"""Get the IID assigned to the given object.
:return: IID assigned to the given object or None if the object is not found.
:rtype: int
"""
return self.reverse_iids.get(obj)
def get_obj(self, iid):
"""Get the object that is assigned the given IID.
:return: The object with the given IID or None if no object has that IID.
:rtype: Service or Characteristic
"""
return self.iids.get(iid)
class Accessory(object):
"""A representation of a HAP accessory.
Inherit from this class to build your own accessories.
At the end of the init of this class, the _set_services method is called.
Use this to set your HAP services.
"""
category = CATEGORY_OTHER
@classmethod
def create(cls, display_name, pincode, aid=STANDALONE_AID):
mac = util.generate_mac()
return cls(display_name, aid=aid, mac=mac, pincode=pincode)
def __init__(self, display_name, aid=None, mac=None, pincode=None,
iid_manager=None, setup_id=None):
"""Initialise with the given properties.
:param display_name: Name to be displayed in the Home app.
:type display_name: str
:param aid: The accessory ID, uniquely identifying this accessory.
`Accessories` that advertised on the network must have the
standalone AID. Defaults to None, in which case the `AccessoryDriver`
will assign the standalone AID to this `Accessory`.
:type aid: int
:param mac: The MAC address of this `Accessory`, needed by HAP clients.
Defaults to None, in which case the `AccessoryDriver`
will assign a random MAC address to this `Accessory`.
:type mac: str
:param pincode: The pincode that HAP clients must prove they know in order
to pair with this `Accessory`. Defaults to None, in which case a random
pincode is generated. The pincode has the format "xxx-xx-xxx", where x is
a digit.
:type pincode: bytearray
:param setup_id: Setup ID can be provided, although, per spec, should be random
every time the instance is started. If not provided on init, will be random.
4 digit string 0-9 A-Z
:type setup_id: str
"""
self.display_name = display_name
self.aid = aid
self.mac = mac
self.config_version = 2
self.reachable = True
self._pincode = pincode
self._setup_id = setup_id
self.broker = None
# threading.Event that gets set when the Accessory should stop.
self.run_sentinel = None
self.event_loop = None
self.aio_stop_event = None
sk, vk = ed25519.create_keypair()
self.private_key = sk
self.public_key = vk
self.paired_clients = {}
self.services = []
self.iid_manager = iid_manager or IIDManager()
self._set_services()
def __repr__(self):
"""Return the representation of the accessory."""
services = [s.display_name for s in self.services]
return "<accessory display_name='{}' services={}>" \
.format(self.display_name, services)
def __getstate__(self):
state = self.__dict__.copy()
state["broker"] = None
state["run_sentinel"] = None
return state
@property
def setup_id(self):
if not getattr(self, '_setup_id', None):
self._setup_id = util.generate_setup_id()
return self._setup_id
@property
def pincode(self):
if not getattr(self, '_pincode', None):
self._pincode = util.generate_pincode()
return self._pincode
def _set_services(self):
"""Sets the services for this accessory.
The default implementation adds only the AccessoryInformation services
and sets its Name characteristic to the Accessory's display name.
.. note:: When inheriting from Accessory and overriding this method,
always call the base implementation first, as it reserves IID of
1 for the Accessory Information service (HAP requirement).
"""
info_service = get_serv_loader().get_service("AccessoryInformation")
info_service.get_characteristic("Name")\
.set_value(self.display_name, False)
info_service.get_characteristic("Manufacturer")\
.set_value("Default-Manufacturer", False)
info_service.get_characteristic("Model")\
.set_value("Default-Model", False)
info_service.get_characteristic("SerialNumber")\
.set_value("Default-SerialNumber", False)
# FIXME: Need to ensure AccessoryInformation is with IID 1.
self.add_service(info_service)
def set_sentinel(self, run_sentinel, aio_stop_event, event_loop):
"""Assign a run sentinel that can signal stopping.
The run sentinel is a threading.Event object that can be used to manage
continuous running of the Accessory, e.g. a loop reading from a sensor every 3
seconds. The sentinel is "set" typically by the AccessoryDriver just before
Accessory.stop is called.
Example usage in the run method:
>>> while not self.run_sentinel.wait(3): # If not set, every 3 seconds
... sensor.readTemperature()
"""
self.run_sentinel = run_sentinel
self.aio_stop_event = aio_stop_event
self.event_loop = event_loop
def config_changed(self):
"""Notify the accessory about configuration changes.
These include new services or updated characteristic values, e.g.
the Name of a service changed.
This method also notifies the broker about the change, so that it can
publish the changes to the world.
.. note:: If you are changing the configuration of a bridged accessory
(i.e. an Accessory that is contained in a Bridge),
you should call the `config_changed` method on the Bridge.
"""
self.config_version += 1
self.broker.config_changed()
def add_service(self, *servs):
"""Add the given services to this Accessory.
This also assigns unique IIDS to the services and their Characteristics.
.. note:: Do not add or remove characteristics from services that have been added
to an Accessory, as this will lead to inconsistent IIDs.
:param servs: Variable number of services to add to this Accessory.
:type: Service
"""
for s in servs:
self.services.append(s)
self.iid_manager.assign(s)
s.broker = self
for c in s.characteristics:
self.iid_manager.assign(c)
c.broker = self
def get_service(self, name):
"""Return a Service with the given name.
A single Service is returned even if more than one Service with the same name
are present.
:param name: The display_name of the Service to search for.
:type name: str
:return: A Service with the given name or None if no such service exists in this
Accessory.
:rtype: Service
"""
return next((s for s in self.services if s.display_name == name), None)
def set_broker(self, broker):
self.broker = broker
def add_paired_client(self, client_uuid, client_public):
"""Adds the given client to the set of paired clients.
:param client_uuid: The client's UUID.
:type client_uuid: uuid.UUID
:param client_public: The client's public key (not the session public key).
:type client_public: bytes
"""
self.paired_clients[client_uuid] = client_public
def remove_paired_client(self, client_uuid):
"""Deletes the given client from the set of paired clients.
:param client_uuid: The client's UUID.
:type client_uuid: uuid.UUID
"""
self.paired_clients.pop(client_uuid)
@property
def paired(self):
return len(self.paired_clients) > 0
@property
def xhm_uri(self):
"""Generates the X-HM:// uri (Setup Code URI)
:rtype: str
"""
buffer = bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00')
value_low = int(self.pincode.replace(b'-', b''), 10)
value_low |= 1 << 28
struct.pack_into('>L', buffer, 4, value_low)
if self.category == CATEGORY_OTHER:
buffer[4] = buffer[4] | 1 << 7
value_high = self.category >> 1
struct.pack_into('>L', buffer, 0, value_high)
encoded_payload = base36.dumps(struct.unpack_from('>L', buffer, 4)[0]
+ (struct.unpack_from('>L', buffer, 0)[0] * (1 << 32))).upper()
encoded_payload = encoded_payload.rjust(9, '0')
return 'X-HM://' + encoded_payload + self.setup_id
@property
def qr_code(self):
"""Generate a QR code for paring with this accessory.
:rtype: QRCode
"""
return QRCode(self.xhm_uri)
def print_qr(self):
"""Print the setup code in QR format to console.
"""
print(self.qr_code.terminal(), flush=True)
def get_characteristic(self, aid, iid):
"""Get the characteristic for the given IID.
The AID is used to verify if the search is in the correct accessory.
"""
if aid != self.aid:
return None
return self.iid_manager.get_obj(iid)
def to_HAP(self):
"""A HAP representation of this Accessory.
:return: A HAP representation of this accessory. For example:
.. code-block:: python
{ "aid": 1,
"services": [{
"iid" 2,
"type": ...,
...
}]
}
:rtype: dict
"""
return {
HAP_REPR_AID: self.aid,
HAP_REPR_SERVICES: [s.to_HAP() for s in self.services],
}
def setup_message(self):
print('Setup payload: %s' % self.xhm_uri, flush=True)
print('Scan this code with your HomeKit app on your iOS device:', flush=True)
self.print_qr()
print('Or enter this code in your HomeKit app on your iOS device: %s' % self.pincode.decode())
def run_at_interval(seconds):
"""Decorator that runs decorated method in a while loop, which repeats every
``seconds`` until the ``Accessory.run_sentinel`` is set.
.. code-block:: python
@Accessory.run_at_interval(3)
def run(self):
print("Hello again world!")
:param seconds: The amount of seconds to wait for the event to be set.
Determines the interval on which the decorated method will be called.
:type seconds: float
"""
# decorator returns a decorator with the argument it got
def _repeat(func):
def _wrapper(self, *args, **kwargs):
while not self.run_sentinel.wait(seconds):
func(self, *args, **kwargs)
return _wrapper
return _repeat
def run(self):
"""Called when the Accessory should start doing its thing.
Called when HAP server is running, advertising is set, etc.
"""
pass
def stop(self):
"""Called when the Accessory should stop what is doing and clean up any resources.
"""
pass
# Broker
def publish(self, value, sender):
"""Append AID and IID of the sender and forward it to the broker.
Characteristics call this method to send updates.
.. note:: The method will not fail if the broker is not set - it will do nothing.
:param data: Data to publish, usually from a Characteristic.
:type data: dict
:param sender: The Service or Characteristic from which the call originated.
:type: Service or Characteristic
"""
if self.broker is None:
return
acc_data = {
HAP_REPR_AID: self.aid,
HAP_REPR_IID: self.iid_manager.get_iid(sender),
HAP_REPR_VALUE: value,
}
self.broker.publish(acc_data)
class AsyncAccessory(Accessory):
def run_at_interval(seconds):
"""Decorator that runs decorated method in a while loop, which repeats every
``seconds`` until the ``Accessory.aio_stop_event`` is set.
.. code-block:: python
@AsyncAccessory.run_at_interval(3)
async def run(self):
print("Hello again world!")
:param seconds: The amount of seconds to wait for the event to be set.
Determines the interval on which the decorated method will be called.
:type seconds: float
"""
# decorator returns a decorator with the argument it got
def _repeat(func):
async def _wrapper(self, *args, **kwargs):
while not await util.event_wait(self.aio_stop_event,
seconds,
self.event_loop):
await func(self, *args, **kwargs)
return _wrapper
return _repeat
async def run(self):
"""Override in the implementation if needed.
"""
pass
class Bridge(AsyncAccessory):
"""A representation of a HAP bridge.
A `Bridge` can have multiple `Accessories`.
"""
category = CATEGORY_BRIDGE
def __init__(self, display_name, mac=None, pincode=None,
iid_manager=None, setup_id=None):
aid = STANDALONE_AID
# A Bridge cannot be Bridge, hence talks directly to HAP clients.
# Thus, we need a mac.
mac = mac or util.generate_mac()
super(Bridge, self).__init__(display_name, aid=aid, mac=mac,
pincode=pincode, iid_manager=iid_manager,
setup_id=setup_id)
self.accessories = {} # aid: acc
def set_sentinel(self, run_sentinel, aio_stop_event, event_loop):
"""Set the same sentinel to all contained accessories."""
super().set_sentinel(run_sentinel, aio_stop_event, event_loop)
for acc in self.accessories.values():
acc.set_sentinel(run_sentinel, aio_stop_event, event_loop)
def add_accessory(self, acc):
"""Add the given ``Accessory`` to this ``Bridge``.
Every ``Accessory`` in a ``Bridge`` must have an AID and this AID must be
unique among all the ``Accessories`` in the same `Bridge`. If the given
``Accessory``'s AID is None, a unique AID will be assigned to it. Otherwise,
it will be verified that the AID is not the standalone aid (``STANDALONE_AID``)
and that there is no other ``Accessory`` already in this ``Bridge`` with that AID.
.. note:: A ``Bridge`` cannot be added to another ``Bridge``.
:param acc: The ``Accessory`` to be bridged.
:type acc: Accessory
:raise ValueError: When the given ``Accessory`` is of category ``CATEGORY_BRIDGE``
or if the AID of the ``Accessory`` clashes with another ``Accessory`` already in this
``Bridge``.
"""
if acc.category == CATEGORY_BRIDGE:
raise ValueError("Bridges cannot be bridged")
if acc.aid is None:
# For some reason AID=7 gets unsupported. See issue #61
acc.aid = next(aid for aid in itertools.count(2)
if aid != 7 and aid not in self.accessories)
elif acc.aid == self.aid or acc.aid in self.accessories:
raise ValueError("Duplicate AID found when attempting to add accessory")
self.accessories[acc.aid] = acc
def set_broker(self, broker):
super(Bridge, self).set_broker(broker)
for _, acc in self.accessories.items():
acc.broker = broker
def to_HAP(self):
"""Returns a HAP representation of itself and all contained accessories.
.. seealso:: Accessory.to_HAP
"""
hap_rep = [super().to_HAP()]
for acc in self.accessories.values():
hap_rep.append(acc.to_HAP())
return hap_rep
def get_characteristic(self, aid, iid):
""".. seealso:: Accessory.to_HAP
"""
if self.aid == aid:
return self.iid_manager.get_obj(iid)
acc = self.accessories.get(aid)
if acc is None:
return None
return acc.get_characteristic(aid, iid)
async def _wrap_in_thread(self, method):
"""Coroutine which starts the given method in a thread.
"""
# Not going through event_loop.run_in_executor, because this thread may never
# terminate.
threading.Thread(target=method).start()
async def run(self):
"""Schedule tasks for each of the accessories' run method.
"""
tasks = []
for acc in self.accessories.values():
if isinstance(acc, AsyncAccessory):
task = self.event_loop.create_task(acc.run())
else:
task = self.event_loop.create_task(self._wrap_in_thread(acc.run))
tasks.append(task)
await asyncio.gather(*tasks, loop=self.event_loop)
def stop(self):
"""Calls stop() on all contained accessories."""
super(Bridge, self).stop()
for acc in self.accessories.values():
acc.stop()
def get_topic(aid, iid):
return str(aid) + "." + str(iid)