From b5f87ec7c10e20eed082839a99d4a48ff9f60fcf Mon Sep 17 00:00:00 2001 From: John Carr Date: Tue, 23 Jul 2019 15:52:32 +0100 Subject: [PATCH 1/7] Prototype --- homekit/controller/ip_implementation.py | 237 ++++++++++++++++++------ homekit/http_impl/secure_http.py | 26 ++- 2 files changed, 197 insertions(+), 66 deletions(-) diff --git a/homekit/controller/ip_implementation.py b/homekit/controller/ip_implementation.py index 5b4bd053..5ef2cd1b 100644 --- a/homekit/controller/ip_implementation.py +++ b/homekit/controller/ip_implementation.py @@ -286,69 +286,25 @@ def get_events(self, characteristics, callback_fun, max_events=-1, max_seconds=- :return: a dict mapping 2-tupels of aid and iid to dicts with status and description, e.g. {(1, 37): {'description': 'Notification is not supported for characteristic.', 'status': -70406}} """ - if not self.session: - self.session = IpSession(self.pairing_data) - data = [] - characteristics_set = set() - for characteristic in characteristics: - aid = characteristic[0] - iid = characteristic[1] - characteristics_set.add('{a}.{i}'.format(a=aid, i=iid)) - data.append({'aid': aid, 'iid': iid, 'ev': True}) - data = json.dumps({'characteristics': data}) + bus = self.get_message_bus() - try: - response = self.session.put('/characteristics', data) - except (AccessoryDisconnectedError, EncryptionError): - self.session.close() - self.session = None - raise + bus.subscribe(characteristics) - # handle error responses - if response.code != 204: - tmp = {} - try: - data = json.loads(response.read().decode()) - except JSONDecodeError: - self.session.close() - self.session = None - raise AccessoryDisconnectedError("Session closed after receiving malformed response from device") - - for characteristic in data['characteristics']: - status = characteristic['status'] - if status == 0: - continue - aid = characteristic['aid'] - iid = characteristic['iid'] - tmp[(aid, iid)] = {'status': status, 'description': HapStatusCodes[status]} - return tmp - - # wait for incoming events - event_count = 0 - s = time.time() - while (max_events == -1 or event_count < max_events) and (max_seconds == -1 or s + max_seconds >= time.time()): - try: - r = self.session.sec_http.handle_event_response() - body = r.read().decode() - except (AccessoryDisconnectedError, EncryptionError): - self.session.close() - self.session = None - raise + for event_count, event in enumerate(bus): + if max_events >= 0 and event_count >= max_events: + break + tmp = [] + for (aid, iid), char in event.items(): + tmp.append(aid, iid, char.get('value')) + callback_fun(tmp) - if len(body) > 0: - try: - r = json.loads(body) - except JSONDecodeError: - self.session.close() - self.session = None - raise AccessoryDisconnectedError("Session closed after receiving malformed response from device") - tmp = [] - for c in r['characteristics']: - tmp.append((c['aid'], c['iid'], c['value'])) - callback_fun(tmp) - event_count += 1 return {} + def get_message_bus(self): + if not self.session: + self.session = IpSession(self.pairing_data) + return IpMessageBus(self) + def identify(self): """ This call can be used to trigger the identification of a paired accessory. A successful call should @@ -404,6 +360,143 @@ def add_pairing(self, additional_controller_pairing_identifier, ios_device_ltpk, data = TLV.decode_bytes(data) # TODO handle the response properly self.session.close() + + def close(self): + if self.session: + self.session.close() + self.session = None + + +class IpMessageBus(object): + + def __init__(self, pairing): + self.pairing = pairing + self.session = pairing.session + + def __iter__(self): + return self + + def __next__(self): + # wait for incoming events + try: + r = self.session.sec_http.handle_event_response() + body = r.read().decode() + except (AccessoryDisconnectedError, EncryptionError): + self.pairing.close() + raise + + if len(body) > 0: + try: + r = json.loads(body) + except JSONDecodeError: + self.pairing.close() + raise AccessoryDisconnectedError("Session closed after receiving malformed response from device") + + tmp = {} + for c in r['characteristics']: + key = (c['aid'], c['iid']) + del c['aid'] + del c['iid'] + + if 'status' in c and c['status'] == 0: + del c['status'] + if 'status' in c and c['status'] != 0: + c['description'] = HapStatusCodes[c['status']] + tmp[key] = c + + return tmp + + return None + + def get_characteristics(self, characteristics, include_meta=False, include_perms=False, include_type=False, + include_events=False): + """ + This method is used to get the current readouts of any characteristic of the accessory. + + :param characteristics: a list of 2-tupels of accessory id and instance id + :param include_meta: if True, include meta information about the characteristics. This contains the format and + the various constraints like maxLen and so on. + :param include_perms: if True, include the permissions for the requested characteristics. + :param include_type: if True, include the type of the characteristics in the result. See CharacteristicsTypes + for translations. + :param include_events: if True on a characteristics that supports events, the result will contain information if + the controller currently is receiving events for that characteristic. Key is 'ev'. + :return: a dict mapping 2-tupels of aid and iid to dicts with value or status and description, e.g. + {(1, 8): {'value': 23.42} + (1, 37): {'description': 'Resource does not exist.', 'status': -70409} + } + """ + if not self.pairing.session: + self.pairing.session = IpSession(self.pairing.pairing_data) + url = '/characteristics?id=' + ','.join([str(x[0]) + '.' + str(x[1]) for x in characteristics]) + if include_meta: + url += '&meta=1' + if include_perms: + url += '&perms=1' + if include_type: + url += '&type=1' + if include_events: + url += '&ev=1' + + try: + self.session.get_nowait(url) + except (AccessoryDisconnectedError, EncryptionError): + self.pairing.close() + raise + + + def put_characteristics(self, characteristics, do_conversion=False, field='value'): + """ + Update the values of writable characteristics. The characteristics have to be identified by accessory id (aid), + instance id (iid). If do_conversion is False (the default), the value must be of proper format for the + characteristic since no conversion is done. If do_conversion is True, the value is converted. + + :param characteristics: a list of 3-tupels of accessory id, instance id and the value + :param do_conversion: select if conversion is done (False is default) + :return: a dict from (aid, iid) onto {status, description} + :raises FormatError: if the input value could not be converted to the target type and conversion was + requested + """ + if not self.pairing.session: + self.pairing.session = IpSession(self.pairing.pairing_data) + data = [] + characteristics_set = set() + for characteristic in characteristics: + aid = characteristic[0] + iid = characteristic[1] + value = characteristic[2] + if do_conversion: + # evaluate proper format + c_format = None + for d in self.pairing.pairing_data['accessories']: + if 'aid' in d and d['aid'] == aid: + for s in d['services']: + for c in s['characteristics']: + if 'iid' in c and c['iid'] == iid: + c_format = c['format'] + + value = check_convert_value(value, c_format) + characteristics_set.add('{a}.{i}'.format(a=aid, i=iid)) + data.append({'aid': aid, 'iid': iid, field: value}) + data = json.dumps({'characteristics': data}) + + try: + self.session.put_nowait('/characteristics', data) + except (AccessoryDisconnectedError, EncryptionError): + self.pairing.close() + raise + + def subscribe(self, characteristics): + self.put_characteristics( + [(aid, iid, True) for (aid, iid) in characteristics], + field='ev', + ) + + def unsubscribe(self, characteristics): + self.put_characteristics( + [(aid, iid, False) for (aid, iid) in characteristics], + field='ev', + ) class IpSession(object): @@ -474,6 +567,14 @@ def get(self, url): """ return self.sec_http.get(url) + def get_nowait(self, url): + """ + Perform HTTP get via the encrypted session. + :param url: The url to request + :return: a homekit.http_impl.HttpResponse object + """ + return self.sec_http.get_nowait(url) + def put(self, url, body, content_type=HttpContentTypes.JSON): """ Perform HTTP put via the encrypted session. @@ -484,6 +585,16 @@ def put(self, url, body, content_type=HttpContentTypes.JSON): """ return self.sec_http.put(url, body, content_type) + def put_nowait(self, url, body, content_type=HttpContentTypes.JSON): + """ + Perform HTTP put via the encrypted session. + :param url: The url to request + :param body: the body of the put request + :param content_type: the content of the content-type header + :return: a homekit.http_impl.HttpResponse object + """ + return self.sec_http.put_nowait(url, body, content_type) + def post(self, url, body, content_type=HttpContentTypes.JSON): """ Perform HTTP post via the encrypted session. @@ -493,3 +604,13 @@ def post(self, url, body, content_type=HttpContentTypes.JSON): :return: a homekit.http_impl.HttpResponse object """ return self.sec_http.post(url, body, content_type) + + def post_nowait(self, url, body, content_type=HttpContentTypes.JSON): + """ + Perform HTTP post via the encrypted session. + :param url: The url to request + :param body: the body of the post request + :param content_type: the content of the content-type header + :return: a homekit.http_impl.HttpResponse object + """ + return self.sec_http.post_nowait(url, body, content_type) \ No newline at end of file diff --git a/homekit/http_impl/secure_http.py b/homekit/http_impl/secure_http.py index 9a721752..574f6baa 100644 --- a/homekit/http_impl/secure_http.py +++ b/homekit/http_impl/secure_http.py @@ -48,26 +48,38 @@ def __init__(self, session, timeout=10): self.timeout = timeout self.lock = threading.Lock() - def get(self, target): + def get_nowait(self, target): data = 'GET {tgt} HTTP/1.1\nHost: {host}:{port}\n\n'.format(tgt=target, host=self.host, port=self.port) data = data.replace("\n", "\r\n") - return self._handle_request(data.encode()) + self._handle_request(data.encode()) - def put(self, target, body, content_type=HttpContentTypes.JSON): + def get(self, target): + self.get_nowait(target) + return self._read_response(self.timeout) + + def put_nowait(self, target, body, content_type=HttpContentTypes.JSON): headers = 'Host: {host}:{port}\n'.format(host=self.host, port=self.port) + \ 'Content-Type: {ct}\n'.format(ct=content_type) + \ 'Content-Length: {len}\n'.format(len=len(body)) data = 'PUT {tgt} HTTP/1.1\n{hdr}\n{body}'.format(tgt=target, hdr=headers, body=body) data = data.replace("\n", "\r\n") - return self._handle_request(data.encode()) + self._handle_request(data.encode()) - def post(self, target, body, content_type=HttpContentTypes.TLV): + def put(self, target, body, content_type=HttpContentTypes.JSON): + self.put_nowait(target, body, content_type) + return self._read_response(self.timeout) + + def post_nowait(self, target, body, content_type=HttpContentTypes.TLV): headers = 'Host: {host}:{port}\n'.format(host=self.host, port=self.port) + \ 'Content-Type: {ct}\n'.format(ct=content_type) + \ 'Content-Length: {len}\n'.format(len=len(body)) data = 'POST {tgt} HTTP/1.1\n{hdr}\n'.format(tgt=target, hdr=headers) data = data.replace("\n", "\r\n") - return self._handle_request(data.encode() + body) + self._handle_request(data.encode() + body) + + def post(self, target, body, content_type=HttpContentTypes.TLV): + self.post_nowait(target, body, content_type) + return self._read_response(self.timeout) def _handle_request(self, data): logging.debug('handle request: %s', data) @@ -88,8 +100,6 @@ def _handle_request(self, data): except OSError as e: raise exceptions.AccessoryDisconnectedError(str(e)) - return self._read_response(self.timeout) - def _read_response(self, timeout=10): # following the information from page 71 about HTTP Message splitting: # The blocks start with 2 byte little endian defining the length of the encrypted data (max 1024 bytes) From bdff3fb7ab24ffe492971cdb8b1c622165c152cb Mon Sep 17 00:00:00 2001 From: John Carr Date: Tue, 23 Jul 2019 17:10:02 +0100 Subject: [PATCH 2/7] Restore max_seconds to get_events and lint fixes --- homekit/controller/ip_implementation.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homekit/controller/ip_implementation.py b/homekit/controller/ip_implementation.py index 5ef2cd1b..8fa2d744 100644 --- a/homekit/controller/ip_implementation.py +++ b/homekit/controller/ip_implementation.py @@ -53,6 +53,7 @@ def close(self): """ if self.session: self.session.close() + self.session = None def _get_pairing_data(self): """ @@ -290,9 +291,12 @@ def get_events(self, characteristics, callback_fun, max_events=-1, max_seconds=- bus.subscribe(characteristics) + start_time = time.time() for event_count, event in enumerate(bus): if max_events >= 0 and event_count >= max_events: break + if max_seconds >= 0 and (time.time() - start_time) >= max_seconds: + break tmp = [] for (aid, iid), char in event.items(): tmp.append(aid, iid, char.get('value')) @@ -360,11 +364,6 @@ def add_pairing(self, additional_controller_pairing_identifier, ios_device_ltpk, data = TLV.decode_bytes(data) # TODO handle the response properly self.session.close() - - def close(self): - if self.session: - self.session.close() - self.session = None class IpMessageBus(object): @@ -444,7 +443,6 @@ def get_characteristics(self, characteristics, include_meta=False, include_perms self.pairing.close() raise - def put_characteristics(self, characteristics, do_conversion=False, field='value'): """ Update the values of writable characteristics. The characteristics have to be identified by accessory id (aid), @@ -613,4 +611,4 @@ def post_nowait(self, url, body, content_type=HttpContentTypes.JSON): :param content_type: the content of the content-type header :return: a homekit.http_impl.HttpResponse object """ - return self.sec_http.post_nowait(url, body, content_type) \ No newline at end of file + return self.sec_http.post_nowait(url, body, content_type) From bcd05550543fff6312129235b2c1b38d31296229 Mon Sep 17 00:00:00 2001 From: John Carr Date: Tue, 23 Jul 2019 17:25:27 +0100 Subject: [PATCH 3/7] Test fix --- tests/regression_test.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/regression_test.py b/tests/regression_test.py index 5d2bff4c..013b4ffc 100644 --- a/tests/regression_test.py +++ b/tests/regression_test.py @@ -105,15 +105,16 @@ def test_requests_have_host_header(self): secure_http = SecureHttp(session) with mock.patch.object(secure_http, '_handle_request') as handle_req: - secure_http.get('/characteristics') - print(handle_req.call_args[0][0]) - assert '\r\nHost: 192.168.1.2:8080\r\n' in handle_req.call_args[0][0].decode() + with mock.patch.object(secure_http, '_read_response'): + secure_http.get('/characteristics') + print(handle_req.call_args[0][0]) + assert '\r\nHost: 192.168.1.2:8080\r\n' in handle_req.call_args[0][0].decode() - secure_http.post('/characteristics', b'') - assert '\r\nHost: 192.168.1.2:8080\r\n' in handle_req.call_args[0][0].decode() + secure_http.post('/characteristics', b'') + assert '\r\nHost: 192.168.1.2:8080\r\n' in handle_req.call_args[0][0].decode() - secure_http.put('/characteristics', b'') - assert '\r\nHost: 192.168.1.2:8080\r\n' in handle_req.call_args[0][0].decode() + secure_http.put('/characteristics', b'') + assert '\r\nHost: 192.168.1.2:8080\r\n' in handle_req.call_args[0][0].decode() def test_requests_only_send_params_for_true_case(self): """ From 41c969fb539e9875c67c99c4f745e788b809c350 Mon Sep 17 00:00:00 2001 From: John Carr Date: Tue, 23 Jul 2019 22:44:49 +0100 Subject: [PATCH 4/7] Mock out what BLE events might look like --- homekit/controller/ble_impl/__init__.py | 92 +++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/homekit/controller/ble_impl/__init__.py b/homekit/controller/ble_impl/__init__.py index 9369387b..76d0d23f 100644 --- a/homekit/controller/ble_impl/__init__.py +++ b/homekit/controller/ble_impl/__init__.py @@ -24,6 +24,8 @@ import sys import uuid import struct +import threading +import queue from distutils.util import strtobool from homekit.controller.tools import AbstractPairing @@ -412,6 +414,96 @@ def add_pairing(self, additional_controller_pairing_identifier, ios_device_ltpk, # TODO handle response properly print('unhandled response:', response) + def get_message_bus(self): + return BleMessageBus(self) + + +class EventsDevice(Device): + + def __init__(self, bus): + self.bus = bus + self.session = bus.session + + def properties_changed(self, sender, changed_properties, invalidated_properties): + if 'ManufacturerData' in changed_properties: + data = self.get_homekit_discovery_data() + + # Detect disconnected notification of state change + # A characteristic has changed but we don't know which one + # Resets back to 1 after overflow, factory reset or firmware update + if data['gsn'] != self.homekit_discovery_data.get('gsn', None): + self.bus.get_characteristics(list(self.bus.subscriptions)) + + return gatt.Device.properties_changed(self, sender, changed_properties, invalidated_properties) + + def characteristic_value_updated(self, characteristic, value): + if value != b'': + # We are only interested in in blank values + return + + # This is a UUID that we can't map to a (aid, iid) + if characteristic.uuid not in self.session.uuid_map: + return + + # Retrieve the value for the UUID that changed + _, c = self.session.uuid_map[characteristic.uuid] + self.bus.get_characteristics([ + (1, c['iid']), + ]) + + +class BleMessageBus(object): + + # FIXME: Think about some kind of stop() / close() to get rid of the thread + + def __init__(self, session): + self.session = session + self.queue = queue.Queue() + + # FIXME: Pass in which accessory this is supposed to be connecting to + self.device = EventsDevice(self) + # FIXME: Create a manager and attach this device to it + self.monitor = threading.Thread(target=self.device.manager.run) + + self.monitor.start() + + self.subscriptions = set() + + def __iter__(self): + return self + + def __next__(self): + return self.queue.get() + + def get_characteristics(self, characteristics, include_meta=False, include_perms=False, include_type=False, + include_events=False): + self.queue.put_nowait( + self.session.get_characteristics( + characteristics, + include_meta=include_meta, + include_perms=include_perms, + include_type=include_type, + include_events=include_events, + ) + ) + + def put_characteristics(self, characteristics, do_conversion=False): + self.queue.put_nowait( + self.session.put_characteristics(characteristics, do_conversion) + ) + + def subscribe(self, characteristics): + for (aid, iid) in characteristics: + self.subscriptions.add((aid, iid)) + fc, fc_id = self.session.find_characteristic_by_iid(iid) + fc.enable_notifications() + + def unsubscribe(self, characteristics): + for (aid, iid) in characteristics: + self.subscriptions.discard((aid, iid)) + fc, fc_id = self.session.find_characteristic_by_iid(iid) + fc.disable_notifications() + class BleSession(object): From 220b0b07b6d6d95b76a2489cf92bbe0d3153c38f Mon Sep 17 00:00:00 2001 From: John Carr Date: Wed, 24 Jul 2019 19:20:06 +0100 Subject: [PATCH 5/7] Wire up ble events a bit more --- homekit/controller/ble_impl/__init__.py | 19 +++++++------ homekit/controller/ip_implementation.py | 38 ------------------------- homekit/controller/tools.py | 23 +++++++++++++-- setup.py | 2 +- 4 files changed, 32 insertions(+), 50 deletions(-) diff --git a/homekit/controller/ble_impl/__init__.py b/homekit/controller/ble_impl/__init__.py index 76d0d23f..f5348a08 100644 --- a/homekit/controller/ble_impl/__init__.py +++ b/homekit/controller/ble_impl/__init__.py @@ -121,10 +121,6 @@ def list_pairings(self): r['controllerType'] = controller_type return tmp - def get_events(self, characteristics, callback_fun, max_events=-1, max_seconds=-1): - # TODO implementation still missing - pass - def identify(self): """ This call can be used to trigger the identification of a paired accessory. A successful call should @@ -420,9 +416,10 @@ def get_message_bus(self): class EventsDevice(Device): - def __init__(self, bus): + def __init__(self, mac_address, manager, bus): self.bus = bus self.session = bus.session + super().__init__(mac_address, maanger) def properties_changed(self, sender, changed_properties, invalidated_properties): if 'ManufacturerData' in changed_properties: @@ -460,11 +457,15 @@ def __init__(self, session): self.session = session self.queue = queue.Queue() - # FIXME: Pass in which accessory this is supposed to be connecting to - self.device = EventsDevice(self) - # FIXME: Create a manager and attach this device to it - self.monitor = threading.Thread(target=self.device.manager.run) + self.manager = DeviceManager() + + self.device = EventsDevice( + mac_address=self.session.pairing_data['AccessoryMAC'], + manager=self.manager, + bus=self, + ) + self.monitor = threading.Thread(target=self.manager.run) self.monitor.start() self.subscriptions = set() diff --git a/homekit/controller/ip_implementation.py b/homekit/controller/ip_implementation.py index 8fa2d744..2f597c57 100644 --- a/homekit/controller/ip_implementation.py +++ b/homekit/controller/ip_implementation.py @@ -266,44 +266,6 @@ def put_characteristics(self, characteristics, do_conversion=False): return data return {} - def get_events(self, characteristics, callback_fun, max_events=-1, max_seconds=-1): - """ - This function is called to register for events on characteristics and receive them. Each time events are - received a call back function is invoked. By that the caller gets information about the events. - - The characteristics are identified via their proper accessory id (aid) and instance id (iid). - - The call back function takes a list of 3-tupels of aid, iid and the value, e.g.: - [(1, 9, 26.1), (1, 10, 30.5)] - - If the input contains characteristics without the event permission or any other error, the function will return - a dict containing tupels of aid and iid for each requested characteristic with error. Those who would have - worked are not in the result. - - :param characteristics: a list of 2-tupels of accessory id (aid) and instance id (iid) - :param callback_fun: a function that is called each time events were recieved - :param max_events: number of reported events, default value -1 means unlimited - :param max_seconds: number of seconds to wait for events, default value -1 means unlimited - :return: a dict mapping 2-tupels of aid and iid to dicts with status and description, e.g. - {(1, 37): {'description': 'Notification is not supported for characteristic.', 'status': -70406}} - """ - bus = self.get_message_bus() - - bus.subscribe(characteristics) - - start_time = time.time() - for event_count, event in enumerate(bus): - if max_events >= 0 and event_count >= max_events: - break - if max_seconds >= 0 and (time.time() - start_time) >= max_seconds: - break - tmp = [] - for (aid, iid), char in event.items(): - tmp.append(aid, iid, char.get('value')) - callback_fun(tmp) - - return {} - def get_message_bus(self): if not self.session: self.session = IpSession(self.pairing_data) diff --git a/homekit/controller/tools.py b/homekit/controller/tools.py index 21fdec70..17c18c96 100644 --- a/homekit/controller/tools.py +++ b/homekit/controller/tools.py @@ -17,6 +17,7 @@ import abc import base64 import binascii +import time from distutils.util import strtobool from homekit.protocol.tlv import TLV, TlvParseException @@ -105,7 +106,6 @@ def put_characteristics(self, characteristics, do_conversion=False): """ pass - @abc.abstractmethod def get_events(self, characteristics, callback_fun, max_events=-1, max_seconds=-1): """ This function is called to register for events on characteristics and receive them. Each time events are @@ -127,7 +127,26 @@ def get_events(self, characteristics, callback_fun, max_events=-1, max_seconds=- :return: a dict mapping 2-tupels of aid and iid to dicts with status and description, e.g. {(1, 37): {'description': 'Notification is not supported for characteristic.', 'status': -70406}} """ - pass + bus = self.get_message_bus() + + bus.subscribe(characteristics) + + start_time = time.time() + for event_count, event in enumerate(bus): + if max_events >= 0 and event_count >= max_events: + break + if max_seconds >= 0 and (time.time() - start_time) >= max_seconds: + break + tmp = [] + for (aid, iid), char in event.items(): + tmp.append(aid, iid, char.get('value')) + callback_fun(tmp) + + return {} + + @abs.abstractmethod + def get_message_bus(self): + raise NotImplementedError(self.get_message_bus) @abc.abstractmethod def identify(self): diff --git a/setup.py b/setup.py index accb4767..790665a6 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ setuptools.setup( name='homekit', packages=setuptools.find_packages(exclude=['tests']), - version='0.15.0', + version='0.14.0', description='Python code to interface HomeKit Accessories and Controllers', author='Joachim Lusiardi', author_email='pypi@lusiardi.de', From 7b1aadf12d271ff20e2619bbc17dcf07395aefe2 Mon Sep 17 00:00:00 2001 From: John Carr Date: Wed, 24 Jul 2019 21:04:29 +0100 Subject: [PATCH 6/7] More like this? --- homekit/controller/ble_impl/__init__.py | 45 ++++++++++++++++++------- homekit/controller/tools.py | 4 +-- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/homekit/controller/ble_impl/__init__.py b/homekit/controller/ble_impl/__init__.py index f5348a08..61dd3b42 100644 --- a/homekit/controller/ble_impl/__init__.py +++ b/homekit/controller/ble_impl/__init__.py @@ -418,8 +418,25 @@ class EventsDevice(Device): def __init__(self, mac_address, manager, bus): self.bus = bus - self.session = bus.session - super().__init__(mac_address, maanger) + self.pairing = bus.pairing + self.session = None + + super().__init__(mac_address, manager) + + def connect_succeeded(self): + self.session = BleSession( + self.pairing.pairing_data, + self.manager.adapter + ) + + for (aid, iid) in self.bus.subscriptions: + fc, fc_id = self.session.find_characteristic_by_iid(iid) + fc.enable_notifications() + + def disconnect(self): + if self.session: + self.session.close() + self.session = None def properties_changed(self, sender, changed_properties, invalidated_properties): if 'ManufacturerData' in changed_properties: @@ -431,7 +448,7 @@ def properties_changed(self, sender, changed_properties, invalidated_properties) if data['gsn'] != self.homekit_discovery_data.get('gsn', None): self.bus.get_characteristics(list(self.bus.subscriptions)) - return gatt.Device.properties_changed(self, sender, changed_properties, invalidated_properties) + return Device.properties_changed(self, sender, changed_properties, invalidated_properties) def characteristic_value_updated(self, characteristic, value): if value != b'': @@ -453,14 +470,14 @@ class BleMessageBus(object): # FIXME: Think about some kind of stop() / close() to get rid of the thread - def __init__(self, session): - self.session = session + def __init__(self, pairing): + self.pairing = pairing self.queue = queue.Queue() - self.manager = DeviceManager() + self.manager = DeviceManager('hci0') self.device = EventsDevice( - mac_address=self.session.pairing_data['AccessoryMAC'], + mac_address=self.pairing.pairing_data['AccessoryMAC'], manager=self.manager, bus=self, ) @@ -479,7 +496,7 @@ def __next__(self): def get_characteristics(self, characteristics, include_meta=False, include_perms=False, include_type=False, include_events=False): self.queue.put_nowait( - self.session.get_characteristics( + self.pairing.get_characteristics( characteristics, include_meta=include_meta, include_perms=include_perms, @@ -490,20 +507,22 @@ def get_characteristics(self, characteristics, include_meta=False, include_perms def put_characteristics(self, characteristics, do_conversion=False): self.queue.put_nowait( - self.session.put_characteristics(characteristics, do_conversion) + self.pairing.put_characteristics(characteristics, do_conversion) ) def subscribe(self, characteristics): for (aid, iid) in characteristics: self.subscriptions.add((aid, iid)) - fc, fc_id = self.session.find_characteristic_by_iid(iid) - fc.enable_notifications() + if self.session: + fc, fc_id = self.session.find_characteristic_by_iid(iid) + fc.enable_notifications() def unsubscribe(self, characteristics): for (aid, iid) in characteristics: self.subscriptions.discard((aid, iid)) - fc, fc_id = self.session.find_characteristic_by_iid(iid) - fc.disable_notifications() + if self.session: + fc, fc_id = self.session.find_characteristic_by_iid(iid) + fc.disable_notifications() class BleSession(object): diff --git a/homekit/controller/tools.py b/homekit/controller/tools.py index 17c18c96..5a1522bd 100644 --- a/homekit/controller/tools.py +++ b/homekit/controller/tools.py @@ -139,12 +139,12 @@ def get_events(self, characteristics, callback_fun, max_events=-1, max_seconds=- break tmp = [] for (aid, iid), char in event.items(): - tmp.append(aid, iid, char.get('value')) + tmp.append((aid, iid, char.get('value'))) callback_fun(tmp) return {} - @abs.abstractmethod + @abc.abstractmethod def get_message_bus(self): raise NotImplementedError(self.get_message_bus) From 22f559820c1c82ce55d791398ae6e1d2942ee697 Mon Sep 17 00:00:00 2001 From: John Carr Date: Wed, 24 Jul 2019 23:41:17 +0100 Subject: [PATCH 7/7] Somewhat working for connected events --- homekit/controller/ble_impl/__init__.py | 175 +++++++++++------------- homekit/controller/ble_impl/device.py | 24 ++++ homekit/controller/tools.py | 1 + homekit/get_events.py | 1 + 4 files changed, 109 insertions(+), 92 deletions(-) diff --git a/homekit/controller/ble_impl/__init__.py b/homekit/controller/ble_impl/__init__.py index 61dd3b42..e994cf18 100644 --- a/homekit/controller/ble_impl/__init__.py +++ b/homekit/controller/ble_impl/__init__.py @@ -66,7 +66,13 @@ def __init__(self, pairing_data, adapter='hci0'): """ self.adapter = adapter self.pairing_data = pairing_data - self.session = None + self._session = None + + @property + def session(self): + if not self._session: + self._session = BleSession(self) + return self._session def close(self): pass @@ -83,8 +89,8 @@ def list_accessories_and_characteristics(self): return resolved_data['data'] def list_pairings(self): - if not self.session: - self.session = BleSession(self.pairing_data, self.adapter) + if not self.session.connected: + self.session.connect() request_tlv = TLV.encode_list([ (TLV.kTLVType_State, TLV.M1), (TLV.kTLVType_Method, TLV.ListPairings) @@ -131,8 +137,8 @@ def identify(self): :return True, if the identification was run, False otherwise """ - if not self.session: - self.session = BleSession(self.pairing_data, self.adapter) + if not self.session.connected: + self.session.connect() cid = -1 aid = -1 for a in self.pairing_data['accessories']: @@ -163,8 +169,8 @@ def get_characteristics(self, characteristics, include_meta=False, include_perms (1, 37): {'description': 'Resource does not exist.', 'status': -70409} } """ - if not self.session: - self.session = BleSession(self.pairing_data, self.adapter) + if not self.session.connected: + self.session.connect() results = {} for aid, cid in characteristics: @@ -179,7 +185,6 @@ def get_characteristics(self, characteristics, include_meta=False, include_perms response = self.session.request(fc, cid, HapBleOpCodes.CHAR_READ) except Exception as e: self.session.close() - self.session = None raise e value = self._convert_to_python(aid, cid, response[1]) if 1 in response else None @@ -341,8 +346,8 @@ def put_characteristics(self, characteristics, do_conversion=False): :raises FormatError: if the input value could not be converted to the target type and conversion was requested """ - if not self.session: - self.session = BleSession(self.pairing_data, self.adapter) + if not self.session.connected: + self.session.connect() results = {} @@ -370,14 +375,13 @@ def put_characteristics(self, characteristics, do_conversion=False): } except Exception as e: self.session.close() - self.session = None raise e return results def add_pairing(self, additional_controller_pairing_identifier, ios_device_ltpk, permissions): - if not self.session: - self.session = BleSession(self.pairing_data, self.adapter) + if not self.session.connected: + self.session.connect() if permissions == 'User': permissions = TLV.kTLVType_Permission_RegularUser elif permissions == 'Admin': @@ -411,81 +415,39 @@ def add_pairing(self, additional_controller_pairing_identifier, ios_device_ltpk, print('unhandled response:', response) def get_message_bus(self): + if not self.session.connected: + self.session.connect() return BleMessageBus(self) -class EventsDevice(Device): - - def __init__(self, mac_address, manager, bus): - self.bus = bus - self.pairing = bus.pairing - self.session = None - - super().__init__(mac_address, manager) - - def connect_succeeded(self): - self.session = BleSession( - self.pairing.pairing_data, - self.manager.adapter - ) - - for (aid, iid) in self.bus.subscriptions: - fc, fc_id = self.session.find_characteristic_by_iid(iid) - fc.enable_notifications() - - def disconnect(self): - if self.session: - self.session.close() - self.session = None - - def properties_changed(self, sender, changed_properties, invalidated_properties): - if 'ManufacturerData' in changed_properties: - data = self.get_homekit_discovery_data() - - # Detect disconnected notification of state change - # A characteristic has changed but we don't know which one - # Resets back to 1 after overflow, factory reset or firmware update - if data['gsn'] != self.homekit_discovery_data.get('gsn', None): - self.bus.get_characteristics(list(self.bus.subscriptions)) - - return Device.properties_changed(self, sender, changed_properties, invalidated_properties) - - def characteristic_value_updated(self, characteristic, value): - if value != b'': - # We are only interested in in blank values - return - - # This is a UUID that we can't map to a (aid, iid) - if characteristic.uuid not in self.session.uuid_map: - return - - # Retrieve the value for the UUID that changed - _, c = self.session.uuid_map[characteristic.uuid] - self.bus.get_characteristics([ - (1, c['iid']), - ]) - - class BleMessageBus(object): # FIXME: Think about some kind of stop() / close() to get rid of the thread def __init__(self, pairing): self.pairing = pairing - self.queue = queue.Queue() - - self.manager = DeviceManager('hci0') + self.session = pairing.session - self.device = EventsDevice( - mac_address=self.pairing.pairing_data['AccessoryMAC'], - manager=self.manager, - bus=self, - ) + self.queue = queue.Queue() - self.monitor = threading.Thread(target=self.manager.run) + self.pairing.session.device.manager.start_discovery() + self.monitor = threading.Thread(target=self.pairing.session.device.manager.run) self.monitor.start() - self.subscriptions = set() + self.session.device.subscribers.append(self._handle_event) + + def _handle_event(self, event, uuid=None): + print(event, uuid) + if event == 'char_updated': + if not uuid or uuid not in self.session.uuid_map: + print('unknown uuid') + return + _, c = self.session.uuid_map[uuid] + self.get_characteristics([ + (1, c['iid']), + ]) + elif event == 'gsn': + self.get_characteristics(self.session.subscriptions) def __iter__(self): return self @@ -511,35 +473,53 @@ def put_characteristics(self, characteristics, do_conversion=False): ) def subscribe(self, characteristics): + session = self.pairing.session for (aid, iid) in characteristics: - self.subscriptions.add((aid, iid)) - if self.session: - fc, fc_id = self.session.find_characteristic_by_iid(iid) + print('subscribe', iid) + session.subscriptions.add((aid, iid)) + if session.connected: + print('enable_not') + fc, fc_id = session.find_characteristic_by_iid(iid) fc.enable_notifications() def unsubscribe(self, characteristics): for (aid, iid) in characteristics: self.subscriptions.discard((aid, iid)) - if self.session: - fc, fc_id = self.session.find_characteristic_by_iid(iid) + if self.pairing.session.connected: + fc, fc_id = self.pairing.session.find_characteristic_by_iid(iid) fc.disable_notifications() class BleSession(object): - def __init__(self, pairing_data, adapter): - self.adapter = adapter - self.pairing_data = pairing_data + def __init__(self, pairing): + self.pairing = pairing + self.session_lock = threading.Lock() + self.subscriptions = set() + self.connected = False + + mac_address = self.pairing.pairing_data['AccessoryMAC'] + manager = DeviceManager(self.pairing.adapter) + self.device = manager.make_device(mac_address) + self.device.disconnect() + + self.device.subscribers.append(self._handle_event) + + def _handle_event(self, event, uuid=None): + if event == 'disconnected': + self.close() + + def connect(self): + with self.session_lock: + if not self.connected: + self._connect() + + def _connect(self): self.c2a_counter = 0 self.a2c_counter = 0 self.c2a_key = None self.a2c_key = None - self.device = None - mac_address = self.pairing_data['AccessoryMAC'] - manager = DeviceManager(self.adapter) - - self.device = manager.make_device(mac_address) logger.debug('connecting to device') self.device.connect() logger.debug('connected to device') @@ -552,7 +532,7 @@ def __init__(self, pairing_data, adapter): self.uuid_map = {} self.iid_map = {} self.short_map = {} - for a in pairing_data['accessories']: + for a in self.pairing.pairing_data['accessories']: for s in a['services']: s_short = None if s['type'].endswith(ServicesTypes.baseUUID): @@ -563,7 +543,7 @@ def __init__(self, pairing_data, adapter): if not char: continue self.iid_map[c['iid']] = (char, c) - self.uuid_map[(s['type'], c['type'])] = (char, c) + self.uuid_map[char.uuid] = (char, c) if s_short and c['type'].endswith(CharacteristicsTypes.baseUUID): c_short = c['type'].split('-', 1)[0].lstrip('0') @@ -584,18 +564,29 @@ def __init__(self, pairing_data, adapter): sys.exit(-1) write_fun = create_ble_pair_setup_write(pair_verify_char, pair_verify_char_info['iid']) - self.c2a_key, self.a2c_key = get_session_keys(None, self.pairing_data, write_fun) + self.c2a_key, self.a2c_key = get_session_keys(None, self.pairing.pairing_data, write_fun) logger.debug('pair_verified, keys: \n\t\tc2a: %s\n\t\ta2c: %s', self.c2a_key.hex(), self.a2c_key.hex()) self.c2a_counter = 0 self.a2c_counter = 0 + self.connected = True + + for (aid, iid) in self.subscriptions: + print('ENABLE') + fc, fc_id = self.find_characteristic_by_iid(iid) + fc.enable_notifications() + + print('REALLY') + def __del__(self): self.close() def close(self): logger.debug('closing session') - self.device.disconnect() + if self.conneced: + self.device.disconnect() + self.connected = False def find_characteristic_by_iid(self, cid): return self.iid_map.get(cid, (None, None)) diff --git a/homekit/controller/ble_impl/device.py b/homekit/controller/ble_impl/device.py index b347dfed..9ae0b7de 100644 --- a/homekit/controller/ble_impl/device.py +++ b/homekit/controller/ble_impl/device.py @@ -53,6 +53,9 @@ def __init__(self, *args, **kwargs): self.name = self._properties.Get('org.bluez.Device1', 'Alias') self.homekit_discovery_data = self.get_homekit_discovery_data() + self.subscribers = [] + self.gsn = self.homekit_discovery_data.get('gsn', 0) + def get_homekit_discovery_data(self): """ Retrieve and decode the latest ManufacturerData from BlueZ for a @@ -114,6 +117,27 @@ def characteristic_write_value_succeeded(self, characteristic): def characteristic_write_value_failed(self, characteristic, error): logger.debug('write failed: %s %s', characteristic, error) + def advertised(self): + data = self.get_homekit_discovery_data() + if 'gsn' not in data: + return + if data['gsn'] != self.gsn: + for callback in self.subscribers: + callback('gsn') + self.gsn = data['gsn'] + + def disconnect_succeeded(self): + for callback in self.subscribers: + callback('disconnect') + + def characteristic_value_updated(self, characteristic, value): + if value != b'': + # We are only interested in in blank values + return + + for callback in self.subscribers: + callback('char_updated', characteristic.uuid) + class DeviceManager(gatt.DeviceManager): diff --git a/homekit/controller/tools.py b/homekit/controller/tools.py index 5a1522bd..d217c5f4 100644 --- a/homekit/controller/tools.py +++ b/homekit/controller/tools.py @@ -130,6 +130,7 @@ def get_events(self, characteristics, callback_fun, max_events=-1, max_seconds=- bus = self.get_message_bus() bus.subscribe(characteristics) + bus.get_characteristics(characteristics) start_time = time.time() for event_count, event in enumerate(bus): diff --git a/homekit/get_events.py b/homekit/get_events.py index b20db72b..cf00824f 100755 --- a/homekit/get_events.py +++ b/homekit/get_events.py @@ -69,6 +69,7 @@ def func(events): except KeyboardInterrupt: sys.exit(-1) except Exception as e: + raise print(e) logging.debug(e, exc_info=True) sys.exit(-1)